[agent] Found by the scheduled NuGet / dotnet bug-hunt routine (ledger #320).
Summary
The v5.0 hosted unwind (remove, rollback, and the hosted → vendored takeover that vendor --revert later lands on) restores every packages.lock.json entry of the patched id to "nuget.org's contentHash". It reads that value from the nuget.org catalog packageHash (UpstreamClient::nuget_content_hash, crates/socket-patch-core/src/patch/redirect/upstream/client.rs:498). The two hashes are different things:
- The catalog
packageHash is the SHA-512 of the whole .nupkg file as served, including .signature.p7s.
- NuGet's lock
contentHash (and .nupkg.metadata) is the package's content hash. For a signed package, NuGet computes it with the signature excluded (PackageArchiveReader.GetContentHash → SignedPackageArchiveUtility).
nuget.org has repository-signed every package since 2018, so for practically every real package the restored lock pins a hash that no download will ever match. Every later dotnet restore, locked or not, fails NU1403: Package content hash validation failed … The package is different than the last restore.
Live values for Newtonsoft.Json 13.0.3:
| value |
hash |
lock contentHash written by dotnet restore (correct, original) |
HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ== |
catalog packageHash (catalog0/data/2023.03.08.07.46.17/newtonsoft.json.13.0.3.json) |
mbJSvHfRxfX3tR/U6n1WU+mWHXswYc+SB/hkOpx8yZZe68hNZGfymJu0cjsaJEkVzCMqePiU6LdIyogqfIn7kg== |
sha512(base64) of the flat-container .nupkg file |
mbJSvH…kg== (equals the catalog value) |
lock after socket-patch remove / rollback |
mbJSvH…kg== ❌ |
The unit tests in upstream/nuget.rs mock the catalog with the same UPSTREAM hash the lock fixture uses, so they can't catch this mismatch.
Impact
Undoing a hosted NuGet patch breaks the project's restore: the command reports success / hosted_reverted, nuget.config comes back byte-exact, and then dotnet restore fails NU1403 on every machine until someone deletes or regenerates the lock by hand. The same bad hash becomes the vendor ledger's recorded original on a hosted → vendored takeover, so vendor --revert later writes it back too.
Repro (Linux, .NET SDK 8.0.131, main 045d7ec, real api.nuget.org)
This uses the Backend stand-in and fixture from crates/socket-patch-cli/tests/e2e_nuget_dotnet_build.rs: app.csproj with RestorePackagesWithLockFile, a nuget.org-only nuget.config, and a real dotnet restore of Newtonsoft.Json 13.0.3.
cp packages.lock.json lock.orig
socket-patch scan --mode hosted --json --yes --api-url $URI --org test-org \
--api-token fake-token --patch-server-url $URI # redirected: 1
# lockfile discovery only recognises production feed URLs, so point the
# stand-in's source at patch.socket.dev (what a real hosted run writes):
sed -i "s#$URI#https://patch.socket.dev#" nuget.config
socket-patch remove pkg:nuget/newtonsoft.json@13.0.3 --json
# status success, events: [{"action":"removed","errorCode":"hosted_reverted",
# "reason":"hosted lockfile pin restored to the upstream registry on remove"}]
cmp nuget.config <original config> # byte-exact
diff lock.orig packages.lock.json
# < "contentHash": "HrC5BXdl…a+zQ=="
# > "contentHash": "mbJSvHfR…n7kg=="
# (and a trailing newline is added)
# fresh checkout, cold global packages folder:
dotnet restore # rc 1: error NU1403 … Newtonsoft.Json.13.0.3
dotnet restore --locked-mode # rc 1: error NU1403
The same result (lock = mbJSv…, NU1403) also comes from:
rollback pkg:nuget/newtonsoft.json@13.0.3: hosted.reverted: [purl], editedFiles: 2, status: success.
- hosted project →
vendor --vendor-source service … (takeover: lock re-pinned to the vendored nupkg) → vendor --revert: status: success, nuget.config unwired, lock = mbJSv….
Reproduced 5 times (remove with an LF, a CRLF and a BOM nuget.config; rollback; takeover → vendor --revert).
Expected vs actual
- Expected: CLI_CONTRACT.md says
remove "a hosted-only match restores its upstream registry entry", and "Takeover reconciliation" says "vendor --revert lands back on upstream registry state". SOCKET_NUGET_URL documents that the restored contentHash "comes from" the catalog packageHash, but the restored entry has to be one NuGet accepts: the original contentHash, which dotnet restore would write.
- Actual: a hash NuGet never produces for a signed package, so restore fails NU1403.
Possible fixes: compute the content hash the way NuGet does (download the nupkg, drop .signature.p7s per SignedPackageArchiveUtility, then hash). Or, when the catalog says the package is signed, refuse with redirect_revert_failed and point to git checkout -- packages.lock.json instead of writing a wrong pin.
Matrix
| OS |
SDK |
path |
result |
| Linux |
8.0.131 |
hosted remove (LF / CRLF / BOM config) |
fail NU1403 |
| Linux |
8.0.131 |
hosted rollback <purl> |
fail NU1403 (locked and plain restore) |
| Linux |
8.0.131 |
hosted → vendor takeover → vendor --revert |
fail (lock = catalog hash) |
| macOS / Windows, SDK 6/9/10 |
— |
— |
not run. The hash comes from nuget.org and NuGet's content-hash algorithm is the same on every OS and SDK, so the mismatch is platform-independent. |
First bad version
Release v4.0.0 isn't affected: there, remove / rollback on a manifest-less hosted project exits manifest_not_found, so there's no hosted unwind. The NuGet unwind arrived with de316b4 (#358, 2026-10-01), which added crates/socket-patch-core/src/patch/redirect/upstream/nuget.rs.
Suspect code
crates/socket-patch-core/src/patch/redirect/upstream/client.rs:498-552: nuget_content_hash returns the catalog packageHash.
crates/socket-patch-core/src/patch/redirect/upstream/nuget.rs:301: writes that value into every lock entry of the id.
crates/socket-patch-core/src/patch/redirect/upstream/nuget.rs:13-14 (module doc), and SOCKET_NUGET_URL in CLI_CONTRACT.md:1040, which describe the same assumption.
[agent] Found by the scheduled NuGet / dotnet bug-hunt routine (ledger #320).
Summary
The v5.0 hosted unwind (
remove,rollback, and the hosted → vendored takeover thatvendor --revertlater lands on) restores everypackages.lock.jsonentry of the patched id to "nuget.org'scontentHash". It reads that value from the nuget.org catalogpackageHash(UpstreamClient::nuget_content_hash, crates/socket-patch-core/src/patch/redirect/upstream/client.rs:498). The two hashes are different things:packageHashis the SHA-512 of the whole.nupkgfile as served, including.signature.p7s.contentHash(and.nupkg.metadata) is the package's content hash. For a signed package, NuGet computes it with the signature excluded (PackageArchiveReader.GetContentHash→SignedPackageArchiveUtility).nuget.org has repository-signed every package since 2018, so for practically every real package the restored lock pins a hash that no download will ever match. Every later
dotnet restore, locked or not, failsNU1403: Package content hash validation failed … The package is different than the last restore.Live values for
Newtonsoft.Json 13.0.3:contentHashwritten bydotnet restore(correct, original)HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==packageHash(catalog0/data/2023.03.08.07.46.17/newtonsoft.json.13.0.3.json)mbJSvHfRxfX3tR/U6n1WU+mWHXswYc+SB/hkOpx8yZZe68hNZGfymJu0cjsaJEkVzCMqePiU6LdIyogqfIn7kg==sha512(base64)of the flat-container.nupkgfilembJSvH…kg==(equals the catalog value)socket-patch remove/rollbackmbJSvH…kg==❌The unit tests in
upstream/nuget.rsmock the catalog with the sameUPSTREAMhash the lock fixture uses, so they can't catch this mismatch.Impact
Undoing a hosted NuGet patch breaks the project's restore: the command reports
success/hosted_reverted,nuget.configcomes back byte-exact, and thendotnet restorefails NU1403 on every machine until someone deletes or regenerates the lock by hand. The same bad hash becomes the vendor ledger's recordedoriginalon a hosted → vendored takeover, sovendor --revertlater writes it back too.Repro (Linux, .NET SDK 8.0.131, main
045d7ec, real api.nuget.org)This uses the
Backendstand-in and fixture fromcrates/socket-patch-cli/tests/e2e_nuget_dotnet_build.rs:app.csprojwithRestorePackagesWithLockFile, a nuget.org-onlynuget.config, and a realdotnet restoreofNewtonsoft.Json 13.0.3.The same result (lock =
mbJSv…, NU1403) also comes from:rollback pkg:nuget/newtonsoft.json@13.0.3:hosted.reverted: [purl],editedFiles: 2,status: success.vendor --vendor-source service …(takeover: lock re-pinned to the vendored nupkg) →vendor --revert:status: success, nuget.config unwired, lock =mbJSv….Reproduced 5 times (remove with an LF, a CRLF and a BOM nuget.config; rollback; takeover → vendor --revert).
Expected vs actual
remove"a hosted-only match restores its upstream registry entry", and "Takeover reconciliation" says "vendor --revertlands back on upstream registry state".SOCKET_NUGET_URLdocuments that the restoredcontentHash"comes from" the catalogpackageHash, but the restored entry has to be one NuGet accepts: the originalcontentHash, whichdotnet restorewould write.Possible fixes: compute the content hash the way NuGet does (download the nupkg, drop
.signature.p7sperSignedPackageArchiveUtility, then hash). Or, when the catalog says the package is signed, refuse withredirect_revert_failedand point togit checkout -- packages.lock.jsoninstead of writing a wrong pin.Matrix
remove(LF / CRLF / BOM config)rollback <purl>vendortakeover →vendor --revertFirst bad version
Release v4.0.0 isn't affected: there,
remove/rollbackon a manifest-less hosted project exitsmanifest_not_found, so there's no hosted unwind. The NuGet unwind arrived withde316b4(#358, 2026-10-01), which addedcrates/socket-patch-core/src/patch/redirect/upstream/nuget.rs.Suspect code
crates/socket-patch-core/src/patch/redirect/upstream/client.rs:498-552:nuget_content_hashreturns the catalogpackageHash.crates/socket-patch-core/src/patch/redirect/upstream/nuget.rs:301: writes that value into every lock entry of the id.crates/socket-patch-core/src/patch/redirect/upstream/nuget.rs:13-14(module doc), andSOCKET_NUGET_URLin CLI_CONTRACT.md:1040, which describe the same assumption.