From cca8529a106337d3251eb6d912dd698034565a45 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 9 Oct 2026 12:47:46 -0400 Subject: [PATCH 1/6] Root vendored-ledger access at the manifest project WIP for #745. Co-Authored-By: Claude Opus 5.5 (1M context) From ec584edb3e71a06c41c0593831b68af61ed1435d Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 9 Oct 2026 13:03:49 -0400 Subject: [PATCH 2/6] Root vendored-ledger access at the manifest project With --manifest-path into another project, rollback, remove, repair, apply --check, vex and agent-mode scan/get read (and rollback/remove saved) the vendor ledger of --cwd while list, apply and vendor --check read the manifest project's. One run could revert one project's agent patches and another project's vendored artifacts, and rollback wrote a ledger its apply lock did not cover. Every ledger read, revert and save now uses GlobalArgs::project_root; the vendored backend and the scan --prune vendored GC run re-rooted there. Hosted/vendored scan and get, and vendor other than --check, rewire --cwd's lockfiles, so they now refuse a manifest in another project (exit 2, manifest_path_foreign_project). An architecture test pins that no ledger access outside the vendoring engine takes a cwd. Fixes #745 Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/CLI_CONTRACT.md | 7 +- crates/socket-patch-cli/src/args.rs | 74 +++- .../src/commands/agent_download.rs | 20 +- crates/socket-patch-cli/src/commands/apply.rs | 4 +- .../socket-patch-cli/src/commands/context.rs | 10 +- crates/socket-patch-cli/src/commands/get.rs | 26 +- crates/socket-patch-cli/src/commands/mod.rs | 28 ++ .../socket-patch-cli/src/commands/remove.rs | 5 +- .../socket-patch-cli/src/commands/repair.rs | 18 +- .../socket-patch-cli/src/commands/rollback.rs | 11 +- .../socket-patch-cli/src/commands/scan/gc.rs | 2 +- .../socket-patch-cli/src/commands/scan/mod.rs | 31 +- .../socket-patch-cli/src/commands/vendor.rs | 26 +- .../src/commands/vendored_backend/mod.rs | 21 +- .../src/commands/vendored_backend/repair.rs | 2 +- crates/socket-patch-cli/src/commands/vex.rs | 14 +- .../src/commands/vex_sources.rs | 5 +- .../tests/manifest_path_ledger_root.rs | 341 ++++++++++++++++++ docs/migrating-to-v5.md | 6 + 19 files changed, 580 insertions(+), 71 deletions(-) create mode 100644 crates/socket-patch-cli/tests/manifest_path_ledger_root.rs diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index bb4592f4b..5f403f4c0 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -49,7 +49,7 @@ Every subcommand accepts the same set of "global" flags via a single shared `Glo | Long | Short | Env var | Default | Type | Semantic | |---|---|---|---|---|---| | `--cwd` | — | `SOCKET_CWD` | `.` | path | Working directory | -| `--manifest-path` | — | `SOCKET_MANIFEST_PATH` | `.socket/manifest.json` | path | Manifest location (resolved relative to `--cwd`) | +| `--manifest-path` | — | `SOCKET_MANIFEST_PATH` | `.socket/manifest.json` | path | Manifest location (resolved relative to `--cwd`). v5.0 (#745): it also selects the project whose stores every command reads and writes — the root the RESOLVED manifest path implies (its `.socket` parent's parent in the standard layout, else the manifest file's directory; exactly `--cwd` for the default path). All sources always come from that SAME project: `list`, `apply` (incl. `--check`), `rollback`, `remove`, `repair`, `vex`, `vendor --check` and agent-mode `scan`/`get` load (and `rollback`/`remove`/`repair`/`scan --prune` revert and save) that project's vendor ledger and its `.socket/vendor/` artifacts, under that project's apply lock, never `--cwd`'s. The forms that rewire `--cwd`'s lockfiles — `scan`/`get --mode hosted\|vendored` and `vendor` other than `--check` — refuse a manifest in another project: usage error (exit 2), code `manifest_path_foreign_project`, checked before anything is read or locked. | | `--api-url` | — | `SOCKET_API_URL` | `https://api.socket.dev` | string | Authenticated API endpoint | | `--api-token` | — | `SOCKET_API_TOKEN` | (none) | string | Auth token (absence selects the public proxy) | | `--org` | `-o` | `SOCKET_ORG_SLUG` | (auto-resolve) | string | Org slug. Resolved once per run from the token (`GET /v0/organizations`) when omitted; if that fails, the whole run uses the public proxy anonymously (free patches only) and warns. | @@ -1415,7 +1415,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | Code | Subcommands | Meaning | |-----------------------|----------------------------------|---------| -| `manifest_not_found` | remove, repair, rollback, vex (not `list` since v5.0: a missing manifest is an empty list) | `.socket/manifest.json` doesn't exist. For `vex` (and `scan --vex`) it fires only when, in addition, NOTHING else names a patch — no vendor-ledger entry, no lockfile reference (hosted or vendored) — and the message says so (exit 2 standalone; `apply`/`vendor --vex` treat it as their calm no-op). v3.5: `repair` proceeds anyway (vendored phase only) when a vendor ledger or vendor-path lockfile references exist, and exits 0 with a `redirect_only_project` skip (not this error) when the project's only patch state is hosted pins in its lockfiles (v5.0; or a pre-v5 `redirect-state.json`). `list` likewise no longer fires this on a hosted-only project: v5.0 lists every hosted pin the lockfiles wire (exit 0, labeled `details.mode: "hosted"` + `details.lockfiles: []` — no `details.ledger`, since hosted mode keeps none; when the manifest exists too, both are shown, purl-sorted with the manifest entry first on a tie). A pin carries its uuid and empty details unless a pre-v5 redirect ledger records the same purl and uuid (read for migration only: its record supplies the vulnerabilities / tier / description); a pre-v5 ledger record whose pin is in no lockfile is not listed. v5.0: `list` reads the vendor ledger the same way — a vendored-only project (every `scan`/`get --mode vendored` project) lists its ledger entries' embedded records labeled `Mode: vendored (recorded in .socket/vendor/state.json)` in human mode — the twin of the hosted `Mode: hosted (wired in )` line — (`details.mode: "vendored"` + `details.ledger: ".socket/vendor/state.json"` in JSON), exit 0. A standalone-`vendor` entry's fallback `record` lists the same way once no manifest entry covers it (by ledger key or base purl) — the copy manifest-less `vex` attests from, so `list` never reports `manifest_not_found` for a tree whose VEX document attests a patch; while the manifest covers it, only the manifest entry is listed. All sources always come from the SAME project: the vendor ledger and the lockfiles are resolved against the root the RESOLVED manifest path implies (its `.socket` parent's parent in the standard layout, else the manifest file's directory — exactly `--cwd` for the default path), so `--manifest-path` into another project reads that project's state, never the local one. The error still fires when NONE of the three sources has a patch, and a present-but-broken manifest still reports `manifest_invalid`/`manifest_unreadable` regardless (corruption is never masked). A malformed pre-v5 redirect ledger degrades to "nothing to consult" with a stderr warning, muted by `--silent` (the pins still list); `list --json` carries it in the run-level `warnings[]` as `redirect_ledger_corrupt` instead of on stderr. v5.0: `rollback` likewise proceeds manifest-less when the vendor ledger or the lockfiles' hosted pins hold work (its error is the legacy `{status: "error", error: {code: "manifest_not_found", message: "Manifest not found"}, path}` shape); only the truly-empty project — no manifest, no vendor ledger, no hosted pin (a lone pre-v5 redirect ledger is deleted, exit 0) — keeps the exit-1 error, and a project whose lockfiles still reference `.socket/vendor/` artifacts with NO vendor ledger gets a distinct error naming `socket-patch repair`. `remove` (v5.0) proceeds manifest-less whenever a vendor ledger file exists or the lockfiles pin a hosted patch (an existence probe and the read-only hosted-pin discovery before the lock; the vendor ledger loads under it): ANY vendor-ledger entry matching the identifier — detached or not — is removed through the ledger path (`--preserve-state` and drift-keeps behave exactly as on the manifest path), a hosted-only match restores its upstream registry entry, and when that state exists but holds nothing for the identifier the error is `not_found` (exit 1), not this code — `manifest_not_found` fires from `remove` only when all three sources are empty. Manifest entries are removed in sorted purl order. | +| `manifest_not_found` | remove, repair, rollback, vex (not `list` since v5.0: a missing manifest is an empty list) | `.socket/manifest.json` doesn't exist. For `vex` (and `scan --vex`) it fires only when, in addition, NOTHING else names a patch — no vendor-ledger entry, no lockfile reference (hosted or vendored) — and the message says so (exit 2 standalone; `apply`/`vendor --vex` treat it as their calm no-op). v3.5: `repair` proceeds anyway (vendored phase only) when a vendor ledger or vendor-path lockfile references exist, and exits 0 with a `redirect_only_project` skip (not this error) when the project's only patch state is hosted pins in its lockfiles (v5.0; or a pre-v5 `redirect-state.json`). `list` likewise no longer fires this on a hosted-only project: v5.0 lists every hosted pin the lockfiles wire (exit 0, labeled `details.mode: "hosted"` + `details.lockfiles: []` — no `details.ledger`, since hosted mode keeps none; when the manifest exists too, both are shown, purl-sorted with the manifest entry first on a tie). A pin carries its uuid and empty details unless a pre-v5 redirect ledger records the same purl and uuid (read for migration only: its record supplies the vulnerabilities / tier / description); a pre-v5 ledger record whose pin is in no lockfile is not listed. v5.0: `list` reads the vendor ledger the same way — a vendored-only project (every `scan`/`get --mode vendored` project) lists its ledger entries' embedded records labeled `Mode: vendored (recorded in .socket/vendor/state.json)` in human mode — the twin of the hosted `Mode: hosted (wired in )` line — (`details.mode: "vendored"` + `details.ledger: ".socket/vendor/state.json"` in JSON), exit 0. A standalone-`vendor` entry's fallback `record` lists the same way once no manifest entry covers it (by ledger key or base purl) — the copy manifest-less `vex` attests from, so `list` never reports `manifest_not_found` for a tree whose VEX document attests a patch; while the manifest covers it, only the manifest entry is listed. All sources always come from the SAME project (see the `--manifest-path` flag row), so `--manifest-path` into another project reads that project's state, never the local one. The error still fires when NONE of the three sources has a patch, and a present-but-broken manifest still reports `manifest_invalid`/`manifest_unreadable` regardless (corruption is never masked). A malformed pre-v5 redirect ledger degrades to "nothing to consult" with a stderr warning, muted by `--silent` (the pins still list); `list --json` carries it in the run-level `warnings[]` as `redirect_ledger_corrupt` instead of on stderr. v5.0: `rollback` likewise proceeds manifest-less when the vendor ledger or the lockfiles' hosted pins hold work (its error is the legacy `{status: "error", error: {code: "manifest_not_found", message: "Manifest not found"}, path}` shape); only the truly-empty project — no manifest, no vendor ledger, no hosted pin (a lone pre-v5 redirect ledger is deleted, exit 0) — keeps the exit-1 error, and a project whose lockfiles still reference `.socket/vendor/` artifacts with NO vendor ledger gets a distinct error naming `socket-patch repair`. `remove` (v5.0) proceeds manifest-less whenever a vendor ledger file exists or the lockfiles pin a hosted patch (an existence probe and the read-only hosted-pin discovery before the lock; the vendor ledger loads under it): ANY vendor-ledger entry matching the identifier — detached or not — is removed through the ledger path (`--preserve-state` and drift-keeps behave exactly as on the manifest path), a hosted-only match restores its upstream registry entry, and when that state exists but holds nothing for the identifier the error is `not_found` (exit 1), not this code — `manifest_not_found` fires from `remove` only when all three sources are empty. Manifest entries are removed in sorted purl order. | | `ambiguous_target` | remove, get, rollback | The identifier is a package name whose last-segment rule selects several packages across the manifest, the vendor ledger and the hosted pins (`core` → `@angular/core` and `@babel/core`). Nothing is changed (exit 1); the message names each package. Use the full name or a purl. See **Target grammar**. | | `manifest_invalid` | list, remove, rollback | Manifest exists but is unparseable. | | `manifest_unreadable` | list, remove, vex, get, rollback | I/O error reading manifest (vex: also an unparseable manifest; exit 2). | @@ -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. | +| `manifest_path_foreign_project` | scan, get, vendor | Usage error (exit 2, v5.0, #745): `--mode hosted\|vendored` (scan's default is hosted) or `vendor` / `vendor --revert` with a `--manifest-path` / `SOCKET_MANIFEST_PATH` whose project (see the flag row) is not `--cwd`. These forms rewire `--cwd`'s lockfiles and vendor ledger, so the manifest must belong to the same project; run from the manifest's project (`--cwd `) or drop the flag. | | `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) or with a `--manifest-path` in another project (v5.0, `manifest_path_foreign_project`; `get` and `vendor` other than `--check` likewise); 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/args.rs b/crates/socket-patch-cli/src/args.rs index 5e0620969..27d978c6b 100644 --- a/crates/socket-patch-cli/src/args.rs +++ b/crates/socket-patch-cli/src/args.rs @@ -514,21 +514,47 @@ impl GlobalArgs { /// interleave two projects' state (CLI_CONTRACT.md: both stores always /// come from the SAME project). pub(crate) fn project_root(&self) -> PathBuf { - let manifest_path = self.resolved_manifest_path(); - match manifest_path.parent() { - Some(dir) - if dir.file_name() - == Some(std::ffi::OsStr::new( - socket_patch_core::constants::SOCKET_DIR, - )) => - { - dir.parent() - .map(Path::to_path_buf) - .unwrap_or_else(|| self.cwd.clone()) - } - Some(dir) => dir.to_path_buf(), - None => self.cwd.clone(), + project_root_of(&self.resolved_manifest_path(), &self.cwd) + } + + /// Whether the resolved manifest belongs to a project other than + /// `--cwd` ([`Self::project_root`] names a different directory). Only + /// a non-default `--manifest-path` / `SOCKET_MANIFEST_PATH` can make + /// this true. + pub(crate) fn manifest_project_is_foreign(&self) -> bool { + let root = self.project_root(); + if root == self.cwd { + return false; + } + match ( + std::fs::canonicalize(&root), + std::fs::canonicalize(&self.cwd), + ) { + (Ok(root), Ok(cwd)) => root != cwd, + _ => true, + } + } + + /// `self` with `cwd` moved to [`Self::project_root`] (and the manifest + /// path re-expressed relative to it, so it resolves to the same file): + /// the view the vendored backend runs under, so a revert or repair + /// touches the ledger, `.socket/vendor/` artifacts and lockfile wiring + /// of ONE project — the manifest's (#745). Borrowed unchanged for the + /// default layout. + pub(crate) fn at_project_root(&self) -> std::borrow::Cow<'_, GlobalArgs> { + let root = self.project_root(); + if root == self.cwd { + return std::borrow::Cow::Borrowed(self); } + let manifest = self.resolved_manifest_path(); + let mut rooted = self.clone(); + rooted.manifest_path = manifest + .strip_prefix(&root) + .unwrap_or(&manifest) + .to_string_lossy() + .into_owned(); + rooted.cwd = root; + std::borrow::Cow::Owned(rooted) } /// The directory the manifest lives in — where `apply.lock`, `blobs/`, @@ -610,6 +636,26 @@ impl GlobalArgs { /// wrong under a non-default `--cwd`. [`GlobalArgs::resolved_manifest_path`] /// always joins a relative path onto `cwd`, so the fallback is reachable /// only for callers handed an unresolved path. +/// [`GlobalArgs::project_root`] for a caller holding a resolved manifest +/// path: the manifest's `.socket` parent's parent in the standard layout, +/// else the manifest file's own directory (`cwd` when it has none). +pub(crate) fn project_root_of(manifest_path: &Path, cwd: &Path) -> PathBuf { + match manifest_path.parent() { + Some(dir) + if dir.file_name() + == Some(std::ffi::OsStr::new( + socket_patch_core::constants::SOCKET_DIR, + )) => + { + dir.parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| cwd.to_path_buf()) + } + Some(dir) if !dir.as_os_str().is_empty() => dir.to_path_buf(), + _ => cwd.to_path_buf(), + } +} + pub(crate) fn socket_dir_of(manifest_path: &Path, cwd: &Path) -> PathBuf { manifest_path .parent() diff --git a/crates/socket-patch-cli/src/commands/agent_download.rs b/crates/socket-patch-cli/src/commands/agent_download.rs index 30471d61e..830a3274a 100644 --- a/crates/socket-patch-cli/src/commands/agent_download.rs +++ b/crates/socket-patch-cli/src/commands/agent_download.rs @@ -412,6 +412,12 @@ impl DownloadParams { crate::args::socket_dir_of(&self.manifest_path, &self.cwd) } + /// The manifest's project, where the vendor ledger lives + /// ([`crate::args::GlobalArgs::project_root`]; #745). + pub(crate) fn project_root(&self) -> PathBuf { + crate::args::project_root_of(&self.manifest_path, &self.cwd) + } + fn crawler_options(&self) -> CrawlerOptions { CrawlerOptions { cwd: self.cwd.clone(), @@ -1200,7 +1206,7 @@ pub(crate) async fn download_patch_records_reusing( // flattened into an empty ledger that then reports a Bun lock remedy. // For the classification below it degrades to empty (no detached entry // to reuse — the vendor step reports the corruption itself). - let vendor_state = load_state(¶ms.cwd).await; + let vendor_state = load_state(¶ms.project_root()).await; // Bun preflight (see `BunVendorRefusal`): this phase feeds the vendor // engine, so it must refuse the same projects BEFORE fetching — // otherwise the view is downloaded for nothing and a package @@ -1296,12 +1302,12 @@ pub(crate) async fn download_patch_records_preflighted( /// function sits on the in-process scan→download→apply chain, whose summed /// poll frames must fit Windows' 1 MiB main-thread stack in debug builds. pub(crate) async fn warn_on_vendored_uuid_drift( - cwd: &Path, + project_root: &Path, quiet: bool, downloaded_patches: &[serde_json::Value], warnings: &mut Vec, ) { - let Ok(vendor_state) = load_state(cwd).await else { + let Ok(vendor_state) = load_state(project_root).await else { return; }; if vendor_state.entries.is_empty() { @@ -1652,7 +1658,13 @@ pub async fn download_and_apply_patches_with( // later. (`scan` never hits this: it filters vendored purls before // download.) The nested apply below skips the vendored purl either way. let mut warnings = batch.warnings; - warn_on_vendored_uuid_drift(¶ms.cwd, quiet, &batch.patches_json, &mut warnings).await; + warn_on_vendored_uuid_drift( + ¶ms.project_root(), + quiet, + &batch.patches_json, + &mut warnings, + ) + .await; if !quiet { eprintln!(); diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index 22ed44888..41f7f51f8 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -578,7 +578,7 @@ async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 { .flatten() .collect(); let vendored = if crate::commands::project_state_in_scope(&args.common) { - socket_patch_core::vendor::vendored_purl_keys(&args.common.cwd).await + socket_patch_core::vendor::vendored_purl_keys(&args.common.project_root()).await } else { Default::default() }; @@ -2180,7 +2180,7 @@ async fn apply_patches_inner( // and patches the global copy even when the cwd project vendors the // same purl (see `project_state_in_scope`). let vendored_purls = if crate::commands::project_state_in_scope(&args.common) { - socket_patch_core::vendor::vendored_purl_keys(&args.common.cwd).await + socket_patch_core::vendor::vendored_purl_keys(&args.common.project_root()).await } else { Default::default() }; diff --git a/crates/socket-patch-cli/src/commands/context.rs b/crates/socket-patch-cli/src/commands/context.rs index 418a58623..d46ebca65 100644 --- a/crates/socket-patch-cli/src/commands/context.rs +++ b/crates/socket-patch-cli/src/commands/context.rs @@ -46,16 +46,12 @@ pub(crate) struct ProjectContext<'a> { } impl<'a> ProjectContext<'a> { + /// Every command's context: the ledgers always load from the manifest's + /// project, so `--manifest-path` never mixes two projects' state (#745). pub(crate) fn new(common: &'a GlobalArgs) -> Self { - Self::rooted(common, common.project_root()) - } - - /// A context whose ledgers load from `root` (commands that read the - /// ledgers of `--cwd` rather than of the manifest's project). - pub(crate) fn rooted(common: &'a GlobalArgs, root: PathBuf) -> Self { Self { common, - root, + root: common.project_root(), snapshot: DiskSnapshot::tracked(&common.cwd), ledgers: OnceCell::new(), locks: OnceCell::new(), diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index e83707d17..a82260e89 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -820,7 +820,7 @@ async fn filter_to_installed_purls( let found = find_packages_for_rollback(&partitioned, &common.crawler_options(), true).await; let mut present: HashSet = found.keys().map(|k| PurlKey::new(k)).collect(); - let ctx = super::context::ProjectContext::rooted(common, common.cwd.clone()); + let ctx = super::context::ProjectContext::new(common); // Manifest membership counts as presence (read-only probe: a corrupt // manifest degrades to "no extension" here — the download path's // fail-closed read still guards every write). @@ -1037,6 +1037,22 @@ pub async fn run(args: GetArgs) -> i32 { &conflict, ); } + // Hosted and vendored mode rewire `--cwd`'s lockfiles and vendor + // ledger: a manifest in another project would split the run (#745). + if let Some(conflict) = (mode != super::scan::ScanMode::Agent) + .then(|| { + super::foreign_manifest_conflict(&args.common, &format!("--mode {}", mode.cli_name())) + }) + .flatten() + { + return usage_error( + JsonCommand::Get, + args.common.json, + args.common.dry_run, + super::FOREIGN_MANIFEST_PROJECT, + &conflict, + ); + } if args.save_only && mode != super::scan::ScanMode::Agent { return usage_error( JsonCommand::Get, @@ -2050,7 +2066,7 @@ async fn save_and_apply_patch( let mut warnings: Vec = Vec::new(); if changed { warn_on_vendored_uuid_drift( - &args.common.cwd, + &args.common.project_root(), quiet, &[serde_json::json!({ "purl": patch.purl, @@ -2316,8 +2332,8 @@ async fn run_get_vendored( // // Human: `Error (): ` on stderr — an error, so it is // exempt from `--silent` like every other `Error (…)` line here. - bun_refusal = bun_vendor_preflight(&args.common.cwd, selected).await; - let ledger = load_state(&args.common.cwd).await; + bun_refusal = bun_vendor_preflight(&args.common.project_root(), selected).await; + let ledger = load_state(&args.common.project_root()).await; vlt_refusals = vlt_vendor_preflight_selected( &args.common.cwd, selected, @@ -2376,7 +2392,7 @@ async fn run_get_vendored( .unwrap_or_default(); let (dl_code, mut result, records) = if prefetched.is_some() { // The preflight above already read the lock: hand its outcome down. - let vendor_state = load_state(&args.common.cwd).await; + let vendor_state = load_state(&args.common.project_root()).await; Box::pin(download_patch_records_preflighted( selected, ¶ms, diff --git a/crates/socket-patch-cli/src/commands/mod.rs b/crates/socket-patch-cli/src/commands/mod.rs index 45329a503..fda4248f3 100644 --- a/crates/socket-patch-cli/src/commands/mod.rs +++ b/crates/socket-patch-cli/src/commands/mod.rs @@ -71,6 +71,34 @@ pub(crate) fn global_mode_conflict( )) } +/// The usage-error code of [`foreign_manifest_conflict`]. +pub(crate) const FOREIGN_MANIFEST_PROJECT: &str = "manifest_path_foreign_project"; + +/// The usage error for a form that writes `--cwd`'s lockfiles and vendor +/// ledger (`scan`/`get --mode hosted|vendored`, `vendor` other than +/// `--check`) when `--manifest-path` resolves into another project, or +/// `None`. Every store a run reads or writes belongs to ONE project — the +/// manifest's ([`crate::args::GlobalArgs::project_root`]; #745) — and these +/// forms rewire the lockfiles of `--cwd`, so there is no single project to +/// write. `what` names the refused form (`--mode vendored`, `vendor`). +pub(crate) fn foreign_manifest_conflict( + common: &crate::args::GlobalArgs, + what: &str, +) -> Option { + if !common.manifest_project_is_foreign() { + return None; + } + let root = common.project_root(); + Some(format!( + "{what} cannot be used with a --manifest-path in another project: it rewires the \ + lockfiles and vendor ledger of --cwd ({}), but the manifest belongs to {}. Run it \ + from the manifest's project (--cwd {}) or drop --manifest-path", + common.cwd.display(), + root.display(), + root.display(), + )) +} + /// The flag that put a run in global scope, as usage errors name it /// (`SOCKET_GLOBAL` / `SOCKET_GLOBAL_PREFIX` set the same fields). pub(crate) fn global_scope_flag(common: &crate::args::GlobalArgs) -> &'static str { diff --git a/crates/socket-patch-cli/src/commands/remove.rs b/crates/socket-patch-cli/src/commands/remove.rs index 6e3591fad..a836d327f 100644 --- a/crates/socket-patch-cli/src/commands/remove.rs +++ b/crates/socket-patch-cli/src/commands/remove.rs @@ -368,7 +368,7 @@ pub async fn run(args: RemoveArgs) -> i32 { let hosted_pins: Vec = hosted_inventory.pins.clone(); if manifest_missing { let vendor_ledger_exists = project_state - && tokio::fs::metadata(cwd.join(VENDOR_STATE_REL)) + && tokio::fs::metadata(args.common.project_root().join(VENDOR_STATE_REL)) .await .is_ok(); if !vendor_ledger_exists && hosted_pins.is_empty() { @@ -450,8 +450,9 @@ pub async fn run(args: RemoveArgs) -> i32 { // the vendored leg. An unreadable ledger degrades to "nothing vendored" // for the rollback and fails closed at the vendored leg — exactly where // the run is about to mutate vendored state. + // From the manifest's project, like the manifest itself (#745). let vendor_state_result = if project_state { - load_state(cwd).await + load_state(&args.common.project_root()).await } else { Ok(VendorState::default()) }; diff --git a/crates/socket-patch-cli/src/commands/repair.rs b/crates/socket-patch-cli/src/commands/repair.rs index 345dc9355..62eb50913 100644 --- a/crates/socket-patch-cli/src/commands/repair.rs +++ b/crates/socket-patch-cli/src/commands/repair.rs @@ -75,15 +75,17 @@ pub async fn run(args: RepairArgs) -> i32 { // bare directory would get. Only cheap existence probes (and the // read-only lockfile scans) run before the lock, so a project with // nothing to repair never grows `.socket/`. + // The vendored phase runs at the manifest's project (#745). let state_file = args .common - .cwd + .project_root() .join(socket_patch_core::vendor::VENDOR_STATE_REL); let mut has_vendor_traces = tokio::fs::metadata(&state_file).await.is_ok(); if !has_vendor_traces { - let refs = - crate::commands::vendored_backend::repair::scan_vendor_references(&args.common.cwd) - .await; + let refs = crate::commands::vendored_backend::repair::scan_vendor_references( + &args.common.project_root(), + ) + .await; has_vendor_traces = !refs.is_empty(); vendor_references = Some(refs); } @@ -154,8 +156,10 @@ pub async fn run(args: RepairArgs) -> i32 { let vendor_references = match vendor_references { Some(refs) => refs, None => { - crate::commands::vendored_backend::repair::scan_vendor_references(&args.common.cwd) - .await + crate::commands::vendored_backend::repair::scan_vendor_references( + &args.common.project_root(), + ) + .await } }; @@ -519,7 +523,7 @@ async fn repair_inner( // result (an unreadable ledger is ITS loud failure), while this scoping // degrades to "nothing vendored" — a corrupt ledger must not hide the // manifest's own missing sources. - let ledger = socket_patch_core::vendor::load_state(&args.common.cwd).await; + let ledger = socket_patch_core::vendor::load_state(&args.common.project_root()).await; let no_entries = std::collections::HashMap::new(); let vendor_entries = ledger.as_ref().map(|s| &s.entries).unwrap_or(&no_entries); // Lockfile vendor references count as vendored even with no ledger diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index 02bcdf968..72e8e0161 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -910,6 +910,11 @@ pub async fn run(args: RollbackArgs) -> i32 { let manifest_path = args.common.resolved_manifest_path(); let cwd = args.common.cwd.clone(); + // The vendor ledger, its artifacts and the lockfile references to them + // belong to the manifest's project (#745): with `--manifest-path` into + // another project, rollback unwinds THAT project's vendoring, under that + // project's apply lock, and never touches `--cwd`'s ledger. + let ledger_root = args.common.project_root(); // ── state discovery ───────────────────────────────────────────────── // Rollback infers what to undo from three sources: the manifest @@ -931,7 +936,7 @@ pub async fn run(args: RollbackArgs) -> i32 { let project_state = crate::commands::project_state_in_scope(&args.common); let manifest_missing = tokio::fs::metadata(&manifest_path).await.is_err(); let vendor_ledger_exists = project_state - && tokio::fs::metadata(cwd.join(".socket/vendor/state.json")) + && tokio::fs::metadata(ledger_root.join(".socket/vendor/state.json")) .await .is_ok(); // The hosted pins the lockfiles wire (read-only discovery; the restore @@ -995,7 +1000,7 @@ pub async fn run(args: RollbackArgs) -> i32 { // ledger holds the pre-vendor originals, so it must come back from // version control first.) let wired = if project_state { - crate::commands::vendored_backend::repair::scan_vendor_references(&cwd).await + crate::commands::vendored_backend::repair::scan_vendor_references(&ledger_root).await } else { Default::default() }; @@ -1053,7 +1058,7 @@ pub async fn run(args: RollbackArgs) -> i32 { // vendored manifest records (see the cleanup below): no vendored leg // runs, and the ledger does not own the global copies, so the in-place // leg restores them. - let loaded_vendor_state = socket_patch_core::vendor::load_state(&cwd).await; + let loaded_vendor_state = socket_patch_core::vendor::load_state(&ledger_root).await; let project_vendored_keys: HashSet = loaded_vendor_state .as_ref() .map(VendorState::purl_keys) diff --git a/crates/socket-patch-cli/src/commands/scan/gc.rs b/crates/socket-patch-cli/src/commands/scan/gc.rs index 38c102440..8ef2a5c56 100644 --- a/crates/socket-patch-cli/src/commands/scan/gc.rs +++ b/crates/socket-patch-cli/src/commands/scan/gc.rs @@ -319,7 +319,7 @@ async fn preview_apply_gc( // The dry pass reverted nothing, so the ledger still holds each entry // (its base purl is part of the relation: a `!x`-encoded golang key). if !vendor_gc.unused_reverted.is_empty() { - if let Ok(state) = socket_patch_core::vendor::load_state(&common.cwd).await { + if let Ok(state) = socket_patch_core::vendor::load_state(&common.project_root()).await { for purl in &vendor_gc.unused_reverted { let Some(entry) = state.entries.get(purl) else { continue; diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index a3d5b08e6..c068f94e3 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -225,9 +225,27 @@ pub fn resolve_mode_flags(args: &mut ScanArgs) -> Result<(), String> { { return Err(conflict); } + // Hosted and vendored mode rewire `--cwd`'s lockfiles and vendor + // ledger: a manifest in another project would split the run (#745). + if let Some(conflict) = foreign_mode_conflict(args) { + return Err(conflict); + } Ok(()) } +/// [`crate::commands::foreign_manifest_conflict`] for a resolved hosted or +/// vendored `args.mode`. +fn foreign_mode_conflict(args: &ScanArgs) -> Option { + args.mode + .filter(|mode| *mode != ScanMode::Agent) + .and_then(|mode| { + crate::commands::foreign_manifest_conflict( + &args.common, + &format!("--mode {}", mode.cli_name()), + ) + }) +} + #[derive(Args, Clone)] pub struct ScanArgs { /// Only scan these directories. In hosted and vendored mode each PATH @@ -921,20 +939,20 @@ pub(super) struct OverlapTakeover { /// [`classify_overlap_takeover_with`] over the on-disk state: the /// lockfiles' hosted pins and the committed vendored ledger. #[cfg(test)] -pub(super) async fn classify_overlap_takeover(common: &GlobalArgs, cwd: &Path) -> OverlapTakeover { +pub(super) async fn classify_overlap_takeover(common: &GlobalArgs, root: &Path) -> OverlapTakeover { // A malformed vendor ledger classifies like a missing one (this path // only feeds takeover warnings; corruption is a hard error on the // write/attest paths). - let vendor = socket_patch_core::vendor::load_state(cwd).await.ok(); + let vendor = socket_patch_core::vendor::load_state(root).await.ok(); // Nothing vendored, nothing to overlap: skip the lockfile walk (#993). let Some(vendor) = vendor.filter(|v| !v.entries.is_empty()) else { return OverlapTakeover::default(); }; - let discovery = crate::commands::discover_wiring(common, cwd).await; + let discovery = crate::commands::discover_wiring(common, root).await; let redirect = crate::commands::hosted_state_from_pins( &socket_patch_core::patch::redirect::upstream::HostedPin::all(&discovery), ); - classify_overlap_takeover_with(cwd, Some(&redirect), Some(&vendor), &discovery).await + classify_overlap_takeover_with(root, Some(&redirect), Some(&vendor), &discovery).await } /// [`classify_overlap_takeover`] over already-loaded state (the hosted @@ -1671,6 +1689,8 @@ async fn run_scan( .is_some_and(|conflict| conflict == message); let code = if global { "global_scope_unsupported" + } else if foreign_mode_conflict(&args).is_some_and(|conflict| conflict == message) { + crate::commands::FOREIGN_MANIFEST_PROJECT } else { "invalid_args" }; @@ -1776,8 +1796,7 @@ async fn run_scan( let manifest_path = args.common.resolved_manifest_path(); // The stores, lock set and wiring discovery this run reads before it // writes anything, each loaded at most once (see `ProjectContext`). - let ctx = - crate::commands::context::ProjectContext::rooted(&args.common, args.common.cwd.clone()); + let ctx = crate::commands::context::ProjectContext::new(&args.common); let socket_dir = args.common.socket_dir(); let overrides = args.common.api_client_overrides(); diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 29bcad9cd..9ee09e8ed 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -954,6 +954,23 @@ pub async fn run(args: VendorArgs) -> i32 { if args.check { return run_check(&args).await; } + // Every other form rewires (or unwires) `--cwd`'s lockfiles and vendor + // ledger: a manifest in another project would split the run (#745). + // `--check` above reads the manifest's project throughout. + let form = if args.revert { + "vendor --revert" + } else { + "vendor" + }; + if let Some(message) = crate::commands::foreign_manifest_conflict(&args.common, form) { + return crate::json_envelope::usage_error( + Command::Vendor, + args.common.json, + args.common.dry_run, + crate::commands::FOREIGN_MANIFEST_PROJECT, + &message, + ); + } apply_env_toggles(&args.common); let manifest_path = args.common.resolved_manifest_path(); @@ -4495,8 +4512,13 @@ pub(crate) async fn run_vendor_gc( manifest_path: &Path, dry_run: bool, ) -> VendorGcSummary { + // The ledger, its artifacts and their wiring are the manifest's + // project's (#745): an agent-mode `scan --prune --manifest-path ../b/…` + // collects b's vendored state, never `--cwd`'s. + let rooted = common.at_project_root(); + let common: &GlobalArgs = &rooted; let mut out = VendorGcSummary::default(); - let mut state = match load_state(&common.cwd).await { + let mut state = match load_state(&common.project_root()).await { Ok(s) if !s.entries.is_empty() => s, // No ledger (or unreadable): only the orphan sweep could apply, and // without a trustworthy ledger it must not delete anything. @@ -4598,7 +4620,7 @@ pub(crate) async fn run_vendor_gc( // artifacts; a failed ledger/manifest rewrite leaves records for // state that is gone, which must not pass silently. if ledger_dirty { - if let Err(e) = save_state(&common.cwd, &state).await { + if let Err(e) = save_state(&common.project_root(), &state).await { let detail = format!( "reverted vendored entries but could not update \ .socket/vendor/state.json: {e}" diff --git a/crates/socket-patch-cli/src/commands/vendored_backend/mod.rs b/crates/socket-patch-cli/src/commands/vendored_backend/mod.rs index 2b0461f30..4ab2d2bf9 100644 --- a/crates/socket-patch-cli/src/commands/vendored_backend/mod.rs +++ b/crates/socket-patch-cli/src/commands/vendored_backend/mod.rs @@ -28,7 +28,10 @@ use crate::json_envelope::Envelope; /// The vendored-mode backend for one run: the run's global args and its /// patch-service config (`--revert` does not need one). pub(crate) struct VendoredBackend<'a> { - pub(crate) common: &'a GlobalArgs, + /// The run's args re-rooted at the manifest's project + /// ([`GlobalArgs::at_project_root`]): the ledger, its artifacts and the + /// lockfile wiring they record always belong to one project (#745). + pub(crate) common: std::borrow::Cow<'a, GlobalArgs>, pub(crate) service: Option<&'a VendorServiceConfig>, } @@ -57,7 +60,10 @@ pub(crate) struct ApplyRequest<'a> { impl<'a> VendoredBackend<'a> { pub(crate) fn new(common: &'a GlobalArgs, service: Option<&'a VendorServiceConfig>) -> Self { - Self { common, service } + Self { + common: common.at_project_root(), + service, + } } /// Vendor `req.manifest`'s records. The caller holds the apply lock. @@ -69,7 +75,7 @@ impl<'a> VendoredBackend<'a> { /// caller's poll frame embeds it (Windows' 1 MiB main-thread stack; see /// `scan_run_fits_windows_main_thread_stack`). pub(crate) async fn apply(&self, req: ApplyRequest<'_>, env: &mut Envelope) -> bool { - let common = self.common; + let common: &GlobalArgs = &self.common; let blobs = req.socket_dir.join("blobs"); let sources = socket_patch_core::patch::apply::PatchSources { blobs_path: &blobs, @@ -173,9 +179,10 @@ pub(crate) struct VendorRevertResult { pub(crate) step: VendorRevertStep, } -/// Revert the vendored ledger entry `key` (see [`VendorRevertStep`]). +/// Revert the vendored ledger entry `key` (see [`VendorRevertStep`]) of +/// the project at `root` (its ledger, artifacts and lockfile wiring). pub(crate) async fn revert_vendor_entry( - cwd: &Path, + root: &Path, key: &str, state: &mut VendorState, opts: RevertOpts, @@ -186,7 +193,7 @@ pub(crate) async fn revert_vendor_entry( step: VendorRevertStep::Missing, }; }; - let outcome = dispatch_revert_one_opts(&entry, cwd, opts).await; + let outcome = dispatch_revert_one_opts(&entry, root, opts).await; let step = if !outcome.success { VendorRevertStep::Failed(outcome.error.unwrap_or_else(|| "unknown error".into())) } else if outcome.kept_artifact { @@ -197,7 +204,7 @@ pub(crate) async fn revert_vendor_entry( VendorRevertStep::Preserved } else { state.entries.remove(key); - match save_state(cwd, state).await { + match save_state(root, state).await { Ok(()) => VendorRevertStep::Reverted, Err(e) => VendorRevertStep::LedgerWriteFailed(e.to_string()), } diff --git a/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs b/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs index e710c139d..0b56ed576 100644 --- a/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs +++ b/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs @@ -256,7 +256,7 @@ impl VendoredBackend<'_> { /// from `--vendor-source` over the run's client, built only when there /// is something to re-vendor. pub(crate) async fn repair(&self, req: RepairRequest<'_>, env: &mut Envelope) -> usize { - let common = self.common; + let common: &GlobalArgs = &self.common; let quiet = common.json || common.silent; let mut repaired = 0usize; diff --git a/crates/socket-patch-cli/src/commands/vex.rs b/crates/socket-patch-cli/src/commands/vex.rs index a7ed47a6a..4431d61fe 100644 --- a/crates/socket-patch-cli/src/commands/vex.rs +++ b/crates/socket-patch-cli/src/commands/vex.rs @@ -1299,7 +1299,8 @@ async fn generate_vex_from_manifest_path_inner( manifest, vendor, redirect, - } = socket_patch_core::ledgers::LoadedLedgers::load(&common.cwd, manifest_path).await; + } = socket_patch_core::ledgers::LoadedLedgers::load(&common.project_root(), manifest_path) + .await; let manifest_file = match manifest { Ok(m) => m, Err(e) => { @@ -1346,11 +1347,12 @@ async fn generate_vex_from_manifest_path_inner( return Err(fail(common, params, "vendor_ledger_corrupt", message).await); } }; - // Rooted where the ledgers are (`--cwd`), and run under `--global` / - // `--global-prefix` too: discovery is what gates the ledgers (core - // discover rule 11); without it the raw-text fallbacks would decide a - // uuid a lockfile mentions, attesting a commented-out or rejected pin. - let discovery = crate::commands::discover_wiring(common, &common.cwd).await; + // Rooted where the ledgers are (the manifest's project, `--cwd` for the + // default layout; #745), and run under `--global` / `--global-prefix` + // too: discovery is what gates the ledgers (core discover rule 11); + // without it the raw-text fallbacks would decide a uuid a lockfile + // mentions, attesting a commented-out or rejected pin. + let discovery = crate::commands::discover_wiring(common, &common.project_root()).await; for diag in &discovery.diagnostics { note_warning(warnings, common, diag.code, diag.detail.clone()); } diff --git a/crates/socket-patch-cli/src/commands/vex_sources.rs b/crates/socket-patch-cli/src/commands/vex_sources.rs index 2ce582171..e64002116 100644 --- a/crates/socket-patch-cli/src/commands/vex_sources.rs +++ b/crates/socket-patch-cli/src/commands/vex_sources.rs @@ -339,7 +339,10 @@ pub(crate) async fn plan( assume_live: &[String], api_client: &RunApiClient, ) -> Plan { - let root = common.cwd.as_path(); + // The ledgers' project (#745): liveness and JVM wiring are judged where + // the ledger and the discovery that gates it were read. + let root_buf = common.project_root(); + let root = root_buf.as_path(); let Sources { manifest, vendor, diff --git a/crates/socket-patch-cli/tests/manifest_path_ledger_root.rs b/crates/socket-patch-cli/tests/manifest_path_ledger_root.rs new file mode 100644 index 000000000..7ecaa8dc1 --- /dev/null +++ b/crates/socket-patch-cli/tests/manifest_path_ledger_root.rs @@ -0,0 +1,341 @@ +//! #745: with `--manifest-path` into another project, every command reads +//! and writes the vendor ledger (`.socket/vendor/state.json`) of the +//! MANIFEST's project (`GlobalArgs::project_root`), never the `--cwd` +//! project's — so one run never interleaves two projects' state. +//! +//! Layout: `a/` is `--cwd`; `b/.socket/manifest.json` is the manifest +//! (`--manifest-path ../b/.socket/manifest.json`). + +#[path = "common/hermetic.rs"] +mod hermetic; + +use std::path::{Path, PathBuf}; + +const MANIFEST_ARG: &str = "../b/.socket/manifest.json"; +const PURL: &str = "pkg:npm/__ledger_root_test__@1.0.0"; +const UUID: &str = "33333333-3333-4333-8333-333333333333"; + +struct Fixture { + _tmp: tempfile::TempDir, + a: PathBuf, + b: PathBuf, +} + +fn fixture() -> Fixture { + let tmp = tempfile::tempdir().expect("tempdir"); + let a = tmp.path().join("a"); + let b = tmp.path().join("b"); + std::fs::create_dir_all(&a).unwrap(); + std::fs::create_dir_all(b.join(".socket")).unwrap(); + std::fs::write(b.join(".socket/manifest.json"), r#"{"patches":{}}"#).unwrap(); + Fixture { _tmp: tmp, a, b } +} + +fn ledger(root: &Path) -> PathBuf { + root.join(".socket/vendor/state.json") +} + +fn corrupt_ledger(root: &Path) { + std::fs::create_dir_all(root.join(".socket/vendor")).unwrap(); + std::fs::write(ledger(root), b"{ this is not json").unwrap(); +} + +/// One detached npm entry with empty wiring: its revert is a pure offline +/// artifact-dir delete plus a ledger write (the emptied ledger is deleted). +fn write_entry(root: &Path) { + let vendor = root.join(".socket/vendor"); + let artifact_dir = vendor.join("npm").join(UUID); + std::fs::create_dir_all(&artifact_dir).unwrap(); + std::fs::write(artifact_dir.join("package.tgz"), b"tgz").unwrap(); + let state = format!( + r#"{{ + "version": 1, + "entries": {{ + "{PURL}": {{ + "ecosystem": "npm", + "basePurl": "{PURL}", + "uuid": "{UUID}", + "artifact": {{ "path": ".socket/vendor/npm/{UUID}/package.tgz" }}, + "detached": true, + "wiring": [] + }} + }} +}}"# + ); + std::fs::write(ledger(root), state).unwrap(); +} + +struct Run { + code: Option, + out: String, +} + +fn run(f: &Fixture, args: &[&str]) -> Run { + let out = hermetic::binary_command() + .args(args) + .arg("--cwd") + .arg(&f.a) + .args(["--manifest-path", MANIFEST_ARG]) + .current_dir(&f.a) + .output() + .expect("run socket-patch"); + Run { + code: out.status.code(), + out: format!( + "stdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ), + } +} + +/// Whether a run's output reports an unreadable vendor ledger. +fn reports_ledger(r: &Run) -> bool { + let o = &r.out; + o.contains("state.json") + || o.contains("vendor_state_unreadable") + || o.contains("vendor_ledger_corrupt") +} + +/// The ledger-reading commands, each as its own argv (`vex` writes into a +/// scratch file outside both projects). +fn commands(f: &Fixture) -> Vec<(&'static str, Vec)> { + let vex_out = f.a.parent().unwrap().join("vex.json"); + vec![ + ("list", vec!["list".into(), "--json".into()]), + ( + "vex", + vec![ + "vex".into(), + "--json".into(), + "--output".into(), + vex_out.to_string_lossy().into_owned(), + ], + ), + ( + "rollback", + vec!["rollback".into(), "--json".into(), "--offline".into()], + ), + ( + "repair", + vec!["repair".into(), "--json".into(), "--offline".into()], + ), + ] + // `remove` reads the ledger only for the identifier it removes (an + // unmatched identifier is `not_found` whatever the ledger holds); its + // root is pinned both ways by `remove_never_modifies_the_cwd_ledger`. +} + +fn run_named(f: &Fixture, argv: &[String]) -> Run { + let refs: Vec<&str> = argv.iter().map(String::as_str).collect(); + run(f, &refs) +} + +/// A corrupt ledger in `--cwd` is not the manifest project's ledger: every +/// command behaves exactly as with no corruption at all. The same +/// corruption in the manifest's project is reported. +#[test] +fn every_command_reads_the_manifest_projects_ledger() { + let names: Vec<&str> = commands(&fixture()).iter().map(|(n, _)| *n).collect(); + for name in names { + let control = { + let f = fixture(); + let argv = commands(&f) + .into_iter() + .find(|(n, _)| *n == name) + .unwrap() + .1; + run_named(&f, &argv) + }; + assert!( + !reports_ledger(&control), + "{name}: the control run has no ledger anywhere\n{}", + control.out + ); + + let f = fixture(); + corrupt_ledger(&f.a); + let argv = commands(&f) + .into_iter() + .find(|(n, _)| *n == name) + .unwrap() + .1; + let in_cwd = run_named(&f, &argv); + assert_eq!( + in_cwd.code, control.code, + "{name}: a corrupt --cwd ledger must not change the exit code\n{}", + in_cwd.out + ); + assert!( + !reports_ledger(&in_cwd), + "{name}: the --cwd ledger is not this run's ledger\n{}", + in_cwd.out + ); + assert_eq!( + std::fs::read(ledger(&f.a)).unwrap(), + b"{ this is not json", + "{name}: the --cwd ledger is never written" + ); + + let f = fixture(); + corrupt_ledger(&f.b); + let argv = commands(&f) + .into_iter() + .find(|(n, _)| *n == name) + .unwrap() + .1; + let in_manifest = run_named(&f, &argv); + assert!( + reports_ledger(&in_manifest), + "{name}: the manifest project's corrupt ledger must be reported\n{}", + in_manifest.out + ); + } +} + +/// `rollback` reverts the manifest project's vendored entries, under the +/// apply lock of that project's `.socket/`, and never touches `--cwd`'s. +#[test] +fn rollback_never_modifies_the_cwd_ledger() { + let f = fixture(); + write_entry(&f.a); + let before = std::fs::read(ledger(&f.a)).unwrap(); + let r = run(&f, &["rollback", "--json", "--offline"]); + assert_eq!(r.code, Some(0), "{}", r.out); + assert_eq!( + std::fs::read(ledger(&f.a)).unwrap(), + before, + "rollback --manifest-path ../b/... must not touch a's ledger\n{}", + r.out + ); + assert!(f.a.join(".socket/vendor/npm").join(UUID).exists()); + + // The same entry in the manifest's project is reverted there. + let f = fixture(); + write_entry(&f.b); + let r = run(&f, &["rollback", "--json", "--offline"]); + assert_eq!(r.code, Some(0), "{}", r.out); + assert!( + !ledger(&f.b).exists(), + "b's only entry is reverted (emptied ledger deleted)\n{}", + r.out + ); + assert!(!f.b.join(".socket/vendor/npm").join(UUID).exists()); +} + +/// `remove` resolves the identifier against the manifest project's ledger. +#[test] +fn remove_never_modifies_the_cwd_ledger() { + let f = fixture(); + write_entry(&f.a); + let before = std::fs::read(ledger(&f.a)).unwrap(); + let r = run(&f, &["remove", PURL, "--json", "--offline", "--yes"]); + assert_ne!(r.code, Some(0), "b holds no {PURL}\n{}", r.out); + assert_eq!(std::fs::read(ledger(&f.a)).unwrap(), before, "{}", r.out); + + let f = fixture(); + write_entry(&f.b); + let r = run(&f, &["remove", PURL, "--json", "--offline", "--yes"]); + assert_eq!(r.code, Some(0), "{}", r.out); + assert!(!ledger(&f.b).exists(), "{}", r.out); +} + +/// Vendored and hosted modes write `--cwd`'s lockfiles: with a manifest in +/// another project there is no single project to write, so they refuse +/// before touching either (usage error, exit 2). +#[test] +fn lockfile_writing_modes_refuse_a_foreign_manifest() { + for argv in [ + &["scan", "--mode", "vendored", "--json", "--offline"][..], + &["scan", "--mode", "hosted", "--json", "--offline"][..], + &["vendor", "--json", "--offline"][..], + &["vendor", "--revert", "--json", "--offline"][..], + ] { + let f = fixture(); + write_entry(&f.a); + let before = std::fs::read(ledger(&f.a)).unwrap(); + let r = run(&f, argv); + assert_eq!(r.code, Some(2), "{argv:?}\n{}", r.out); + assert!( + r.out.contains("manifest_path_foreign_project"), + "{argv:?}\n{}", + r.out + ); + assert_eq!(std::fs::read(ledger(&f.a)).unwrap(), before, "{argv:?}"); + assert!(!ledger(&f.b).exists(), "{argv:?}"); + } +} + +/// The vendoring engine: it runs only where `--cwd` IS the manifest's +/// project — under `foreign_manifest_conflict` (`scan`/`get --mode +/// hosted|vendored`, `vendor` other than `--check`) or through the +/// re-rooted `VendoredBackend` / `run_vendor_gc` — so its `cwd` is that +/// project's root. +const ENGINE_FILES: &[&str] = &[ + "commands/vendor.rs", + "commands/scan/vendor_flow.rs", + "commands/scan/hosted.rs", + "commands/bun_preflight.rs", + "commands/vlt_heal.rs", +]; + +/// Architecture pin: every vendor-ledger load/save outside the vendoring +/// engine derives its root from `project_root()` — no call site passes a +/// `cwd`. +#[test] +fn every_ledger_access_is_rooted_at_project_root() { + let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut offenders = Vec::new(); + let mut stack = vec![src]; + while let Some(dir) = stack.pop() { + for entry in std::fs::read_dir(&dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + stack.push(path); + continue; + } + if path.extension().and_then(|e| e.to_str()) != Some("rs") { + continue; + } + let rel = path.to_string_lossy().replace('\\', "/"); + if ENGINE_FILES.iter().any(|f| rel.ends_with(f)) { + continue; + } + let text = std::fs::read_to_string(&path).unwrap(); + // Unit-test modules root their ledgers at a tempdir. + let lines: Vec<&str> = text.lines().collect(); + let prod_end = (0..lines.len()) + .find(|&i| { + lines[i].trim() == "#[cfg(test)]" + && lines + .get(i + 1) + .is_some_and(|next| next.trim_start().starts_with("mod ")) + }) + .unwrap_or(lines.len()); + for (i, line) in lines[..prod_end].iter().enumerate() { + for call in [ + "load_state(", + "save_state(", + "save_state_shared(", + "LoadedLedgers::load(", + "vendored_purl_keys(", + ] { + let Some(at) = line.find(call) else { continue }; + // `core_update::load_state()` is the self-update state. + if line.contains("core_update::") || line.contains("fn ") { + continue; + } + let arg = &line[at + call.len()..]; + if arg.contains("cwd") { + offenders.push(format!("{}:{}: {}", path.display(), i + 1, line.trim())); + } + } + } + } + } + assert!( + offenders.is_empty(), + "vendor-ledger access rooted at a cwd instead of project_root():\n{}", + offenders.join("\n") + ); +} diff --git a/docs/migrating-to-v5.md b/docs/migrating-to-v5.md index 25b12b257..00e54117f 100644 --- a/docs/migrating-to-v5.md +++ b/docs/migrating-to-v5.md @@ -25,6 +25,12 @@ existing scripts against the new CLI; the [changelog](../CHANGELOG.md) and `+socket.` versions. Re-running vendoring or repair migrates older wiring. The tag is visible in `CARGO_PKG_VERSION`; see [Cargo details](ecosystems.md#cargo-vendored-wiring-in-cargotoml). +- `--manifest-path` (`SOCKET_MANIFEST_PATH`) now selects one project for every + command: the vendor ledger `.socket/vendor/state.json` and its artifacts are + read and written in the manifest's project, never in `--cwd`'s. Hosted and + vendored `scan` / `get` and `vendor` (other than `--check`) rewire `--cwd`'s + lockfiles, so they refuse a manifest in another project with + `manifest_path_foreign_project` (exit 2). Run them from the manifest's project. - `list` succeeds on an empty project. Hosted results identify lockfiles instead of a hosted ledger. Scripts must use the updated [JSON shapes and exit codes](../crates/socket-patch-cli/CLI_CONTRACT.md#json-output-shapes). From e7b6023c7484fd4079b7272502ffa4a3ecdf9c62 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 9 Oct 2026 13:46:57 -0400 Subject: [PATCH 3/6] Keep a bare --manifest-path file in the cwd project A manifest file outside any .socket/ directory (--manifest-path state/patches.json, /etc/socket/manifest.json) relocates only the manifest; the project whose vendor ledger and lockfiles a run uses stays --cwd. Previously project_root() named the file's own directory, so list and apply looked for the ledger next to the relocated manifest, and the new foreign-manifest guard would have refused hosted scan for such a file. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- crates/socket-patch-cli/src/args.rs | 77 +++++++++++-------- .../tests/manifest_path_ledger_root.rs | 19 +++++ docs/migrating-to-v5.md | 2 + 4 files changed, 65 insertions(+), 35 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 5f403f4c0..65c2b1e2f 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -49,7 +49,7 @@ Every subcommand accepts the same set of "global" flags via a single shared `Glo | Long | Short | Env var | Default | Type | Semantic | |---|---|---|---|---|---| | `--cwd` | — | `SOCKET_CWD` | `.` | path | Working directory | -| `--manifest-path` | — | `SOCKET_MANIFEST_PATH` | `.socket/manifest.json` | path | Manifest location (resolved relative to `--cwd`). v5.0 (#745): it also selects the project whose stores every command reads and writes — the root the RESOLVED manifest path implies (its `.socket` parent's parent in the standard layout, else the manifest file's directory; exactly `--cwd` for the default path). All sources always come from that SAME project: `list`, `apply` (incl. `--check`), `rollback`, `remove`, `repair`, `vex`, `vendor --check` and agent-mode `scan`/`get` load (and `rollback`/`remove`/`repair`/`scan --prune` revert and save) that project's vendor ledger and its `.socket/vendor/` artifacts, under that project's apply lock, never `--cwd`'s. The forms that rewire `--cwd`'s lockfiles — `scan`/`get --mode hosted\|vendored` and `vendor` other than `--check` — refuse a manifest in another project: usage error (exit 2), code `manifest_path_foreign_project`, checked before anything is read or locked. | +| `--manifest-path` | — | `SOCKET_MANIFEST_PATH` | `.socket/manifest.json` | path | Manifest location (resolved relative to `--cwd`). v5.0 (#745): it also selects the project whose stores every command reads and writes — the root the RESOLVED manifest path implies: its `.socket` parent's parent when the manifest sits in a `.socket/` directory (exactly `--cwd` for the default path), else `--cwd` — a manifest file elsewhere (`state/patches.json`, `/etc/socket/manifest.json`) relocates only the manifest and its lock/artifact directory, not the project (v5.0; previously the file's own directory). All sources always come from that SAME project: `list`, `apply` (incl. `--check`), `rollback`, `remove`, `repair`, `vex`, `vendor --check` and agent-mode `scan`/`get` load (and `rollback`/`remove`/`repair`/`scan --prune` revert and save) that project's vendor ledger and its `.socket/vendor/` artifacts, under the manifest's apply lock, never `--cwd`'s. The forms that rewire `--cwd`'s lockfiles — `scan`/`get --mode hosted\|vendored` and `vendor` other than `--check` — refuse a manifest in another project: usage error (exit 2), code `manifest_path_foreign_project`, checked before anything is read or locked. | | `--api-url` | — | `SOCKET_API_URL` | `https://api.socket.dev` | string | Authenticated API endpoint | | `--api-token` | — | `SOCKET_API_TOKEN` | (none) | string | Auth token (absence selects the public proxy) | | `--org` | `-o` | `SOCKET_ORG_SLUG` | (auto-resolve) | string | Org slug. Resolved once per run from the token (`GET /v0/organizations`) when omitted; if that fails, the whole run uses the public proxy anonymously (free patches only) and warns. | diff --git a/crates/socket-patch-cli/src/args.rs b/crates/socket-patch-cli/src/args.rs index 27d978c6b..a6f3dc7e5 100644 --- a/crates/socket-patch-cli/src/args.rs +++ b/crates/socket-patch-cli/src/args.rs @@ -442,7 +442,18 @@ impl GlobalArgs { "is a directory, not a manifest file", )); } - let root = self.project_root(); + // The directory that must exist: the project of a `.socket/` + // manifest (`.socket/` itself is created on demand), else the + // manifest file's own directory. + let in_socket_dir = manifest.parent().and_then(Path::file_name) + == Some(std::ffi::OsStr::new( + socket_patch_core::constants::SOCKET_DIR, + )); + let root = if in_socket_dir { + self.project_root() + } else { + self.socket_dir() + }; if !creates_manifest && !root.is_dir() { return Err(not_dir( "--manifest-path", @@ -502,17 +513,16 @@ impl GlobalArgs { } } - /// The project root whose `.socket/` state stores — manifest, vendor - /// ledger — belong together: the RESOLVED manifest's - /// directory, stepping out of a standard `.socket/` layout when the - /// manifest lives in one. For the default `/.socket/manifest.json` - /// this is exactly `cwd`; for a `--manifest-path` into another project - /// it is that project's root (its `.socket` parent's parent); for a - /// bare file like `--manifest-path /tmp/x/abs.json` it is the file's - /// own directory. Every command that reads more than one store must - /// derive them from THIS root, so `--manifest-path` can never - /// interleave two projects' state (CLI_CONTRACT.md: both stores always - /// come from the SAME project). + /// The project root whose state stores — manifest, vendor ledger and + /// its `.socket/vendor/` artifacts — belong together. For the default + /// `/.socket/manifest.json` this is exactly `cwd`; for a + /// `--manifest-path` into another project's `.socket/` it is that + /// project's root (the `.socket` parent's parent); a manifest file + /// outside any `.socket/` directory (`--manifest-path + /// state/patches.json`, `/etc/socket/manifest.json`) relocates only + /// the manifest — the project stays `cwd`. Every command derives every + /// store from THIS root, so `--manifest-path` can never interleave two + /// projects' state (CLI_CONTRACT.md `--manifest-path` row; #745). pub(crate) fn project_root(&self) -> PathBuf { project_root_of(&self.resolved_manifest_path(), &self.cwd) } @@ -631,31 +641,28 @@ impl GlobalArgs { } } -/// The `.socket/`-role directory for `manifest_path`: its parent, falling -/// back to `cwd` for a bare relative file name — never `"."`, which is -/// wrong under a non-default `--cwd`. [`GlobalArgs::resolved_manifest_path`] -/// always joins a relative path onto `cwd`, so the fallback is reachable -/// only for callers handed an unresolved path. /// [`GlobalArgs::project_root`] for a caller holding a resolved manifest /// path: the manifest's `.socket` parent's parent in the standard layout, -/// else the manifest file's own directory (`cwd` when it has none). +/// else `cwd` (a manifest file outside any `.socket/` directory relocates +/// only the manifest, not the project). pub(crate) fn project_root_of(manifest_path: &Path, cwd: &Path) -> PathBuf { - match manifest_path.parent() { - Some(dir) - if dir.file_name() + manifest_path + .parent() + .filter(|dir| { + dir.file_name() == Some(std::ffi::OsStr::new( socket_patch_core::constants::SOCKET_DIR, - )) => - { - dir.parent() - .map(Path::to_path_buf) - .unwrap_or_else(|| cwd.to_path_buf()) - } - Some(dir) if !dir.as_os_str().is_empty() => dir.to_path_buf(), - _ => cwd.to_path_buf(), - } + )) + }) + .and_then(Path::parent) + .map_or_else(|| cwd.to_path_buf(), Path::to_path_buf) } +/// The `.socket/`-role directory for `manifest_path`: its parent, falling +/// back to `cwd` for a bare relative file name — never `"."`, which is +/// wrong under a non-default `--cwd`. [`GlobalArgs::resolved_manifest_path`] +/// always joins a relative path onto `cwd`, so the fallback is reachable +/// only for callers handed an unresolved path. pub(crate) fn socket_dir_of(manifest_path: &Path, cwd: &Path) -> PathBuf { manifest_path .parent() @@ -1495,18 +1502,20 @@ mod tests { assert_eq!(args.socket_dir(), other.join(".socket")); } - /// A bare manifest file outside any `.socket/` layout: the file's own - /// directory plays both roles. + /// A bare manifest file outside any `.socket/` layout relocates only + /// the manifest: its directory holds the lock and artifacts, while the + /// project (vendor ledger, lockfiles) stays `cwd` (#745). #[test] - fn project_root_of_a_bare_manifest_file_is_its_directory() { + fn project_root_of_a_bare_manifest_file_is_cwd() { let args = GlobalArgs { cwd: PathBuf::from("/work/project"), manifest_path: "custom/mp.json".to_string(), ..GlobalArgs::default() }; let custom = PathBuf::from("/work/project").join("custom"); - assert_eq!(args.project_root(), custom); + assert_eq!(args.project_root(), PathBuf::from("/work/project")); assert_eq!(args.socket_dir(), custom); + assert!(!args.manifest_project_is_foreign()); } /// `socket_dir_of` on a raw relative file name falls back to `cwd`, diff --git a/crates/socket-patch-cli/tests/manifest_path_ledger_root.rs b/crates/socket-patch-cli/tests/manifest_path_ledger_root.rs index 7ecaa8dc1..4b9db443e 100644 --- a/crates/socket-patch-cli/tests/manifest_path_ledger_root.rs +++ b/crates/socket-patch-cli/tests/manifest_path_ledger_root.rs @@ -240,6 +240,25 @@ fn remove_never_modifies_the_cwd_ledger() { assert!(!ledger(&f.b).exists(), "{}", r.out); } +/// A manifest file outside any `.socket/` directory relocates only the +/// manifest: the project, and so the ledger rollback reverts, stays `--cwd`. +#[test] +fn a_bare_manifest_file_keeps_the_cwd_project() { + let f = fixture(); + write_entry(&f.a); + std::fs::create_dir_all(f.a.join("state")).unwrap(); + let out = hermetic::binary_command() + .args(["rollback", "--json", "--offline", "--cwd"]) + .arg(&f.a) + .args(["--manifest-path", "state/patches.json"]) + .current_dir(&f.a) + .output() + .expect("run socket-patch"); + let text = String::from_utf8_lossy(&out.stdout); + assert_eq!(out.status.code(), Some(0), "{text}"); + assert!(!ledger(&f.a).exists(), "a's entry is reverted\n{text}"); +} + /// Vendored and hosted modes write `--cwd`'s lockfiles: with a manifest in /// another project there is no single project to write, so they refuse /// before touching either (usage error, exit 2). diff --git a/docs/migrating-to-v5.md b/docs/migrating-to-v5.md index 00e54117f..7474e14a4 100644 --- a/docs/migrating-to-v5.md +++ b/docs/migrating-to-v5.md @@ -31,6 +31,8 @@ existing scripts against the new CLI; the [changelog](../CHANGELOG.md) and vendored `scan` / `get` and `vendor` (other than `--check`) rewire `--cwd`'s lockfiles, so they refuse a manifest in another project with `manifest_path_foreign_project` (exit 2). Run them from the manifest's project. + A manifest file outside a `.socket/` directory (`--manifest-path + state/patches.json`) moves only the manifest; the project stays `--cwd`. - `list` succeeds on an empty project. Hosted results identify lockfiles instead of a hosted ledger. Scripts must use the updated [JSON shapes and exit codes](../crates/socket-patch-cli/CLI_CONTRACT.md#json-output-shapes). From 8c4f7dc357b1f7f926c149b17aed05bb343a5fd4 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 9 Oct 2026 14:47:25 -0400 Subject: [PATCH 4/6] Unwind hosted pins in the manifest project too Bugbot on #1345: rollback, remove and repair read the vendor ledger from the manifest's project but still discovered and restored hosted pins (and the pre-v5 redirect ledger) in --cwd, so a foreign-manifest rollback could unwind two projects at once, and missed the pins list and vex report. The hosted inventory, the legacy-ledger probes and retirement, and the hosted restore leg now run at project_root too. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../src/commands/hosted_unwind.rs | 3 + .../socket-patch-cli/src/commands/remove.rs | 3 +- .../socket-patch-cli/src/commands/repair.rs | 4 +- .../socket-patch-cli/src/commands/rollback.rs | 16 +++-- .../tests/manifest_path_ledger_root.rs | 60 +++++++++++++++++++ 6 files changed, 77 insertions(+), 11 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 65c2b1e2f..304dfd5cc 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -49,7 +49,7 @@ Every subcommand accepts the same set of "global" flags via a single shared `Glo | Long | Short | Env var | Default | Type | Semantic | |---|---|---|---|---|---| | `--cwd` | — | `SOCKET_CWD` | `.` | path | Working directory | -| `--manifest-path` | — | `SOCKET_MANIFEST_PATH` | `.socket/manifest.json` | path | Manifest location (resolved relative to `--cwd`). v5.0 (#745): it also selects the project whose stores every command reads and writes — the root the RESOLVED manifest path implies: its `.socket` parent's parent when the manifest sits in a `.socket/` directory (exactly `--cwd` for the default path), else `--cwd` — a manifest file elsewhere (`state/patches.json`, `/etc/socket/manifest.json`) relocates only the manifest and its lock/artifact directory, not the project (v5.0; previously the file's own directory). All sources always come from that SAME project: `list`, `apply` (incl. `--check`), `rollback`, `remove`, `repair`, `vex`, `vendor --check` and agent-mode `scan`/`get` load (and `rollback`/`remove`/`repair`/`scan --prune` revert and save) that project's vendor ledger and its `.socket/vendor/` artifacts, under the manifest's apply lock, never `--cwd`'s. The forms that rewire `--cwd`'s lockfiles — `scan`/`get --mode hosted\|vendored` and `vendor` other than `--check` — refuse a manifest in another project: usage error (exit 2), code `manifest_path_foreign_project`, checked before anything is read or locked. | +| `--manifest-path` | — | `SOCKET_MANIFEST_PATH` | `.socket/manifest.json` | path | Manifest location (resolved relative to `--cwd`). v5.0 (#745): it also selects the project whose stores every command reads and writes — the root the RESOLVED manifest path implies: its `.socket` parent's parent when the manifest sits in a `.socket/` directory (exactly `--cwd` for the default path), else `--cwd` — a manifest file elsewhere (`state/patches.json`, `/etc/socket/manifest.json`) relocates only the manifest and its lock/artifact directory, not the project (v5.0; previously the file's own directory). All sources always come from that SAME project: `list`, `apply` (incl. `--check`), `rollback`, `remove`, `repair`, `vex`, `vendor --check` and agent-mode `scan`/`get` load (and `rollback`/`remove`/`repair`/`scan --prune` revert and save) that project's vendor ledger and its `.socket/vendor/` artifacts, under the manifest's apply lock, never `--cwd`'s; `list`, `vex`, `rollback`, `remove` and `repair` likewise read (and `rollback`/`remove` unwind) that project's lockfile hosted pins and pre-v5 redirect ledger. Installed copies are still found from `--cwd` (or the global scope). The forms that rewire `--cwd`'s lockfiles — `scan`/`get --mode hosted\|vendored` and `vendor` other than `--check` — refuse a manifest in another project: usage error (exit 2), code `manifest_path_foreign_project`, checked before anything is read or locked. | | `--api-url` | — | `SOCKET_API_URL` | `https://api.socket.dev` | string | Authenticated API endpoint | | `--api-token` | — | `SOCKET_API_TOKEN` | (none) | string | Auth token (absence selects the public proxy) | | `--org` | `-o` | `SOCKET_ORG_SLUG` | (auto-resolve) | string | Org slug. Resolved once per run from the token (`GET /v0/organizations`) when omitted; if that fails, the whole run uses the public proxy anonymously (free patches only) and warns. | diff --git a/crates/socket-patch-cli/src/commands/hosted_unwind.rs b/crates/socket-patch-cli/src/commands/hosted_unwind.rs index 3eaffc461..1dce88048 100644 --- a/crates/socket-patch-cli/src/commands/hosted_unwind.rs +++ b/crates/socket-patch-cli/src/commands/hosted_unwind.rs @@ -42,6 +42,9 @@ pub(crate) async fn run_hosted_leg(common: &GlobalArgs, pins: &[HostedPin]) -> H use socket_patch_core::patch::redirect::upstream::{ restore_upstream, PinStatus, RestoreOptions, }; + // The pins are the manifest's project's (#745): restore them there. + let rooted = common.at_project_root(); + let common: &GlobalArgs = &rooted; let mut out = HostedLegOutcome::default(); if pins.is_empty() { diff --git a/crates/socket-patch-cli/src/commands/remove.rs b/crates/socket-patch-cli/src/commands/remove.rs index a836d327f..5e15b77d3 100644 --- a/crates/socket-patch-cli/src/commands/remove.rs +++ b/crates/socket-patch-cli/src/commands/remove.rs @@ -342,7 +342,6 @@ pub async fn run(args: RemoveArgs) -> i32 { let loud = !args.common.json && !args.common.silent; let manifest_path = args.common.resolved_manifest_path(); - let cwd = &args.common.cwd; // ── state discovery ───────────────────────────────────────────────── // A manifest-less project (vendored mode keeps its records in the @@ -361,7 +360,7 @@ pub async fn run(args: RemoveArgs) -> i32 { let project_state = crate::commands::project_state_in_scope(&args.common); let manifest_missing = tokio::fs::metadata(&manifest_path).await.is_err(); let hosted_inventory = if project_state { - crate::commands::hosted_inventory(&args.common, cwd).await + crate::commands::hosted_inventory(&args.common, &args.common.project_root()).await } else { Default::default() }; diff --git a/crates/socket-patch-cli/src/commands/repair.rs b/crates/socket-patch-cli/src/commands/repair.rs index 62eb50913..e5955c46b 100644 --- a/crates/socket-patch-cli/src/commands/repair.rs +++ b/crates/socket-patch-cli/src/commands/repair.rs @@ -92,10 +92,10 @@ pub async fn run(args: RepairArgs) -> i32 { if !has_vendor_traces { let legacy_ledger = args .common - .cwd + .project_root() .join(socket_patch_core::patch::redirect::REDIRECT_STATE_REL); let hosted = tokio::fs::metadata(&legacy_ledger).await.is_ok() - || !crate::commands::hosted_inventory(&args.common, &args.common.cwd) + || !crate::commands::hosted_inventory(&args.common, &args.common.project_root()) .await .is_empty(); if hosted { diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index 72e8e0161..48f344634 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -839,6 +839,9 @@ async fn run_vendored_leg( /// describe (v5 never writes it; it is read only for migration). A wet run /// only; a failure is a warning (the file is inert). pub(crate) async fn retire_legacy_redirect_ledger(common: &GlobalArgs) -> Option<(String, String)> { + // The manifest's project's ledger and lockfiles (#745). + let rooted = common.at_project_root(); + let common: &GlobalArgs = &rooted; let path = common .cwd .join(socket_patch_core::patch::redirect::REDIRECT_STATE_REL); @@ -910,10 +913,11 @@ pub async fn run(args: RollbackArgs) -> i32 { let manifest_path = args.common.resolved_manifest_path(); let cwd = args.common.cwd.clone(); - // The vendor ledger, its artifacts and the lockfile references to them - // belong to the manifest's project (#745): with `--manifest-path` into - // another project, rollback unwinds THAT project's vendoring, under that - // project's apply lock, and never touches `--cwd`'s ledger. + // The project state — vendor ledger, its artifacts, the lockfiles' + // hosted pins and vendor references — belongs to the manifest's project + // (#745): with `--manifest-path` into another project, rollback unwinds + // THAT project's state, under that project's apply lock, and never + // touches `--cwd`'s. Installed copies are still found from `--cwd`. let ledger_root = args.common.project_root(); // ── state discovery ───────────────────────────────────────────────── @@ -942,7 +946,7 @@ pub async fn run(args: RollbackArgs) -> i32 { // The hosted pins the lockfiles wire (read-only discovery; the restore // re-reads every file under the lock before it writes). let hosted_inventory = if project_state { - crate::commands::hosted_inventory(&args.common, &cwd).await + crate::commands::hosted_inventory(&args.common, &ledger_root).await } else { Default::default() }; @@ -958,7 +962,7 @@ pub async fn run(args: RollbackArgs) -> i32 { // Only a pre-v5 hosted ledger left: no lockfile pins it any more, // so there is nothing to restore — retire the stale file (a wet run // only) instead of failing on the missing manifest. - let legacy = cwd.join(socket_patch_core::patch::redirect::REDIRECT_STATE_REL); + let legacy = ledger_root.join(socket_patch_core::patch::redirect::REDIRECT_STATE_REL); if project_state && tokio::fs::symlink_metadata(&legacy).await.is_ok() { let warning = retire_legacy_redirect_ledger(&args.common).await; if args.common.json { diff --git a/crates/socket-patch-cli/tests/manifest_path_ledger_root.rs b/crates/socket-patch-cli/tests/manifest_path_ledger_root.rs index 4b9db443e..dcd9405ac 100644 --- a/crates/socket-patch-cli/tests/manifest_path_ledger_root.rs +++ b/crates/socket-patch-cli/tests/manifest_path_ledger_root.rs @@ -240,6 +240,66 @@ fn remove_never_modifies_the_cwd_ledger() { assert!(!ledger(&f.b).exists(), "{}", r.out); } +/// A yarn.lock hosted-wired to the mock patch host `http://patch.test`. +fn write_hosted_pin(root: &Path) { + std::fs::write( + root.join("yarn.lock"), + "# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.\n\ + # yarn lockfile v1\n\n\nleft-pad@1.2.3:\n version \"1.2.3\"\n resolved \ + \"http://patch.test/patch/npm/left-pad/1.2.3/66666666-6666-4666-8666-666666666666/\ + 55555555-5555-4555-8555-555555555555/left-pad-1.2.3.tgz\"\n integrity \ + sha512-PATCHEDpatched==\n", + ) + .unwrap(); +} + +/// The hosted leg follows the same project: `rollback` and `remove` unwind +/// the manifest project's hosted pins, never `--cwd`'s. +#[test] +fn hosted_pins_come_from_the_manifest_project() { + // `remove` echoes its identifier, so it is judged by status instead. + let mentions = |argv: &[&str], r: &Run| { + if argv[0] == "remove" { + !r.out.contains("\"notFound\"") + } else { + r.out.contains("left-pad") + } + }; + for argv in [ + &["rollback", "--json", "--offline"][..], + &[ + "remove", + "pkg:npm/left-pad@1.2.3", + "--json", + "--offline", + "--yes", + ][..], + ] { + let mut full = argv.to_vec(); + full.extend(["--patch-server-url", "http://patch.test"]); + + let f = fixture(); + write_hosted_pin(&f.a); + let before = std::fs::read(f.a.join("yarn.lock")).unwrap(); + let r = run(&f, &full); + assert!( + !mentions(argv, &r), + "{argv:?}: a's hosted pin is not this run's\n{}", + r.out + ); + assert_eq!(std::fs::read(f.a.join("yarn.lock")).unwrap(), before); + + let f = fixture(); + write_hosted_pin(&f.b); + let r = run(&f, &full); + assert!( + mentions(argv, &r), + "{argv:?}: b's hosted pin is this run's\n{}", + r.out + ); + } +} + /// A manifest file outside any `.socket/` directory relocates only the /// manifest: the project, and so the ledger rollback reverts, stays `--cwd`. #[test] From aae567e1fb80e44f1f1455f766e00dc2ef85a428 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Sat, 10 Oct 2026 10:20:02 -0400 Subject: [PATCH 5/6] Run standalone vex at the manifest's project vex loaded the ledgers and wiring discovery from the manifest's project but detected the product, crawled installed copies and ran the pnpm/PnP guards in --cwd. With --manifest-path into another project, that project's hosted pins could vouch for --cwd's lockfile-only packages, and the product came from the wrong package.json. vex now runs under GlobalArgs::at_project_root(), so the whole document describes one project (a no-op in the default layout). Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/src/commands/vex.rs | 10 ++++- .../tests/manifest_path_ledger_root.rs | 44 +++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/vex.rs b/crates/socket-patch-cli/src/commands/vex.rs index 4431d61fe..f2cf467bf 100644 --- a/crates/socket-patch-cli/src/commands/vex.rs +++ b/crates/socket-patch-cli/src/commands/vex.rs @@ -376,8 +376,14 @@ pub async fn run(args: VexArgs) -> i32 { api_client: RunApiClient::new(), }; - let manifest_path = args.common.resolved_manifest_path(); - match generate_vex_from_manifest_path(&args.common, ¶ms, &manifest_path).await { + // One project for the whole document (#745): the ledgers and the + // wiring discovery are the manifest's project's, so product detection, + // the installed-copy crawl and the pnpm / PnP guards run there too + // (`cwd` moved to it; a no-op in the default layout). Mixing them would + // let another project's wiring vouch for this one's packages. + let common = args.common.at_project_root(); + let manifest_path = common.resolved_manifest_path(); + match generate_vex_from_manifest_path(&common, ¶ms, &manifest_path).await { Ok(mut summary) => { run_warnings.append(&mut summary.warnings); summary.warnings = run_warnings; diff --git a/crates/socket-patch-cli/tests/manifest_path_ledger_root.rs b/crates/socket-patch-cli/tests/manifest_path_ledger_root.rs index dcd9405ac..742aaddfa 100644 --- a/crates/socket-patch-cli/tests/manifest_path_ledger_root.rs +++ b/crates/socket-patch-cli/tests/manifest_path_ledger_root.rs @@ -300,6 +300,50 @@ fn hosted_pins_come_from_the_manifest_project() { } } +/// `vex` builds one project's document: with the manifest (and so the +/// ledgers and wiring) in `b`, the product is detected in `b` too, never +/// from `--cwd`'s package.json. +#[test] +fn vex_detects_the_product_in_the_manifest_project() { + let f = fixture(); + let a64 = "a".repeat(64); + let b64 = "b".repeat(64); + std::fs::write( + f.b.join(".socket/manifest.json"), + format!( + r#"{{"patches":{{"pkg:npm/lodash@4.17.20":{{ + "uuid":"{UUID}","exportedAt":"2024-01-01T00:00:00Z", + "files":{{"package/index.js":{{"beforeHash":"{a64}","afterHash":"{b64}"}}}}, + "vulnerabilities":{{"GHSA-aaaa-bbbb-cccc":{{"cves":["CVE-2024-1111"], + "summary":"s","severity":"high","description":"d"}}}}, + "description":"d","license":"MIT","tier":"free"}}}}}}"# + ), + ) + .unwrap(); + for (dir, name) in [(&f.a, "cwd-app"), (&f.b, "manifest-app")] { + std::fs::write( + dir.join("package.json"), + format!(r#"{{"name":"{name}","version":"1.0.0"}}"#), + ) + .unwrap(); + } + let out = f.a.parent().unwrap().join("vex.json"); + let r = run( + &f, + &[ + "vex", + "--json", + "--no-verify", + "--output", + out.to_str().unwrap(), + ], + ); + assert_eq!(r.code, Some(0), "{}", r.out); + let doc = std::fs::read_to_string(&out).unwrap(); + assert!(doc.contains("pkg:npm/manifest-app@1.0.0"), "{doc}"); + assert!(!doc.contains("cwd-app"), "{doc}"); +} + /// A manifest file outside any `.socket/` directory relocates only the /// manifest: the project, and so the ledger rollback reverts, stays `--cwd`. #[test] From 666fd48f4316649c30c6bd3193ba0d91d157e354 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Sat, 10 Oct 2026 13:02:55 -0400 Subject: [PATCH 6/6] Refuse embedded --vex with a manifest in another project `apply --vex` and agent-mode / report-only `scan --vex` patch --cwd's installed copies, but the VEX document's product, ledgers and lockfile wiring come from the manifest's project (#745), so one document would attest one project from another's state. Re-rooting would instead verify copies the run never patched. Refuse the combination up front with the existing usage-error code `manifest_path_foreign_project` (exit 2), using the same project-root comparison (`manifest_project_is_foreign`) as the hosted/vendored refusals. A manifest resolving into --cwd's own project is never refused; `apply --check` (no document) and standalone `vex` (runs wholly in the manifest's project) stay allowed. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/CLI_CONTRACT.md | 6 +- crates/socket-patch-cli/src/commands/apply.rs | 16 +++++ crates/socket-patch-cli/src/commands/mod.rs | 29 ++++++++ .../socket-patch-cli/src/commands/scan/mod.rs | 9 +++ .../tests/manifest_path_ledger_root.rs | 70 +++++++++++++++++++ docs/migrating-to-v5.md | 4 +- 6 files changed, 130 insertions(+), 4 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 6c5a780d5..144722ecd 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -91,7 +91,7 @@ Every subcommand accepts the same set of "global" flags via a single shared `Glo | Long | Short | Env var | Default | Type | Semantic | |---|---|---|---|---|---| | `--cwd` | — | `SOCKET_CWD` | `.` | path | Working directory | -| `--manifest-path` | — | `SOCKET_MANIFEST_PATH` | `.socket/manifest.json` | path | Manifest location (resolved relative to `--cwd`). v5.0 (#745): it also selects the project whose stores every command reads and writes — the root the RESOLVED manifest path implies: its `.socket` parent's parent when the manifest sits in a `.socket/` directory (exactly `--cwd` for the default path), else `--cwd` — a manifest file elsewhere (`state/patches.json`, `/etc/socket/manifest.json`) relocates only the manifest and its lock/artifact directory, not the project (v5.0; previously the file's own directory). All sources always come from that SAME project: `list`, `apply` (incl. `--check`), `rollback`, `remove`, `repair`, `vex`, `vendor --check` and agent-mode `scan`/`get` load (and `rollback`/`remove`/`repair`/`scan --prune` revert and save) that project's vendor ledger and its `.socket/vendor/` artifacts, under the manifest's apply lock, never `--cwd`'s; `list`, `vex`, `rollback`, `remove` and `repair` likewise read (and `rollback`/`remove` unwind) that project's lockfile hosted pins and pre-v5 redirect ledger. Installed copies are still found from `--cwd` (or the global scope). The forms that rewire `--cwd`'s lockfiles — `scan`/`get --mode hosted\|vendored` and `vendor` other than `--check` — refuse a manifest in another project: usage error (exit 2), code `manifest_path_foreign_project`, checked before anything is read or locked. | +| `--manifest-path` | — | `SOCKET_MANIFEST_PATH` | `.socket/manifest.json` | path | Manifest location (resolved relative to `--cwd`). v5.0 (#745): it also selects the project whose stores every command reads and writes — the root the RESOLVED manifest path implies: its `.socket` parent's parent when the manifest sits in a `.socket/` directory (exactly `--cwd` for the default path), else `--cwd` — a manifest file elsewhere (`state/patches.json`, `/etc/socket/manifest.json`) relocates only the manifest and its lock/artifact directory, not the project (v5.0; previously the file's own directory). All sources always come from that SAME project: `list`, `apply` (incl. `--check`), `rollback`, `remove`, `repair`, `vex`, `vendor --check` and agent-mode `scan`/`get` load (and `rollback`/`remove`/`repair`/`scan --prune` revert and save) that project's vendor ledger and its `.socket/vendor/` artifacts, under the manifest's apply lock, never `--cwd`'s; `list`, `vex`, `rollback`, `remove` and `repair` likewise read (and `rollback`/`remove` unwind) that project's lockfile hosted pins and pre-v5 redirect ledger. Installed copies are still found from `--cwd` (or the global scope). The forms that rewire `--cwd`'s lockfiles — `scan`/`get --mode hosted\|vendored` and `vendor` other than `--check` — refuse a manifest in another project: usage error (exit 2), code `manifest_path_foreign_project`, checked before anything is read or locked. So does an embedded `--vex` on `apply` (not `--check`) and agent-mode or report-only `scan`: those runs patch `--cwd`'s installed copies, while the document would attest the manifest's project; standalone `vex` runs wholly in the manifest's project and is allowed. A manifest that resolves into `--cwd`'s own project (the default path, `./.socket/manifest.json`, or a `..` detour back into `--cwd`) is never refused. | | `--api-url` | — | `SOCKET_API_URL` | `https://api.socket.dev` | string | Authenticated API endpoint | | `--api-token` | — | `SOCKET_API_TOKEN` | (none) | string | Auth token (absence selects the public proxy) | | `--org` | `-o` | `SOCKET_ORG_SLUG` | (auto-resolve) | string | Org slug. Resolved once per run from the token (`GET /v0/organizations`) when omitted; if that fails, the whole run uses the public proxy anonymously (free patches only) and warns. | @@ -1501,7 +1501,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. | -| `manifest_path_foreign_project` | scan, get, vendor | Usage error (exit 2, v5.0, #745): `--mode hosted\|vendored` (scan's default is hosted) or `vendor` / `vendor --revert` with a `--manifest-path` / `SOCKET_MANIFEST_PATH` whose project (see the flag row) is not `--cwd`. These forms rewire `--cwd`'s lockfiles and vendor ledger, so the manifest must belong to the same project; run from the manifest's project (`--cwd `) or drop the flag. | +| `manifest_path_foreign_project` | scan, get, vendor, apply | Usage error (exit 2, v5.0, #745): `--mode hosted\|vendored` (scan's default is hosted) or `vendor` / `vendor --revert` with a `--manifest-path` / `SOCKET_MANIFEST_PATH` whose project (see the flag row) is not `--cwd`. Also an embedded `--vex` / `SOCKET_VEX` on `apply` (except `apply --check`, which never generates one) or on any other `scan` with such a manifest: the run patches `--cwd`'s installed copies, but the document's product, ledgers and lockfile wiring would come from the manifest's project; run from the manifest's project, drop `--manifest-path`, or use standalone `vex`, which runs wholly in the manifest's project. These forms rewire `--cwd`'s lockfiles and vendor ledger, so the manifest must belong to the same project; run from the manifest's project (`--cwd `) or drop the flag. | | `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. | @@ -1820,7 +1820,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) or with a `--manifest-path` in another project (v5.0, `manifest_path_foreign_project`; `get` and `vendor` other than `--check` likewise); 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. | +| `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) or with a `--manifest-path` in another project (v5.0, `manifest_path_foreign_project`; `get` and `vendor` other than `--check` likewise, as do `apply --vex` and any other `scan --vex`); 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/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index 47b586880..959a758af 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -809,6 +809,22 @@ fn refuse_yarn_pnp(args: &ApplyArgs) -> i32 { pub async fn run(args: ApplyArgs) -> i32 { apply_env_toggles(&args.common); + // `--vex` attests the manifest's project but this run patches `--cwd`'s + // installed copies: refuse a manifest in another project before + // anything is read (#745). `--check` never generates a document. + if !args.check { + if let Some(message) = + crate::commands::foreign_manifest_vex_conflict(&args.common, &args.vex, "apply") + { + return crate::json_envelope::usage_error( + Command::Apply, + args.common.json, + args.common.dry_run, + crate::commands::FOREIGN_MANIFEST_PROJECT, + &message, + ); + } + } let manifest_path = args.common.resolved_manifest_path(); // No manifest → nothing to apply: a clean exit-0 no-op (load-bearing diff --git a/crates/socket-patch-cli/src/commands/mod.rs b/crates/socket-patch-cli/src/commands/mod.rs index ee156fd7d..07ed2c4bd 100644 --- a/crates/socket-patch-cli/src/commands/mod.rs +++ b/crates/socket-patch-cli/src/commands/mod.rs @@ -180,6 +180,35 @@ pub(crate) fn foreign_manifest_conflict( )) } +/// The usage error for an embedded `--vex` (`apply`, agent-mode `scan`) +/// when `--manifest-path` resolves into another project, or `None` (no +/// `--vex`, or the manifest is `--cwd`'s own). Same code as +/// [`foreign_manifest_conflict`]. These forms patch `--cwd`'s installed +/// copies, but the document's other sources — product, ledgers, lockfile +/// wiring — belong to the manifest's project (#745), so one document would +/// attest one project from another's state. Re-rooting the run instead +/// would verify copies the run never patched. Standalone `vex` runs wholly +/// in the manifest's project and stays allowed. `host` names the command. +pub(crate) fn foreign_manifest_vex_conflict( + common: &crate::args::GlobalArgs, + vex: &vex::VexEmbedArgs, + host: &str, +) -> Option { + if vex.vex.is_none() || !common.manifest_project_is_foreign() { + return None; + } + let root = common.project_root(); + Some(format!( + "--vex cannot be used with a --manifest-path in another project: {host} patches the \ + installed packages of --cwd ({}), but the manifest belongs to {}, so the VEX \ + document would mix the two projects. Run it from the manifest's project (--cwd {}), \ + drop --manifest-path, or generate the document separately with `socket-patch vex`", + common.cwd.display(), + root.display(), + root.display(), + )) +} + /// The flag that put a run in global scope, as usage errors name it /// (`SOCKET_GLOBAL` / `SOCKET_GLOBAL_PREFIX` set the same fields). pub(crate) fn global_scope_flag(common: &crate::args::GlobalArgs) -> &'static str { diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index 3f6f8b60c..55bf835b4 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -1772,6 +1772,15 @@ async fn run_scan( Err(message) => return scan_usage_error(&args, "mode_ambiguous", &message), } } + // Hosted and vendored mode refused a foreign manifest above; agent mode + // (and a report-only scan) still refuses one under `--vex`: it patches + // `--cwd`'s installed copies, the document attests the manifest's + // project (#745). + if let Some(message) = + crate::commands::foreign_manifest_vex_conflict(&args.common, &args.vex, "scan") + { + return scan_usage_error(&args, crate::commands::FOREIGN_MANIFEST_PROJECT, &message); + } // The repo's socket.yml policy, read once per invocation before any // write (an invalid file fails the run closed). diff --git a/crates/socket-patch-cli/tests/manifest_path_ledger_root.rs b/crates/socket-patch-cli/tests/manifest_path_ledger_root.rs index 742aaddfa..26695e7a9 100644 --- a/crates/socket-patch-cli/tests/manifest_path_ledger_root.rs +++ b/crates/socket-patch-cli/tests/manifest_path_ledger_root.rs @@ -462,3 +462,73 @@ fn every_ledger_access_is_rooted_at_project_root() { offenders.join("\n") ); } + +/// The embedded `--vex` of `apply` and agent-mode `scan`: the run patches +/// `--cwd`'s installed copies but the document's sources (product, +/// ledgers, lockfile wiring) belong to the manifest's project, so a foreign +/// manifest is refused before anything runs — no document is written. +#[test] +fn embedded_vex_refuses_a_foreign_manifest() { + for argv in [ + &["apply", "--json", "--offline"][..], + &["apply", "--json", "--offline", "--dry-run"][..], + &["scan", "--mode", "agent", "--json", "--offline"][..], + &["scan", "--sync", "--json", "--offline"][..], + ] { + let f = fixture(); + let vex = f.a.join("out.vex.json"); + let mut full: Vec<&str> = argv.to_vec(); + let vex_arg = vex.to_str().unwrap(); + full.extend(["--vex", vex_arg]); + let r = run(&f, &full); + assert_eq!(r.code, Some(2), "{argv:?}\n{}", r.out); + assert!( + r.out.contains("manifest_path_foreign_project") && r.out.contains("--vex"), + "{argv:?}\n{}", + r.out + ); + assert!(!vex.exists(), "{argv:?}: no document is written"); + } +} + +/// The refusal is only for a manifest in ANOTHER project: an explicit +/// path to `--cwd`'s own manifest — spelled as the default, with `./`, or +/// through a `..` detour back into `--cwd` — is the same project, and the +/// embedded `--vex` runs (an empty manifest attests nothing; not refused). +#[test] +fn embedded_vex_allows_the_cwd_projects_own_manifest() { + for manifest in [ + ".socket/manifest.json", + "./.socket/manifest.json", + "../b/.socket/manifest.json", + ] { + for argv in [ + &["apply", "--json", "--offline"][..], + &["scan", "--mode", "agent", "--json", "--offline"][..], + ] { + let f = fixture(); + let vex = f.b.join("out.vex.json"); + let out = hermetic::binary_command() + .args(argv) + .arg("--vex") + .arg(&vex) + .arg("--cwd") + .arg(&f.b) + .args(["--manifest-path", manifest]) + .current_dir(&f.b) + .output() + .expect("run socket-patch"); + let text = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + assert!( + !text.contains("manifest_path_foreign_project") + && !text.contains("in another project"), + "{manifest} {argv:?}\n{text}" + ); + assert_ne!(out.status.code(), Some(2), "{manifest} {argv:?}\n{text}"); + } + } +} diff --git a/docs/migrating-to-v5.md b/docs/migrating-to-v5.md index f394646ae..e5d0f7f3c 100644 --- a/docs/migrating-to-v5.md +++ b/docs/migrating-to-v5.md @@ -43,7 +43,9 @@ existing scripts against the new CLI; the [changelog](../CHANGELOG.md) and read and written in the manifest's project, never in `--cwd`'s. Hosted and vendored `scan` / `get` and `vendor` (other than `--check`) rewire `--cwd`'s lockfiles, so they refuse a manifest in another project with - `manifest_path_foreign_project` (exit 2). Run them from the manifest's project. + `manifest_path_foreign_project` (exit 2). So do `apply --vex` and agent-mode + `scan --vex`, which patch `--cwd` but would attest the manifest's project. + Run them from the manifest's project, or use standalone `vex`. A manifest file outside a `.socket/` directory (`--manifest-path state/patches.json`) moves only the manifest; the project stays `--cwd`. - `list` succeeds on an empty project. Hosted results identify lockfiles instead