Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc

**Mode resolution (`resolve_mode_flags`, MAJOR in v5.0).** `--mode`, or `--sync` (agent mode), picks the mode. With neither, `scan` and `get` keep the mode the project's patch state already records (`mode_from_project_state`, #1088; both stores are read from the project the resolved manifest belongs to, so a `--manifest-path` into another project consults that project's ledger): a non-empty vendor ledger (`.socket/vendor/state.json`; also an unreadable or malformed one, so the vendored flow reports it) means **vendored**, a manifest holding patches the ledger does not already cover (by key or base purl), or an unreadable or malformed manifest (so the agent flow reports it), means **agent** — a record the ledger covers, as `get --save-only` then `vendor` leaves, is vendored state — and a project with neither runs **hosted** mode — JSON and human alike (see the hosted paragraph below and [`scan` and `get` JSON](#scan-and-get-json-v50-major)). A project holding both agent and vendored state is a usage error (exit 2, code `mode_ambiguous`, naming the three `--mode` values) and writes nothing. So is a vendor ledger beside hosted pins in the project's lockfiles (#1437), the mixed state a partial `--mode hosted` takeover or per-package `get --mode` leaves: hosted mode keeps no ledger, so its pins are its state, and keeping vendored mode would take every pin over. A human-mode run that kept vendored or agent mode says so on stderr (`Note: using --mode vendored because … already holds vendored patches; pass --mode explicitly to switch modes`); `--json` and `--silent` print no note. **Switching modes is always explicit**: the in-place vendored → hosted takeover (see **Staged takeover**) runs only under `--mode hosted`, the hosted → vendored takeover only under `--mode vendored`, and a bare `scan`/`get` never converts a project. With hosted/vendored PATH arguments and no `--mode`, each project directory takes its mode from its own state. The one exception to the state-derived default: a `--prune` or `--global`/`--global-prefix` scan with no mode has no project lockfile to rewire, so it is **report-only** — discovery, the table, the `updates` array and the `redirectState` block below, plus the `--prune` GC — and, in human mode, ends with the hint `To apply these patches in place, run:` / ` socket-patch scan --mode agent [PATHS]` / ` socket-patch get <package-name-or-purl-or-CVE-ID>`. A global scan's hint carries the run's scope, so it can be run verbatim: `-g` (`scan --mode agent -g` / `get -g <…>`), or `--global-prefix <dir>` when a prefix was given (the directory shell-quoted when it needs it). An explicit `--mode hosted` or `--mode vendored` with `--global`/`--global-prefix` is a usage error (exit 2: global installs have no project lockfile to redirect, or to wire vendored artifacts into); `get` enforces the same rule with the same wording.

**Global scope never touches the project's state (v5.0).** A `--global`/`--global-prefix` run that starts inside a project acts on the global installs only. The `--cwd` project's hosted pins and vendor ledger are not its target: `rollback` and `remove` run no hosted or vendored leg (they restore the global copies and drop their manifest records; `rollback` keeps the manifest records of purls the project vendors), a pre-v5 hosted ledger is never retired, and the project's vendor ledger does not own the global copies, so `apply` and `scan --mode agent` patch the global copy of a purl the project vendors (no `vendored` skip, no `vendored_ownership_retained` warning). The standalone `vendor` command acts only on the project, so every form of it (plain, `--revert`, `--check`) is a usage error under global scope: exit 2, human `Error: <flag> cannot be used with vendor[ --revert| --check]: global installs have no project lockfile to …`, JSON `{status: "error", error: {code: "global_scope_unsupported", message}}`, checked before the project is read or locked (#498).
**Global scope never touches the project's state (v5.0).** A `--global`/`--global-prefix` run that starts inside a project acts on the global installs only. The `--cwd` project's hosted pins and vendor ledger are not its target: `rollback` and `remove` run no hosted or vendored leg (they restore the global copies and drop their manifest records; `rollback` keeps the manifest records of purls the project vendors), a pre-v5 hosted ledger is never retired, and the project's vendor ledger does not own the global copies, so `apply` and `scan --mode agent` patch the global copy of a purl the project vendors (no `vendored` skip, no `vendored_ownership_retained` warning). One global store is the project's own install: a NuGet project restores into the global packages folder, so when the global run's NuGet folder is one the project restores into, the project's vendored NuGet packages there stay the ledger's (#489). `apply` and `scan --mode agent` skip them as `vendored` (scan adds `vendored_ownership_retained`), and `rollback` and `remove` leave them patched, each reported as a `skipped` / `vendored` event (human: ` [skip] <purl> (vendored: …)`); `rollback` keeps their manifest records, as above. The project's folders are the ones its restores recorded (`obj/project.assets.json` `packageFolders`), plus the global packages folder while a project under it has no restore yet, so a project that restores into `RestorePackagesPath` leaves the global folder's copies to the global run. The standalone `vendor` command acts only on the project, so every form of it (plain, `--revert`, `--check`) is a usage error under global scope: exit 2, human `Error: <flag> cannot be used with vendor[ --revert| --check]: global installs have no project lockfile to …`, JSON `{status: "error", error: {code: "global_scope_unsupported", message}}`, checked before the project is read or locked (#498).

**pnpm 11+ global installs are one root each.** pnpm 11+ gives every `pnpm add -g` its own install, `$PNPM_HOME/global/v<N>/<hash>/node_modules` (each with its own `.pnpm`), and `pnpm root -g` prints their parent. `--global` and a `--global-prefix` naming such a directory (a `v<N>` dir that is not itself a `node_modules` and has at least one child install carrying pnpm's `.modules.yaml`) crawl every child `node_modules` as a separate root, so `apply`, `rollback`, `scan` and `vex` see every install's copy of a package, not just the first install's (#435). One physical copy two installs both link is still visited once. Any other prefix (pnpm <= 10's `global/5/node_modules`, npm's) is crawled as the single root it names.

Expand Down Expand Up @@ -1531,7 +1531,7 @@ command's integration tests check its output.
| `no_files` | `skipped` | rollback (v5.0): the patch record lists no files, so nothing was restored. |
| `hosted_restore_refused` / `hosted_write_failed` | `failed` (`details.mode: "hosted"`) | rollback (v5.0): a hosted pin's upstream restore was refused (detail names the `git checkout` remedy), or writing the restored lockfiles failed (artifact-level, no `purl`). Exit 1. |
| `gc_skipped` | rollback `warnings[]` | rollback (v5.0): the artifact GC could not run (a before-blob gate abort, an unreadable vendor ledger); `gc` is absent. Not raised under `--preserve-state`, which never asks for one. |
| `vendored` | `skipped` | apply (every ecosystem) + scan `--mode agent`: the package is managed by `socket-patch vendor`; the command yields ownership (scan also skips the download). v5.0: rollback no longer yields — its vendored leg reverts these entries by default (a corrupt vendor ledger surfaces via the `vendor_state_unreadable` warning + exit 1 — the skip cannot name purls, since naming them needs the ledger). Scan `--mode agent --json` additionally surfaces one run-level `vendored_ownership_retained` warning naming the skipped purls (additive; exit/status unchanged). |
| `vendored` | `skipped` | apply (every ecosystem) + scan `--mode agent`: the package is managed by `socket-patch vendor`; the command yields ownership (scan also skips the download). v5.0: rollback no longer yields — its vendored leg reverts these entries by default, except under global scope, where `rollback`/`remove` skip a vendored NuGet project's own install with this code (#489) (a corrupt vendor ledger surfaces via the `vendor_state_unreadable` warning + exit 1 — the skip cannot name purls, since naming them needs the ledger). Scan `--mode agent --json` additionally surfaces one run-level `vendored_ownership_retained` warning naming the skipped purls (additive; exit/status unchanged). |
| `vendor_reverted` | `removed` | remove: vendoring reverted (lock fragments restored, artifact + ledger entry gone) as part of removing the patch. v5.0: carries `details.mode: "vendored"` (as do the vendored leg's `skipped` events). |
| `vendor_revert_failed` | top-level error; rollback `failed` | remove: the vendor revert failed; the manifest was NOT modified. Rollback (v5.0): a `failed` event with `details.mode: "vendored"` (`vendor_state_write_failed` when the revert landed but the ledger could not be saved); the entry, artifact and manifest record survive for a retry, exit 1. |
| `vendor_state_retained` | `skipped` | remove `--skip-rollback`: vendor wiring + artifact deliberately left in place (the next `vendor` run reconciles the dropped entry). Also the top-level error code when `--skip-rollback` targets a vendored patch with no manifest record (every `scan`/`get --mode vendored` entry — and, v5.0, the ledger-only leftover of an earlier `remove --skip-rollback` of a manifest-tracked vendored patch). |
Expand Down
26 changes: 14 additions & 12 deletions crates/socket-patch-cli/src/commands/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -327,18 +327,19 @@ async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 {

// The `apply` scope: `--ecosystems`, minus vendor-owned purls (matched
// exactly as `apply` matches them; the ledger owns the PROJECT's
// copies only, so a global check verifies every in-scope copy).
// copies only, so a global check verifies every in-scope copy but the
// project's own NuGet install, #489).
let manifest_purls: Vec<String> = manifest.patches.keys().cloned().collect();
let in_scope: HashSet<String> =
partition_purls(&manifest_purls, args.common.ecosystems.as_deref())
.into_values()
.flatten()
.collect();
let vendored = if crate::commands::project_state_in_scope(&args.common) {
socket_patch_core::vendor::vendored_purl_keys(&args.common.project_root()).await
} else {
Default::default()
};
let vendored = crate::commands::vendor_owned_keys(
&args.common,
&socket_patch_core::vendor::vendored_purl_keys(&args.common.project_root()).await,
)
.await;
let owned_by_apply = |purl: &str| in_scope.contains(purl) && !purl_keys_cover(&vendored, purl);

{
Expand Down Expand Up @@ -2217,12 +2218,13 @@ async fn apply_patches_inner(
// unreadable state degrades to "nothing vendored" (fail-open).
// The ledger owns the PROJECT's copies only: a global apply restores
// 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.project_root()).await
} else {
Default::default()
};
// same purl (see `project_state_in_scope`), unless that copy is the
// project's own NuGet install (#489).
let vendored_purls = crate::commands::vendor_owned_keys(
&args.common,
&socket_patch_core::vendor::vendored_purl_keys(&args.common.project_root()).await,
)
.await;
let is_vendored = |p: &str| purl_keys_cover(&vendored_purls, p);
let (mut results, mut matched_manifest_purls, vendored_bases) =
synthesize_vendor_owned_results(&target_manifest_purls, &vendored_purls);
Expand Down
36 changes: 36 additions & 0 deletions crates/socket-patch-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,42 @@ pub(crate) fn project_state_in_scope(common: &crate::args::GlobalArgs) -> bool {
!common.is_global()
}

/// The vendor-ledger keys (`ledger`, the project's
/// [`VendorState::purl_keys`]) that own this run's installed copies. In
/// project scope that is the whole ledger. Under global scope the ledger
/// owns only the project's copies ([`project_state_in_scope`]), and one
/// global store is the project's install: a NuGet PackageReference project
/// restores into the global packages folder, so its global copy of a
/// vendored package IS the project's patched copy (#489). There the
/// ledger's NuGet keys still own it, and every other key owns nothing.
///
/// [`VendorState::purl_keys`]: socket_patch_core::vendor::VendorState::purl_keys
pub(crate) async fn vendor_owned_keys(
common: &crate::args::GlobalArgs,
ledger: &std::collections::HashSet<socket_patch_core::utils::purl_key::PurlKey>,
) -> std::collections::HashSet<socket_patch_core::utils::purl_key::PurlKey> {
use socket_patch_core::crawlers::types::Ecosystem;
if project_state_in_scope(common) {
return ledger.clone();
}
if !common.ecosystem_selected(Ecosystem::Nuget) {
return Default::default();
}
let nuget: std::collections::HashSet<_> = ledger
.iter()
.filter(|key| Ecosystem::from_purl(key.as_str()) == Some(Ecosystem::Nuget))
.cloned()
.collect();
if nuget.is_empty()
|| !socket_patch_core::crawlers::NuGetCrawler::new()
.global_roots_shared_with(&common.crawler_options(), &common.project_root())
.await
{
return Default::default();
}
nuget
}

/// The mode a `scan`/`get` run with no `--mode` uses for the project
/// (CLI_CONTRACT.md, Mode resolution): the mode the project's own patch
/// state already records, so a bare run never converts the project to
Expand Down
48 changes: 42 additions & 6 deletions crates/socket-patch-cli/src/commands/remove.rs
Original file line number Diff line number Diff line change
Expand Up @@ -617,15 +617,31 @@ pub async fn run(args: RemoveArgs) -> i32 {
// Vendor-owned purls are excluded from the in-place restore (the
// vendored leg below reverts them) unless their Cargo shared-cache copy
// still carries an agent-mode patch (#336); an unreadable ledger degrades to
// "nothing vendored" here and fails closed at that leg.
let vendored_keys: HashSet<PurlKey> = vendor_state_result
.as_ref()
.map(socket_patch_core::vendor::VendorState::purl_keys)
.unwrap_or_default();
// "nothing vendored" here and fails closed at that leg. Under global
// scope the project's ledger still owns its own NuGet install (#489).
// The identifier is the scope, so `--ecosystems` does not narrow it.
let vendored_keys: HashSet<PurlKey> = if project_state {
vendor_state_result
.as_ref()
.map(socket_patch_core::vendor::VendorState::purl_keys)
.unwrap_or_default()
} else {
crate::commands::vendor_owned_keys(
&GlobalArgs {
ecosystems: None,
..args.common.clone()
},
&socket_patch_core::vendor::vendored_purl_keys(&args.common.project_root()).await,
)
.await
};
// One `rolledBack` event per restored installed copy (recorded, so
// `summary.rolledBack` counts them). A dry run restores nothing, so it
// records none.
let mut rolled_back_events: Vec<PatchEvent> = Vec::new();
// Copies a global run left to the project's vendor ledger (#489): each
// a `skipped` / `vendored` event, since no vendored leg reports them.
let mut project_owned: Vec<PatchEvent> = Vec::new();
// In-scope manifest entries the nested rollback SKIPPED because the
// crawler found no installed package (`RollbackOutcome::not_installed`,
// sorted). These were NOT reverted — and "not installed" can also mean
Expand Down Expand Up @@ -666,6 +682,23 @@ pub async fn run(args: RemoveArgs) -> i32 {
{
Ok(outcome) => {
rollback_not_installed = outcome.not_installed;
if !project_state {
if loud {
super::rollback::print_project_owned(
&args.common,
&outcome.vendored_skipped,
);
printed_progress |= !outcome.vendored_skipped.is_empty();
}
project_owned = outcome
.vendored_skipped
.iter()
.map(|purl| {
PatchEvent::new(PatchAction::Skipped, purl.clone())
.with_reason("vendored", super::rollback::PROJECT_OWNED_INSTALL)
})
.collect();
}
rollback_warnings = outcome.warnings;
if !outcome.success {
track_patch_remove_failed("Rollback failed during patch removal", &telemetry)
Expand Down Expand Up @@ -790,7 +823,10 @@ pub async fn run(args: RemoveArgs) -> i32 {
}
};
let vendored_matches = vendor_entries_matching(&vendor_state, Some(&manifest), &target);
let mut vendor_leg = RemoveVendorLeg::default();
let mut vendor_leg = RemoveVendorLeg {
skipped: project_owned,
..Default::default()
};
if !vendored_matches.is_empty() {
if args.skip_rollback {
for (key, _) in &vendored_matches {
Expand Down
Loading
Loading