From c37462ec9ec06f7f46e47cc1948aa079793d502f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 18:23:04 +0000 Subject: [PATCH 1/5] Start fix for #489 Assisted-by: Claude Code:claude-opus-5-5 From cd8a112bc92b1da639508de1edecaf740d16a6ea Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 18:36:02 +0000 Subject: [PATCH 2/5] Keep -g off a vendored NuGet project's install A NuGet PackageReference project restores into the global packages folder, so a global run's copy of a package the project vendors is the project's own patched install. Since #446, `scan -g --mode agent` and `apply -g` took it over as an agent patch, and `rollback -g` or `remove -g` restored upstream bytes into it. A locked restore never re-extracts it, so the project built unpatched with exit 0 while vex kept attesting. Global runs now leave that copy to the project's vendor ledger when the global folder is one the project restores into: scan skips it as vendored_ownership_retained, apply skips it as vendored, and rollback and remove keep it patched. A global store the project doesn't use is still patched and restored as before. Fixes #489 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/src/commands/apply.rs | 26 +- crates/socket-patch-cli/src/commands/mod.rs | 33 ++ .../socket-patch-cli/src/commands/remove.rs | 19 +- .../socket-patch-cli/src/commands/rollback.rs | 14 +- .../socket-patch-cli/src/commands/scan/mod.rs | 10 +- .../tests/global_scope_project_state.rs | 390 +++++++++++++++++- .../src/crawlers/nuget_crawler.rs | 70 ++++ 7 files changed, 533 insertions(+), 29 deletions(-) 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..a7164b63b 100644 --- a/crates/socket-patch-cli/src/commands/mod.rs +++ b/crates/socket-patch-cli/src/commands/mod.rs @@ -50,6 +50,39 @@ 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(); + } + 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..9a7076153 100644 --- a/crates/socket-patch-cli/src/commands/remove.rs +++ b/crates/socket-patch-cli/src/commands/remove.rs @@ -617,11 +617,20 @@ 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). + 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( + &args.common, + &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. diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index 0b0e90d7b..e55726e52 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -1542,7 +1542,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 +1557,14 @@ 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 = if project_state { + vendor_state_result + .as_ref() + .map(VendorState::purl_keys) + .unwrap_or_default() + } else { + crate::commands::vendor_owned_keys(&args.common, &project_vendored_keys).await + }; // ── scope resolution ──────────────────────────────────────────────── let manifest = if manifest_missing { 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/global_scope_project_state.rs b/crates/socket-patch-cli/tests/global_scope_project_state.rs index 90a618253..9671ddb33 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,392 @@ 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}"); + 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}"); + 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..6cc126e96 100644 --- a/crates/socket-patch-core/src/crawlers/nuget_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/nuget_crawler.rs @@ -50,6 +50,34 @@ impl NuGetCrawler { Ok(package_roots(options).await.paths) } + /// Whether a global run under `global` reads a package root 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. False when either side has + /// no root (no .NET project at `project_root`, no global folder). + 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 project = CrawlerOptions { + cwd: project_root.to_path_buf(), + global: false, + global_prefix: None, + }; + let project_roots = package_roots(&project).await.paths; + let canonical = |p: &PathBuf| std::fs::canonicalize(p).unwrap_or_else(|_| p.clone()); + let project_roots: HashSet = project_roots.iter().map(canonical).collect(); + global_roots + .iter() + .any(|root| project_roots.contains(&canonical(root))) + } + /// Crawl all discovered package paths and return every package found. /// /// A project-local `packages/` folder (and every root of a project @@ -1303,6 +1331,48 @@ mod tests { ); } + /// #489: a global prefix the project's restore uses is the project's + /// store; any other folder, or a directory with no .NET project, is not. + #[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(); + std::fs::write(project.path().join("app.csproj"), "").unwrap(); + std::fs::create_dir(project.path().join("obj")).unwrap(); + let assets = serde_json::json!({ + "packageFolders": { format!("{}/", store.path().display()): {} }, + "libraries": {}, + }); + std::fs::write( + project.path().join("obj/project.assets.json"), + assets.to_string(), + ) + .unwrap(); + let global = |prefix: &Path| CrawlerOptions { + cwd: project.path().to_path_buf(), + global: true, + global_prefix: Some(prefix.to_path_buf()), + }; + let crawler = NuGetCrawler::new(); + assert!( + crawler + .global_roots_shared_with(&global(store.path()), project.path()) + .await + ); + assert!( + !crawler + .global_roots_shared_with(&global(other.path()), project.path()) + .await + ); + assert!( + !crawler + .global_roots_shared_with(&global(store.path()), other.path()) + .await, + "no .NET project: nothing of the project's lives in the store" + ); + } + #[tokio::test] #[serial_test::serial] async fn test_nuget_home_env_var() { From d723ef9d3e5c1f88b8a00f59c91e4526c4ee871f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 18:41:11 +0000 Subject: [PATCH 3/5] Document and e2e-test -g over NuGet installs Describe in CLI_CONTRACT.md the one store a global run leaves to the project: a NuGet project's own install in the global packages folder it restores into. Add a real-dotnet capstone: vendor, locked restore, scan -g --mode agent, rollback -g, then a second locked restore must still extract the patched bytes. Refs #489 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../tests/e2e_nuget_dotnet_build.rs | 102 ++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 82fdd8b5d..633adb758 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 (`rollback` keeps their manifest records, as above). 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. 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..ee8219c56 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,108 @@ 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:#}"); + + 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 From 2731209fa5e6f848b63f4d71cb38715b77199c6b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 19:17:19 +0000 Subject: [PATCH 4/5] Seed the pre-fix record in the -g capstone With the fix, scan -g no longer records the project's install, so the rollback leg had no manifest to read. Seed the agent record an older scan -g wrote, so the test proves rollback -g leaves the install patched for users upgrading from an affected version. Refs #489 Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/e2e_nuget_dotnet_build.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) 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 ee8219c56..8b5831646 100644 --- a/crates/socket-patch-cli/tests/e2e_nuget_dotnet_build.rs +++ b/crates/socket-patch-cli/tests/e2e_nuget_dotnet_build.rs @@ -1067,6 +1067,25 @@ fn nuget_vendored_project_survives_global_agent_scan_and_rollback() { ); 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); From 8c4fb086bc1281625fa7918aae8cc2b3ed424c7e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 19:39:10 +0000 Subject: [PATCH 5/5] Match -g NuGet ownership to restore folders Review follow-ups for the global-scope NuGet ownership check: - A project that restores into RestorePackagesPath never reads the global packages folder, so its global copies stay the global run's. The check now uses the folders the project's restores recorded, and the global folder only while a project has no restore yet. - Repositories whose projects all live in subdirectories, with no root .NET marker, are covered too. - rollback -g and remove -g now report each copy they leave to the project as a skipped/vendored event and a human [skip] line, instead of skipping it silently. - Runs whose --ecosystems excludes nuget skip the check, and path canonicalization moves onto the blocking pool. Refs #489 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 4 +- crates/socket-patch-cli/src/commands/mod.rs | 3 + .../socket-patch-cli/src/commands/remove.rs | 31 +++- .../socket-patch-cli/src/commands/rollback.rs | 50 +++++-- .../tests/global_scope_project_state.rs | 12 ++ .../src/crawlers/nuget_crawler.rs | 141 +++++++++++++----- 6 files changed, 193 insertions(+), 48 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 633adb758..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). 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 (`rollback` keeps their manifest records, as above). 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/mod.rs b/crates/socket-patch-cli/src/commands/mod.rs index a7164b63b..27d5fd9ff 100644 --- a/crates/socket-patch-cli/src/commands/mod.rs +++ b/crates/socket-patch-cli/src/commands/mod.rs @@ -68,6 +68,9 @@ pub(crate) async fn vendor_owned_keys( 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)) diff --git a/crates/socket-patch-cli/src/commands/remove.rs b/crates/socket-patch-cli/src/commands/remove.rs index 9a7076153..ad3e4442f 100644 --- a/crates/socket-patch-cli/src/commands/remove.rs +++ b/crates/socket-patch-cli/src/commands/remove.rs @@ -619,6 +619,7 @@ pub async fn run(args: RemoveArgs) -> i32 { // still carries an agent-mode patch (#336); an unreadable ledger degrades to // "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() @@ -626,7 +627,10 @@ pub async fn run(args: RemoveArgs) -> i32 { .unwrap_or_default() } else { crate::commands::vendor_owned_keys( - &args.common, + &GlobalArgs { + ecosystems: None, + ..args.common.clone() + }, &socket_patch_core::vendor::vendored_purl_keys(&args.common.project_root()).await, ) .await @@ -635,6 +639,9 @@ pub async fn run(args: RemoveArgs) -> i32 { // `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 @@ -675,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) @@ -799,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 e55726e52..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| { @@ -1557,14 +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 = if project_state { - vendor_state_result - .as_ref() - .map(VendorState::purl_keys) - .unwrap_or_default() - } else { - crate::commands::vendor_owned_keys(&args.common, &project_vendored_keys).await - }; + let vendored_keys: HashSet = + crate::commands::vendor_owned_keys(&args.common, &project_vendored_keys).await; // ── scope resolution ──────────────────────────────────────────────── let manifest = if manifest_missing { @@ -2359,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, @@ -2370,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) @@ -6169,6 +6199,7 @@ mod tests { manifest, results, not_installed: &[], + project_owned: &[], vendored, vendor_entries: &[], hosted, @@ -6274,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/tests/global_scope_project_state.rs b/crates/socket-patch-cli/tests/global_scope_project_state.rs index 9671ddb33..423073bef 100644 --- a/crates/socket-patch-cli/tests/global_scope_project_state.rs +++ b/crates/socket-patch-cli/tests/global_scope_project_state.rs @@ -794,6 +794,12 @@ fn global_rollback_keeps_a_vendored_nuget_projects_install() { 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::( @@ -816,6 +822,12 @@ fn global_remove_keeps_a_vendored_nuget_projects_install() { 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"); } diff --git a/crates/socket-patch-core/src/crawlers/nuget_crawler.rs b/crates/socket-patch-core/src/crawlers/nuget_crawler.rs index 6cc126e96..79e354a32 100644 --- a/crates/socket-patch-core/src/crawlers/nuget_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/nuget_crawler.rs @@ -50,12 +50,19 @@ impl NuGetCrawler { Ok(package_roots(options).await.paths) } - /// Whether a global run under `global` reads a package root the + /// 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. False when either side has - /// no root (no .NET project at `project_root`, no global folder). + /// 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, @@ -65,17 +72,19 @@ impl NuGetCrawler { if global_roots.is_empty() { return false; } - let project = CrawlerOptions { - cwd: project_root.to_path_buf(), - global: false, - global_prefix: None, - }; - let project_roots = package_roots(&project).await.paths; - let canonical = |p: &PathBuf| std::fs::canonicalize(p).unwrap_or_else(|_| p.clone()); - let project_roots: HashSet = project_roots.iter().map(canonical).collect(); - global_roots - .iter() - .any(|root| project_roots.contains(&canonical(root))) + 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. @@ -626,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 { @@ -1331,45 +1360,87 @@ 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, or a directory with no .NET project, is not. + /// 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(); - std::fs::write(project.path().join("app.csproj"), "").unwrap(); - std::fs::create_dir(project.path().join("obj")).unwrap(); - let assets = serde_json::json!({ - "packageFolders": { format!("{}/", store.path().display()): {} }, - "libraries": {}, - }); - std::fs::write( - project.path().join("obj/project.assets.json"), - assets.to_string(), - ) - .unwrap(); - let global = |prefix: &Path| CrawlerOptions { - cwd: project.path().to_path_buf(), - global: true, - global_prefix: Some(prefix.to_path_buf()), - }; + write_restore(project.path(), store.path()); let crawler = NuGetCrawler::new(); assert!( crawler - .global_roots_shared_with(&global(store.path()), project.path()) + .global_roots_shared_with(&global_at(store.path()), project.path()) .await ); assert!( !crawler - .global_roots_shared_with(&global(other.path()), project.path()) + .global_roots_shared_with(&global_at(other.path()), project.path()) .await ); assert!( !crawler - .global_roots_shared_with(&global(store.path()), other.path()) + .global_roots_shared_with(&global_at(store.path()), other.path()) .await, - "no .NET project: nothing of the project's lives in the store" + "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 ); }