From dee10a4ebf8046278dbdc96ff0ee5dfd3f256ea0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 07:02:00 +0000 Subject: [PATCH 1/3] Start refactor for #1202 Assisted-by: Claude Code:claude-opus-5-5 From 38793538249e273f3f3c1910a618e883a2eb4201 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 07:07:04 +0000 Subject: [PATCH 2/3] Find NuGet packages by normalized version Agent-mode apply, rollback and VEX locate a NuGet package's directory through the crawler's find_by_purls, which keyed versions by lowercase only. NuGet's identity is the normalized version, so a project pinned as 1.0.0.0 never found the global folder's foo/1.0.0/, and a packages.config folder Foo.1.0.0.0/ was invisible to a purl at 1.0.0. The lookup now goes through the same normalize_nuget_version the vendored feed and lock match use: the global folder under the normalized version (then the as-written one), and the legacy folder fallback by case-insensitive id plus normalized version. Spellings that already matched still match the same directory. Refs #1202 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/crawlers/nuget_crawler.rs | 186 +++++++++++++++--- 1 file changed, 163 insertions(+), 23 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/nuget_crawler.rs b/crates/socket-patch-core/src/crawlers/nuget_crawler.rs index ad2c4d981..2b3aea10a 100644 --- a/crates/socket-patch-core/src/crawlers/nuget_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/nuget_crawler.rs @@ -5,6 +5,7 @@ use super::listing::{list_dir_sync, ListedEntry}; use super::types::{CrawledPackage, CrawlerOptions}; use crate::patch::path_safety; use crate::utils::fs::{is_dir, is_dir_sync, run_blocking}; +use crate::vendor::nuget_feed::normalize_nuget_version; #[cfg(test)] mod oracle; @@ -147,25 +148,38 @@ fn find_by_purls_sync(pkg_path: &Path, purls: &[String]) -> HashMap//. - // NuGet lowercases BOTH the id and the version when it lays - // out the global packages folder, so a prerelease tag like - // `2.0.0-RC1` lives on disk as `2.0.0-rc1`. Lowercasing only - // the name (but not the version) would miss those packages. - let global_dir = pkg_path - .join(name.to_lowercase()) - .join(version.to_lowercase()); + // NuGet's package identity is the normalized version + // (`1.0.0.0` = `1.0.0` = `1.00.0`), the rule `PurlKey` and the + // vendored feed share. + let normalized = normalize_nuget_version(version); + // Global cache layout: //. + // NuGet lays out the global packages folder under the lowercased + // id and the lowercased normalized version, so a prerelease tag + // like `2.0.0-RC1` lives on disk as `2.0.0-rc1` and a 4-part + // `1.0.0.0` as `1.0.0`. The as-written spelling (lowercased) is + // tried after it, for a folder some other tool laid out verbatim. + let name_dir = pkg_path.join(name.to_lowercase()); + let global_dirs = [ + Some(name_dir.join(&normalized)), + (normalized != version.to_lowercase()).then(|| name_dir.join(version.to_lowercase())), + ]; // Legacy layout: ./, tried exact-case first, then - // case-insensitively (NuGet names are case-insensitive). + // by identity (case-insensitive id, normalized version) over the + // root's listing: `packages.config` folders keep the version as + // the project wrote it, so `Foo.1.0.0.0/` holds `@1.0.0`. let legacy_dir = pkg_path.join(format!("{name}.{version}")); - let found = if verify_nuget_package(&global_dir) { - Some(global_dir) + let found = if let Some(dir) = global_dirs + .into_iter() + .flatten() + .find(|dir| verify_nuget_package(dir)) + { + Some(dir) } else if verify_nuget_package(&legacy_dir) { Some(legacy_dir) } else { let names = super::listing::names_memoized(pkg_path, &mut root_names); - find_legacy_dir_case_insensitive(pkg_path, &names, name, version) + find_legacy_dir_by_identity(pkg_path, &names, name, &normalized) }; if let Some(path) = found { @@ -310,18 +324,18 @@ fn verify_nuget_package(path: &Path) -> bool { }) } -/// Find a legacy package directory with case-insensitive matching, over the -/// package root's (lossy) entry names in readdir order. -fn find_legacy_dir_case_insensitive( +/// Find a legacy `./` package directory for `name` at the +/// normalized version `normalized` ([`normalize_nuget_version`]), over the +/// package root's (lossy) entry names in readdir order: the id matches +/// case-insensitively and the folder's version normalizes to `normalized`. +fn find_legacy_dir_by_identity( pkg_path: &Path, root_names: &[String], name: &str, - version: &str, + normalized: &str, ) -> Option { - let target = format!("{}.{}", name.to_lowercase(), version.to_lowercase()); - for dir_name_str in root_names { - if dir_name_str.to_lowercase() == target { + if legacy_dir_is(dir_name_str, name, normalized) { let path = pkg_path.join(dir_name_str); if verify_nuget_package(&path) { return Some(path); @@ -332,6 +346,20 @@ fn find_legacy_dir_case_insensitive( None } +/// Whether the legacy folder name `dir_name` is `.` for an +/// id equal to `name` ignoring case and a version that normalizes to +/// `normalized`. Every `.` is a candidate boundary, since both the id and +/// the version may contain dots. +fn legacy_dir_is(dir_name: &str, name: &str, normalized: &str) -> bool { + let name = name.to_lowercase(); + dir_name.match_indices('.').any(|(i, _)| { + let version = &dir_name[i + 1..]; + !version.is_empty() + && dir_name[..i].to_lowercase() == name + && normalize_nuget_version(version) == normalized + }) +} + /// Get the NuGet global packages folder. /// /// Checks `NUGET_PACKAGES` env var, falls back to `~/.nuget/packages/` @@ -1339,7 +1367,119 @@ mod tests { assert!(result.contains_key("pkg:nuget/Contoso.Widgets@2.0.0-RC1")); } - /// Guard on `find_legacy_dir_case_insensitive`'s verification gate: a + /// NuGet identity (#1202): the global packages folder is laid out under + /// the normalized version, so a 4-part `@1.0.0.0` (the spelling a + /// `packages.config` project and its legacy folders carry) and a + /// zero-padded `@1.00.0` both resolve to `foo/1.0.0/`, through the same + /// `normalize_nuget_version` the vendored feed and lock match use. + #[tokio::test] + async fn test_find_by_purls_global_cache_normalizes_version() { + let dir = tempfile::tempdir().unwrap(); + let pkg_dir = dir.path().join("foo").join("1.0.0"); + tokio::fs::create_dir_all(pkg_dir.join("lib")) + .await + .unwrap(); + + let purls: Vec = [ + "pkg:nuget/Foo@1.0.0.0", + "pkg:nuget/Foo@1.00.0", + "pkg:nuget/Foo@1.0.0+build.7", + ] + .iter() + .map(|p| p.to_string()) + .collect(); + let result = NuGetCrawler::new() + .find_by_purls(dir.path(), &purls) + .await + .unwrap(); + for purl in &purls { + let pkg = result + .get(purl) + .unwrap_or_else(|| panic!("{purl} not found: {result:?}")); + assert_eq!(pkg.path, pkg_dir); + assert_eq!(pkg.purl, *purl, "the row keeps the requested purl"); + } + // A different release is still a different package. + let other = vec!["pkg:nuget/Foo@1.0.0.1".to_string()]; + let result = NuGetCrawler::new() + .find_by_purls(dir.path(), &other) + .await + .unwrap(); + assert!(result.is_empty(), "{result:?}"); + } + + /// A global-layout folder written under the as-written version (not + /// NuGet's own layout) is still found, after the normalized one. + #[tokio::test] + async fn test_find_by_purls_global_cache_as_written_version_still_found() { + let dir = tempfile::tempdir().unwrap(); + let pkg_dir = dir.path().join("foo").join("1.0.0.0"); + tokio::fs::create_dir_all(pkg_dir.join("lib")) + .await + .unwrap(); + + let purls = vec!["pkg:nuget/Foo@1.0.0.0".to_string()]; + let result = NuGetCrawler::new() + .find_by_purls(dir.path(), &purls) + .await + .unwrap(); + assert_eq!(result["pkg:nuget/Foo@1.0.0.0"].path, pkg_dir); + } + + /// NuGet identity (#1202): a legacy `packages/./` folder + /// keeps the version as `packages.config` spelled it, so + /// `Foo.1.0.0.0/` holds `@1.0.0` and `Foo.1.0.0/` holds `@1.0.0.0`. + #[tokio::test] + async fn test_find_by_purls_legacy_layout_normalizes_version() { + for (folder, purl) in [ + ("Foo.1.0.0.0", "pkg:nuget/Foo@1.0.0"), + ("Foo.1.0.0", "pkg:nuget/Foo@1.0.0.0"), + ("foo.01.0.0", "pkg:nuget/FOO@1.0.0"), + ("Foo.2.0.0-RC1", "pkg:nuget/Foo@2.0.0.0-rc1"), + ] { + let dir = tempfile::tempdir().unwrap(); + let pkg_dir = dir.path().join(folder); + tokio::fs::create_dir_all(pkg_dir.join("lib")) + .await + .unwrap(); + + let purls = vec![purl.to_string()]; + let result = NuGetCrawler::new() + .find_by_purls(dir.path(), &purls) + .await + .unwrap(); + assert_eq!( + result.get(purl).map(|p| &p.path), + Some(&pkg_dir), + "{folder} should hold {purl}" + ); + } + } + + /// The identity match splits `.` at every dot, but only an + /// id equal to the purl's and a version equal after normalization + /// match: `Foo.Bar.1.0.0` is not `Foo`, `Foo.1.0.0.1` is not `@1.0.0`. + #[test] + fn legacy_dir_is_matches_identity_only() { + assert!(legacy_dir_is("Foo.Bar.1.0.0.0", "foo.bar", "1.0.0")); + assert!(legacy_dir_is("Foo.Bar.1.0.0", "FOO.BAR", "1.0.0")); + assert!(!legacy_dir_is("Foo.Bar.1.0.0", "Foo", "1.0.0")); + assert!(!legacy_dir_is("Foo.1.0.0.1", "Foo", "1.0.0")); + assert!(!legacy_dir_is("Foo.1.0.0", "Fo", "1.0.0")); + assert!(!legacy_dir_is("Foo", "Foo", "1.0.0")); + assert!( + !legacy_dir_is("Foo.", "Foo", "0.0.0"), + "an empty version is no version" + ); + // The as-written spelling still matches, whatever it is. + assert!(legacy_dir_is( + "Foo.latest", + "foo", + &normalize_nuget_version("LATEST") + )); + } + + /// Guard on `find_legacy_dir_by_identity`'s verification gate: a /// directory whose NAME matches the legacy `.` target /// case-insensitively but whose contents do not verify as a NuGet /// package (no `lib/`, no `.nuspec`) must be skipped — the lookup @@ -1850,15 +1990,15 @@ mod tests { "hollow.1.0.0".to_string(), ]; assert_eq!( - find_legacy_dir_case_insensitive(&root, &names, "NEWTONSOFT.JSON", "13.0.3"), + find_legacy_dir_by_identity(&root, &names, "NEWTONSOFT.JSON", "13.0.3"), Some(root.join("newtonsoft.json.13.0.3")) ); assert_eq!( - find_legacy_dir_case_insensitive(&root, &names, "Hollow", "1.0.0"), + find_legacy_dir_by_identity(&root, &names, "Hollow", "1.0.0"), None ); assert_eq!( - find_legacy_dir_case_insensitive(&root, &names, "Missing", "9.9.9"), + find_legacy_dir_by_identity(&root, &names, "Missing", "9.9.9"), None ); From 034c1890d12ea8ff637082b19698851e93fcceba Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 09:10:53 +0000 Subject: [PATCH 3/3] Prefer the as-written NuGet folder spelling A cache or packages/ folder holding both spellings of one release (foo/1.0.0.0/ and foo/1.0.0/) now resolves to the folder main always picked, the as-written one, and only falls back to the normalized spelling. The legacy fallback likewise prefers the case-insensitive . match over an equal release spelled differently. Refs #1202 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/crawlers/nuget_crawler.rs | 81 ++++++++++++++----- 1 file changed, 60 insertions(+), 21 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/nuget_crawler.rs b/crates/socket-patch-core/src/crawlers/nuget_crawler.rs index 2b3aea10a..397104586 100644 --- a/crates/socket-patch-core/src/crawlers/nuget_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/nuget_crawler.rs @@ -157,11 +157,12 @@ fn find_by_purls_sync(pkg_path: &Path, purls: &[String]) -> HashMap./, tried exact-case first, then // by identity (case-insensitive id, normalized version) over the @@ -179,7 +180,7 @@ fn find_by_purls_sync(pkg_path: &Path, purls: &[String]) -> HashMap bool { }) } -/// Find a legacy `./` package directory for `name` at the -/// normalized version `normalized` ([`normalize_nuget_version`]), over the -/// package root's (lossy) entry names in readdir order: the id matches -/// case-insensitively and the folder's version normalizes to `normalized`. +/// Find a legacy `./` package directory for `name` at +/// `version`, over the package root's (lossy) entry names in readdir order. +/// A folder spelling `.` ignoring case wins; otherwise any +/// folder whose id matches case-insensitively and whose version normalizes +/// to `normalized` ([`normalize_nuget_version`]). fn find_legacy_dir_by_identity( pkg_path: &Path, root_names: &[String], name: &str, + version: &str, normalized: &str, ) -> Option { - for dir_name_str in root_names { - if legacy_dir_is(dir_name_str, name, normalized) { - let path = pkg_path.join(dir_name_str); - if verify_nuget_package(&path) { - return Some(path); - } - } - } - - None + let target = format!("{}.{}", name.to_lowercase(), version.to_lowercase()); + let verified = |dir_name: &String| { + let path = pkg_path.join(dir_name); + verify_nuget_package(&path).then_some(path) + }; + root_names + .iter() + .filter(|dir_name| dir_name.to_lowercase() == target) + .find_map(verified) + .or_else(|| { + root_names + .iter() + .filter(|dir_name| legacy_dir_is(dir_name, name, normalized)) + .find_map(verified) + }) } /// Whether the legacy folder name `dir_name` is `.` for an @@ -1456,6 +1464,37 @@ mod tests { } } + /// Where a cache holds both spellings of one release, the folder main + /// resolved (the as-written one) still wins, in both layouts. + #[tokio::test] + async fn test_find_by_purls_as_written_spelling_wins_over_normalized() { + let dir = tempfile::tempdir().unwrap(); + let as_written = dir.path().join("foo").join("1.0.0.0"); + for d in [&as_written, &dir.path().join("foo").join("1.0.0")] { + tokio::fs::create_dir_all(d.join("lib")).await.unwrap(); + } + let purls = vec!["pkg:nuget/Foo@1.0.0.0".to_string()]; + let result = NuGetCrawler::new() + .find_by_purls(dir.path(), &purls) + .await + .unwrap(); + assert_eq!(result["pkg:nuget/Foo@1.0.0.0"].path, as_written); + + // Legacy fallback: the case-insensitive spelling of the purl beats + // an equal release under another spelling, whatever readdir says. + let root = dir.path().join("packages"); + for d in ["Foo.1.0.0.0", "foo.1.0.0"] { + tokio::fs::create_dir_all(root.join(d).join("lib")) + .await + .unwrap(); + } + let names = ["Foo.1.0.0.0".to_string(), "foo.1.0.0".to_string()]; + assert_eq!( + find_legacy_dir_by_identity(&root, &names, "FOO", "1.0.0", "1.0.0"), + Some(root.join("foo.1.0.0")) + ); + } + /// The identity match splits `.` at every dot, but only an /// id equal to the purl's and a version equal after normalization /// match: `Foo.Bar.1.0.0` is not `Foo`, `Foo.1.0.0.1` is not `@1.0.0`. @@ -1990,15 +2029,15 @@ mod tests { "hollow.1.0.0".to_string(), ]; assert_eq!( - find_legacy_dir_by_identity(&root, &names, "NEWTONSOFT.JSON", "13.0.3"), + find_legacy_dir_by_identity(&root, &names, "NEWTONSOFT.JSON", "13.0.3", "13.0.3"), Some(root.join("newtonsoft.json.13.0.3")) ); assert_eq!( - find_legacy_dir_by_identity(&root, &names, "Hollow", "1.0.0"), + find_legacy_dir_by_identity(&root, &names, "Hollow", "1.0.0", "1.0.0"), None ); assert_eq!( - find_legacy_dir_by_identity(&root, &names, "Missing", "9.9.9"), + find_legacy_dir_by_identity(&root, &names, "Missing", "9.9.9", "9.9.9"), None );