From 3d5c7b6a9ba8f17454a6c0564f9eb243191f5701 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 06:01:59 +0000 Subject: [PATCH 1/2] Start refactor for #1202 Assisted-by: Claude Code:claude-opus-5-5 From 8c9349309ec2abc20d9a4a49010a86d3902315c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 06:09:49 +0000 Subject: [PATCH 2/2] Key NuGet purls by the normalized version A NuGet package vendored under a non-normalized purl version, such as the 4-part pkg:nuget/Foo@1.0.0.0 spelling packages.config projects use, was wired against the lock's normalized `resolved` version but judged by PurlKey, which only lowercased NuGet versions. VEX, vendor --check and the scan --prune GC then saw the live entry as unused, so prune silently reverted a working patch. PurlKey now folds a NuGet version through the one normalize_nuget_version the vendored backend and upstream restore already use, the same way it folds composer release spellings. The canonical spelling is unchanged. Tests pin the issue's vectors, sweep PurlKey against the vendored version match, and vendor at @13.0.3.0 against a lock resolving 13.0.3, which must stay in use and live. Refs #1202 Assisted-by: Claude Code:claude-opus-5-5 --- .../socket-patch-core/src/utils/purl_key.rs | 136 +++++++++++++++- .../src/utils/purl_key_nuget_vendor_tests.rs | 151 ++++++++++++++++++ 2 files changed, 279 insertions(+), 8 deletions(-) create mode 100644 crates/socket-patch-core/src/utils/purl_key_nuget_vendor_tests.rs diff --git a/crates/socket-patch-core/src/utils/purl_key.rs b/crates/socket-patch-core/src/utils/purl_key.rs index d3f4f4685..5c0878380 100644 --- a/crates/socket-patch-core/src/utils/purl_key.rs +++ b/crates/socket-patch-core/src/utils/purl_key.rs @@ -29,6 +29,7 @@ use std::fmt; use crate::crawlers::python_crawler::canonicalize_pypi_name; use crate::utils::composer_version::composer_version_key; use crate::utils::purl::{normalize_purl, strip_purl_qualifiers}; +use crate::vendor::nuget_feed::normalize_nuget_version; /// The canonical spelling of a purl's package release: surrounding /// whitespace trimmed, qualifiers and subpath stripped, components @@ -44,8 +45,9 @@ use crate::utils::purl::{normalize_purl, strip_purl_qualifiers}; /// Every other ecosystem keeps its spelling: npm forbids uppercase, and /// Maven groups and Go module paths are case-sensitive. /// -/// Composer release spellings (`3.0.2` vs `3.0.2.0`) still differ here; -/// compare with [`PurlKey`], which folds them. +/// Composer (`3.0.2` vs `3.0.2.0`) and NuGet (`13.0.3` vs `13.0.3.0`) +/// release spellings still differ here; compare with [`PurlKey`], which +/// folds them. pub fn canonical_base_purl(purl: &str) -> String { let base = normalize_purl(strip_purl_qualifiers(purl.trim())).into_owned(); let Some(rest) = base.strip_prefix("pkg:") else { @@ -74,10 +76,18 @@ pub fn canonical_base_purl(purl: &str) -> String { /// The identity of a purl's package release; equal for exactly the /// spellings that name the same release. See the [module docs](self). /// -/// The string form is [`canonical_base_purl`], with a composer -/// `pkg:composer//@` version replaced by its release -/// identity ([`composer_version_key`]: `v3.0.2` → `3.0.2.0`). It contains no -/// internal sentinels, so rollout and policy reports may show it. +/// The string form is [`canonical_base_purl`], with the version replaced by +/// its release identity where the ecosystem defines one: +/// +/// - a composer `pkg:composer//@`: +/// [`composer_version_key`] (`v3.0.2` → `3.0.2.0`); +/// - a NuGet `pkg:nuget/@`: [`normalize_nuget_version`], the +/// `NuGetVersion.ToNormalizedString()` form the vendored backend, upstream +/// restore and the lock's `resolved` field use (`13.0.3.0` → `13.0.3`, +/// build metadata dropped). +/// +/// It contains no internal sentinels, so rollout and policy reports may show +/// it. #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct PurlKey(String); @@ -86,7 +96,7 @@ impl PurlKey { /// ignored, so every release variant of one `name@version` shares it. pub fn new(purl: &str) -> Self { let canonical = canonical_base_purl(purl); - PurlKey(composer_identity(&canonical).unwrap_or(canonical)) + PurlKey(release_identity(&canonical).unwrap_or(canonical)) } /// [`PurlKey::new`] followed by `purl`'s verbatim `?qualifiers` / @@ -127,6 +137,26 @@ impl AsRef for PurlKey { } } +/// The canonical base with its version replaced by the ecosystem's release +/// identity; `None` where the spelling already is the identity. +fn release_identity(canonical: &str) -> Option { + composer_identity(canonical).or_else(|| nuget_identity(canonical)) +} + +/// `pkg:nuget/@` for a canonical NuGet base with an +/// id and a version; `None` for anything else. +fn nuget_identity(canonical: &str) -> Option { + let rest = canonical.strip_prefix("pkg:nuget/")?; + let (id, version) = rest.rsplit_once('@')?; + if id.is_empty() || version.is_empty() { + return None; + } + Some(format!( + "pkg:nuget/{id}@{}", + normalize_nuget_version(version) + )) +} + /// `pkg:composer//@` for a canonical /// composer base with a `vendor/name` coordinate and a version; `None` for /// anything else (which then keys as its canonical spelling). @@ -143,6 +173,10 @@ fn composer_identity(canonical: &str) -> Option { )) } +#[cfg(test)] +#[path = "purl_key_nuget_vendor_tests.rs"] +mod nuget_vendor_tests; + #[cfg(test)] mod tests { use super::*; @@ -228,7 +262,8 @@ mod tests { assert!(!PurlKey::new("pkg:composer/psr/log@not-a-version") .as_str() .contains('\u{1}')); - // Only composer gets release identity; other types stay version-exact. + // Only composer and NuGet get release identity; other types stay + // version-exact. assert!(!PurlKey::same("pkg:npm/x@1.0", "pkg:npm/x@1.0.0.0")); // Malformed composer coordinates key as their canonical spelling. assert_eq!( @@ -241,6 +276,91 @@ mod tests { ); } + /// #1202: NuGet's package identity is the normalized version, the one + /// the vendored backend wires (`locked_at`, the feed leaf) and the lock + /// records as `resolved`. A 4-part `packages.config` spelling, a padded + /// or zero-led segment, a pre-release case variant and build metadata + /// all name the same release. + #[test] + fn nuget_version_spellings_share_the_normalized_key() { + for (a, b) in [ + ("pkg:nuget/A@1.0.0.0", "pkg:nuget/a@1.0.0"), + ( + "pkg:nuget/Newtonsoft.Json@13.0.3.0", + "pkg:nuget/newtonsoft.json@13.0.3", + ), + ("pkg:nuget/A@1.0", "pkg:nuget/A@1.0.0"), + ("pkg:nuget/A@1.02.3", "pkg:nuget/A@1.2.3"), + ("pkg:nuget/A@1.0.0-RC1", "pkg:nuget/a@1.0.0-rc1"), + ("pkg:nuget/A@1.0.0+build.5", "pkg:nuget/A@1.0.0"), + ("pkg:nuget/A@1.0.0%2Bbuild.5", "pkg:nuget/A@1.0.0"), + ("pkg:nuget/A@1.0.0.0?repository_url=x", "pkg:nuget/A@1.0.0"), + ] { + assert!(PurlKey::same(a, b), "{a} vs {b}"); + } + assert_eq!( + PurlKey::new("pkg:nuget/Microsoft.Web.Infrastructure@1.0.0.0").as_str(), + "pkg:nuget/microsoft.web.infrastructure@1.0.0" + ); + // A non-zero revision is part of the identity. + assert!(!PurlKey::same("pkg:nuget/A@1.0.0.1", "pkg:nuget/A@1.0.0")); + assert!(!PurlKey::same("pkg:nuget/A@1.0.0-rc1", "pkg:nuget/A@1.0.0")); + // The canonical spelling keeps the as-written version. + assert_eq!( + canonical_base_purl("pkg:nuget/A@1.0.0.0"), + "pkg:nuget/a@1.0.0.0" + ); + // An id or version that is missing keys as its canonical spelling. + assert_eq!(PurlKey::new("pkg:nuget/A").as_str(), "pkg:nuget/a"); + assert_eq!(PurlKey::new("pkg:nuget/A@").as_str(), "pkg:nuget/a@"); + // The qualified key still tells release variants apart. + assert_eq!( + PurlKey::qualified("pkg:nuget/A@1.0.0.0?x=1").as_str(), + "pkg:nuget/a@1.0.0?x=1" + ); + } + + /// One identity rule: two NuGet purls share a [`PurlKey`] exactly when + /// the vendored backend's version match (`normalize_nuget_version`, as + /// `locked_at` and upstream restore compare) and NuGet's + /// case-insensitive id match both say they are the same release. + #[test] + fn nuget_key_agrees_with_the_vendored_version_match() { + let ids = ["Newtonsoft.Json", "newtonsoft.json", "Other"]; + let versions = [ + "13.0.3", + "13.0.3.0", + "13.00.3", + "13.0.3.1", + "13.0", + "13.0.0", + "13.0.3-Beta", + "13.0.3-beta", + "13.0.3+meta", + "13.0.4", + ]; + for (ia, va) in ids + .iter() + .flat_map(|i| versions.iter().map(move |v| (i, v))) + { + for (ib, vb) in ids + .iter() + .flat_map(|i| versions.iter().map(move |v| (i, v))) + { + let vendored = ia.eq_ignore_ascii_case(ib) + && normalize_nuget_version(va) == normalize_nuget_version(vb); + assert_eq!( + PurlKey::same( + &format!("pkg:nuget/{ia}@{va}"), + &format!("pkg:nuget/{ib}@{vb}") + ), + vendored, + "{ia}@{va} vs {ib}@{vb}" + ); + } + } + } + /// B20 / #553: the NuGet global-cache crawl spells the purl lowercase, /// the API mixed-case; B73: a PEP 503 or NuGet case variant names the /// same release. diff --git a/crates/socket-patch-core/src/utils/purl_key_nuget_vendor_tests.rs b/crates/socket-patch-core/src/utils/purl_key_nuget_vendor_tests.rs new file mode 100644 index 000000000..f9d67998d --- /dev/null +++ b/crates/socket-patch-core/src/utils/purl_key_nuget_vendor_tests.rs @@ -0,0 +1,151 @@ +//! #1202: a NuGet entry vendored under a non-normalized purl version must +//! stay live for every reader that judges vendored wiring through +//! [`PurlKey`]: VEX discovery, `vendor --check` and the `scan --prune` GC. +//! The vendored backend matches the lock's `resolved` through +//! `normalize_nuget_version`; the liveness gates compare purls through +//! `PurlKey`. Both now use the same rule. + +use std::collections::HashMap; +use std::io::Write as _; +use std::path::Path; + +use crate::hash::git_sha256::compute_git_sha256_from_bytes; +use crate::manifest::schema::{PatchFileInfo, PatchRecord}; +use crate::patch::apply::PatchSources; +use crate::vendor::VendorOutcome; + +const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; +const PRISTINE: &[u8] = b"The MIT License (MIT)\nCopyright (c) 2007 James Newton-King\n"; +const PATCHED: &[u8] = + b"The MIT License (MIT)\n// SOCKET-PATCH-MARKER\nCopyright (c) 2007 James Newton-King\n"; + +fn nupkg(license: &[u8]) -> Vec { + 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.Json13.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() +} + +/// A restored project resolving `Newtonsoft.Json` `13.0.3`, its global-cache +/// copy, and a blob store carrying the patched `LICENSE.md`. +async fn fixture(root: &Path) -> (std::path::PathBuf, std::path::PathBuf, PatchRecord) { + let installed = root.join("packages/newtonsoft.json/13.0.3"); + tokio::fs::create_dir_all(installed.join("lib/net6.0")) + .await + .unwrap(); + tokio::fs::write( + installed.join("newtonsoft.json.13.0.3.nupkg"), + nupkg(PRISTINE), + ) + .await + .unwrap(); + // The service fixture builds its grant from `..nupkg`, + // the as-written spelling; NuGet's cache keeps the normalized one above. + tokio::fs::write( + installed.join("newtonsoft.json.13.0.3.0.nupkg"), + nupkg(PRISTINE), + ) + .await + .unwrap(); + tokio::fs::write(installed.join("LICENSE.md"), PRISTINE) + .await + .unwrap(); + tokio::fs::write( + installed.join("lib/net6.0/Newtonsoft.Json.dll"), + b"MZ-fake-assembly", + ) + .await + .unwrap(); + let after = compute_git_sha256_from_bytes(PATCHED); + let blobs = root.join("blobs"); + tokio::fs::create_dir_all(&blobs).await.unwrap(); + tokio::fs::write(blobs.join(&after), PATCHED).await.unwrap(); + let lock = serde_json::json!({ + "version": 1, + "dependencies": { + "net8.0": { + "Newtonsoft.Json": { + "type": "Direct", + "requested": "[13.0.3, )", + "resolved": "13.0.3", + "contentHash": "ORIGINALcachedhash==" + } + } + } + }); + tokio::fs::write( + root.join("packages.lock.json"), + serde_json::to_string_pretty(&lock).unwrap(), + ) + .await + .unwrap(); + let files = HashMap::from([( + "LICENSE.md".to_string(), + PatchFileInfo { + before_hash: compute_git_sha256_from_bytes(PRISTINE), + after_hash: after, + }, + )]); + let record = PatchRecord { + uuid: UUID.to_string(), + exported_at: "2026-06-09T00:00:00Z".to_string(), + files, + vulnerabilities: HashMap::new(), + description: String::new(), + license: String::new(), + tier: String::new(), + }; + (installed, blobs, record) +} + +/// Every vendored-liveness reader agrees that an entry vendored at +/// `@13.0.3.0` (the 4-part `packages.config` spelling) against a lock +/// resolving `13.0.3` is in use. `test_support::vendor_nuget` asserts the +/// prune GC's verdict (`vendor_entry_in_use != Some(false)`); this test also +/// pins the VEX claim and `vendor --check`'s liveness. +#[tokio::test] +async fn nuget_vendored_at_a_four_part_version_stays_live() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let (installed, blobs, record) = fixture(root).await; + let sources = PatchSources::blobs_only(&blobs); + let outcome = crate::vendor::test_support::vendor_nuget( + "pkg:nuget/Newtonsoft.Json@13.0.3.0", + installed.as_path(), + root, + &record, + &sources, + "2026-06-09T00:00:00Z", + false, + false, + None, + ) + .await; + let VendorOutcome::Done { + result, + entry: Some(entry), + .. + } = outcome + else { + panic!("vendor_nuget did not vendor: {outcome:?}"); + }; + assert!(result.success, "{result:?}"); + assert_eq!(entry.base_purl, "pkg:nuget/Newtonsoft.Json@13.0.3.0"); + + let refs = crate::vex::discover::discover_patched_refs(root).await; + assert_eq!(refs.vendor_entry_in_use(root, &entry).await, Some(true)); + assert!(refs.vendor_entry_live(root, &entry).await); +}