[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_version pads to three parts, drops a zero 4th part, strips leading zeros and lowercases (it mirrors NuGetVersion.ToNormalizedString()). The lock match locked_at and 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 own resolved spelling, and the vendored liveness gates compare them with PurlKey::same: vendored_claim, vendor_entry_live and vendor_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_refs over 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, because vendor_entry_in_use == Some(false). The user's project is silently un-patched.
vendor --check reports the wiring as lost (vendor.rs#L1186-L1187).
vex treats the entry as unwired and doesn't attest the fix.
- Agent mode:
find_by_purls(@1.0.0) cannot find a legacy packages/Foo.1.0.0.0 folder, 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_version out of vendor/nuget_feed.rs into a shared NuGet identity helper, for example formats::nuget::normalize_version or utils::purl_key.
- Make
PurlKey's nuget arm 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
Dependencies
None. vendor/nuget_feed.rs may be touched by open vendored PRs; check for overlaps before claiming.
[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.3and13.0.3.00are the same package. Two rules for that identity exist on main @03b9418.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.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 existingfixture(true, None), whosepackages.lock.jsonresolvesNewtonsoft.Json13.0.3.vendor_nuget("pkg:nuget/Newtonsoft.Json@13.0.3.0", …)with a service fixture.vex::discover::discover_patched_refsover the result.The repository's own test helper
test_support::assert_fresh_vendor_in_useexists to catch exactly this ("the prune GC would revert a freshly vendored … entry"), so the test had to callvendor_nugetdirectly 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.configprojects: legacypackages/<Id>.<Version>/folders such asMicrosoft.Web.Infrastructure.1.0.0.0, and the crawler emits@1.0.0.0from those. The patch API also sendsnugetVersionNormseparately fromversion, 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.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_claimcompares the ledger purl withPurlKey, while the lock carriesnuget_version_norm. A regression test should cover it.Proposed change
normalize_nuget_versionout ofvendor/nuget_feed.rsinto a shared NuGet identity helper, for exampleformats::nuget::normalize_versionorutils::purl_key.PurlKey'snugetarm normalize the version through it.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.rsand possiblypatch/redirect/upstream/nuget.rs, plus tests. Out of scope: the hosted other-version lock walk (#593), andnuget.configreading (#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.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).scan --prunekeeps that entry.packages/Foo.1.0.0.0for@1.0.0, and the global-folderfoo/1.0.0for@1.0.0.0.normalize_nuget_versiondefinition in the crate.nuget_feed,vex::discoverandpurl_keytests stay green.Dependencies
None.
vendor/nuget_feed.rsmay be touched by open vendored PRs; check for overlaps before claiming.