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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 128 additions & 8 deletions crates/socket-patch-core/src/utils/purl_key.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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/<vendor>/<name>@<version>` 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/<vendor>/<name>@<version>`:
/// [`composer_version_key`] (`v3.0.2` → `3.0.2.0`);
/// - a NuGet `pkg:nuget/<id>@<version>`: [`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);

Expand All @@ -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` /
Expand Down Expand Up @@ -127,6 +137,26 @@ impl AsRef<str> 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<String> {
composer_identity(canonical).or_else(|| nuget_identity(canonical))
}

/// `pkg:nuget/<id>@<normalized version>` for a canonical NuGet base with an
/// id and a version; `None` for anything else.
fn nuget_identity(canonical: &str) -> Option<String> {
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/<vendor>/<name>@<release identity>` for a canonical
/// composer base with a `vendor/name` coordinate and a version; `None` for
/// anything else (which then keys as its canonical spelling).
Expand All @@ -143,6 +173,10 @@ fn composer_identity(canonical: &str) -> Option<String> {
))
}

#[cfg(test)]
#[path = "purl_key_nuget_vendor_tests.rs"]
mod nuget_vendor_tests;

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -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!(
Expand All @@ -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.
Expand Down
151 changes: 151 additions & 0 deletions crates/socket-patch-core/src/utils/purl_key_nuget_vendor_tests.rs
Original file line number Diff line number Diff line change
@@ -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<u8> {
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"<?xml version=\"1.0\"?><Types/>"),
("_rels/.rels", b"<?xml version=\"1.0\"?><Relationships/>"),
(
"Newtonsoft.Json.nuspec",
b"<?xml version=\"1.0\"?><package><metadata><id>Newtonsoft.Json</id><version>13.0.3</version></metadata></package>",
),
("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 `<id>.<purl version>.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);
}
Loading