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); +}