Repository navigation
NuGet version identity is normalized by vendor but not by PurlKey, so a freshly vendored 4-part version is judged unused and pruned #1202
Description
[agent] Filed by the scheduled architecture audit routine (ecosystems and formats). Register: discussion #560 register.
Kind: bug. Source: new finding (inconsistent logic within one ecosystem: vendored vs VEX/liveness/crawler); register E93.
Problem
NuGet's package identity uses the normalized version: 13.0.3.0, 13.0.3 and 13.0.3.00 are the same package. Two rules for that identity exist on main @ 03b9418.
- The vendored backend and upstream restore normalize.
normalize_nuget_versionpads to three parts, drops a zero 4th part, strips leading zeros and lowercases (it mirrorsNuGetVersion.ToNormalizedString()). The lock matchlocked_atand the feed leaf name use it (nuget_feed.rs#L142-L149,#L214-L215).Hosted `rewrite_nuget` writes the API's `nuget_version_norm` ([`redirect/mod.rs#L5581-L5585`](https://github.com/SocketDev/socket-patch/blob/03b9418c1a8e262924c060b1648a89a385433316/crates/socket-patch-core/src/patch/redirect/mod.rs#L5581-L5585)),`` and upstream restore normalizes as well. - The shared purl identity only lowercases. In
PurlKey,"nuget" => (name.to_lowercase(), version.map(str::to_lowercase)). VEX discovery builds its refs from the lock's ownresolvedspelling, and the vendored liveness gates compare them withPurlKey::same:vendored_claim,vendor_entry_liveandvendor_entry_in_use. The NuGet crawler also only lowercases the version when it looks up a package directory (nuget_crawler.rs#L194-L196).``
The two rules have already drifted: vendor wires a package under one spelling, and every reader that decides whether that wiring is alive compares under another.
Proof (executed 3× on 03b9418)
I added a throwaway test to vendor::nuget_feed::tests, reusing the existing fixture(true, None), whose packages.lock.json resolves Newtonsoft.Json 13.0.3.
- It calls
vendor_nuget("pkg:nuget/Newtonsoft.Json@13.0.3.0", …)with a service fixture. - It runs
vex::discover::discover_patched_refsover the result.
vendor success=true base_purl=pkg:nuget/Newtonsoft.Json@13.0.3.0
(artifact newtonsoft.json.13.0.3.nupkg; lock entry re-pinned to the vendored hash)
vex ref purl=pkg:nuget/newtonsoft.json@13.0.3 (same uuid, same artifact)
vendor_entry_live = false
vendor_entry_in_use = Some(false)
PurlKey::same("…@13.0.3.0", "…@13.0.3") = false
normalize_nuget_version("13.0.3.0") == normalize_nuget_version("13.0.3") = true
The repository's own test helper test_support::assert_fresh_vendor_in_use exists to catch exactly this ("the prune GC would revert a freshly vendored … entry"), so the test had to call vendor_nuget directly to get past it.
Impact
The trigger is a vendored NuGet entry whose purl version is not in normalized form. The 4-part form is the usual one for packages.config projects: legacy packages/<Id>.<Version>/ folders such as Microsoft.Web.Infrastructure.1.0.0.0, and the crawler emits @1.0.0.0 from those. The patch API also sends nugetVersionNorm separately from version, so the two can differ. What the user sees:
scan --prune(run_vendor_gc) reverts a live, correctly wired vendored patch, becausevendor_entry_in_use == Some(false). The user's project is silently un-patched.vendor --checkreports the wiring as lost (vendor.rs#L1186-L1187).vextreats the entry as unwired and doesn't attest the fix.- Agent mode:
find_by_purls(@1.0.0)cannot find a legacypackages/Foo.1.0.0.0folder, and the reverse.
Severity is P3: NuGet only, and it needs a non-normalized spelling. But the failure mode is a silent revert.
I did not execute the hosted case: hosted_claim compares the ledger purl with PurlKey, while the lock carries nuget_version_norm. A regression test should cover it.
Proposed change
- Move
normalize_nuget_versionout ofvendor/nuget_feed.rsinto a shared NuGet identity helper, for exampleformats::nuget::normalize_versionorutils::purl_key. - Make
PurlKey'snugetarm normalize the version through it. - Make the NuGet crawler's directory lookup try the normalized form, which is what the global packages folder uses, and the legacy as-written form.
- Delete the private copy in
nuget_feed.rs, and any upstream-restore copy that duplicates the same rule.
Size and scope
About 40–80 production lines in utils/purl_key.rs, vendor/nuget_feed.rs, crawlers/nuget_crawler.rs and possibly patch/redirect/upstream/nuget.rs, plus tests. Out of scope: the hosted other-version lock walk (#593), and nuget.config reading (#594).
Acceptance criteria
-
PurlKey::same("pkg:nuget/A@1.0.0.0", "pkg:nuget/a@1.0.0")is true, and…@1.0.0-RC1vs…@1.0.0-rc1is true. Semver build metadata is ignored, as NuGet does. - Regression test:
vendor_nugetat@13.0.3.0against a lock resolving13.0.3passesassert_fresh_vendor_in_use(vendor_entry_in_use == Some(true),vendor_entry_live == true). - Regression test:
scan --prunekeeps that entry. - The crawler finds
packages/Foo.1.0.0.0for@1.0.0, and the global-folderfoo/1.0.0for@1.0.0.0. - There is one
normalize_nuget_versiondefinition in the crate. - The existing
nuget_feed,vex::discoverandpurl_keytests stay green.
Dependencies
None. vendor/nuget_feed.rs may be touched by open vendored PRs; check for overlaps before claiming.
Activity
audit-ecosystems: 2026-10-09 run (E44 decision #1200, E93 #1202, E73 …
mikolalysenko commentedon Oct 9, 2026
[agent] Claiming this issue for the architecture refactor routine (highest leverage: the only free-file candidate that closes a bug by collapsing a duplicated identity rule;
PurlKeygets NuGet's release identity through the onenormalize_nuget_version). Branch: arch-refactor/1202-nuget-purl-identity. Claim-ID: 2026-10-09T05:56:36Z-7c41e9Slice taken:
PurlKey(thescan --prune/vendor --check/vexliveness half). The crawler's directory lookup and the move ofnormalize_nuget_versionintoformats::nugetstay for later, becausecrawlers/nuget_crawler.rsandvendor/nuget_feed.rsare in open PRs #1126 and #1041.Generated by Claude Code
mikolalysenko commentedon Oct 9, 2026
[agent] Draft PR: #1230 (the
PurlKeyslice).Generated by Claude Code
Key NuGet purls by the normalized version
mikolalysenko commentedon Oct 9, 2026
[agent] Claiming this issue for the architecture refactor routine (highest leverage: the crawler is the last NuGet identity reader that only lowercases;
find_by_purlswill key the global folder and the legacypackages/<Id>.<Version>match through the onenormalize_nuget_version, the same rule asPurlKeyin #1230). Branch: arch-refactor/1202-nuget-crawler-identity. Claim-ID: 2026-10-09T06:56:09Z-4b9e1cSlice taken: the crawler directory lookup (
crawlers/nuget_crawler.rsonly; disjoint from #1230). What remains after both: movingnormalize_nuget_versionout ofvendor/nuget_feed.rsintoformats::nugetonce that file is free.Generated by Claude Code
mikolalysenko commentedon Oct 9, 2026
[agent] Draft PR for the crawler slice: #1239.
Generated by Claude Code
Find NuGet packages by normalized version
Prefer the as-written NuGet folder spelling
mikolalysenko commentedon Oct 12, 2026
[agent] Janitor: releasing the stale claim
Claim-ID: 2026-10-09T06:56:09Z-4b9e1c. #1230, #1239 and #1358 merged slices (last 2026-10-09 22:05Z); no open PR for the rest of this issue, and the claimer has been silent since 2026-10-09T07:02:20Z (more than 48h). Removingagent:claimedso another fixer can pick up what remains.Generated by Claude Code