diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index edc6d674d..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`) | +| `--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. | @@ -1479,7 +1479,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). | @@ -1501,6 +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, 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. | @@ -1819,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); 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/args.rs b/crates/socket-patch-cli/src/args.rs index 23cf8925a..432288ea3 100644 --- a/crates/socket-patch-cli/src/args.rs +++ b/crates/socket-patch-cli/src/args.rs @@ -431,7 +431,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", @@ -491,35 +502,60 @@ 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 { - 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` and `blobs/` /// sit, and the obsolete `diffs/` and `packages/` the cleanup sweeps /// remove (`/.socket` by default). The one @@ -595,6 +631,23 @@ impl GlobalArgs { } } +/// [`GlobalArgs::project_root`] for a caller holding a resolved manifest +/// path: the manifest's `.socket` parent's parent in the standard layout, +/// 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 { + manifest_path + .parent() + .filter(|dir| { + dir.file_name() + == Some(std::ffi::OsStr::new( + socket_patch_core::constants::SOCKET_DIR, + )) + }) + .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`] @@ -1435,18 +1488,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/src/commands/agent_download.rs b/crates/socket-patch-cli/src/commands/agent_download.rs index e7f691386..adc5e5aed 100644 --- a/crates/socket-patch-cli/src/commands/agent_download.rs +++ b/crates/socket-patch-cli/src/commands/agent_download.rs @@ -410,6 +410,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(), @@ -1198,7 +1204,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 @@ -1294,12 +1300,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() { @@ -1649,7 +1655,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 e1aecdf32..959a758af 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -324,7 +324,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() }; @@ -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 @@ -1933,7 +1949,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 3c296c21b..fed344a86 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -821,7 +821,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). @@ -1056,6 +1056,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, @@ -2055,7 +2071,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, @@ -2320,8 +2336,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, @@ -2380,7 +2396,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/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/mod.rs b/crates/socket-patch-cli/src/commands/mod.rs index f0118380d..07ed2c4bd 100644 --- a/crates/socket-patch-cli/src/commands/mod.rs +++ b/crates/socket-patch-cli/src/commands/mod.rs @@ -152,6 +152,63 @@ 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 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/remove.rs b/crates/socket-patch-cli/src/commands/remove.rs index 295f556c8..c75fc7173 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,14 +360,14 @@ 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() }; 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 +449,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 9c9abea9e..70721c0ef 100644 --- a/crates/socket-patch-cli/src/commands/repair.rs +++ b/crates/socket-patch-cli/src/commands/repair.rs @@ -73,25 +73,27 @@ 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); } 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 { @@ -152,8 +154,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 } }; @@ -499,7 +503,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 b9412efc6..3f4e847ae 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,6 +913,12 @@ pub async fn run(args: RollbackArgs) -> i32 { let manifest_path = args.common.resolved_manifest_path(); let cwd = args.common.cwd.clone(); + // 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 ───────────────────────────────────────────────── // Rollback infers what to undo from three sources: the manifest @@ -931,13 +940,13 @@ 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 // 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() }; @@ -953,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 { @@ -995,7 +1004,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 +1062,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 fdf71eea0..05809ce29 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 c8e060250..55bf835b4 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -274,9 +274,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 @@ -969,20 +987,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 @@ -1729,6 +1747,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" }; @@ -1752,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). @@ -1852,8 +1881,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 c9adc6951..9707f807b 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -926,6 +926,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(); @@ -4471,8 +4488,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. @@ -4574,7 +4596,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 043ad08ce..41d9c6d94 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, @@ -172,9 +178,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, @@ -185,7 +192,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 { @@ -196,7 +203,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..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; @@ -1299,7 +1305,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 +1353,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 9b039a96f..6242a72f1 100644 --- a/crates/socket-patch-cli/src/commands/vex_sources.rs +++ b/crates/socket-patch-cli/src/commands/vex_sources.rs @@ -347,7 +347,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..26695e7a9 --- /dev/null +++ b/crates/socket-patch-cli/tests/manifest_path_ledger_root.rs @@ -0,0 +1,534 @@ +//! #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); +} + +/// 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 + ); + } +} + +/// `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] +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). +#[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") + ); +} + +/// 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 be221dd39..e5d0f7f3c 100644 --- a/docs/migrating-to-v5.md +++ b/docs/migrating-to-v5.md @@ -38,6 +38,16 @@ 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). 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 of a hosted ledger. Scripts must use the updated [JSON shapes and exit codes](../crates/socket-patch-cli/CLI_CONTRACT.md#json-output-shapes).