diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index a3ed909d6..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 | +| 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: @@ -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..a27a5b827 100644 --- a/crates/socket-patch-core/src/vex/discover/nuget.rs +++ b/crates/socket-patch-core/src/vex/discover/nuget.rs @@ -22,22 +22,31 @@ //! | 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). +//! 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 +//! 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 +83,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). @@ -129,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 @@ -268,28 +278,50 @@ 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`] (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 { + /// 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. hashes: BTreeSet, /// Some entry of this version carries no `contentHash`. 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 → resolved 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. @@ -326,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 { @@ -352,11 +384,35 @@ async fn load_lock(ctx: &DiscoverCtx<'_>, out: &mut Discovery) -> Lock { } }; for entry in nuget_lock_entries(&doc) { - let pins = index - .entry(entry.id.to_ascii_lowercase()) - .or_default() - .entry(entry.resolved.trim().to_string()) + 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()); @@ -371,26 +427,33 @@ 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). -fn lock_versions( - index: &BTreeMap>, - src: &SocketSource, - cfg_rel: &str, - out: &mut Discovery, -) -> Option)>> { - let versions = index.get(&src.id.to_ascii_lowercase())?; +/// 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 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 (version, pins) in versions { + 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; @@ -399,9 +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(rows) + 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) +} + +/// `(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( + locks: &IdLocks, + version: &str, + src: &SocketSource, + serves: &str, + cfg_rel: &str, +) -> 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(); + 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, + 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 ───────────────────────────────────────────────────────────────── @@ -481,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 { @@ -496,9 +658,11 @@ fn emit_hosted( } None } - Lock::Parsed(index) => lock_versions(index, src, cfg_rel, out), + Lock::Parsed(index) => index + .get(&src.id.to_ascii_lowercase()) + .filter(|locks| !locks.versions.is_empty()), }; - let Some(rows) = rows else { + let Some(locks) = locks else { out.diag( DIAG_REF_UNATTRIBUTABLE, cfg_rel, @@ -510,29 +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; }; - 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, @@ -541,41 +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 locked = match lock { - Lock::Parsed(index) => lock_versions(index, src, cfg_rel, out), + let locks = match lock { + Lock::Parsed(index) => index.get(&id_lower), Lock::Absent | Lock::Unusable => None, }; - if let Some(locked) = locked { - for (version, integrity) in locked { - 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)), - _ => { - 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), @@ -592,7 +794,11 @@ async fn emit_vendored( ); continue; }; - out.push(PatchedRef::vendored(purl, &vref, cfg_rel, integrity)); + push_ref( + out, + PatchedRef::vendored(purl, &vref, cfg_rel, integrity), + other, + ); } } @@ -1676,4 +1882,359 @@ 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 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( + &[(&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); + // Each mark names the OTHER version, not its own. + assert!( + out.unattested[0] + .detail + .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: each mark names the + // lock holding the other version. + 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("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(); + 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`): 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( + &[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 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); + 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_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( + "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 + ); + // 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: 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"); + p.write( + "packages.lock.json", + lock(&[("net8.0", "Newtonsoft.Json", "13.0.1", Some("T1RIRVI="))]), + ); + let out = run(&p).await; + 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(); + 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` 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( + &[(&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)), + ("net7.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); + 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. 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))]), + ); + 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(); + 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()); + + // 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, + )] + ); + } }