Skip to content

NuGet version identity is normalized by vendor but not by PurlKey, so a freshly vendored 4-part version is judged unused and pruned #1202

Open

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.

  1. 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.
  2. 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

  • PurlKey::same("pkg:nuget/A@1.0.0.0", "pkg:nuget/a@1.0.0") is true, and …@1.0.0-RC1 vs …@1.0.0-rc1 is true. Semver build metadata is ignored, as NuGet does.
  • Regression test: vendor_nuget at @13.0.3.0 against a lock resolving 13.0.3 passes assert_fresh_vendor_in_use (vendor_entry_in_use == Some(true), vendor_entry_live == true).
  • Regression test: scan --prune keeps that entry.
  • The crawler finds packages/Foo.1.0.0.0 for @1.0.0, and the global-folder foo/1.0.0 for @1.0.0.0.
  • There is one normalize_nuget_version definition in the crate.
  • The existing nuget_feed, vex::discover and purl_key tests stay green.

Dependencies

None. vendor/nuget_feed.rs may be touched by open vendored PRs; check for overlaps before claiming.

Activity

  1. added
    bugSomething isn't working
    arch-auditFiled by a scheduled architecture audit routine (see the architecture review discussion)
    on Oct 9, 2026
  2. mikolalysenko commented on Oct 9, 2026

    @mikolalysenko
    CollaboratorAuthor

    [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; PurlKey gets NuGet's release identity through the one normalize_nuget_version). Branch: arch-refactor/1202-nuget-purl-identity. Claim-ID: 2026-10-09T05:56:36Z-7c41e9

    Slice taken: PurlKey (the scan --prune / vendor --check / vex liveness half). The crawler's directory lookup and the move of normalize_nuget_version into formats::nuget stay for later, because crawlers/nuget_crawler.rs and vendor/nuget_feed.rs are in open PRs #1126 and #1041.


    Generated by Claude Code

  3. mikolalysenko commented on Oct 9, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Draft PR: #1230 (the PurlKey slice).


    Generated by Claude Code

  4. mikolalysenko commented on Oct 9, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Claiming this issue for the architecture refactor routine (highest leverage: the crawler is the last NuGet identity reader that only lowercases; find_by_purls will key the global folder and the legacy packages/<Id>.<Version> match through the one normalize_nuget_version, the same rule as PurlKey in #1230). Branch: arch-refactor/1202-nuget-crawler-identity. Claim-ID: 2026-10-09T06:56:09Z-4b9e1c

    Slice taken: the crawler directory lookup (crawlers/nuget_crawler.rs only; disjoint from #1230). What remains after both: moving normalize_nuget_version out of vendor/nuget_feed.rs into formats::nuget once that file is free.


    Generated by Claude Code

  5. mikolalysenko commented on Oct 9, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Draft PR for the crawler slice: #1239.


    Generated by Claude Code

  6. mikolalysenko commented on Oct 12, 2026

    @mikolalysenko
    CollaboratorAuthor

    [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). Removing agent:claimed so another fixer can pick up what remains.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    agent:triagedarch-auditFiled by a scheduled architecture audit routine (see the architecture review discussion)bugSomething isn't workingpm:nugetNuGet / dotnetpriority:p3

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions