diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 82fdd8b5d..3d1195c29 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -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 `. A global scan's hint carries the run's scope, so it can be run verbatim: `-g` (`scan --mode agent -g` / `get -g <…>`), or `--global-prefix ` when a prefix was given (the directory shell-quoted when it needs it). An explicit `--mode hosted` or `--mode vendored` with `--global`/`--global-prefix` is a usage error (exit 2: global installs have no project lockfile to redirect, or to wire vendored artifacts into); `get` enforces the same rule with the same wording. -**Global scope never touches the project's state (v5.0).** A `--global`/`--global-prefix` run that starts inside a project acts on the global installs only. The `--cwd` project's hosted pins and vendor ledger are not its target: `rollback` and `remove` run no hosted or vendored leg (they restore the global copies and drop their manifest records; `rollback` keeps the manifest records of purls the project vendors), a pre-v5 hosted ledger is never retired, and the project's vendor ledger does not own the global copies, so `apply` and `scan --mode agent` patch the global copy of a purl the project vendors (no `vendored` skip, no `vendored_ownership_retained` warning). The standalone `vendor` command acts only on the project, so every form of it (plain, `--revert`, `--check`) is a usage error under global scope: exit 2, human `Error: cannot be used with vendor[ --revert| --check]: global installs have no project lockfile to …`, JSON `{status: "error", error: {code: "global_scope_unsupported", message}}`, checked before the project is read or locked (#498). +**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] (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: 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//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` 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. @@ -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). | diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index 5c16e2423..bcfb85190 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -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 = manifest.patches.keys().cloned().collect(); let in_scope: HashSet = 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); { @@ -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); diff --git a/crates/socket-patch-cli/src/commands/mod.rs b/crates/socket-patch-cli/src/commands/mod.rs index 96f4037cf..27d5fd9ff 100644 --- a/crates/socket-patch-cli/src/commands/mod.rs +++ b/crates/socket-patch-cli/src/commands/mod.rs @@ -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, +) -> std::collections::HashSet { + 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 diff --git a/crates/socket-patch-cli/src/commands/remove.rs b/crates/socket-patch-cli/src/commands/remove.rs index 5c5ae5f6c..ad3e4442f 100644 --- a/crates/socket-patch-cli/src/commands/remove.rs +++ b/crates/socket-patch-cli/src/commands/remove.rs @@ -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 = 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 = 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 = 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 = 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 @@ -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) @@ -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 { diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index 0b0e90d7b..3fd9d1816 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -1077,6 +1077,24 @@ async fn run_vendored_leg( /// write failure (an artifact-level event, `hosted_write_failed`). const HOSTED_WRITE_FAILURE_KEY: &str = "files"; +/// Why a global run leaves a copy to the project's vendor ledger (#489): the +/// `skipped` / `vendored` event detail `rollback -g` and `remove -g` share. +pub(crate) const PROJECT_OWNED_INSTALL: &str = + "the project's own install, managed by `socket-patch vendor`: the global packages \ + folder is where the project restores it, so a global run leaves it patched"; + +/// The human `[skip]` lines for the copies a global run left to the +/// project's vendor ledger (#489); nothing in project scope, where the +/// vendored leg reports each entry. +pub(crate) fn print_project_owned(common: &GlobalArgs, purls: &[String]) { + if crate::commands::project_state_in_scope(common) || common.silent { + return; + } + for purl in purls { + println!(" [skip] {purl} (vendored: {PROJECT_OWNED_INSTALL})"); + } +} + /// Everything the main rollback run reports, for [`build_rollback_envelope`]. struct RollbackReport<'a> { dry_run: bool, @@ -1086,6 +1104,9 @@ struct RollbackReport<'a> { manifest: &'a PatchManifest, results: &'a [RollbackResult], not_installed: &'a [String], + /// Manifest entries a global run left to the project's vendor ledger: + /// the project's own NuGet install (#489). + project_owned: &'a [String], vendored: &'a VendoredLegOutcome, vendor_entries: &'a [(String, socket_patch_core::vendor::VendorEntry)], hosted: &'a HostedLegOutcome, @@ -1147,6 +1168,13 @@ fn build_rollback_envelope(report: &RollbackReport<'_>) -> Envelope { for purl in report.not_installed { env.record(not_installed_event(purl, manifest_uuid(purl))); } + for purl in report.project_owned { + env.record(with_uuid( + PatchEvent::new(PatchAction::Skipped, purl.as_str()) + .with_reason("vendored", PROJECT_OWNED_INSTALL), + manifest_uuid(purl), + )); + } // ── vendored leg ── let vendored = |details: serde_json::Value| { @@ -1542,7 +1570,7 @@ pub async fn run(args: RollbackArgs) -> i32 { // Under global scope the ledger is read only to keep the project's // 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. + // leg restores them, except the project's own NuGet install (#489). let loaded_vendor_state = socket_patch_core::vendor::load_state(&ledger_root).await; let project_vendored_keys: HashSet = loaded_vendor_state .as_ref() @@ -1557,10 +1585,8 @@ pub async fn run(args: RollbackArgs) -> i32 { let vendor_corrupt = vendor_state_result.is_err(); // An unreadable ledger degrades to "nothing vendored" for the in-place // leg (its own containment is the `vendor_state_unreadable` exit below). - let vendored_keys: HashSet = vendor_state_result - .as_ref() - .map(VendorState::purl_keys) - .unwrap_or_default(); + let vendored_keys: HashSet = + crate::commands::vendor_owned_keys(&args.common, &project_vendored_keys).await; // ── scope resolution ──────────────────────────────────────────────── let manifest = if manifest_missing { @@ -2355,6 +2381,11 @@ pub async fn run(args: RollbackArgs) -> i32 { manifest: &manifest, results: &results, not_installed: ¬_installed, + project_owned: if project_state { + &[] + } else { + &vendored_excluded + }, vendored: &vendored_leg, vendor_entries: &vendor_entries, hosted: &hosted_leg, @@ -2366,7 +2397,10 @@ pub async fn run(args: RollbackArgs) -> i32 { paths: path_scope.raw(), }); println!("{}", env.to_pretty_json()); - } else if !args.common.silent && !results.is_empty() { + } else if !args.common.silent && results.is_empty() { + print_project_owned(&args.common, &vendored_excluded); + } else if !args.common.silent { + print_project_owned(&args.common, &vendored_excluded); let cwd_abs = std::fs::canonicalize(&cwd).unwrap_or_else(|_| cwd.clone()); let lines = if args.common.dry_run { format_rollback_dry_run_counts(&results, &cwd_abs) @@ -6165,6 +6199,7 @@ mod tests { manifest, results, not_installed: &[], + project_owned: &[], vendored, vendor_entries: &[], hosted, @@ -6270,6 +6305,7 @@ mod tests { }; let env = build_rollback_envelope(&RollbackReport { not_installed: ¬_installed, + project_owned: &[], removed: &removed, warnings: &warnings, gc: Some(gc), diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index 413869c28..d9f79b2d2 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -2025,13 +2025,11 @@ async fn run_scan( .unwrap_or_default(); // The ledger owns and records the PROJECT's copies only: a global // scan's agent leg patches the global copy even when the cwd project - // vendors the same purl (see `project_state_in_scope`). + // vendors the same purl (see `project_state_in_scope`), unless that + // copy is the project's own NuGet install (#489). let project_state = crate::commands::project_state_in_scope(&args.common); - let vendor_owned_purls: HashSet = if project_state { - vendored_purls.clone() - } else { - HashSet::new() - }; + let vendor_owned_purls: HashSet = + crate::commands::vendor_owned_keys(&args.common, &vendored_purls).await; // Read existing manifest once for update detection. let existing_manifest = ctx.ledgers().await.manifest; diff --git a/crates/socket-patch-cli/tests/e2e_nuget_dotnet_build.rs b/crates/socket-patch-cli/tests/e2e_nuget_dotnet_build.rs index f9c582852..8b5831646 100644 --- a/crates/socket-patch-cli/tests/e2e_nuget_dotnet_build.rs +++ b/crates/socket-patch-cli/tests/e2e_nuget_dotnet_build.rs @@ -991,6 +991,127 @@ fn nuget_vendored_dotnet_restore_then_manifestless_vex() { ); } +// ── global runs over a vendored project's restore folder (#489) ──────── + +/// A vendored project's locked restore extracts the patched nupkg into the +/// global packages folder, so a global agent run that starts in the project +/// sees the project's own install there. `scan -g --mode agent` must yield +/// it to the vendor ledger and `rollback -g` must leave it patched: a locked +/// restore never re-extracts it (the lock and the nupkg's sha512 still +/// match), so restoring upstream bytes there silently unpatched the build. +#[test] +#[ignore = "real .NET SDK + nuget.org: run with --ignored (CI e2e matrix pins each SDK major)"] +fn nuget_vendored_project_survives_global_agent_scan_and_rollback() { + let sb = Sandbox::new(); + let Some(dn) = Dotnet::probe("vendored-global", &sb) else { + return; + }; + let sdk = dn.version.clone(); + let fixture = sb.dir("fixture"); + let store_fx = sb.dir("store-fixture"); + restore_fixture(&dn, &sb, &fixture, &store_fx); + + let pristine = std::fs::read(pkg_dir(&store_fx).join(FILE_KEY)).unwrap(); + let mut patched = pristine.clone(); + patched.extend_from_slice(MARKER); + let upstream = std::fs::read(pkg_dir(&store_fx).join(NUPKG_NAME)).unwrap(); + let nupkg = patched_nupkg(&upstream, &patched); + let backend = Backend::start(VENDORED_UUID, &pristine, &patched, Some(&nupkg)); + let uri = backend.uri(); + let api = ["--api-url", &uri, "--org", ORG, "--api-token", "fake-token"]; + + let mut args = vec![ + "scan", + "--mode", + "vendored", + "--vendor-source", + "service", + "--json", + "--yes", + ]; + args.extend(api); + let (code, env, stderr) = socket_patch(&fixture, &store_fx, &args); + assert_eq!( + code, + Some(0), + "SDK {sdk} scan --mode vendored: {env:#}\n{stderr}" + ); + + // The project's machine: a fresh checkout whose locked restore + // extracted the patched bytes into its global packages folder. + let checkout = sb.dir("checkout"); + fresh_checkout(&fixture, &checkout); + let store = sb.dir("store-checkout"); + dn.restore_ok( + &sb, + &checkout, + &store, + &["--locked-mode"], + "vendored restore", + ); + let installed = pkg_dir(&store).join(FILE_KEY); + assert_eq!(std::fs::read(&installed).unwrap(), patched, "SDK {sdk}"); + let ledger = std::fs::read(checkout.join(socket_patch_core::vendor::VENDOR_STATE_REL)).unwrap(); + + let mut args = vec!["scan", "-g", "--mode", "agent", "--json", "--yes"]; + args.extend(api); + let (code, env, stderr) = socket_patch(&checkout, &store, &args); + assert_eq!( + code, + Some(0), + "SDK {sdk} scan -g --mode agent: {env:#}\n{stderr}" + ); + assert!( + env.to_string().contains("vendored_ownership_retained"), + "SDK {sdk}: the project's install stays the ledger's: {env:#}" + ); + assert_eq!(env["summary"]["applied"], 0, "SDK {sdk}: {env:#}"); + + // A global agent scan from before #489 recorded the project's install + // as its own: `rollback -g` must still leave it patched. + let record = serde_json::json!({ "patches": { PURL: { + "uuid": VENDORED_UUID, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { FILE_KEY: { + "beforeHash": git_sha256(&pristine), + "afterHash": git_sha256(&patched), + }}, + "vulnerabilities": {}, + "description": "", + "license": "MIT", + "tier": "free", + }}}); + std::fs::write( + checkout.join(".socket/manifest.json"), + serde_json::to_vec_pretty(&record).unwrap(), + ) + .unwrap(); + let mut args = vec!["rollback", "-g", "--json", "--yes"]; + args.extend(api); + let (code, env, stderr) = socket_patch(&checkout, &store, &args); + assert_eq!(code, Some(0), "SDK {sdk} rollback -g: {env:#}\n{stderr}"); + assert_eq!(env["summary"]["rolledBack"], 0, "SDK {sdk}: {env:#}"); + + // The project's next locked restore keeps building the patched bytes. + dn.restore_ok( + &sb, + &checkout, + &store, + &["--locked-mode"], + "restore after -g", + ); + assert_eq!( + std::fs::read(&installed).unwrap(), + patched, + "SDK {sdk}: the project's install must stay PATCHED after scan -g + rollback -g" + ); + assert_eq!( + std::fs::read(checkout.join(socket_patch_core::vendor::VENDOR_STATE_REL)).unwrap(), + ledger, + "SDK {sdk}: the vendor ledger is untouched" + ); +} + // ── solution layout: member and named locks (#353, #514) ────────────── /// A solution layout: `nuget.config` at the root, one project under diff --git a/crates/socket-patch-cli/tests/global_scope_project_state.rs b/crates/socket-patch-cli/tests/global_scope_project_state.rs index 90a618253..423073bef 100644 --- a/crates/socket-patch-cli/tests/global_scope_project_state.rs +++ b/crates/socket-patch-cli/tests/global_scope_project_state.rs @@ -13,7 +13,9 @@ //! 3. `rollback -g` and `remove -g` leave a vendored project's wiring, //! artifact and ledger as they were (#445); //! 4. `apply -g` and `scan -g --mode agent` patch the global copy of a purl -//! the project vendors (#445, the reverse direction); +//! the project vendors (#445, the reverse direction), except a NuGet +//! project's own install in the global packages folder it restores into, +//! which every global run leaves to the project's ledger (#489); //! 5. the standalone `vendor` command (plain, `--revert`, `--check`) is a //! usage error under global scope, so it neither vendors into nor //! reverts the project (#498). @@ -570,6 +572,404 @@ async fn global_agent_scan_patches_a_purl_the_project_vendors() { project.assert_untouched("scan --global-prefix --mode agent"); } +// ═════════ 4b. a vendored NuGet project's global packages folder (#489) ═════════ +// +// A PackageReference project restores into the global packages folder, so +// a global run's copy of a package the project vendors IS the project's +// installed copy: the restore extracted it from the vendored nupkg, and a +// locked restore never re-extracts it. The project's ledger still owns it +// under global scope; a global store the project does not restore into +// stays the global run's to patch. + +const N_UUID: &str = "4f4f6b2c-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; +const N_PURL: &str = "pkg:nuget/Newtonsoft.Json@13.0.3"; +const N_PRISTINE: &[u8] = b"The MIT License (MIT)\nCopyright (c) 2007 James Newton-King\n"; +const N_PATCHED: &[u8] = + b"The MIT License (MIT)\n// SOCKET-PATCH-MARKER\nCopyright (c) 2007 James Newton-King\n"; + +fn nupkg(license: &[u8]) -> Vec { + use std::io::Write as _; + let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let opts = zip::write::SimpleFileOptions::default(); + let files: &[(&str, &[u8])] = &[ + ("[Content_Types].xml", b""), + ("_rels/.rels", b""), + ( + "Newtonsoft.Json.nuspec", + b"Newtonsoft.Json\ + 13.0.3", + ), + ("lib/net6.0/Newtonsoft.Json.dll", b"MZ-fake-assembly"), + ("LICENSE.md", license), + ]; + for (name, bytes) in files { + zw.start_file(*name, opts).unwrap(); + zw.write_all(bytes).unwrap(); + } + zw.finish().unwrap().into_inner() +} + +/// `Newtonsoft.Json` `13.0.3` extracted in global-packages-folder layout +/// under `store`, its `LICENSE.md` holding `license`. +fn write_newtonsoft(store: &Path, license: &[u8]) -> PathBuf { + let dir = store.join("newtonsoft.json/13.0.3"); + std::fs::create_dir_all(dir.join("lib/net6.0")).unwrap(); + std::fs::write(dir.join("newtonsoft.json.13.0.3.nupkg"), nupkg(N_PRISTINE)).unwrap(); + std::fs::write( + dir.join("newtonsoft.json.nuspec"), + "Newtonsoft.Json13.0.3", + ) + .unwrap(); + std::fs::write( + dir.join("lib/net6.0/Newtonsoft.Json.dll"), + b"MZ-fake-assembly", + ) + .unwrap(); + std::fs::write(dir.join("LICENSE.md"), license).unwrap(); + dir +} + +/// A vendored NuGet project at `/project` whose restore uses the +/// global packages folder `/store` (`NUGET_PACKAGES`), after a locked +/// restore extracted the vendored nupkg there: the store copy is PATCHED. +struct VendoredNuget { + tmp: tempfile::TempDir, + ledger: Vec, +} + +impl VendoredNuget { + fn root(&self) -> PathBuf { + self.tmp.path().join("project") + } + fn store(&self) -> PathBuf { + self.tmp.path().join("store") + } + fn license(&self) -> Vec { + std::fs::read(self.store().join("newtonsoft.json/13.0.3/LICENSE.md")).unwrap() + } + fn env(&self) -> Vec<(&'static str, String)> { + vec![ + ("SOCKET_PATCH_SERVER_URL", PATCH_HOST.to_string()), + ("NUGET_PACKAGES", self.store().to_str().unwrap().to_string()), + ] + } + fn run(&self, args: &[&str]) -> (i32, String, String) { + let env = self.env(); + let env: Vec<(&str, &str)> = env.iter().map(|(k, v)| (*k, v.as_str())).collect(); + common::run_with_env(&self.root(), args, &env) + } + fn assert_untouched(&self, what: &str) { + assert_eq!( + self.license(), + N_PATCHED, + "{what}: the project's installed copy in the global packages folder must stay patched" + ); + assert_eq!( + std::fs::read(self.root().join(".socket/vendor/state.json")).unwrap(), + self.ledger, + "{what}: the vendor ledger must stay byte-identical" + ); + } +} + +fn vendored_nuget() -> VendoredNuget { + let tmp = tempfile::tempdir().expect("tempdir"); + let root = tmp.path().join("project"); + let store = tmp.path().join("store"); + write_newtonsoft(&store, N_PRISTINE); + std::fs::create_dir_all(root.join("obj")).unwrap(); + std::fs::write( + root.join("app.csproj"), + "\ + net8.0\ + true\ + \ + \ + \n", + ) + .unwrap(); + let lock = json!({ + "version": 1, + "dependencies": { "net8.0": { "Newtonsoft.Json": { + "type": "Direct", + "requested": "[13.0.3, )", + "resolved": "13.0.3", + "contentHash": "ORIGINALcachedhash==" + }}} + }); + std::fs::write( + root.join("packages.lock.json"), + serde_json::to_string_pretty(&lock).unwrap(), + ) + .unwrap(); + let assets = json!({ + "version": 3, + "libraries": { "Newtonsoft.Json/13.0.3": { "type": "package", "path": "newtonsoft.json/13.0.3" } }, + "packageFolders": { format!("{}/", store.display()): {} }, + }); + std::fs::write(root.join("obj/project.assets.json"), assets.to_string()).unwrap(); + + let after_hash = git_sha256(N_PATCHED); + let manifest = json!({ + "patches": { + N_PURL: { + "uuid": N_UUID, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { "LICENSE.md": { + "beforeHash": git_sha256(N_PRISTINE), + "afterHash": after_hash + }}, + "vulnerabilities": {}, + "description": "synthetic global-scope patch", + "license": "MIT", + "tier": "free" + } + } + }); + let socket = root.join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + std::fs::write( + socket.join("manifest.json"), + serde_json::to_vec_pretty(&manifest).unwrap(), + ) + .unwrap(); + std::fs::write(socket.join("blobs").join(&after_hash), N_PATCHED).unwrap(); + std::fs::write( + socket.join("blobs").join(git_sha256(N_PRISTINE)), + N_PRISTINE, + ) + .unwrap(); + + let service = prebuilt_common::Server::project(&root); + let (code, stdout, stderr) = common::run_with_env( + &root, + &["vendor", "--json", "--silent", "--lock-timeout", "5"], + &[ + ("SOCKET_VENDOR_URL", &service.uri), + ("NUGET_PACKAGES", store.to_str().unwrap()), + ], + ); + assert_eq!( + code, 0, + "fixture vendor: stdout=\n{stdout}\nstderr=\n{stderr}" + ); + let ledger = std::fs::read(root.join(".socket/vendor/state.json")).unwrap(); + assert!( + String::from_utf8_lossy(&ledger).contains("pkg:nuget/"), + "sanity: the ledger vendors Newtonsoft.Json: {}", + String::from_utf8_lossy(&ledger) + ); + // The locked restore extracts the vendored (patched) nupkg into the + // global packages folder. + std::fs::write(store.join("newtonsoft.json/13.0.3/LICENSE.md"), N_PATCHED).unwrap(); + VendoredNuget { tmp, ledger } +} + +fn events_with(v: &Value, action: &str) -> Vec { + v["events"] + .as_array() + .map(|e| { + e.iter() + .filter(|e| e["action"] == action) + .cloned() + .collect() + }) + .unwrap_or_default() +} + +/// `rollback -g` from a vendored NuGet project leaves the project's +/// installed copy in the global packages folder patched (#489): restoring +/// it silently unpatched the project, since a locked restore never +/// re-extracts it. +#[test] +fn global_rollback_keeps_a_vendored_nuget_projects_install() { + let project = vendored_nuget(); + for scope in [ + vec!["-g"], + vec!["--global-prefix", project.store().to_str().unwrap()], + ] { + let mut args = vec!["rollback", "--json", "--yes", "--offline"]; + args.extend(scope.iter().copied()); + let (code, stdout, stderr) = project.run(&args); + assert_eq!(code, 0, "{args:?}: stdout={stdout}\nstderr={stderr}"); + let v = parse(&stdout, &stderr); + assert!(events_with(&v, "rolledBack").is_empty(), "{args:?}: {v}"); + assert!( + events_with(&v, "skipped") + .iter() + .any(|e| e["purl"] == N_PURL && e["errorCode"] == "vendored"), + "{args:?}: the kept install is reported: {v}" + ); + project.assert_untouched(&format!("{args:?}")); + assert!( + serde_json::from_slice::( + &std::fs::read(project.root().join(".socket/manifest.json")).unwrap() + ) + .unwrap()["patches"] + .get(N_PURL) + .is_some(), + "{args:?}: the project's vendored record must stay in the manifest" + ); + } +} + +/// `remove -g ` leaves the project's install patched too (#489). +#[test] +fn global_remove_keeps_a_vendored_nuget_projects_install() { + let project = vendored_nuget(); + let (code, stdout, stderr) = + project.run(&["remove", N_PURL, "--json", "--yes", "--offline", "-g"]); + assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); + let v = parse(&stdout, &stderr); + assert!(events_with(&v, "rolledBack").is_empty(), "{v}"); + assert!( + events_with(&v, "skipped") + .iter() + .any(|e| e["purl"] == N_PURL && e["errorCode"] == "vendored"), + "the kept install is reported: {v}" + ); + project.assert_untouched("remove -g"); +} + +/// `apply -g` yields the project's install to its ledger (#489) instead of +/// taking it over as a global agent copy. +#[test] +fn global_apply_skips_a_vendored_nuget_projects_install() { + let project = vendored_nuget(); + let (code, stdout, stderr) = project.run(&["apply", "--json", "--offline", "-g"]); + assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); + let v = parse(&stdout, &stderr); + assert!(events_with(&v, "applied").is_empty(), "{v}"); + assert!( + events_with(&v, "skipped") + .iter() + .any(|e| e["purl"] == N_PURL && e["errorCode"] == "vendored"), + "the project's install is a vendored skip: {v}" + ); + project.assert_untouched("apply -g"); +} + +/// `scan -g --mode agent` skips the project's install as +/// `vendored_ownership_retained` (#489) and records no agent patch for it. +#[tokio::test] +async fn global_agent_scan_skips_a_vendored_nuget_projects_install() { + let project = vendored_nuget(); + // v5 vendored mode is manifest-free: nothing but the ledger marks the + // copy as the project's. + std::fs::write( + project.root().join(".socket/manifest.json"), + "{\n \"patches\": {}\n}\n", + ) + .unwrap(); + let mock = MockServer::start().await; + mount_nuget_api(&mock).await; + let uri = mock.uri(); + let (code, stdout, stderr) = project.run(&[ + "scan", + "--json", + "--yes", + "--mode", + "agent", + "-g", + "--api-url", + &uri, + "--api-token", + "fake-token", + "--org", + ORG, + ]); + assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); + let v = parse(&stdout, &stderr); + let codes: Vec<&str> = v["warnings"] + .as_array() + .map(|w| w.iter().filter_map(|w| w["code"].as_str()).collect()) + .unwrap_or_default(); + assert!( + codes.contains(&"vendored_ownership_retained"), + "the project's ledger owns its install: {v}" + ); + assert_eq!( + std::fs::read_to_string(project.root().join(".socket/manifest.json")).unwrap(), + "{\n \"patches\": {}\n}\n", + "no agent record for the project's install: {v}" + ); + project.assert_untouched("scan -g --mode agent"); +} + +/// A global store the project does not restore into is still the global +/// run's (#446): `rollback --global-prefix ` restores its copy. +#[test] +fn global_rollback_restores_a_store_the_nuget_project_does_not_use() { + let project = vendored_nuget(); + let other = empty_prefix(); + let copy = write_newtonsoft(other.path(), N_PATCHED); + let (code, stdout, stderr) = project.run(&[ + "rollback", + "--json", + "--yes", + "--offline", + "--global-prefix", + other.path().to_str().unwrap(), + ]); + assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); + assert_eq!( + std::fs::read(copy.join("LICENSE.md")).unwrap(), + N_PRISTINE, + "the unrelated global copy must be restored: {stdout}" + ); + project.assert_untouched("rollback --global-prefix "); +} + +async fn mount_nuget_api(mock: &MockServer) { + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "packages": [{ + "purl": N_PURL, + "patches": [{ + "uuid": N_UUID, "purl": N_PURL, "tier": "free", + "cveIds": [], "ghsaIds": ["GHSA-newtonsoft-0"], + "severity": "high", "title": "Newtonsoft.Json", + }] + }], + "canAccessPaidPatches": false, + }))) + .mount(mock) + .await; + let vulns = json!({ + "GHSA-newtonsoft-0": { "cves": [], "summary": "s", "severity": "high", "description": "d" } + }); + Mock::given(method("GET")) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.*(?i:newtonsoft\\.json)(%40|@)13\\.0\\.3$" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "patches": [{ + "uuid": N_UUID, "purl": N_PURL, "publishedAt": "2026-01-01T00:00:00Z", + "description": "Newtonsoft.Json", "license": "MIT", "tier": "free", + "vulnerabilities": vulns, + }], + "canAccessPaidPatches": false, + }))) + .mount(mock) + .await; + use base64::Engine; + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{N_UUID}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "uuid": N_UUID, "purl": N_PURL, "publishedAt": "2026-01-01T00:00:00Z", + "files": { "LICENSE.md": { + "beforeHash": git_sha256(N_PRISTINE), + "afterHash": git_sha256(N_PATCHED), + "blobContent": base64::engine::general_purpose::STANDARD.encode(N_PATCHED), + }}, + "vulnerabilities": vulns, + "description": "Newtonsoft.Json", "license": "MIT", "tier": "free", + }))) + .mount(mock) + .await; +} + // ═══════════════════ 5. the vendor command under global scope ═══════════════════ /// Every way to ask for global scope: the flags and their env vars. diff --git a/crates/socket-patch-core/src/crawlers/nuget_crawler.rs b/crates/socket-patch-core/src/crawlers/nuget_crawler.rs index 34837c618..79e354a32 100644 --- a/crates/socket-patch-core/src/crawlers/nuget_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/nuget_crawler.rs @@ -50,6 +50,43 @@ impl NuGetCrawler { Ok(package_roots(options).await.paths) } + /// Whether a global run under `global` reads a package folder the + /// project at `project_root` restores into (#489). A PackageReference + /// project restores into the global packages folder, so there the + /// "global copy" of a package IS the project's installed copy, and the + /// project's own patch state still owns it. + /// + /// The project's folders are the ones its restores used + /// ([`project_restore_folders`]), not every root a project-scope crawl + /// would read: a project that restores into `RestorePackagesPath` never + /// reads the global folder, so the global copies there stay the global + /// run's. Not gated on a root-level .NET marker: the caller already + /// knows the project vendors NuGet packages, and its projects may all + /// live in subdirectories. + pub async fn global_roots_shared_with( + &self, + global: &CrawlerOptions, + project_root: &Path, + ) -> bool { + let global_roots = package_roots(global).await.paths; + if global_roots.is_empty() { + return false; + } + let chain = settings_chain(Some(project_root)).await; + let root = project_root.to_path_buf(); + run_blocking(move || { + let canonical = |p: &PathBuf| std::fs::canonicalize(p).unwrap_or_else(|_| p.clone()); + let project: HashSet = project_restore_folders(&root, &chain) + .iter() + .map(canonical) + .collect(); + global_roots + .iter() + .any(|root| project.contains(&canonical(root))) + }) + .await + } + /// Crawl all discovered package paths and return every package found. /// /// A project-local `packages/` folder (and every root of a project @@ -598,6 +635,26 @@ async fn package_roots(options: &CrawlerOptions) -> PackageRoots { roots } +/// The package folders the projects under `root` install into: every +/// restore's recorded `packageFolders`, plus the global packages folder +/// NuGet would use when some project has no parsed restore yet (its next +/// restore lands there) or no restore names a folder at all, plus the +/// packages.config folder when a `packages.config` lives under `root`. +fn project_restore_folders(root: &Path, chain: &SettingsChain) -> Vec { + let repository_path = chain.folder("repositoryPath"); + let global = global_packages_folder(chain); + let skip: Vec = repository_path.iter().chain(&global).cloned().collect(); + let walk = restore_walk(root, &skip); + let mut folders = walk.package_folders; + if walk.libraries.is_none() || folders.is_empty() { + folders.extend(global); + } + if walk.packages_config { + folders.push(repository_path.unwrap_or_else(|| root.join("packages"))); + } + folders +} + /// What one `project.assets.json` says. #[derive(Debug, Default)] struct ProjectAssets { @@ -1303,6 +1360,90 @@ mod tests { ); } + /// `dir/obj/project.assets.json` recording a restore into `folder`. + fn write_restore(dir: &Path, folder: &Path) { + std::fs::create_dir_all(dir.join("obj")).unwrap(); + std::fs::write(dir.join("app.csproj"), "").unwrap(); + let assets = serde_json::json!({ + "packageFolders": { format!("{}/", folder.display()): {} }, + "libraries": {}, + }); + std::fs::write(dir.join("obj/project.assets.json"), assets.to_string()).unwrap(); + } + + fn global_at(prefix: &Path) -> CrawlerOptions { + CrawlerOptions { + cwd: prefix.to_path_buf(), + global: true, + global_prefix: Some(prefix.to_path_buf()), + } + } + + /// #489: a global prefix the project's restore uses is the project's + /// store; any other folder is not, and neither is a directory with no + /// .NET project and no restore. + #[tokio::test] + async fn global_roots_shared_with_the_projects_restore_folder() { + let store = tempfile::tempdir().unwrap(); + let other = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + write_restore(project.path(), store.path()); + let crawler = NuGetCrawler::new(); + assert!( + crawler + .global_roots_shared_with(&global_at(store.path()), project.path()) + .await + ); + assert!( + !crawler + .global_roots_shared_with(&global_at(other.path()), project.path()) + .await + ); + assert!( + !crawler + .global_roots_shared_with(&global_at(store.path()), other.path()) + .await, + "no restore under `other` names the store" + ); + } + + /// A restore relocated by `RestorePackagesPath` never reads the global + /// packages folder, so that folder is not the project's (#489 review). + #[tokio::test] + async fn global_roots_shared_with_ignores_a_folder_the_restore_relocated_away_from() { + let global = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + let pkgs = project.path().join("pkgs"); + std::fs::create_dir_all(&pkgs).unwrap(); + write_restore(project.path(), &pkgs); + let crawler = NuGetCrawler::new(); + assert!( + !crawler + .global_roots_shared_with(&global_at(global.path()), project.path()) + .await + ); + assert!( + crawler + .global_roots_shared_with(&global_at(&pkgs), project.path()) + .await + ); + } + + /// A repository whose projects all live in subdirectories (no root + /// `.sln`, project file or NuGet config) still shares its restores' + /// folder (#489 review). + #[tokio::test] + async fn global_roots_shared_with_sees_restores_below_a_markerless_root() { + let store = tempfile::tempdir().unwrap(); + let repo = tempfile::tempdir().unwrap(); + write_restore(&repo.path().join("src/App"), store.path()); + assert!( + NuGetCrawler::new() + .global_roots_shared_with(&global_at(store.path()), repo.path()) + .await + ); + } + #[tokio::test] #[serial_test::serial] async fn test_nuget_home_env_var() {