From 9c8b7ea4ff546223fc6491c2402afefa2e1c43fc Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 4 Nov 2026 10:46:25 -0600 Subject: [PATCH 1/2] Stop attesting NuGet versions the writers refuse When a governed packages.lock.json locks a Socket-mapped NuGet id at a version other than the patched one (another target framework, or another project's lock), both writers refuse to wire it (redirect_nuget_lock_other_version, vendor_nuget_lock_other_version): the exact-id mapping routes every version to a source that serves only the patched one, so that framework cannot restore. Discovery instead emitted an attested ref per locked version, including the unpatched one. Discovery now applies the writers' rule. Each ref of such a source is still emitted (rollback, remove, list and the ledgers' liveness gates keep seeing the wiring) but marked with the new UnattestedKind::NugetLockOtherVersion, which the VEX plan omits with the vex_nuget_lock_other_version note. The patched version comes from where the files name it: the vendored feed's single nupkg; a hosted url names only the uuid, so any two locked versions qualify there. Lock versions are now keyed through normalize_nuget_version, as the writers' locked_at / other_versions match them, so 1.0 and 1.0.0 are one version with one pin check rather than two refs with independent pins. Co-Authored-By: Claude Opus 5.5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 5 +- .../src/commands/vex_sources.rs | 9 + .../socket-patch-core/src/vex/discover/mod.rs | 7 + .../src/vex/discover/nuget.rs | 391 +++++++++++++++++- 4 files changed, 392 insertions(+), 20 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index a3ed909d6..24bbd637d 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -539,7 +539,7 @@ Discovery is read-only, never touches the network, and never fails the run: a ma | composer | `composer.lock` (`packages` + `packages-dev`) | `dist.url` on the patch host | `dist: {type: "path", url: ".socket/vendor/composer//…", reference: ""}` | `dist.shasum`, required | | maven | `pom.xml` (+ `.mvn/maven.config`, `.mvn/checksums/checksums.sha256`) | a dependency version `-socket.` matching exactly ONE `socket-patch-` repository on the patch host | pre-v5 single-POM wiring only: `socket-patch-vendor-` repository + exactly one jar under `.socket/vendor/maven//` with a matching `.sha1`. A v5 suffixed pin served from `.socket/vendor/maven2` is no reference here: its ledger entry's wiring check gates it, so without a ledger it is not attested | Trusted Checksums line when enabled; not required (the suffixed version is the pin) | | gradle (v5.0) | `.socket/gradle/hosted-index.tsv`, the owned hosted script, and every settings script and lock file the script graph reaches | an index row whose repository URL is on the patch host and names the row's uuid, live only while the owned script is intact, every build's settings file applies it with the current index digest, every lock entry of the GA is the suffixed version, no `settings-gradle.lockfile` names the GA and no build script sets a custom `lockFile` (otherwise `patched_ref_invalid`) | none here: a vendored Gradle entry is gated by its ledger entry's wiring check | the suffixed copies installed in the Gradle cache; required (never the lock basis), because the script lets a higher upstream version resolve | -| nuget | the first of `nuget.config` / `NuGet.config` / `NuGet.Config`, + `packages.lock.json` | source `socket-patch-` + its exclusive exact-id ``; version from `packages.lock.json` | the same mapping onto `.socket/vendor/nuget/`; version from the lock, else the feed's single nupkg | `contentHash`, required | +| nuget | the first of `nuget.config` / `NuGet.config` / `NuGet.Config`, + `packages.lock.json` | source `socket-patch-` + its exclusive exact-id ``; version from `packages.lock.json` | the same mapping onto `.socket/vendor/nuget/`; version from the lock, else the feed's single nupkg | `contentHash`, required. Lock versions match under `normalize_nuget_version`, as the writers match them (`1.0` and `1.0.0` are one version). When the governed locks also lock the mapped id at a version other than the patched one (another target framework or project), which both writers refuse (`redirect_nuget_lock_other_version`, `vendor_nuget_lock_other_version`), every reference of that source is kept but not attested (`vex_nuget_lock_other_version`). The patched version is the vendored feed's single nupkg; a hosted url names no version, so there any two locked versions qualify. | | deno | `deno.lock`'s npm section, only as evidence against an npm-family pin of a `name@version` it also locks, which `vex` then omits (see **Unattested references**, #406); hosted and vendored npm runs warn about such a pin (`redirect_npm_deno_lock` / `vendor_npm_deno_lock`) | — (no hosted mode) | — (no vendored backend) | — | Recognition rules that hold for every ecosystem: @@ -547,7 +547,7 @@ Recognition rules that hold for every ecosystem: * **Patch hosts.** A hosted reference counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin, with no userinfo. The uuid is the URL's LAST canonical-uuid path segment, because grant tokens may themselves be uuid-shaped. The Go module prefix is fixed. `socket-patch-` registry / repository / source names count only through a pin. For a URL on any other host, see **Patch hosts** above. * **Pins, not definitions.** A registry, index or source *definition* alone (cargo `[registries]`, nuget ``, pom ``, uv index tables, `.npmrc`) never makes a reference, because it survives a reverted pin. Sections the package manager ignores are not read: npm's v2 `dependencies` mirror, a `.cargo/config.toml` shadowed by `.cargo/config`. A Socket pin inside a maven `` is diagnosed, never a reference. * **Contested locks.** When one lock wires a package to a patch and another lock resolves the same `name@version` from a non-Socket source, the build's bytes depend on which package manager runs. The reference is then dropped with a `patched_ref_unattributable` diagnostic naming both files. This applies across npm / pnpm / yarn / bun and across uv / pylock / poetry / pdm / Pipfile.lock / requirements. PEP 723 script locks neither contest nor are contested. The root `requirements.txt` and its in-root `-r` includes count as one lock here: pip reads them as one requirement set, where a direct reference wins over a compatible `==` pin of the same version in another file of the set (#1086). A PyPI version matches under PEP 440 equality, so a requirements line pinned in any spelling that can only install the wired release (`==1.2.3.0`, `==v1.2.3`, `===1.2.3`) contests it like `==1.2.3` does; a range or wildcard (`~=1.2.3`, `==1.2.*`) does not (#1602). A **bundled** npm copy (`inBundle: true`, or v1 `bundled: true`, beneath a dependency that bundles it; the root project's own bundles install from the lock, #1618) of the same `name@version` contests the reference too, in the same lock, in the other npm lock of a shrinkwrap/package-lock pair, or in any other lock. npm unpacks it from the parent package's tarball, so no rewire reaches it and it stays unpatched. Bun and vlt unpack bundled copies the same way (#469, #471). pnpm unpacks bundled copies too, but its lock cannot tie one to a reference (see **Unattested references** below). For Bun, that is a `bun.lock` entry whose meta is `{ "bundled": true }`, or a `bun.lockb` record that a dependency edge with the `bundled` behavior bit reaches, including one Bun shares with a regular install. Such an entry is never a reference, and it contests the reference the same way. For vlt, the lock records no node for a bundled copy, so the copy is found in the installed store: a real package directory inside a store package's own `node_modules`. yarn classic's `yarn.lock` records nothing about bundled copies either (#758), so for a lock with a Socket wiring they are found in the installed `node_modules`: each package a parent's `bundleDependencies` / `bundledDependencies` names inside the parent's own `node_modules`, the dependencies of those beside them, and everything nested under them. Such a copy contests the reference in `yarn.lock` (the diagnostic names the parent's lock entry and the copy's directory); a lock-only `vex` with no `node_modules` can't see it. Hosted and vendored scans skip these copies with `redirect_bun_bundled_instance_skipped` / `redirect_vlt_bundled_instance_skipped` / `vendor_bundled_instance_skipped`, and a yarn classic hosted scan names one with `redirect_copy_unpatched`. When a bundled copy is the only instance, vendoring refuses with `vendor_lock_entry_not_rewritable`. A reference withheld only because such an unreachable copy installs beside it (a bundled npm / Bun / vlt copy, an npm copy beneath a `hasShrinkwrap` package (#753) or one npm installs from a git / url / `file:` spec, or a yarn classic git or `file:` directory block of the same `name@version`) is still the rewriter's own wiring of exactly one package version: it is never attested, but `rollback`, `remove`, `list` and the hosted → vendored takeover treat it as an ordinary hosted pin and restore its upstream registry entry (#828), rather than refusing it with `hosted_wiring_contested`. Another entry of the **same** npm, Bun or yarn lock that resolves the wired `name@version` from a non-Socket source (for example a workspace member added after the rewire, then `npm install` / `bun install` / `yarn install`; for yarn berry, a registry locator such as `left-pad@npm:1.3.0` beside the hosted `…::__archiveUrl=` one) contests the reference too (#588). The package manager installs both entries, and that copy stays unpatched. Re-running `scan` / `vendor` rewires every copy. -* **Unattested references.** Some evidence shows a build may run a copy no wiring reaches, but cannot be tied to the reference's exact `name@version` or cannot say the build runs it. The reference then stays a reference: `list`, `rollback` and `remove` find it, and the ledgers' liveness gates (`vendor --check`, `scan`) keep treating the wiring as live, because re-running `scan` / `vendor` could never clear the evidence. Only `vex` omits it, as a run warning and as the `failed[].reason`. Four cases besides Gradle's (`vex_gradle_lock_above_base`): **pnpm bundled copies** (`vex_pnpm_bundled_copy`): a `packages:` entry's `bundledDependencies:` names the copies pnpm unpacks from that package's own tarball, but pnpm never locks them, so the bundled version is not in the lock. A pnpm reference whose package name a `bundledDependencies` list in the same lock names is omitted whatever its version (a missed attestation when the bundled copy is another version, never a false one), and `bundledDependencies: true` (or a value that cannot be read) omits every reference of that lock. It reaches no other lock. **deno.lock** (`vex_deno_lock_copy`): `deno install` installs a `package.json` project's npm dependencies from `deno.lock` and never reads `package-lock.json` / `pnpm-lock.yaml` / `yarn.lock`, so an npm-family reference whose `name@version` the `deno.lock` npm section also locks is omitted (#406). Whether Deno or another package manager populates `node_modules` (`nodeModulesDir: "manual"` allows either) is not in the files, so this holds whatever `nodeModulesDir` says. **Maven reactor roots** (`vex_maven_reactor_root`): a hosted pin in a root `pom.xml` that declares `` / ``, or whose subprojects Maven 4 discovers (see the hosted Maven rewriter), is omitted, because a module's own literal `` overrides a root pin and module poms are not read (#261); hosted scan refuses such roots (`redirect_maven_multimodule_unsupported`), so this only covers pins an older release wrote. **Maven classifier copies** (`vex_maven_classifier_unpatched`): a `` dependency of a hosted-pinned GA is its own coordinate; the classifier-less pin does not retarget it and the grant serves only the main jar, so Maven fetches it from the next repository. When a live, non-profile classifier copy other than `sources` / `javadoc` (`tests`, a native or JDK-specific build) resolves any version other than the suffixed pin — its own literal ``, a `` entry with the same classifier, or no version in the root pom — the pin is omitted. `sources` / `javadoc` copies never run on a classpath and do not withhold the statement; hosted scan only warns about them (`redirect_maven_classifier_unsupported`) and skips the dep when an executable copy names the patched release. +* **Unattested references.** Some evidence shows a build may run a copy no wiring reaches, but cannot be tied to the reference's exact `name@version` or cannot say the build runs it. The reference then stays a reference: `list`, `rollback` and `remove` find it, and the ledgers' liveness gates (`vendor --check`, `scan`) keep treating the wiring as live, because re-running `scan` / `vendor` could never clear the evidence. Only `vex` omits it, as a run warning and as the `failed[].reason`. Four cases besides Gradle's (`vex_gradle_lock_above_base`) and NuGet's (`vex_nuget_lock_other_version`, see the table above): **pnpm bundled copies** (`vex_pnpm_bundled_copy`): a `packages:` entry's `bundledDependencies:` names the copies pnpm unpacks from that package's own tarball, but pnpm never locks them, so the bundled version is not in the lock. A pnpm reference whose package name a `bundledDependencies` list in the same lock names is omitted whatever its version (a missed attestation when the bundled copy is another version, never a false one), and `bundledDependencies: true` (or a value that cannot be read) omits every reference of that lock. It reaches no other lock. **deno.lock** (`vex_deno_lock_copy`): `deno install` installs a `package.json` project's npm dependencies from `deno.lock` and never reads `package-lock.json` / `pnpm-lock.yaml` / `yarn.lock`, so an npm-family reference whose `name@version` the `deno.lock` npm section also locks is omitted (#406). Whether Deno or another package manager populates `node_modules` (`nodeModulesDir: "manual"` allows either) is not in the files, so this holds whatever `nodeModulesDir` says. **Maven reactor roots** (`vex_maven_reactor_root`): a hosted pin in a root `pom.xml` that declares `` / ``, or whose subprojects Maven 4 discovers (see the hosted Maven rewriter), is omitted, because a module's own literal `` overrides a root pin and module poms are not read (#261); hosted scan refuses such roots (`redirect_maven_multimodule_unsupported`), so this only covers pins an older release wrote. **Maven classifier copies** (`vex_maven_classifier_unpatched`): a `` dependency of a hosted-pinned GA is its own coordinate; the classifier-less pin does not retarget it and the grant serves only the main jar, so Maven fetches it from the next repository. When a live, non-profile classifier copy other than `sources` / `javadoc` (`tests`, a native or JDK-specific build) resolves any version other than the suffixed pin — its own literal ``, a `` entry with the same classifier, or no version in the root pom — the pin is omitted. `sources` / `javadoc` copies never run on a classpath and do not withhold the statement; hosted scan only warns about them (`redirect_maven_classifier_unsupported`) and skips the dep when an executable copy names the patched release. * **Shrinkwrap without a twin (#899).** npm >= 12 never reads `npm-shrinkwrap.json`: on a project whose only npm lock is the shrinkwrap it resolves the tree from the registry and writes a fresh `package-lock.json`, so a Socket reference there reaches npm <= 11 only. The reference is still discovered, so `list`, `rollback` and `remove` manage it, but `vex` omits the patch (`vex_npm_shrinkwrap_only`, as a run warning and as the `failed[].reason`). A `package-lock.json` beside it (wired the same way) makes it attestable again. Hosted and vendored runs still rewire the shrinkwrap and warn `redirect_npm_shrinkwrap_only` / `vendor_npm_shrinkwrap_only`. * **Lockless pins.** With no lock to name a version, a `Cargo.toml` pin (every declaration on `socket-patch-`, that registry defined on the patch host for the same uuid) or an exclusive nuget exact-id mapping is never a reference on its own, so v5.0 does not attest it (nor does `list` show it, or `rollback` / `remove` restore it — restore those files from version control). Only a pre-v5 redirect-ledger record naming a version the pin admits keeps it live. The same holds for a gem wired only in the `Gemfile` (the pre-bundler-2.6 mixed state, lock not converged). @@ -2678,6 +2678,7 @@ match it, withholds the statement: | `vex_deno_lock_copy` | run warning and `failed[].reason` | An npm pin is wired, but `deno.lock` locks the same `name@version`, which `deno install` installs without the wiring; no statement while it does. `vendor --check` and `scan` still treat the wiring as live. | | `vex_maven_reactor_root` | run warning and `failed[].reason` | A hosted Maven pin sits in a root `pom.xml` that declares `` / `` (a profile's included), where a module's own literal `` overrides it; only the root pom is read, so no statement (#261). `list`, `rollback` and `remove` still find the pin. | | `vex_maven_classifier_unpatched` | run warning and `failed[].reason` | A hosted Maven pin's GA also has a live executable `` copy (not `sources` / `javadoc`) at a version other than the pin, which the classifier-less pin does not retarget and the grant does not serve; no statement while it does. `list`, `rollback` and `remove` still find the pin. | +| `vex_nuget_lock_other_version` | run warning and `failed[].reason` | A NuGet Socket source maps an id that a governed `packages.lock.json` also locks at a version other than the patched one; the mapping routes every version to a source that serves only the patched one, so that target framework cannot restore, and both writers refuse such a lock. No statement until every target framework locks only the patched version. `list`, `rollback` and `remove` still find the wiring. | | `vex_gradle_derived_cache_unchecked` | run warning | The derived-cache walk was cut short (a very large transforms cache); the statement is still emitted, the copies the walk did reach were checked. | A vendored Gradle entry attests only while its wiring is live (apply line, index rows, diff --git a/crates/socket-patch-cli/src/commands/vex_sources.rs b/crates/socket-patch-cli/src/commands/vex_sources.rs index 25e49273e..a47e2ddf1 100644 --- a/crates/socket-patch-cli/src/commands/vex_sources.rs +++ b/crates/socket-patch-cli/src/commands/vex_sources.rs @@ -232,6 +232,10 @@ const NOTE_MAVEN_REACTOR_ROOT: &str = "vex_maven_reactor_root"; /// classifier copy at another version, which the pin does not reach /// (`vex::Unattested`). const NOTE_MAVEN_CLASSIFIER_UNPATCHED: &str = "vex_maven_classifier_unpatched"; +/// Omission tag and note: a NuGet Socket source's id is also locked at a +/// version other than the patched one, which that source cannot serve +/// (`vex::Unattested`). +const NOTE_NUGET_LOCK_OTHER_VERSION: &str = "vex_nuget_lock_other_version"; /// The omission tag of an [`Unattested`](socket_patch_core::vex::Unattested) /// ref, and the remedy its note ends with. @@ -268,6 +272,11 @@ fn unattested_note(kind: UnattestedKind) -> (&'static str, &'static str) { "not attested while that classifier copy resolves an unpatched version; the \ wiring itself is intact, so re-running `scan` does not change this", ), + UnattestedKind::NugetLockOtherVersion => ( + NOTE_NUGET_LOCK_OTHER_VERSION, + "not attested until every target framework locks only the patched version (align \ + them on it, restore, and re-run `scan` / `vendor`)", + ), } } diff --git a/crates/socket-patch-core/src/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index 815d4644d..f8400fc49 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -606,6 +606,13 @@ pub enum UnattestedKind { /// version: the classifier-less pin does not retarget it and the grant /// serves only the main jar, so the build runs the public classifier jar. MavenClassifierUnpatched, + /// A NuGet Socket source whose mapped id a governed `packages.lock.json` + /// also locks at a version other than the patched one: the mapping + /// routes every version of the id to a source that serves only the + /// patched one, so that target framework cannot restore — the case both + /// writers refuse (`redirect_nuget_lock_other_version`, + /// `vendor_nuget_lock_other_version`). + NugetLockOtherVersion, } /// A ref discovery emits (so rollback, remove and list find the wiring, diff --git a/crates/socket-patch-core/src/vex/discover/nuget.rs b/crates/socket-patch-core/src/vex/discover/nuget.rs index 584b727bb..be5715150 100644 --- a/crates/socket-patch-core/src/vex/discover/nuget.rs +++ b/crates/socket-patch-core/src/vex/discover/nuget.rs @@ -37,7 +37,15 @@ //! derives it by stripping `sha512-` from the patch's SRI — so it is carried //! as [`LockIntegrity::Sri`] `sha512-`, and only when every lock //! entry of that version agrees (divergent hashes fail restore: diagnosed, -//! no ref). +//! no ref). Versions are keyed through `normalize_nuget_version`, as the +//! writers match lock entries (`1.0` and `1.0.0` are one version). +//! +//! The mapping routes EVERY version of the id to a source that serves only +//! the patched one, so both writers refuse a lock that also locks the id at +//! another version (`*_nuget_lock_other_version`). Here such a source's refs +//! stay refs (the wiring is still there to roll back) but are marked +//! [`UnattestedKind::NugetLockOtherVersion`], never attested: see +//! [`other_versions`] for how the patched version is known. //! //! **`integrity_required = true`** for hosted refs: `rewrite_nuget` refuses a //! dep without a sha512 (`redirect_nuget_missing_sha512`) and always writes @@ -74,14 +82,14 @@ use serde_json::Value; use super::{ parse_json, simple_purl, socket_patch_name_uuid, vendor_ref, vendor_uuid_dir, DiscoverCtx, - Discovery, PatchedRef, UnlockedPin, WiringMode, DIAG_LOCKFILE_UNPARSEABLE, + Discovery, PatchedRef, UnattestedKind, UnlockedPin, WiringMode, DIAG_LOCKFILE_UNPARSEABLE, DIAG_LOCKFILE_UNREADABLE, DIAG_REF_INVALID, DIAG_REF_UNATTRIBUTABLE, }; use crate::formats::nuget::lock::nuget_lock_entries; use crate::formats::nuget::{parse_config, NugetConfig}; use crate::vendor::lock_inventory::LockIntegrity; use crate::vendor::nuget_config::{same_file, CONFIG_NAMES}; -use crate::vendor::nuget_feed::{is_plain_nuget_token, nupkg_leaf}; +use crate::vendor::nuget_feed::{is_plain_nuget_token, normalize_nuget_version, nupkg_leaf}; use crate::vendor::path::VENDOR_DIR; /// The lock NuGet writes beside the project (default name). @@ -268,9 +276,16 @@ fn is_nuget_id(id: &str) -> bool { // ── packages.lock.json ─────────────────────────────────────────────────── -/// What the lock says about one `(id, resolved version)`. +/// What the governed locks say about one `(id, version)`, the version +/// keyed through [`normalize_nuget_version`] exactly as the writers match +/// it (`locked_at`, `other_versions`): `1.0` and `1.0.0` are one package. #[derive(Debug, Default)] struct Pins { + /// The first trimmed `resolved` spelling seen — the purl's version + /// (purls compare NuGet versions through the same normalization). + spelling: String, + /// The governed locks that lock this version. + files: BTreeSet, /// Distinct non-empty `contentHash` values across target frameworks. hashes: BTreeSet, /// Some entry of this version carries no `contentHash`. @@ -281,7 +296,7 @@ enum Lock { Absent, /// Present but unreadable / unparseable (already diagnosed). Unusable, - /// Lowercased id → resolved version → pins. + /// Lowercased id → normalized version → pins. Parsed(BTreeMap>), } @@ -352,11 +367,17 @@ async fn load_lock(ctx: &DiscoverCtx<'_>, out: &mut Discovery) -> Lock { } }; for entry in nuget_lock_entries(&doc) { + // The raw `resolved`, normalized as the writers' `other_versions` + // does: a spelling they count as another version is one here. let pins = index .entry(entry.id.to_ascii_lowercase()) .or_default() - .entry(entry.resolved.trim().to_string()) - .or_default(); + .entry(normalize_nuget_version(entry.resolved)) + .or_insert_with(|| Pins { + spelling: entry.resolved.trim().to_string(), + ..Pins::default() + }); + pins.files.insert(rel.clone()); match entry.content_hash { Some(hash) if !hash.trim().is_empty() => { pins.hashes.insert(hash.trim().to_string()); @@ -371,18 +392,32 @@ async fn load_lock(ctx: &DiscoverCtx<'_>, out: &mut Discovery) -> Lock { Lock::Parsed(index) } -/// The lock's `(version, integrity)` rows for `id`, or `None` when the lock -/// has no entry for it. A version whose entries disagree on `contentHash` is -/// diagnosed and dropped (NuGet fails that restore). +/// What the governed locks say about one Socket source's id. +struct Locked { + /// `(version, integrity)` per locked version (the first spelling of + /// each). A version whose entries disagree on `contentHash` is diagnosed + /// and dropped (NuGet fails that restore). + rows: Vec<(String, Option)>, + /// `(file, detail)` when the id is also locked at a version other than + /// the patched one ([`other_versions`]): every row is then wired but + /// [`UnattestedKind::NugetLockOtherVersion`]. + other: Option<(String, String)>, +} + +/// The governed locks' rows for `src`'s id, or `None` when no lock has an +/// entry for it. `patched` is the patched version, normalized, when the +/// files name it (the vendored feed's single nupkg); see [`other_versions`]. fn lock_versions( index: &BTreeMap>, src: &SocketSource, + patched: Option<&str>, cfg_rel: &str, out: &mut Discovery, -) -> Option)>> { +) -> Option { let versions = index.get(&src.id.to_ascii_lowercase())?; let mut rows = Vec::new(); - for (version, pins) in versions { + for pins in versions.values() { + let version = &pins.spelling; if pins.hashes.len() > 1 { out.diag( DIAG_REF_INVALID, @@ -401,7 +436,57 @@ fn lock_versions( }; rows.push((version.clone(), integrity)); } - Some(rows) + Some(Locked { + rows, + other: other_versions(versions, src, patched, cfg_rel), + }) +} + +/// The writers' other-version rule (`formats::nuget::lock::other_versions`): +/// the exact-id mapping routes EVERY version of the id to a Socket source +/// that serves only the patched one, so a lock entry at any other version +/// cannot restore, and both writers refuse to wire such a lock +/// (`redirect_nuget_lock_other_version`, `vendor_nuget_lock_other_version`). +/// The writers take the patched version from the patch; of the files, only +/// the vendored feed names it (`patched`). A hosted source's url names only +/// the uuid, so there any two locked versions mean one is not the patched +/// one, whichever that is. `(file, detail)` of the first such lock, or +/// `None` when the project is wired as the writers would wire it. +fn other_versions( + versions: &BTreeMap, + src: &SocketSource, + patched: Option<&str>, + cfg_rel: &str, +) -> Option<(String, String)> { + let others: Vec<(&String, &Pins)> = match patched { + Some(patched) => versions.iter().filter(|(v, _)| *v != patched).collect(), + None if versions.len() > 1 => versions.iter().collect(), + None => return None, + }; + let file = others.first()?.1.files.first()?.clone(); + let listed = others + .iter() + .map(|(v, pins)| { + let files: Vec<&str> = pins.files.iter().map(String::as_str).collect(); + format!("{v} ({})", files.join(", ")) + }) + .collect::>() + .join(", "); + let detail = match patched { + Some(patched) => format!( + "the NuGet locks also lock {} at {listed}, while {cfg_rel} routes every version of \ + it to the vendored feed {VENDOR_DIR}/nuget/{}, which serves only {patched}, so \ + those target frameworks cannot restore", + src.id, src.uuid + ), + None => format!( + "the NuGet locks lock {} at several versions ({listed}), while {cfg_rel} routes \ + every version of it to Socket patch source {}, which serves only the patched one, \ + so a target framework locking another cannot restore", + src.id, src.key + ), + }; + Some((file, detail)) } // ── refs ───────────────────────────────────────────────────────────────── @@ -496,9 +581,9 @@ fn emit_hosted( } None } - Lock::Parsed(index) => lock_versions(index, src, cfg_rel, out), + Lock::Parsed(index) => lock_versions(index, src, None, cfg_rel, out), }; - let Some(rows) = rows else { + let Some(Locked { rows, other }) = rows else { out.diag( DIAG_REF_UNATTRIBUTABLE, cfg_rel, @@ -522,6 +607,17 @@ fn emit_hosted( ); continue; }; + // Still a ref (rollback, remove and the ledger's liveness gate see + // the wiring, which a re-run cannot clear either), never attested. + if let Some((file, detail)) = &other { + out.unattested( + &purl, + &src.uuid, + file, + detail.clone(), + UnattestedKind::NugetLockOtherVersion, + ); + } out.push(PatchedRef::hosted( purl, src.uuid.clone(), @@ -543,17 +639,25 @@ async fn emit_vendored( let id_lower = src.id.to_ascii_lowercase(); // (purl version, artifact leaf, integrity) let mut rows: Vec<(String, String, Option)> = Vec::new(); + let leaves = feed_leaves(ctx, &src.uuid, &id_lower).await; + // The version the feed serves: the writer vendors exactly one nupkg per + // patch, named for the patched version. + let patched = match leaves.as_slice() { + [(_, version)] => Some(normalize_nuget_version(version)), + _ => None, + }; let locked = match lock { - Lock::Parsed(index) => lock_versions(index, src, cfg_rel, out), + Lock::Parsed(index) => lock_versions(index, src, patched.as_deref(), cfg_rel, out), Lock::Absent | Lock::Unusable => None, }; + let mut other = None; if let Some(locked) = locked { - for (version, integrity) in locked { + other = locked.other; + for (version, integrity) in locked.rows { let leaf = nupkg_leaf(&id_lower, &version); rows.push((version, leaf, integrity)); } } else { - let leaves = feed_leaves(ctx, &src.uuid, &id_lower).await; match leaves.as_slice() { [(leaf, version)] => rows.push((version.clone(), leaf.clone(), None)), _ => { @@ -592,6 +696,15 @@ async fn emit_vendored( ); continue; }; + if let Some((file, detail)) = &other { + out.unattested( + &purl, + &src.uuid, + file, + detail.clone(), + UnattestedKind::NugetLockOtherVersion, + ); + } out.push(PatchedRef::vendored(purl, &vref, cfg_rel, integrity)); } } @@ -1676,4 +1789,246 @@ mod tests { assert_refs(&out, &[]); assert_eq!(diag_codes(&out), vec![DIAG_REF_INVALID]); } + + /// The kinds of the [`Unattested`] marks, per canonical purl. + fn unattested(out: &Discovery) -> Vec<(String, UnattestedKind)> { + out.unattested + .iter() + .map(|u| (u.purl.clone(), u.kind)) + .collect() + } + + /// Both writers refuse a lock that locks the mapped id at a version + /// other than the patched one (`*_nuget_lock_other_version`): the + /// mapping routes every version to a source serving only the patched + /// one, so that target framework cannot restore. A hosted url names no + /// version, so ANY two locked versions (across target frameworks or + /// across governed locks) mean one is unpatched: every ref stays (the + /// ledger's liveness, rollback and remove still see the wiring) but is + /// never attested. + #[tokio::test] + async fn hosted_id_locked_at_two_versions_is_unattested() { + let cfg = config( + &[(&socket_key(UUID_A), &index_url(UUID_A))], + &[(&socket_key(UUID_A), "Newtonsoft.Json")], + ); + let expected = [ + ( + "pkg:nuget/newtonsoft.json@13.0.1", + UUID_A, + WiringMode::Hosted, + ), + ( + "pkg:nuget/newtonsoft.json@13.0.3", + UUID_A, + WiringMode::Hosted, + ), + ]; + let marks = vec![ + ( + "pkg:nuget/newtonsoft.json@13.0.1".to_string(), + UnattestedKind::NugetLockOtherVersion, + ), + ( + "pkg:nuget/newtonsoft.json@13.0.3".to_string(), + UnattestedKind::NugetLockOtherVersion, + ), + ]; + + // Two target frameworks of one lock. + let p = Project::new(); + p.write("nuget.config", &cfg); + p.write( + "packages.lock.json", + lock(&[ + ("net6.0", "Newtonsoft.Json", "13.0.1", Some("T1RIRVI=")), + ("net8.0", "Newtonsoft.Json", "13.0.3", Some(HASH)), + ]), + ); + let out = run(&p).await; + assert_refs(&out, &expected); + assert_eq!(unattested(&out), marks); + assert!( + out.unattested[0] + .detail + .contains("13.0.1 (packages.lock.json)"), + "{}", + out.unattested[0].detail + ); + // Still live wiring for the ledger record of either version. + assert_eq!( + out.hosted_claim("pkg:nuget/newtonsoft.json@13.0.3", UUID_A), + Some(true) + ); + + // The root lock and a member project's lock. + let p = Project::new(); + p.write("nuget.config", &cfg); + p.write( + "packages.lock.json", + lock(&[("net8.0", "Newtonsoft.Json", "13.0.3", Some(HASH))]), + ); + p.write("src/App/App.csproj", ""); + p.write( + "src/App/packages.lock.json", + lock(&[("net8.0", "Newtonsoft.Json", "13.0.1", Some("T1RIRVI="))]), + ); + let out = run(&p).await; + assert_refs(&out, &expected); + assert_eq!(unattested(&out), marks); + assert_eq!( + out.unattested[0].file, + std::path::PathBuf::from("src/App/packages.lock.json") + ); + + // One version (the writers' own output) is attested as before. + let p = Project::new(); + p.write("nuget.config", &cfg); + p.write( + "packages.lock.json", + lock(&[ + ("net6.0", "Newtonsoft.Json", "13.0.3", Some(HASH)), + ("net8.0", "Newtonsoft.Json", "13.0.3", Some(HASH)), + ]), + ); + let out = run(&p).await; + assert_refs(&out, &expected[1..]); + assert!(out.unattested.is_empty(), "{:?}", out.unattested); + } + + /// The vendored feed names the patched version (its single + /// `..nupkg`): a lock entry at any other version, + /// beside the patched one or alone, is the writer's refusal. + #[tokio::test] + async fn vendored_id_locked_at_another_version_is_unattested() { + let cfg = config( + &[ORG, (&socket_key(UUID_A), &feed(UUID_A))], + &[("nuget.org", "*"), (&socket_key(UUID_A), "Newtonsoft.Json")], + ); + let nupkg = format!(".socket/vendor/nuget/{UUID_A}/newtonsoft.json.13.0.3.nupkg"); + let other = ( + "pkg:nuget/newtonsoft.json@13.0.1".to_string(), + UnattestedKind::NugetLockOtherVersion, + ); + let patched = ( + "pkg:nuget/newtonsoft.json@13.0.3".to_string(), + UnattestedKind::NugetLockOtherVersion, + ); + + let p = Project::new(); + p.write("nuget.config", &cfg); + p.write(&nupkg, b"PK"); + p.write( + "packages.lock.json", + lock(&[ + ("net6.0", "Newtonsoft.Json", "13.0.1", Some("T1RIRVI=")), + ("net8.0", "Newtonsoft.Json", "13.0.3", Some(HASH)), + ]), + ); + let out = run(&p).await; + assert_eq!(out.refs.len(), 2, "{:#?}", out.refs); + assert_eq!(unattested(&out), vec![other.clone(), patched]); + assert!( + out.unattested[0].detail.contains("serves only 13.0.3"), + "{}", + out.unattested[0].detail + ); + // The ledger's own artifact is still wired (rollback / remove). + assert_eq!( + out.vendored_claim("pkg:nuget/newtonsoft.json@13.0.3", UUID_A, &nupkg), + Some(true) + ); + + // The lock names only another version than the feed serves. + let p = Project::new(); + p.write("nuget.config", &cfg); + p.write(&nupkg, b"PK"); + p.write( + "packages.lock.json", + lock(&[("net8.0", "Newtonsoft.Json", "13.0.1", Some("T1RIRVI="))]), + ); + let out = run(&p).await; + assert_eq!(unattested(&out), vec![other]); + + // The patched version, in a spelling that normalizes to the feed's. + let p = Project::new(); + p.write("nuget.config", &cfg); + p.write(&nupkg, b"PK"); + p.write( + "packages.lock.json", + lock(&[("net8.0", "Newtonsoft.Json", "13.0.3.0", Some(HASH))]), + ); + let out = run(&p).await; + assert_refs( + &out, + &[( + "pkg:nuget/newtonsoft.json@13.0.3.0", + UUID_A, + WiringMode::Vendored, + )], + ); + assert_eq!(out.refs[0].artifact_rel.as_deref(), Some(nupkg.as_str())); + assert!(out.unattested.is_empty(), "{:?}", out.unattested); + } + + /// Versions are keyed through `normalize_nuget_version`, as the writers + /// match them: `13.0.3` and `13.0.3.0` in two target frameworks are ONE + /// package, so one ref whose pin is checked across both, never two refs + /// with independent pins. + #[tokio::test] + async fn non_normalized_version_spellings_are_one_version() { + let cfg = config( + &[(&socket_key(UUID_A), &index_url(UUID_A))], + &[(&socket_key(UUID_A), "Newtonsoft.Json")], + ); + let p = Project::new(); + p.write("nuget.config", &cfg); + p.write( + "packages.lock.json", + lock(&[ + ("net6.0", "Newtonsoft.Json", "13.0.3", Some(HASH)), + ("net8.0", "Newtonsoft.Json", "13.0.3.0", Some(HASH)), + ]), + ); + let out = run(&p).await; + assert_refs( + &out, + &[( + "pkg:nuget/newtonsoft.json@13.0.3", + UUID_A, + WiringMode::Hosted, + )], + ); + assert!(out.refs[0].lockfile_basis_ok()); + assert!(out.unattested.is_empty(), "{:?}", out.unattested); + + // The two spellings pinned to different bytes: one package with + // conflicting contentHash values, which NuGet fails to restore. + let p = Project::new(); + p.write("nuget.config", &cfg); + p.write( + "packages.lock.json", + lock(&[ + ("net6.0", "Newtonsoft.Json", "13.0.3", Some(HASH)), + ("net8.0", "Newtonsoft.Json", "13.0.3.0", Some("T1RIRVI=")), + ]), + ); + let out = run(&p).await; + assert_refs(&out, &[]); + assert_eq!(diag_codes(&out), vec![DIAG_REF_INVALID]); + + // One spelling pinned, the other not: not a Socket-written pin. + let p = Project::new(); + p.write("nuget.config", &cfg); + p.write( + "packages.lock.json", + lock(&[ + ("net6.0", "Newtonsoft.Json", "13.0.3", Some(HASH)), + ("net8.0", "Newtonsoft.Json", "13.0.03", None), + ]), + ); + let out = run(&p).await; + assert_eq!(out.refs.len(), 1, "{:#?}", out.refs); + assert!(!out.refs[0].lockfile_basis_ok()); + } } From 56bffb2a9ea7ed380270bfe6334fbd73e7bc0bb7 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 4 Nov 2026 15:38:19 -0600 Subject: [PATCH 2/2] Fix review findings in NuGet other-version discovery - Vendored: the feed's single nupkg names the patched version, so it is the only ref, even when no lock entry is at it (keeping the ledger's vendored_claim live); other locked versions are reported in the unattested detail, never as refs pointing at nupkgs that don't exist. - Each ref's other-version check excludes its own version: the detail and file name the lock holding the OTHER version, listed in NuGet precedence (13.0.9 before 13.0.10), and say "also lock" only when the ref's version is locked. - Trim `resolved` before normalizing; an empty one is diagnosed and counts as another version (the writers would refuse it). - The contentHash-conflict diagnostic names the locks and spellings. - Only Socket-mapped ids are indexed; one push_ref helper marks refs. Co-Authored-By: Claude Opus 5.5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../src/vex/discover/nuget.rs | 612 ++++++++++++------ 2 files changed, 410 insertions(+), 204 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 24bbd637d..d6ec88ea4 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -539,7 +539,7 @@ Discovery is read-only, never touches the network, and never fails the run: a ma | composer | `composer.lock` (`packages` + `packages-dev`) | `dist.url` on the patch host | `dist: {type: "path", url: ".socket/vendor/composer//…", reference: ""}` | `dist.shasum`, required | | maven | `pom.xml` (+ `.mvn/maven.config`, `.mvn/checksums/checksums.sha256`) | a dependency version `-socket.` matching exactly ONE `socket-patch-` repository on the patch host | pre-v5 single-POM wiring only: `socket-patch-vendor-` repository + exactly one jar under `.socket/vendor/maven//` with a matching `.sha1`. A v5 suffixed pin served from `.socket/vendor/maven2` is no reference here: its ledger entry's wiring check gates it, so without a ledger it is not attested | Trusted Checksums line when enabled; not required (the suffixed version is the pin) | | gradle (v5.0) | `.socket/gradle/hosted-index.tsv`, the owned hosted script, and every settings script and lock file the script graph reaches | an index row whose repository URL is on the patch host and names the row's uuid, live only while the owned script is intact, every build's settings file applies it with the current index digest, every lock entry of the GA is the suffixed version, no `settings-gradle.lockfile` names the GA and no build script sets a custom `lockFile` (otherwise `patched_ref_invalid`) | none here: a vendored Gradle entry is gated by its ledger entry's wiring check | the suffixed copies installed in the Gradle cache; required (never the lock basis), because the script lets a higher upstream version resolve | -| nuget | the first of `nuget.config` / `NuGet.config` / `NuGet.Config`, + `packages.lock.json` | source `socket-patch-` + its exclusive exact-id ``; version from `packages.lock.json` | the same mapping onto `.socket/vendor/nuget/`; version from the lock, else the feed's single nupkg | `contentHash`, required. Lock versions match under `normalize_nuget_version`, as the writers match them (`1.0` and `1.0.0` are one version). When the governed locks also lock the mapped id at a version other than the patched one (another target framework or project), which both writers refuse (`redirect_nuget_lock_other_version`, `vendor_nuget_lock_other_version`), every reference of that source is kept but not attested (`vex_nuget_lock_other_version`). The patched version is the vendored feed's single nupkg; a hosted url names no version, so there any two locked versions qualify. | +| nuget | the first of `nuget.config` / `NuGet.config` / `NuGet.Config`, + `packages.lock.json` | source `socket-patch-` + its exclusive exact-id ``; version from `packages.lock.json` | the same mapping onto `.socket/vendor/nuget/`; version from the feed's single nupkg (the patched version), else the lock | `contentHash`, required. Lock versions match under `normalize_nuget_version` of the trimmed `resolved`, as the writers match them (`1.0` and `1.0.0` are one version; an empty one is `patched_ref_invalid`). When the governed locks also lock the mapped id at a version other than the patched one (another target framework or project), which both writers refuse (`redirect_nuget_lock_other_version`, `vendor_nuget_lock_other_version`), the reference is kept but not attested (`vex_nuget_lock_other_version`, naming the other versions and their locks). Vendored, the patched version is the feed's single nupkg: it is the only reference, even when no lock entry is at it, and other locked versions are never references. A hosted url names no version, so each hosted reference is checked against its own version: any two locked versions withhold them all, and a single locked version other than the patch's is gated by the patch record (`record_mismatch`). | | deno | `deno.lock`'s npm section, only as evidence against an npm-family pin of a `name@version` it also locks, which `vex` then omits (see **Unattested references**, #406); hosted and vendored npm runs warn about such a pin (`redirect_npm_deno_lock` / `vendor_npm_deno_lock`) | — (no hosted mode) | — (no vendored backend) | — | Recognition rules that hold for every ecosystem: diff --git a/crates/socket-patch-core/src/vex/discover/nuget.rs b/crates/socket-patch-core/src/vex/discover/nuget.rs index be5715150..a27a5b827 100644 --- a/crates/socket-patch-core/src/vex/discover/nuget.rs +++ b/crates/socket-patch-core/src/vex/discover/nuget.rs @@ -22,23 +22,24 @@ //! | writer | `value` | mode | version from | pin | //! |---|---|---|---|---| //! | `patch::redirect::rewrite_nuget` (`scan --mode hosted`) | `https://patch.socket.dev/patch-registry/nuget///index.json` ([`DiscoverCtx::hosted_uuid`], must equal the key's uuid) | Hosted | every `packages.lock.json` entry for the id (`dependencies...resolved`, id case-insensitive) | its `contentHash` | -//! | `vendor::nuget_feed` (`vendor`, `scan --mode vendored`) | `.socket/vendor/nuget/` ([`vendor_uuid_dir`], must be the nuget dir of the key's uuid) | Vendored | the lock's `resolved` as above, else the feed's single `..nupkg` | `contentHash` when locked | +//! | `vendor::nuget_feed` (`vendor`, `scan --mode vendored`) | `.socket/vendor/nuget/` ([`vendor_uuid_dir`], must be the nuget dir of the key's uuid) | Vendored | the feed's single `..nupkg` (the patched version), else every locked `resolved` as above | `contentHash` when locked | //! //! The vendored artifact is `.socket/vendor/nuget//. //! .nupkg` ([`nupkg_leaf`], the writer's own stable leaf: //! lowercased id, version through the SAME `normalize_nuget_version`) — the exact //! `VendorArtifact::path` the ledger records, which the CLI's liveness check -//! compares verbatim. With no lock (NuGet only writes one under -//! `RestorePackagesWithLockFile`) the feed dir is listed instead and must -//! hold exactly one nupkg for the mapped id; its name is split at the KNOWN -//! id, not guessed from the first digit-leading segment. +//! compares verbatim. The feed dir is listed for the patched version (one +//! nupkg for the mapped id; its name is split at the KNOWN id, not guessed +//! from the first digit-leading segment); with no lock (NuGet only writes +//! one under `RestorePackagesWithLockFile`) it must hold exactly one. //! //! `contentHash` is base64 sha512 of the nupkg bytes — the hosted rewriter //! derives it by stripping `sha512-` from the patch's SRI — so it is carried //! as [`LockIntegrity::Sri`] `sha512-`, and only when every lock //! entry of that version agrees (divergent hashes fail restore: diagnosed, -//! no ref). Versions are keyed through `normalize_nuget_version`, as the -//! writers match lock entries (`1.0` and `1.0.0` are one version). +//! no ref). Versions are keyed through `normalize_nuget_version` of the +//! trimmed `resolved`, as the writers match lock entries (`1.0` and `1.0.0` +//! are one version). //! //! The mapping routes EVERY version of the id to a source that serves only //! the patched one, so both writers refuse a lock that also locks the id at @@ -137,7 +138,8 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { // and a malformed one is not this reader's business. return; } - let lock = load_lock(ctx, out).await; + let ids: BTreeSet = sources.iter().map(|s| s.id.to_ascii_lowercase()).collect(); + let lock = load_lock(ctx, &ids, out).await; for src in &sources { // Another source mapping the same exact id ties with ours (NuGet may // restore it from either); without a lock's contentHash to reject @@ -277,13 +279,15 @@ fn is_nuget_id(id: &str) -> bool { // ── packages.lock.json ─────────────────────────────────────────────────── /// What the governed locks say about one `(id, version)`, the version -/// keyed through [`normalize_nuget_version`] exactly as the writers match -/// it (`locked_at`, `other_versions`): `1.0` and `1.0.0` are one package. +/// keyed through [`normalize_nuget_version`] (of the trimmed `resolved`) as +/// the writers match it (`locked_at`, `other_versions`): `1.0` and `1.0.0` +/// are one package. #[derive(Debug, Default)] struct Pins { - /// The first trimmed `resolved` spelling seen — the purl's version - /// (purls compare NuGet versions through the same normalization). - spelling: String, + /// Every trimmed `resolved` spelling of this version, first seen first + /// (never empty). The first is the purl's version: purls compare NuGet + /// versions through the same normalization. + spellings: Vec, /// The governed locks that lock this version. files: BTreeSet, /// Distinct non-empty `contentHash` values across target frameworks. @@ -292,19 +296,32 @@ struct Pins { unpinned: bool, } +/// What the governed locks say about one Socket-mapped id. +#[derive(Debug, Default)] +struct IdLocks { + /// Normalized version → pins. + versions: BTreeMap, + /// Locks with an entry for the id whose `resolved` is empty (diagnosed). + /// No version to attest, but the writers count it as another version + /// (it does not normalize to the patched one), so it withholds the + /// statement like one. + empty: BTreeSet, +} + enum Lock { Absent, /// Present but unreadable / unparseable (already diagnosed). Unusable, - /// Lowercased id → normalized version → pins. - Parsed(BTreeMap>), + /// Lowercased Socket-mapped id → what the locks say about it. + Parsed(BTreeMap), } /// Every lock the root config governs: the root `packages.lock.json`, and on /// disk each lock a project under the root restores into (#353, #514; the /// writers pin all of them). Their entries are merged: one version's -/// `contentHash` must agree across them, as within one lock. -async fn load_lock(ctx: &DiscoverCtx<'_>, out: &mut Discovery) -> Lock { +/// `contentHash` must agree across them, as within one lock. Only the +/// Socket-mapped `ids` (lowercased) are indexed. +async fn load_lock(ctx: &DiscoverCtx<'_>, ids: &BTreeSet, out: &mut Discovery) -> Lock { let mut rels = vec![PACKAGES_LOCK.to_string()]; // On disk only (the in-memory engine refuses NuGet). Walked through the // view, never its raw root: a re-scan's read cache keeps recording. @@ -341,7 +358,7 @@ async fn load_lock(ctx: &DiscoverCtx<'_>, out: &mut Discovery) -> Lock { } } } - let mut index: BTreeMap> = BTreeMap::new(); + let mut index: BTreeMap = BTreeMap::new(); let mut any = false; for rel in &rels { if !ctx.exists(rel).await { @@ -367,17 +384,35 @@ async fn load_lock(ctx: &DiscoverCtx<'_>, out: &mut Discovery) -> Lock { } }; for entry in nuget_lock_entries(&doc) { - // The raw `resolved`, normalized as the writers' `other_versions` - // does: a spelling they count as another version is one here. - let pins = index - .entry(entry.id.to_ascii_lowercase()) - .or_default() - .entry(normalize_nuget_version(entry.resolved)) - .or_insert_with(|| Pins { - spelling: entry.resolved.trim().to_string(), - ..Pins::default() - }); - pins.files.insert(rel.clone()); + let id = entry.id.to_ascii_lowercase(); + if !ids.contains(&id) { + continue; + } + let id_locks = index.entry(id).or_default(); + let resolved = entry.resolved.trim(); + if resolved.is_empty() { + if id_locks.empty.insert(rel.clone()) { + out.diag( + DIAG_REF_INVALID, + rel, + format!( + "{rel}: {} is locked with an empty resolved version", + entry.id + ), + ); + } + continue; + } + let pins = id_locks + .versions + .entry(normalize_nuget_version(resolved)) + .or_default(); + if !pins.spellings.iter().any(|s| s == resolved) { + pins.spellings.push(resolved.to_string()); + } + if !pins.files.contains(rel) { + pins.files.insert(rel.clone()); + } match entry.content_hash { Some(hash) if !hash.trim().is_empty() => { pins.hashes.insert(hash.trim().to_string()); @@ -392,40 +427,33 @@ async fn load_lock(ctx: &DiscoverCtx<'_>, out: &mut Discovery) -> Lock { Lock::Parsed(index) } -/// What the governed locks say about one Socket source's id. -struct Locked { - /// `(version, integrity)` per locked version (the first spelling of - /// each). A version whose entries disagree on `contentHash` is diagnosed - /// and dropped (NuGet fails that restore). - rows: Vec<(String, Option)>, - /// `(file, detail)` when the id is also locked at a version other than - /// the patched one ([`other_versions`]): every row is then wired but - /// [`UnattestedKind::NugetLockOtherVersion`]. - other: Option<(String, String)>, +/// One locked version of a Socket source's id. +struct Row { + /// Normalized ([`normalize_nuget_version`]). + norm: String, + /// The purl's version (the first spelling). + spelling: String, + integrity: Option, } -/// The governed locks' rows for `src`'s id, or `None` when no lock has an -/// entry for it. `patched` is the patched version, normalized, when the -/// files name it (the vendored feed's single nupkg); see [`other_versions`]. -fn lock_versions( - index: &BTreeMap>, - src: &SocketSource, - patched: Option<&str>, - cfg_rel: &str, - out: &mut Discovery, -) -> Option { - let versions = index.get(&src.id.to_ascii_lowercase())?; +/// The locked versions of `src`'s id, sorted by NuGet precedence. A version +/// whose entries disagree on `contentHash` is diagnosed and dropped (NuGet +/// fails that restore). +fn lock_rows(locks: &IdLocks, src: &SocketSource, cfg_rel: &str, out: &mut Discovery) -> Vec { let mut rows = Vec::new(); - for pins in versions.values() { - let version = &pins.spelling; + for (norm, pins) in sorted_versions(locks) { if pins.hashes.len() > 1 { + let files: Vec<&str> = pins.files.iter().map(String::as_str).collect(); out.diag( DIAG_REF_INVALID, - PACKAGES_LOCK, + files[0], format!( - "{PACKAGES_LOCK}: {} {version} (routed to Socket patch {} by {cfg_rel}) has \ - conflicting contentHash values across target frameworks", - src.id, src.uuid + "{}: {} {} (routed to Socket patch {} by {cfg_rel}) is locked with \ + conflicting contentHash values", + files.join(", "), + src.id, + pins.spellings.join(" / "), + src.uuid ), ); continue; @@ -434,59 +462,108 @@ fn lock_versions( (Some(hash), false) => Some(LockIntegrity::Sri(format!("sha512-{hash}"))), _ => None, }; - rows.push((version.clone(), integrity)); + rows.push(Row { + norm: norm.clone(), + spelling: pins.spellings[0].clone(), + integrity, + }); } - Some(Locked { - rows, - other: other_versions(versions, src, patched, cfg_rel), - }) + rows +} + +/// `locks`' versions in NuGet precedence order (`13.0.9` before `13.0.10`, +/// a pre-release before its release), not string order. +fn sorted_versions(locks: &IdLocks) -> Vec<(&String, &Pins)> { + let mut versions: Vec<(&String, &Pins)> = locks.versions.iter().collect(); + versions.sort_by(|(a, _), (b, _)| precedence(a).cmp(&precedence(b)).then(a.cmp(b))); + versions +} + +/// A normalized NuGet version's sort key: numeric segments, then a release +/// above its pre-releases, then the pre-release label. Orders listings only. +fn precedence(norm: &str) -> (Vec, bool, &str) { + let (core, pre) = match norm.split_once('-') { + Some((core, pre)) => (core, pre), + None => (norm, ""), + }; + let segments = core + .split('.') + .map(|s| s.parse().unwrap_or(u64::MAX)) + .collect(); + (segments, pre.is_empty(), pre) } -/// The writers' other-version rule (`formats::nuget::lock::other_versions`): -/// the exact-id mapping routes EVERY version of the id to a Socket source -/// that serves only the patched one, so a lock entry at any other version -/// cannot restore, and both writers refuse to wire such a lock -/// (`redirect_nuget_lock_other_version`, `vendor_nuget_lock_other_version`). -/// The writers take the patched version from the patch; of the files, only -/// the vendored feed names it (`patched`). A hosted source's url names only -/// the uuid, so there any two locked versions mean one is not the patched -/// one, whichever that is. `(file, detail)` of the first such lock, or -/// `None` when the project is wired as the writers would wire it. +/// `(file, detail)` of an [`other_versions`] finding. +type OtherVersion = (String, String); + +/// The writers' other-version rule (`formats::nuget::lock::other_versions`) +/// for a ref of `version` (normalized): the exact-id mapping routes EVERY +/// version of the id to a Socket source that serves only the patched one, +/// so a lock entry at any other version cannot restore, and both writers +/// refuse such a lock (`redirect_nuget_lock_other_version`, +/// `vendor_nuget_lock_other_version`). `serves` names that source. +/// `(file, detail)`, the file a lock holding the first other version (in +/// NuGet precedence), or `None` when every entry is at `version`. +/// +/// The writers know the patched version from the patch. Of the files, only +/// the vendored feed names it; a hosted url names only the uuid, so a +/// hosted ref is checked against its own version: of two locked versions, +/// one is unpatched, whichever that is, and both refs are withheld. A +/// single locked version that is not the patched one is the VEX plan's to +/// catch: the patch record names another package version. fn other_versions( - versions: &BTreeMap, + locks: &IdLocks, + version: &str, src: &SocketSource, - patched: Option<&str>, + serves: &str, cfg_rel: &str, -) -> Option<(String, String)> { - let others: Vec<(&String, &Pins)> = match patched { - Some(patched) => versions.iter().filter(|(v, _)| *v != patched).collect(), - None if versions.len() > 1 => versions.iter().collect(), - None => return None, - }; - let file = others.first()?.1.files.first()?.clone(); - let listed = others - .iter() - .map(|(v, pins)| { +) -> Option { + let mut listed = Vec::new(); + let mut file = None; + for (norm, pins) in sorted_versions(locks) { + if norm != version { let files: Vec<&str> = pins.files.iter().map(String::as_str).collect(); - format!("{v} ({})", files.join(", ")) - }) - .collect::>() - .join(", "); - let detail = match patched { - Some(patched) => format!( - "the NuGet locks also lock {} at {listed}, while {cfg_rel} routes every version of \ - it to the vendored feed {VENDOR_DIR}/nuget/{}, which serves only {patched}, so \ - those target frameworks cannot restore", - src.id, src.uuid - ), - None => format!( - "the NuGet locks lock {} at several versions ({listed}), while {cfg_rel} routes \ - every version of it to Socket patch source {}, which serves only the patched one, \ - so a target framework locking another cannot restore", - src.id, src.key - ), + file.get_or_insert_with(|| files[0].to_string()); + listed.push(format!("{} ({})", pins.spellings[0], files.join(", "))); + } + } + if !locks.empty.is_empty() { + let files: Vec<&str> = locks.empty.iter().map(String::as_str).collect(); + file.get_or_insert_with(|| files[0].to_string()); + listed.push(format!("an empty version ({})", files.join(", "))); + } + let file = file?; + let also = if locks.versions.contains_key(version) { + "also lock" + } else { + "lock" }; - Some((file, detail)) + Some(( + file, + format!( + "the NuGet locks {also} {} at {}, while {cfg_rel} routes every version of it to \ + {serves}, so those target frameworks cannot restore", + src.id, + listed.join(", ") + ), + )) +} + +/// Push `r`, marked [`UnattestedKind::NugetLockOtherVersion`] when the +/// locks also hold another version ([`other_versions`]): still a ref, so +/// rollback, remove and the ledgers' liveness gates see the wiring (which +/// a re-run cannot clear either), but never attested. +fn push_ref(out: &mut Discovery, r: PatchedRef, other: Option) { + if let Some((file, detail)) = other { + out.unattested( + &r.purl, + &r.uuid, + &file, + detail, + UnattestedKind::NugetLockOtherVersion, + ); + } + out.push(r); } // ── refs ───────────────────────────────────────────────────────────────── @@ -566,7 +643,7 @@ fn emit_hosted( exclusive: bool, out: &mut Discovery, ) { - let rows = match lock { + let locks = match lock { Lock::Unusable => return, Lock::Absent => { if exclusive { @@ -581,9 +658,11 @@ fn emit_hosted( } None } - Lock::Parsed(index) => lock_versions(index, src, None, cfg_rel, out), + Lock::Parsed(index) => index + .get(&src.id.to_ascii_lowercase()) + .filter(|locks| !locks.versions.is_empty()), }; - let Some(Locked { rows, other }) = rows else { + let Some(locks) = locks else { out.diag( DIAG_REF_UNATTRIBUTABLE, cfg_rel, @@ -595,40 +674,44 @@ fn emit_hosted( ); return; }; - for (version, integrity) in rows { - let Some(purl) = simple_purl("nuget", &src.id, &version) else { + let serves = format!( + "Socket patch source {}, which serves only the patched version", + src.key + ); + for row in lock_rows(locks, src, cfg_rel, out) { + let Some(purl) = simple_purl("nuget", &src.id, &row.spelling) else { out.diag( DIAG_REF_INVALID, PACKAGES_LOCK, format!( - "{PACKAGES_LOCK}: {} resolves to the unusable version {version:?}", - src.id + "{PACKAGES_LOCK}: {} resolves to the unusable version {:?}", + src.id, row.spelling ), ); continue; }; - // Still a ref (rollback, remove and the ledger's liveness gate see - // the wiring, which a re-run cannot clear either), never attested. - if let Some((file, detail)) = &other { - out.unattested( - &purl, - &src.uuid, - file, - detail.clone(), - UnattestedKind::NugetLockOtherVersion, - ); - } - out.push(PatchedRef::hosted( - purl, - src.uuid.clone(), - cfg_rel, - Some(&src.value), - integrity, - true, - )); + let other = other_versions(locks, &row.norm, src, &serves, cfg_rel); + push_ref( + out, + PatchedRef::hosted( + purl, + src.uuid.clone(), + cfg_rel, + Some(&src.value), + row.integrity, + true, + ), + other, + ); } } +/// Vendored refs for `src`. The feed names the patched version: the writer +/// vendors exactly one `..nupkg` per patch, so with a +/// single nupkg for the id the ref is that version alone — pinned by the +/// lock's entries at it, if any — and a lock entry at any other version is +/// reported through [`other_versions`], never as a ref of its own (no +/// artifact serves it). Without one, the lock's versions are the refs. async fn emit_vendored( ctx: &DiscoverCtx<'_>, cfg_rel: &str, @@ -637,49 +720,64 @@ async fn emit_vendored( out: &mut Discovery, ) { let id_lower = src.id.to_ascii_lowercase(); - // (purl version, artifact leaf, integrity) - let mut rows: Vec<(String, String, Option)> = Vec::new(); - let leaves = feed_leaves(ctx, &src.uuid, &id_lower).await; - // The version the feed serves: the writer vendors exactly one nupkg per - // patch, named for the patched version. - let patched = match leaves.as_slice() { - [(_, version)] => Some(normalize_nuget_version(version)), - _ => None, - }; - let locked = match lock { - Lock::Parsed(index) => lock_versions(index, src, patched.as_deref(), cfg_rel, out), + let locks = match lock { + Lock::Parsed(index) => index.get(&id_lower), Lock::Absent | Lock::Unusable => None, }; - let mut other = None; - if let Some(locked) = locked { - other = locked.other; - for (version, integrity) in locked.rows { - let leaf = nupkg_leaf(&id_lower, &version); - rows.push((version, leaf, integrity)); - } - } else { - match leaves.as_slice() { - [(leaf, version)] => rows.push((version.clone(), leaf.clone(), None)), - _ => { - out.diag( - DIAG_REF_UNATTRIBUTABLE, - cfg_rel, - format!( - "{cfg_rel}: Socket patch source {} routes {} to the vendored feed \ - {VENDOR_DIR}/nuget/{}, which holds {} {} for it and no \ - {PACKAGES_LOCK} entry says which version is restored", - src.key, - src.id, - src.uuid, - leaves.len(), - if leaves.len() == 1 { "nupkg" } else { "nupkgs" }, - ), - ); - return; + let serves = format!("the vendored feed {VENDOR_DIR}/nuget/{}", src.uuid); + // (purl version, artifact leaf, integrity, other versions) + let mut rows: Vec<(String, String, Option, Option)> = Vec::new(); + let leaves = feed_leaves(ctx, &src.uuid, &id_lower).await; + if let [(leaf, version)] = leaves.as_slice() { + let norm = normalize_nuget_version(version); + match locks { + Some(locks) => { + let serves = format!("{serves}, which serves only {version}"); + let other = other_versions(locks, &norm, src, &serves, cfg_rel); + let locked = lock_rows(locks, src, cfg_rel, out) + .into_iter() + .find(|row| row.norm == norm); + match locked { + Some(row) => { + let leaf = nupkg_leaf(&id_lower, &row.spelling); + rows.push((row.spelling, leaf, row.integrity, other)); + } + // Locked at conflicting hashes: diagnosed, no ref. + None if locks.versions.contains_key(&norm) => {} + // Not locked at the patched version: the feed's own + // artifact, unpinned — still the writer's wiring. + None => rows.push((version.clone(), leaf.clone(), None, other)), + } } + None => rows.push((version.clone(), leaf.clone(), None, None)), + } + } else if let Some(locks) = locks.filter(|locks| !locks.versions.is_empty()) { + // No single feed nupkg to name the patched version: every locked + // version is a ref, checked against its own version as hosted. + let serves = format!("{serves}, which serves only the patched version"); + for row in lock_rows(locks, src, cfg_rel, out) { + let other = other_versions(locks, &row.norm, src, &serves, cfg_rel); + let leaf = nupkg_leaf(&id_lower, &row.spelling); + rows.push((row.spelling, leaf, row.integrity, other)); } + } else { + out.diag( + DIAG_REF_UNATTRIBUTABLE, + cfg_rel, + format!( + "{cfg_rel}: Socket patch source {} routes {} to the vendored feed \ + {VENDOR_DIR}/nuget/{}, which holds {} {} for it and no \ + {PACKAGES_LOCK} entry says which version is restored", + src.key, + src.id, + src.uuid, + leaves.len(), + if leaves.len() == 1 { "nupkg" } else { "nupkgs" }, + ), + ); + return; } - for (version, leaf, integrity) in rows { + for (version, leaf, integrity, other) in rows { let artifact = format!("{VENDOR_DIR}/nuget/{}/{leaf}", src.uuid); let (Some(purl), Some(vref)) = ( simple_purl("nuget", &src.id, &version), @@ -696,16 +794,11 @@ async fn emit_vendored( ); continue; }; - if let Some((file, detail)) = &other { - out.unattested( - &purl, - &src.uuid, - file, - detail.clone(), - UnattestedKind::NugetLockOtherVersion, - ); - } - out.push(PatchedRef::vendored(purl, &vref, cfg_rel, integrity)); + push_ref( + out, + PatchedRef::vendored(purl, &vref, cfg_rel, integrity), + other, + ); } } @@ -1802,10 +1895,10 @@ mod tests { /// other than the patched one (`*_nuget_lock_other_version`): the /// mapping routes every version to a source serving only the patched /// one, so that target framework cannot restore. A hosted url names no - /// version, so ANY two locked versions (across target frameworks or - /// across governed locks) mean one is unpatched: every ref stays (the - /// ledger's liveness, rollback and remove still see the wiring) but is - /// never attested. + /// version, so each ref is checked against its own: of two locked + /// versions (across target frameworks or governed locks) one is + /// unpatched, and every ref stays (the ledger's liveness, rollback and + /// remove still see the wiring) but is never attested. #[tokio::test] async fn hosted_id_locked_at_two_versions_is_unattested() { let cfg = config( @@ -1848,20 +1941,29 @@ mod tests { let out = run(&p).await; assert_refs(&out, &expected); assert_eq!(unattested(&out), marks); + // Each mark names the OTHER version, not its own. assert!( out.unattested[0] .detail - .contains("13.0.1 (packages.lock.json)"), + .contains("also lock Newtonsoft.Json at 13.0.3 (packages.lock.json)"), "{}", out.unattested[0].detail ); + assert!( + out.unattested[1] + .detail + .contains("at 13.0.1 (packages.lock.json)"), + "{}", + out.unattested[1].detail + ); // Still live wiring for the ledger record of either version. assert_eq!( out.hosted_claim("pkg:nuget/newtonsoft.json@13.0.3", UUID_A), Some(true) ); - // The root lock and a member project's lock. + // The root lock and a member project's lock: each mark names the + // lock holding the other version. let p = Project::new(); p.write("nuget.config", &cfg); p.write( @@ -1878,8 +1980,45 @@ mod tests { assert_eq!(unattested(&out), marks); assert_eq!( out.unattested[0].file, + std::path::PathBuf::from("packages.lock.json") + ); + assert_eq!( + out.unattested[1].file, + std::path::PathBuf::from("src/App/packages.lock.json") + ); + + // Other versions are listed in NuGet precedence (13.0.9 before + // 13.0.10), and the file is the first one's lock. + let p = Project::new(); + p.write("nuget.config", &cfg); + p.write( + "packages.lock.json", + lock(&[ + ("net6.0", "Newtonsoft.Json", "13.0.3", Some(HASH)), + ("net8.0", "Newtonsoft.Json", "13.0.10", Some("T1RIRVI=")), + ]), + ); + p.write("src/App/App.csproj", ""); + p.write( + "src/App/packages.lock.json", + lock(&[("net8.0", "Newtonsoft.Json", "13.0.9", Some("TklORQ=="))]), + ); + let out = run(&p).await; + let mark = out + .unattested + .iter() + .find(|u| u.purl == "pkg:nuget/newtonsoft.json@13.0.3") + .expect("13.0.3 is unattested"); + assert_eq!( + mark.file, std::path::PathBuf::from("src/App/packages.lock.json") ); + assert!( + mark.detail + .contains("at 13.0.9 (src/App/packages.lock.json), 13.0.10 (packages.lock.json)"), + "{}", + mark.detail + ); // One version (the writers' own output) is attested as before. let p = Project::new(); @@ -1897,8 +2036,10 @@ mod tests { } /// The vendored feed names the patched version (its single - /// `..nupkg`): a lock entry at any other version, - /// beside the patched one or alone, is the writer's refusal. + /// `..nupkg`): the ref is that version alone, and + /// a lock entry at any other version, beside the patched one or + /// instead of it, withholds it (the writer's refusal) without becoming + /// a ref of its own (no artifact serves it). #[tokio::test] async fn vendored_id_locked_at_another_version_is_unattested() { let cfg = config( @@ -1906,14 +2047,15 @@ mod tests { &[("nuget.org", "*"), (&socket_key(UUID_A), "Newtonsoft.Json")], ); let nupkg = format!(".socket/vendor/nuget/{UUID_A}/newtonsoft.json.13.0.3.nupkg"); - let other = ( - "pkg:nuget/newtonsoft.json@13.0.1".to_string(), - UnattestedKind::NugetLockOtherVersion, - ); - let patched = ( + let patched = [( + "pkg:nuget/newtonsoft.json@13.0.3", + UUID_A, + WiringMode::Vendored, + )]; + let mark = vec![( "pkg:nuget/newtonsoft.json@13.0.3".to_string(), UnattestedKind::NugetLockOtherVersion, - ); + )]; let p = Project::new(); p.write("nuget.config", &cfg); @@ -1926,10 +2068,17 @@ mod tests { ]), ); let out = run(&p).await; - assert_eq!(out.refs.len(), 2, "{:#?}", out.refs); - assert_eq!(unattested(&out), vec![other.clone(), patched]); + assert_refs(&out, &patched); + assert_eq!( + out.refs[0].locked_integrity, + Some(LockIntegrity::Sri(format!("sha512-{HASH}"))) + ); + assert_eq!(unattested(&out), mark); assert!( - out.unattested[0].detail.contains("serves only 13.0.3"), + out.unattested[0].detail.contains( + "also lock Newtonsoft.Json at 13.0.1 (packages.lock.json), while nuget.config \ + routes every version of it to the vendored feed" + ) && out.unattested[0].detail.contains("serves only 13.0.3"), "{}", out.unattested[0].detail ); @@ -1939,7 +2088,9 @@ mod tests { Some(true) ); - // The lock names only another version than the feed serves. + // The lock names only another version than the feed serves: the + // feed's artifact stays the (unpinned, unattested) ref, so the + // ledger's claim on it stays live. let p = Project::new(); p.write("nuget.config", &cfg); p.write(&nupkg, b"PK"); @@ -1948,7 +2099,21 @@ mod tests { lock(&[("net8.0", "Newtonsoft.Json", "13.0.1", Some("T1RIRVI="))]), ); let out = run(&p).await; - assert_eq!(unattested(&out), vec![other]); + assert_refs(&out, &patched); + assert_eq!(out.refs[0].artifact_rel.as_deref(), Some(nupkg.as_str())); + assert_eq!(out.refs[0].locked_integrity, None); + assert_eq!(unattested(&out), mark); + assert!( + out.unattested[0] + .detail + .starts_with("the NuGet locks lock Newtonsoft.Json at 13.0.1"), + "{}", + out.unattested[0].detail + ); + assert_eq!( + out.vendored_claim("pkg:nuget/newtonsoft.json@13.0.3", UUID_A, &nupkg), + Some(true) + ); // The patched version, in a spelling that normalizes to the feed's. let p = Project::new(); @@ -1971,10 +2136,11 @@ mod tests { assert!(out.unattested.is_empty(), "{:?}", out.unattested); } - /// Versions are keyed through `normalize_nuget_version`, as the writers - /// match them: `13.0.3` and `13.0.3.0` in two target frameworks are ONE - /// package, so one ref whose pin is checked across both, never two refs - /// with independent pins. + /// Versions are keyed through `normalize_nuget_version` of the trimmed + /// `resolved`, as the writers match them: `13.0.3`, ` 13.0.3` and + /// `13.0.3.0` in several target frameworks are ONE package, so one ref + /// whose pin is checked across all, never several refs with + /// independent pins. #[tokio::test] async fn non_normalized_version_spellings_are_one_version() { let cfg = config( @@ -1987,6 +2153,7 @@ mod tests { "packages.lock.json", lock(&[ ("net6.0", "Newtonsoft.Json", "13.0.3", Some(HASH)), + ("net7.0", "Newtonsoft.Json", " 13.0.3 ", Some(HASH)), ("net8.0", "Newtonsoft.Json", "13.0.3.0", Some(HASH)), ]), ); @@ -2001,21 +2168,31 @@ mod tests { ); assert!(out.refs[0].lockfile_basis_ok()); assert!(out.unattested.is_empty(), "{:?}", out.unattested); + assert!(out.diagnostics.is_empty(), "{:?}", out.diagnostics); // The two spellings pinned to different bytes: one package with - // conflicting contentHash values, which NuGet fails to restore. + // conflicting contentHash values, which NuGet fails to restore. The + // diagnostic names the locks and the spellings. let p = Project::new(); p.write("nuget.config", &cfg); p.write( "packages.lock.json", - lock(&[ - ("net6.0", "Newtonsoft.Json", "13.0.3", Some(HASH)), - ("net8.0", "Newtonsoft.Json", "13.0.3.0", Some("T1RIRVI=")), - ]), + lock(&[("net6.0", "Newtonsoft.Json", "13.0.3", Some(HASH))]), + ); + p.write("src/App/App.csproj", ""); + p.write( + "src/App/packages.lock.json", + lock(&[("net8.0", "Newtonsoft.Json", "13.0.3.0", Some("T1RIRVI="))]), ); let out = run(&p).await; assert_refs(&out, &[]); assert_eq!(diag_codes(&out), vec![DIAG_REF_INVALID]); + let detail = &out.diagnostics[0].detail; + assert!( + detail.starts_with("packages.lock.json, src/App/packages.lock.json: ") + && detail.contains("13.0.3 / 13.0.3.0"), + "{detail}" + ); // One spelling pinned, the other not: not a Socket-written pin. let p = Project::new(); @@ -2030,5 +2207,34 @@ mod tests { let out = run(&p).await; assert_eq!(out.refs.len(), 1, "{:#?}", out.refs); assert!(!out.refs[0].lockfile_basis_ok()); + + // An empty `resolved` is no version to attest (diagnosed), but the + // writers count it as another version: the ref is withheld. + let p = Project::new(); + p.write("nuget.config", &cfg); + p.write( + "packages.lock.json", + lock(&[ + ("net6.0", "Newtonsoft.Json", "13.0.3", Some(HASH)), + ("net8.0", "Newtonsoft.Json", " ", Some(HASH)), + ]), + ); + let out = run(&p).await; + assert_refs( + &out, + &[( + "pkg:nuget/newtonsoft.json@13.0.3", + UUID_A, + WiringMode::Hosted, + )], + ); + assert_eq!(diag_codes(&out), vec![DIAG_REF_INVALID]); + assert_eq!( + unattested(&out), + vec![( + "pkg:nuget/newtonsoft.json@13.0.3".to_string(), + UnattestedKind::NugetLockOtherVersion, + )] + ); } }