From 75b4daad59a654bddf9e5df7bc0a46022ee9126d Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 4 Nov 2026 10:44:20 -0600 Subject: [PATCH 01/20] Stop attesting Maven pins a profile or user property overrides VEX discovery attested two Maven pins the build may not resolve: 1. Profile declarations (J1). Discovery dropped every dependency inside from the version grouping and diagnosed only suffixed profile versions. A profile declaring the pinned GA at the base version (or any other version) left the hosted ref attested, yet whenever that profile is active Maven resolves the unpatched jar. The hosted rewriter leaves profile declarations as written (redirect_maven_profile_dependency_unpatched) and vendored mode treats every profile as live, so discovery now agrees with the writers: a profile declaration of the pinned GA (main jar in or , a declaration an undefined coordinate property may make that GA, or an executable classifier copy) at any version other than the pin keeps the ref but marks it unattested with the new UnattestedKind::MavenProfileUnpatched (CLI note vex_maven_profile_unpatched). A profile declaration with no takes the live pin and does not withhold it. The pre-v5 single-pom vendored wiring gets the same check against its vendored version. 2. .mvn/maven.config user properties (J4). A ${prop} was resolved from the pom's only, though coordinates already read maven.config's -D user properties. Maven looks user properties up first, so ${v} with 1.0-socket.x in the pom and -Dv=1.0 in maven.config builds the base release while discovery attested the pin. PomScope now lays the user properties over the root once, and both version resolution and coordinate interpolation read that map. The vendored reactor's interpolator is a method on the multi-pom Reactor, so it is not reused here; this is the minimal change in formats/maven. Tests (each fails on origin/main): - formats::maven::tests::version_property_reads_user_properties_first - vex::discover::maven::tests::version_property_reads_maven_config_overrides - vex::discover::maven::tests::profile_declaration_off_the_pin_is_not_attested - vex::discover::maven::tests::vendored_ref_under_a_profile_version_is_not_attested No existing test asserted the old behavior; the golden corpus is unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 3 +- .../src/commands/vex_sources.rs | 10 + .../src/formats/maven/mod.rs | 48 +++- .../src/vex/discover/maven.rs | 270 +++++++++++++++++- .../socket-patch-core/src/vex/discover/mod.rs | 6 + 5 files changed, 323 insertions(+), 14 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index a3ed909d6..62afd8b7a 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -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`. Five 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. **Maven profile declarations** (`vex_maven_profile_unpatched`): a `` declaration in the root `pom.xml` of a pinned GA — its main jar in `` or ``, or an executable classifier copy — at any version other than the pin (the base release, another release, a `${property}` the pom does not resolve, an executable classifier copy with no version of its own) overrides the pin whenever that profile is active, so the reference is omitted. Activation is not evaluated: every profile counts, as in vendored mode, which rewrites profile declarations too; hosted scan leaves them as written (`redirect_maven_profile_dependency_unpatched`). A profile declaration without a `` takes the live pin and does not withhold the statement. The same holds for a pre-v5 single-pom vendored repository, which serves only the original version. * **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_maven_profile_unpatched` | run warning and `failed[].reason` | A Maven pin's GA is also declared inside `` in the root `pom.xml` at a version other than the pin (or as an executable classifier copy off it), which overrides the pin whenever that profile is active; no statement while it does. `list`, `rollback` and `remove` still find the pin. | | `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..b1de1f94b 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 hosted Maven pin's GA is also declared inside +/// `` at another version, which overrides the pin whenever that +/// profile is active (`vex::Unattested`). +const NOTE_MAVEN_PROFILE_UNPATCHED: &str = "vex_maven_profile_unpatched"; /// The omission tag of an [`Unattested`](socket_patch_core::vex::Unattested) /// ref, and the remedy its note ends with. @@ -268,6 +272,12 @@ 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::MavenProfileUnpatched => ( + NOTE_MAVEN_PROFILE_UNPATCHED, + "not attested while a profile declares it at another version; the wiring itself is \ + intact, so re-running `scan` does not change this (drop that profile or \ + set it to the pin, or re-patch with `scan --mode vendored`)", + ), } } diff --git a/crates/socket-patch-core/src/formats/maven/mod.rs b/crates/socket-patch-core/src/formats/maven/mod.rs index 66a5db09d..7b63b0179 100644 --- a/crates/socket-patch-core/src/formats/maven/mod.rs +++ b/crates/socket-patch-core/src/formats/maven/mod.rs @@ -41,8 +41,9 @@ pub(crate) struct PomRepo { pub(crate) struct PomDep { pub(crate) group: String, pub(crate) artifact: String, - /// Trimmed literal version, `${prop}` resolved from the root - /// `` when possible; `None` when the declaration has none. + /// Trimmed literal version, `${prop}` resolved (one level) from the + /// build's user properties, else the root ``, when + /// possible; `None` when the declaration has none. pub(crate) version: Option, /// The non-empty ``: a sibling artifact of the GA (sources, /// tests, a native build), managed and resolved apart from the main jar. @@ -67,7 +68,8 @@ pub(crate) struct PomScope { pub(crate) masked: String, ignored: Vec, profiles: Vec, - /// The root `` (see [`properties`]). + /// The root `` (see [`properties`]) with the build's user + /// properties laid over them: what a `${name}` resolves to. properties: BTreeMap, /// [`Self::properties`] plus the `${project.*}` model references a /// coordinate may name (see [`model_refs`]). @@ -84,8 +86,8 @@ impl PomScope { /// [`Self::new`], with the user properties the build passes Maven /// (`.mvn/maven.config`'s `-D`, see [`maven_config_properties`]): they - /// override a same-named pom property when a coordinate is - /// interpolated, as Maven's model interpolator reads them after the + /// override a same-named pom property when a coordinate or a version is + /// interpolated, as Maven's model interpolator looks them up before the /// model's own. pub(crate) fn with_user_properties( raw: &str, @@ -103,9 +105,9 @@ impl PomScope { ] .concat(); let profiles = elements(&masked, "profiles")?; - let properties = properties(&masked, &ignored, &profiles)?; + let mut properties = properties(&masked, &ignored, &profiles)?; + properties.extend(user.iter().map(|(k, v)| (k.clone(), v.clone()))); let mut coordinate_refs = properties.clone(); - coordinate_refs.extend(user.iter().map(|(k, v)| (k.clone(), v.clone()))); // A `project.*` / `pom.*` reference names the model, whatever a // same-named property says; the deprecated bare form yields to one. for (name, value) in model_refs(&masked, &coordinate_refs.clone())? { @@ -733,6 +735,38 @@ mod tests { assert!(!scope.coordinate_refs.contains_key("project.version")); } + /// Maven's model interpolator reads user properties (`.mvn/maven.config`'s + /// `-D`) before the pom's own ``, for a `` just as + /// for a coordinate: `-Dv=1.0` makes `${v}` the base release whatever + /// the pom defines. + #[test] + fn version_property_reads_user_properties_first() { + let pom = "1.0-socket.777777772\ + \ + ga${v}\ + gb${w}\ + gc${u}\ + "; + let versions = |user: &[(&str, &str)]| { + let user = user + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + super::parse_pom_with(pom, &user) + .unwrap() + .deps + .into_iter() + .map(|d| d.version.unwrap()) + .collect::>() + }; + assert_eq!(versions(&[]), ["1.0-socket.77777777", "2", "${u}"]); + assert_eq!( + versions(&[("v", "1.0"), ("u", "3")]), + ["1.0", "2", "3"], + "a user property wins over the pom's and defines one it lacks" + ); + } + #[test] fn suffix_grammar_is_exact() { assert_eq!( diff --git a/crates/socket-patch-core/src/vex/discover/maven.rs b/crates/socket-patch-core/src/vex/discover/maven.rs index cc4c69fe8..8500bbc98 100644 --- a/crates/socket-patch-core/src/vex/discover/maven.rs +++ b/crates/socket-patch-core/src/vex/discover/maven.rs @@ -49,8 +49,14 @@ //! retarget and the grant does not serve: a live executable one (anything //! but `sources` / `javadoc`) that resolves any version other than the pin //! — its own literal, a classifier-keyed managed entry, or none in this -//! pom — keeps the ref but marks it unattested. A `${property}` -//! version is resolved one level from the root ``. +//! pom — keeps the ref but marks it unattested. So does a `` +//! declaration of the GA (main jar, or an executable classifier copy) at +//! any version other than the pin: the rewriter leaves it as written, and +//! whenever that profile is active it overrides the pin (vendored mode +//! treats every profile as live and rewrites it too). A `${property}` +//! version is resolved one level from `.mvn/maven.config`'s `-D` user +//! properties, else the root `` (Maven reads user properties +//! first). //! //! Integrity: when the pom sha256 was known the rewriter also writes Maven //! Trusted Checksums — `.mvn/checksums/checksums.sha256` lines @@ -92,9 +98,11 @@ //! deploy targets) and dependencies inside `` / `` //! (plugin classpaths) are not project resolution and are skipped silently; //! `` are only read when activated, so a Socket repository or pin -//! there is diagnosed, never a ref. Submodule poms and parent poms are not -//! read (root-only). Gradle builds are discovered by `gradle.rs` (the -//! hosted index) and the vendored JVM backend's own probes. +//! there is diagnosed, never a ref, and a profile declaration of a pinned +//! GA at another version withholds the pin's attestation. Submodule poms +//! and parent poms are not read (root-only). Gradle builds are discovered +//! by `gradle.rs` (the hosted index) and the vendored JVM backend's own +//! probes. use std::collections::{BTreeMap, BTreeSet}; @@ -129,7 +137,7 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { return; }; // `.mvn/maven.config`'s `-D` user properties win over the pom's own - // when Maven interpolates a coordinate. + // when Maven interpolates a coordinate or a version. let config = ctx.read_text(MVN_CONFIG, out).await; let user_properties = match &config { Some(config) => maven_config_properties(config), @@ -321,6 +329,15 @@ async fn extract_hosted( UnattestedKind::MavenClassifierUnpatched, ); } + if let Some(detail) = profile_override(&pom.deps, group, artifact, &pinned) { + out.unattested( + &purl, + uuid, + POM, + detail, + UnattestedKind::MavenProfileUnpatched, + ); + } ties.entry(*uuid).or_default().insert(( purl, group.clone(), @@ -531,6 +548,17 @@ async fn extract_vendored( continue; } } + // A profile that moves the GA off the vendored version bypasses + // the vendored repository whenever it is active. + if let Some(detail) = profile_override(deps, group, artifact, version) { + out.unattested( + &purl, + uuid, + POM, + detail, + UnattestedKind::MavenProfileUnpatched, + ); + } out.push(PatchedRef::vendored(purl, &vref, POM, None)); } } @@ -747,6 +775,60 @@ fn unpatched_executable_classifier<'a>( }) } +/// Why a `` declaration of `group:artifact` moves it off +/// `pinned` whenever that profile is active, for the first such one: a +/// main-jar declaration (or one an undefined coordinate property may make +/// the main jar, [`may_shadow`]) at another version, or an executable +/// classifier copy that does not name `pinned` itself. A profile's +/// `` and `` merge over the pom's own, +/// so either overrides the pin; activation is not evaluated, so every +/// profile counts, as in vendored mode. A main-jar declaration without a +/// version takes the live one and moves nothing. +fn profile_override(deps: &[PomDep], group: &str, artifact: &str, pinned: &str) -> Option { + deps.iter().filter(|d| d.in_profile).find_map(|d| { + let literal = d.group == group && d.artifact == artifact; + let what = match d.classifier.as_deref() { + None if literal + || may_shadow( + (&d.group, &d.artifact), + d.type_text.as_deref(), + None, + (group, artifact), + ) => + { + let version = d.version.as_deref().filter(|v| *v != pinned)?; + format!("{version}") + } + Some(c) if literal && !NON_EXECUTABLE_CLASSIFIERS.contains(&c) => { + match d.version.as_deref() { + Some(v) if v == pinned => return None, + Some(v) => format!("classifier {c} at {v}"), + None => format!("classifier {c} with no of its own"), + } + } + _ => return None, + }; + let section = if d.managed { + " " + } else { + "" + }; + let named = if literal { + format!("{group}:{artifact}") + } else { + format!( + "{}:{} (which may be {group}:{artifact})", + d.group, d.artifact + ) + }; + Some(format!( + "{POM}: a {section}declaration of {named} carries {what}, not the \ + patched {pinned}; whenever that profile is active Maven resolves it instead of \ + the pin" + )) + }) +} + #[cfg(test)] mod tests { use super::super::testing::*; @@ -1467,6 +1549,150 @@ mod tests { } } + /// Maven gives `.mvn/maven.config`'s `-D` user properties precedence + /// over the pom's `` for a `` too: a pin the pom + /// spells through a property the build config redefines to the base + /// release resolves the unpatched jar, so it is no attested pin. + #[tokio::test] + async fn version_property_reads_maven_config_overrides() { + let repo = hosted_repo(&format!("socket-patch-{UUID_A}"), ®istry_url(UUID_A)); + let pin = suffixed(UUID_A); + for (value, config, attested) in [ + // The build config moves the pin back to the base release. + (pin.as_str(), "-Dslf4j.version=1.7.36\n", false), + (pin.as_str(), "--define slf4j.version=1.7.30\n", false), + // The build config supplies the pin itself. + ("1.7.36", &format!("-Dslf4j.version={pin}\n"), true), + // Unrelated user properties leave the pom's own. + (pin.as_str(), "-Dother=1\n", true), + ] { + let p = Project::new(); + p.write( + "pom.xml", + pom(&format!( + "{value}\n\ + \n{}\n\n{repo}\n", + dep("org.slf4j", "slf4j-api", Some("${slf4j.version}")), + )), + ); + p.write(".mvn/maven.config", config); + let out = run(&p).await; + let attested_refs: Vec<_> = out + .refs + .iter() + .filter(|r| { + r.purl == FX_PURL + && !out + .unattested + .iter() + .any(|u| u.purl == r.purl && u.uuid == r.uuid) + }) + .collect(); + assert_eq!( + !attested_refs.is_empty(), + attested, + "{config}: {:?} {:?} {:?}", + out.refs, + out.unattested, + out.diagnostics + ); + } + } + + /// A `` declaration of the pinned GA applies whenever that + /// profile is active, and the hosted rewriter leaves it as written + /// (`redirect_maven_profile_dependency_unpatched`); vendored mode treats + /// every profile as live. One at any version other than the pin keeps + /// the ref (rollback / remove / list still find it) but is never + /// attested: with the profile on, Maven resolves the unpatched jar. + #[tokio::test] + async fn profile_declaration_off_the_pin_is_not_attested() { + let repo = hosted_repo(&format!("socket-patch-{UUID_A}"), ®istry_url(UUID_A)); + let pin = suffixed(UUID_A); + let project = |profile: &str| { + let p = Project::new(); + p.write( + "pom.xml", + pom(&format!( + "\n{}\n\n{repo}\n\ + p\n{profile}\n", + dep("org.slf4j", "slf4j-api", Some(&pin)), + )), + ); + p + }; + let direct = |v: Option<&str>| { + format!( + "\n{}\n", + dep("org.slf4j", "slf4j-api", v) + ) + }; + let managed = |v: Option<&str>| { + format!( + "\n{}\n", + dep("org.slf4j", "slf4j-api", v) + ) + }; + let classified = |c: &str, v: Option<&str>| { + let v = v.map_or(String::new(), |v| format!("{v}")); + format!( + "org.slf4j\ + slf4j-api{v}{c}\ + \n" + ) + }; + for profile in [ + // The base release, directly or managed. + direct(Some("1.7.36")), + managed(Some("1.7.36")), + // Another release, or one this pom cannot resolve. + direct(Some("1.7.30")), + direct(Some("${undefined}")), + // Another Socket pin. + direct(Some("1.7.36-socket.88888888")), + // An executable classifier copy off the pin. + classified("tests", Some("1.7.36")), + classified("tests", None), + ] { + let out = run(&project(&profile)).await; + assert_refs(&out, &[(FX_PURL, UUID_A, WiringMode::Hosted)]); + assert_eq!(out.unattested.len(), 1, "{profile}: {:?}", out.unattested); + let u = &out.unattested[0]; + assert_eq!(u.kind, UnattestedKind::MavenProfileUnpatched, "{profile}"); + assert_eq!( + (u.purl.as_str(), u.uuid.as_str()), + (FX_PURL, UUID_A), + "{profile}" + ); + assert!( + u.detail.contains("org.slf4j:slf4j-api") && u.detail.contains(""), + "{}", + u.detail + ); + } + + for profile in [ + // No version: the live pin applies. + direct(None), + // The pin itself. + direct(Some(&pin)), + // Another GA. + format!( + "\n{}\n", + dep("org.slf4j", "slf4j-simple", Some("1.7.36")) + ), + // A non-executable classifier copy, or one on the pin. + classified("sources", Some("1.7.36")), + classified("tests", Some(&pin)), + // Profile properties only. + "1\n".to_string(), + ] { + let out = run(&project(&profile)).await; + assert_refs(&out, &[(FX_PURL, UUID_A, WiringMode::Hosted)]); + assert!(out.unattested.is_empty(), "{profile}: {:?}", out.unattested); + } + } + /// A versioned declaration whose coordinate is a property this pom /// does not define may be the pinned GA, so the pin is not attested /// past it (#683). @@ -1684,6 +1910,38 @@ mod tests { assert!(!r.lockfile_basis_ok()); } + /// The retired vendored wiring serves the ORIGINAL GAV: a profile that + /// moves the GA to another version bypasses it whenever it is active, + /// so the ref stays but is not attested. + #[tokio::test] + async fn vendored_ref_under_a_profile_version_is_not_attested() { + for (version, attested) in [("1.10.1", false), ("1.10.0", true)] { + let p = vendored_project(UUID_A); + let wired = std::fs::read_to_string(p.root().join("pom.xml")).expect("read pom"); + let at = wired.rfind("").expect("project close"); + p.write( + "pom.xml", + format!( + "{}p\n{}\n{}", + &wired[..at], + dep("org.apache.commons", "commons-text", Some(version)), + &wired[at..] + ), + ); + let out = run(&p).await; + assert_refs(&out, &[(V_PURL, UUID_A, WiringMode::Vendored)]); + if attested { + assert!(out.unattested.is_empty(), "{version}: {:?}", out.unattested); + } else { + assert_eq!(out.unattested.len(), 1, "{version}: {:?}", out.unattested); + assert_eq!( + out.unattested[0].kind, + UnattestedKind::MavenProfileUnpatched + ); + } + } + } + #[tokio::test] async fn vendored_url_spellings() { for spelled in [ diff --git a/crates/socket-patch-core/src/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index 815d4644d..1e663b93a 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -606,6 +606,12 @@ 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 Maven pin (hosted, or the pre-v5 single-pom vendored repository) + /// whose GA a `` declaration of the root pom gives another + /// version (the base release, another release, an + /// unresolved `${property}`): whenever that profile is active it + /// overrides the pin, so the build resolves the unpatched jar. + MavenProfileUnpatched, } /// A ref discovery emits (so rollback, remove and list find the wiring, From 97651787277e7fb870bc781768e8d09ff9982f02 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 4 Nov 2026 14:25:47 -0600 Subject: [PATCH 02/20] Address review: Maven discovery bypass checks match Maven precedence Review findings on the profile / user-property attestation fix: - A profile entry was flagged even when the live pin is a direct literal , which beats management; likewise a profile-managed classifier entry no declaration takes. Profile management now withholds the pin only when management governs it (no live direct version). The old test case managed(Some("1.7.36")) beside a direct pin asserted the wrong outcome and now expects an attested pin; the governing case (managed live pin) expects the opposite. - A version-less profile classifier copy was always flagged. One shared classifier check now covers live and profile copies and resolves a version-less copy from the classifier-keyed management (live, with a profile's entry overriding it; none in this pom fails closed). Profile classifier copies report MavenClassifierUnpatched, like live ones. - The same check now runs for the pre-v5 vendored wiring too, where any executable classifier copy comes from the next repository (the vendored tree holds only the main jar). formats::maven gains NON_EXECUTABLE_CLASSIFIERS / is_executable_classifier, worded as in #2006 so the two merge cleanly. - A pom-typed (or java-source / javadoc) profile declaration carries no code and no longer counts against the pin. Other types (test-jar, ejb) still do: they put a jar on a classpath. - PomDep now keeps both the version as the pom spells it (`version`, what hosted writes and rolls back) and as the build resolves it (`build_version`, -D user properties first). Pins are found from the former, so a pin .mvn/maven.config moves stays a ref (list / rollback / remove and liveness keep the wiring) but is marked unattested with the new UnattestedKind::MavenConfigOverride (CLI note vex_maven_config_override). A Socket version only maven.config supplies is diagnosed (DIAG_REF_UNATTRIBUTABLE naming .mvn/maven.config), never a pom.xml ref, so nothing edits pom.xml for it. - The build view leaves project.* / pom.* to the model: -Dproject.version no longer resolves a ${project.version} dependency version. - Cleanup: main_jar_declarations / classifier_declarations take a scope predicate and back ga_versions, the classifier check and the profile check; group_by_ga is gone (live_gas lists the pin candidates); trusted_checksums takes the maven.config text extract already read. Tests: version_property_reads_maven_config_overrides, profile_declaration_off_the_pin_is_not_attested and vendored_ref_bypassed_by_a_profile_or_classifier_is_not_attested (renamed from vendored_ref_under_a_profile_version_is_not_attested) were extended with a case per finding; each new case failed on the previous commit. formats::maven gains model_reference_version_ignores_user_properties. The golden corpus is unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 7 +- .../src/commands/vex_sources.rs | 10 + .../src/formats/maven/mod.rs | 98 +- .../src/vex/discover/maven.rs | 839 +++++++++++------- .../socket-patch-core/src/vex/discover/mod.rs | 8 +- 5 files changed, 640 insertions(+), 322 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 62afd8b7a..7d9a61c76 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -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`. Five 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. **Maven profile declarations** (`vex_maven_profile_unpatched`): a `` declaration in the root `pom.xml` of a pinned GA — its main jar in `` or ``, or an executable classifier copy — at any version other than the pin (the base release, another release, a `${property}` the pom does not resolve, an executable classifier copy with no version of its own) overrides the pin whenever that profile is active, so the reference is omitted. Activation is not evaluated: every profile counts, as in vendored mode, which rewrites profile declarations too; hosted scan leaves them as written (`redirect_maven_profile_dependency_unpatched`). A profile declaration without a `` takes the live pin and does not withhold the statement. The same holds for a pre-v5 single-pom vendored repository, which serves only the original version. +* **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`. Six 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 classifier copy other than `sources` / `javadoc` (`tests`, a native or JDK-specific build), live or inside ``, resolves any version other than the suffixed pin — its own literal ``, a `` entry with the same classifier (a profile's overriding the live one), or no version in the root pom — the pin is omitted. A pre-v5 single-pom vendored repository holds only the main jar, so any such copy omits its reference. `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. **Maven profile declarations** (`vex_maven_profile_unpatched`): a `` declaration of a pinned GA's main jar in the root `pom.xml` at any version other than the pin (the base release, another release, a `${property}` the pom does not resolve) overrides the pin whenever that profile is active, so the reference is omitted. A profile `` entry always counts; a profile `` entry counts only when management governs the pin (no direct `` outside ``), since a direct literal version beats management. A declaration without a `` takes the live pin, and a `pom` (or `java-source` / `javadoc`) typed one carries no code; neither withholds the statement. Activation is not evaluated: every profile counts, as in vendored mode, which rewrites profile declarations too; hosted scan leaves them as written (`redirect_maven_profile_dependency_unpatched`). The same holds for a pre-v5 single-pom vendored repository, which serves only the original version. **Maven user properties** (`vex_maven_config_override`): Maven resolves a `${property}` `` from `.mvn/maven.config`'s `-D` user properties before the pom's `` (`${project.*}` / `${pom.*}` always come from the model). A pin the pom spells through a property that `.mvn/maven.config` redefines resolves that other version, so the reference is omitted. A Socket version that only a `-D` user property supplies is not `pom.xml` wiring (hosted scan never writes one, and `rollback` / `remove` never edit `.mvn/maven.config`): it is no reference at all, only a diagnostic. * **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). @@ -2677,8 +2677,9 @@ match it, withholds the statement: | `vex_pnpm_bundled_copy` | run warning and `failed[].reason` | An npm pin is wired, but its pnpm lock names a package whose `bundledDependencies` may ship an unpatched copy of it (the lock does not record that copy's version); no statement while that package bundles it. `vendor --check` and `scan` still treat the wiring as live. | | `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_maven_profile_unpatched` | run warning and `failed[].reason` | A Maven pin's GA is also declared inside `` in the root `pom.xml` at a version other than the pin (or as an executable classifier copy off it), which overrides the pin whenever that profile is active; no statement while it does. `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 an executable `` copy (not `sources` / `javadoc`; live or inside ``) 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_maven_profile_unpatched` | run warning and `failed[].reason` | A Maven pin's GA main jar is also declared inside `` in the root `pom.xml` at a version other than the pin (a profile `` entry only where management governs the pin), which overrides the pin whenever that profile is active; no statement while it does. `list`, `rollback` and `remove` still find the pin. | +| `vex_maven_config_override` | run warning and `failed[].reason` | A hosted Maven pin is spelled through a `${property}` that a `-D` user property in `.mvn/maven.config` redefines, so Maven resolves another version; no statement while it does. `list`, `rollback` and `remove` still find the pin. | | `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 b1de1f94b..322b05f49 100644 --- a/crates/socket-patch-cli/src/commands/vex_sources.rs +++ b/crates/socket-patch-cli/src/commands/vex_sources.rs @@ -236,6 +236,10 @@ const NOTE_MAVEN_CLASSIFIER_UNPATCHED: &str = "vex_maven_classifier_unpatched"; /// `` at another version, which overrides the pin whenever that /// profile is active (`vex::Unattested`). const NOTE_MAVEN_PROFILE_UNPATCHED: &str = "vex_maven_profile_unpatched"; +/// Omission tag and note: a hosted Maven pin is spelled through a +/// `${property}` that `.mvn/maven.config` redefines, so the build resolves +/// another version (`vex::Unattested`). +const NOTE_MAVEN_CONFIG_OVERRIDE: &str = "vex_maven_config_override"; /// The omission tag of an [`Unattested`](socket_patch_core::vex::Unattested) /// ref, and the remedy its note ends with. @@ -278,6 +282,12 @@ fn unattested_note(kind: UnattestedKind) -> (&'static str, &'static str) { intact, so re-running `scan` does not change this (drop that profile or \ set it to the pin, or re-patch with `scan --mode vendored`)", ), + UnattestedKind::MavenConfigOverride => ( + NOTE_MAVEN_CONFIG_OVERRIDE, + "not attested while .mvn/maven.config redefines the property it is spelled \ + through; the wiring itself is intact, so re-running `scan` does not change this \ + (drop that -D from .mvn/maven.config, or write the pinned version literally)", + ), } } diff --git a/crates/socket-patch-core/src/formats/maven/mod.rs b/crates/socket-patch-core/src/formats/maven/mod.rs index 7b63b0179..6625d3e1e 100644 --- a/crates/socket-patch-core/src/formats/maven/mod.rs +++ b/crates/socket-patch-core/src/formats/maven/mod.rs @@ -42,9 +42,14 @@ pub(crate) struct PomDep { pub(crate) group: String, pub(crate) artifact: String, /// Trimmed literal version, `${prop}` resolved (one level) from the - /// build's user properties, else the root ``, when - /// possible; `None` when the declaration has none. + /// root `` when possible: the version the pom itself + /// spells, which the hosted rewriter writes and rolls back. `None` when + /// the declaration has none. pub(crate) version: Option, + /// [`Self::version`] as the build resolves it: `${prop}` resolved from + /// the build's user properties first (`.mvn/maven.config`'s `-D`), + /// else the root `` — Maven's own lookup order. + pub(crate) build_version: Option, /// The non-empty ``: a sibling artifact of the GA (sources, /// tests, a native build), managed and resolved apart from the main jar. pub(crate) classifier: Option, @@ -68,9 +73,13 @@ pub(crate) struct PomScope { pub(crate) masked: String, ignored: Vec, profiles: Vec, - /// The root `` (see [`properties`]) with the build's user - /// properties laid over them: what a `${name}` resolves to. + /// The root `` (see [`properties`]). properties: BTreeMap, + /// [`Self::properties`] with the build's user properties laid over + /// them, except `project.*` / `pom.*`, which Maven reads from the model + /// before any user property: what a `${name}` version resolves to in + /// the build. + build_properties: BTreeMap, /// [`Self::properties`] plus the `${project.*}` model references a /// coordinate may name (see [`model_refs`]). coordinate_refs: BTreeMap, @@ -105,13 +114,19 @@ impl PomScope { ] .concat(); let profiles = elements(&masked, "profiles")?; - let mut properties = properties(&masked, &ignored, &profiles)?; - properties.extend(user.iter().map(|(k, v)| (k.clone(), v.clone()))); + let properties = properties(&masked, &ignored, &profiles)?; + let mut build_properties = properties.clone(); + build_properties.extend( + user.iter() + .filter(|(k, _)| !is_model_reference(k)) + .map(|(k, v)| (k.clone(), v.clone())), + ); let mut coordinate_refs = properties.clone(); + coordinate_refs.extend(user.iter().map(|(k, v)| (k.clone(), v.clone()))); // A `project.*` / `pom.*` reference names the model, whatever a // same-named property says; the deprecated bare form yields to one. for (name, value) in model_refs(&masked, &coordinate_refs.clone())? { - if name.starts_with("project.") || name.starts_with("pom.") { + if is_model_reference(&name) { coordinate_refs.insert(name, value); } else { coordinate_refs.entry(name).or_insert(value); @@ -122,6 +137,7 @@ impl PomScope { ignored, profiles, properties, + build_properties, coordinate_refs, }) } @@ -250,7 +266,12 @@ pub(crate) fn parse_pom_with(raw: &str, user: &BTreeMap) -> Resu artifact, version: dep .version_text - .map(|v| resolve_property(&v, &scope.properties)), + .as_deref() + .map(|v| resolve_property(v, &scope.properties)), + build_version: dep + .version_text + .as_deref() + .map(|v| resolve_property(v, &scope.build_properties)), classifier: dep.classifier, type_text: dep.type_text, managed: inside(dep.element.start, &managed), @@ -553,6 +574,25 @@ pub(crate) fn may_shadow( && may_name(dep_artifact, artifact) } +/// The classifiers whose jars never run on a classpath: `sources` and +/// `javadoc`. Every other classifier copy of a patched GA (tests, a native +/// or JDK-specific build) carries code, so it must resolve the patched +/// release too. The hosted rewriter, VEX discovery and the vendored +/// reactor's attestation share this rule. +pub(crate) const NON_EXECUTABLE_CLASSIFIERS: &[&str] = &["sources", "javadoc"]; + +/// Whether a `` copy of a GA carries code onto a classpath +/// (see [`NON_EXECUTABLE_CLASSIFIERS`]). +pub(crate) fn is_executable_classifier(classifier: &str) -> bool { + !NON_EXECUTABLE_CLASSIFIERS.contains(&classifier) +} + +/// A `project.*` / `pom.*` reference: Maven reads it from the model, ahead +/// of any user or pom property of the same name. +fn is_model_reference(name: &str) -> bool { + name.starts_with("project.") || name.starts_with("pom.") +} + fn is_property_name(name: &str) -> bool { !name.is_empty() && name @@ -756,17 +796,51 @@ mod tests { .unwrap() .deps .into_iter() - .map(|d| d.version.unwrap()) + .map(|d| (d.version.unwrap(), d.build_version.unwrap())) .collect::>() }; - assert_eq!(versions(&[]), ["1.0-socket.77777777", "2", "${u}"]); + let both = |v: &str| (v.to_string(), v.to_string()); + assert_eq!( + versions(&[]), + [both("1.0-socket.77777777"), both("2"), both("${u}")] + ); assert_eq!( versions(&[("v", "1.0"), ("u", "3")]), - ["1.0", "2", "3"], - "a user property wins over the pom's and defines one it lacks" + [ + ("1.0-socket.77777777".to_string(), "1.0".to_string()), + both("2"), + ("${u}".to_string(), "3".to_string()), + ], + "the build's user property wins over the pom's and defines one it lacks; \ + `version` keeps what the pom spells" ); } + /// Maven reads `${project.*}` / `${pom.*}` from the model before any + /// user property: `-Dproject.version=` does not move a dependency + /// version written through the model. + #[test] + fn model_reference_version_ignores_user_properties() { + let pom = "1\ + ga${project.version}\ + gb${pom.version}\ + "; + let user = [ + ("project.version".to_string(), "9".to_string()), + ("pom.version".to_string(), "9".to_string()), + ] + .into_iter() + .collect(); + let deps = super::parse_pom_with(pom, &user).unwrap().deps; + for d in &deps { + assert_eq!(d.build_version, d.version, "{d:?}"); + assert!( + d.build_version.as_deref().is_some_and(|v| v != "9"), + "{d:?}" + ); + } + } + #[test] fn suffix_grammar_is_exact() { assert_eq!( diff --git a/crates/socket-patch-core/src/vex/discover/maven.rs b/crates/socket-patch-core/src/vex/discover/maven.rs index 8500bbc98..acb5bd0ca 100644 --- a/crates/socket-patch-core/src/vex/discover/maven.rs +++ b/crates/socket-patch-core/src/vex/discover/maven.rs @@ -46,17 +46,22 @@ //! own literal `` overrides it, and only the root pom is read here //! (the rewriter refuses such roots, #261). A `` copy of the //! GA is its own coordinate, which the classifier-less pin does not -//! retarget and the grant does not serve: a live executable one (anything -//! but `sources` / `javadoc`) that resolves any version other than the pin -//! — its own literal, a classifier-keyed managed entry, or none in this -//! pom — keeps the ref but marks it unattested. So does a `` -//! declaration of the GA (main jar, or an executable classifier copy) at -//! any version other than the pin: the rewriter leaves it as written, and -//! whenever that profile is active it overrides the pin (vendored mode -//! treats every profile as live and rewrites it too). A `${property}` -//! version is resolved one level from `.mvn/maven.config`'s `-D` user -//! properties, else the root `` (Maven reads user properties -//! first). +//! retarget and the grant does not serve: an executable one (anything but +//! `sources` / `javadoc`), live or in a profile, that resolves any version +//! other than the pin — its own literal, a classifier-keyed managed entry, +//! or none in this pom — keeps the ref but marks it unattested. So does a +//! `` declaration of the main jar at any version other than the +//! pin (a profile's `` only where management governs +//! the pin): the rewriter leaves it as written, and whenever that profile +//! is active it overrides the pin (vendored mode treats every profile as +//! live and rewrites it too). A `${property}` version is resolved one +//! level from the root `` — the pin as the pom spells it — and +//! again with `.mvn/maven.config`'s `-D` user properties first, as the +//! build resolves it (Maven's order; `${project.*}` stays the model's): a +//! pin the build config moves keeps the ref but marks it unattested, and a +//! Socket version only the build config supplies is diagnosed, never a +//! ref (hosted mode never writes one; rollback / remove never edit +//! `.mvn/maven.config`). //! //! Integrity: when the pom sha256 was known the rewriter also writes Maven //! Trusted Checksums — `.mvn/checksums/checksums.sha256` lines @@ -112,8 +117,8 @@ use super::{ DIAG_REF_UNATTRIBUTABLE, }; use crate::formats::maven::{ - is_maven_coordinate, is_maven_version_text, maven_config_properties, may_shadow, - parse_pom_with, split_socket_version, Pom, PomDep, PomRepo, + is_executable_classifier, is_maven_coordinate, is_maven_version_text, maven_config_properties, + may_shadow, parse_pom_with, split_socket_version, Pom, PomDep, PomRepo, }; use crate::patch::redirect::{MVN_CHECKSUMS, MVN_CONFIG, TRUSTED_CHECKSUMS_ON}; use crate::utils::digest::sha256_hex; @@ -169,15 +174,15 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { } } - let gas = group_by_ga(&pom.deps); + let gas = live_gas(&pom.deps); let reactor = crate::vendor::jvm::maven_reactor::declares_modules(&raw) || (crate::vendor::jvm::maven_reactor::may_discover_subprojects(&raw, config.as_deref()) && !crate::vendor::jvm::maven_reactor::implicit_subprojects_in(&ctx.view) .await .is_empty()); - extract_hosted(ctx, &pom, &gas, &hosted, reactor, out).await; + extract_hosted(ctx, &pom, &gas, &hosted, reactor, config.as_deref(), out).await; if !vendored.is_empty() { - extract_vendored(ctx, &pom.deps, &gas, &vendored, out).await; + extract_vendored(ctx, &pom.deps, &vendored, out).await; } } @@ -186,9 +191,10 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { async fn extract_hosted( ctx: &DiscoverCtx<'_>, pom: &Pom, - gas: &BTreeMap<(String, String), GaVersions>, + gas: &BTreeSet<(String, String)>, hosted: &BTreeMap, reactor: bool, + config: Option<&str>, out: &mut Discovery, ) { // uuid -> the (purl, g, a, suffixed version) pins that tie to it. @@ -216,8 +222,33 @@ async fn extract_hosted( } } - for (group, artifact) in gas.keys() { - let versions = &ga_versions(&pom.deps, gas, group, artifact); + // A Socket version only a `-D` user property supplies is no pom.xml + // wiring: hosted mode never writes one, and rollback / remove never + // edit `.mvn/maven.config`. + for dep in pom.deps.iter().filter(|d| !d.in_profile) { + let Some(built) = dep + .build_version + .as_deref() + .filter(|v| split_socket_version(v).is_some() && dep.version.as_deref() != Some(v)) + else { + continue; + }; + out.diag( + DIAG_REF_UNATTRIBUTABLE, + POM, + format!( + "{POM}: {}:{} {} is the Socket version {built} only through \ + a -D user property in {MVN_CONFIG}, which hosted mode never writes and \ + rollback / remove never edit; it is not {POM} wiring", + dep.group, + dep.artifact, + dep.version.as_deref().unwrap_or_default(), + ), + ); + } + + for (group, artifact) in gas { + let versions = &ga_versions(&pom.deps, group, artifact, pom_version); let socket_pins: BTreeSet<&str> = versions .direct .iter() @@ -237,14 +268,7 @@ async fn extract_hosted( ); continue; } - // Maven's own precedence: a direct literal version wins over the - // managed one; the managed version applies only when no direct - // declaration carries a version. - let effective = if versions.direct.is_empty() { - &versions.managed - } else { - &versions.direct - }; + let effective = versions.effective(); let pinned = match effective.iter().collect::>().as_slice() { [only] if split_socket_version(only).is_some() => (*only).clone(), _ => { @@ -309,34 +333,9 @@ async fn extract_hosted( UnattestedKind::MavenReactorRoot, ); } - // An executable classifier copy (tests, a native build) is - // its own coordinate: the classifier-less pin does not - // retarget it and the grant does not serve it, so Maven - // fetches it from the next repository — the public release. - if let Some((classifier, version)) = - unpatched_executable_classifier(&pom.deps, group, artifact, &pinned) + for (kind, detail) in bypasses(&pom.deps, group, artifact, &pinned, Wiring::Hosted) { - out.unattested( - &purl, - uuid, - POM, - format!( - "{POM}: {ga}:{classifier} resolves {}, not the \ - patched {pinned}; the Socket patch covers only the main jar, so that \ - classifier copy keeps the unpatched code on the classpath", - version.unwrap_or("(none in this pom)") - ), - UnattestedKind::MavenClassifierUnpatched, - ); - } - if let Some(detail) = profile_override(&pom.deps, group, artifact, &pinned) { - out.unattested( - &purl, - uuid, - POM, - detail, - UnattestedKind::MavenProfileUnpatched, - ); + out.unattested(&purl, uuid, POM, detail, kind); } ties.entry(*uuid).or_default().insert(( purl, @@ -395,7 +394,7 @@ async fn extract_hosted( continue; }; if checksums.is_none() { - checksums = Some(trusted_checksums(ctx, out).await); + checksums = Some(trusted_checksums(ctx, config, out).await); } let jar = layout::artifact_path(group, artifact, pinned, None, "jar"); let integrity = checksums @@ -414,12 +413,17 @@ async fn extract_hosted( } /// `path -> sha256 hex` from `.mvn/checksums/checksums.sha256`, or empty -/// when `.mvn/maven.config` does not switch the trusted-checksums resolver -/// on (the file is then inert and pins nothing). Malformed lines are -/// skipped, like the rewriter's own merge. -async fn trusted_checksums(ctx: &DiscoverCtx<'_>, out: &mut Discovery) -> BTreeMap { +/// when `config` (`.mvn/maven.config`'s text, read once by [`extract`]) +/// does not switch the trusted-checksums resolver on (the file is then +/// inert and pins nothing). Malformed lines are skipped, like the +/// rewriter's own merge. +async fn trusted_checksums( + ctx: &DiscoverCtx<'_>, + config: Option<&str>, + out: &mut Discovery, +) -> BTreeMap { let mut map = BTreeMap::new(); - let Some(config) = ctx.read_text(MVN_CONFIG, out).await else { + let Some(config) = config else { return map; }; if !config @@ -448,7 +452,6 @@ async fn trusted_checksums(ctx: &DiscoverCtx<'_>, out: &mut Discovery) -> BTreeM async fn extract_vendored( ctx: &DiscoverCtx<'_>, deps: &[PomDep], - gas: &BTreeMap<(String, String), GaVersions>, vendored: &BTreeSet, out: &mut Discovery, ) { @@ -527,12 +530,8 @@ async fn extract_vendored( // The vendored repository serves the ORIGINAL GAV; a pom that now // pins this GA to another version no longer consumes the artifact. { - let versions = ga_versions(deps, gas, group, artifact); - let effective = if versions.direct.is_empty() { - &versions.managed - } else { - &versions.direct - }; + let versions = ga_versions(deps, group, artifact, pom_version); + let effective = versions.effective(); let conflicting = !effective.is_empty() && !effective.contains(version) && effective.iter().all(|v| !v.contains("${")); @@ -548,16 +547,8 @@ async fn extract_vendored( continue; } } - // A profile that moves the GA off the vendored version bypasses - // the vendored repository whenever it is active. - if let Some(detail) = profile_override(deps, group, artifact, version) { - out.unattested( - &purl, - uuid, - POM, - detail, - UnattestedKind::MavenProfileUnpatched, - ); + for (kind, detail) in bypasses(deps, group, artifact, version, Wiring::Vendored) { + out.unattested(&purl, uuid, POM, detail, kind); } out.push(PatchedRef::vendored(purl, &vref, POM, None)); } @@ -672,163 +663,276 @@ fn classify_repo(ctx: &DiscoverCtx<'_>, repo: &PomRepo, out: &mut Discovery) -> // ── dependency versions ────────────────────────────────────────────── -/// Distinct literal versions of one `groupId:artifactId`, split by where -/// they are declared (profile-scoped declarations excluded). -#[derive(Debug, Default, Clone)] +/// Distinct versions of one `groupId:artifactId`'s main jar among the +/// declarations Maven always reads (profiles excluded), split by where +/// they are declared. +#[derive(Debug, Default, Clone, PartialEq, Eq)] struct GaVersions { direct: BTreeSet, managed: BTreeSet, } -/// What Maven may resolve for `group:artifact`'s main jar: its own -/// declarations ([`group_by_ga`]) plus the version of every declaration -/// Maven always reads whose groupId or artifactId is a property this pom -/// does not define and may be this GA (#683, -/// [`may_shadow`](crate::formats::maven::may_shadow)) — its version then -/// takes part in Maven's precedence, unseen under the literal key. -fn ga_versions( - deps: &[PomDep], - gas: &BTreeMap<(String, String), GaVersions>, - group: &str, - artifact: &str, -) -> GaVersions { - let mut versions = gas - .get(&(group.to_string(), artifact.to_string())) - .cloned() - .unwrap_or_default(); - for dep in deps.iter().filter(|d| !d.in_profile) { - let Some(version) = &dep.version else { - continue; - }; - if may_shadow( - (&dep.group, &dep.artifact), - dep.type_text.as_deref(), - dep.classifier.as_deref(), - (group, artifact), - ) { - if dep.managed { - versions.managed.insert(version.clone()); - } else { - versions.direct.insert(version.clone()); - } +impl GaVersions { + /// Maven's own precedence: a direct literal version wins over the + /// managed one; the managed version applies only when no direct + /// declaration carries a version. + fn effective(&self) -> &BTreeSet { + if self.direct.is_empty() { + &self.managed + } else { + &self.direct } } - versions } -fn group_by_ga(deps: &[PomDep]) -> BTreeMap<(String, String), GaVersions> { - let mut map: BTreeMap<(String, String), GaVersions> = BTreeMap::new(); - // A classifier variant (`sources`, `tests`, a native build) is its own - // artifact, managed apart from the main jar: its version neither pins - // nor shadows the main jar's. - for dep in deps - .iter() - .filter(|d| !d.in_profile && d.classifier.is_none()) - { - let Some(version) = &dep.version else { +/// Which version of a declaration to read: as the pom spells it +/// ([`pom_version`], what the hosted rewriter writes and rolls back) or as +/// the build resolves it ([`build_version`]). +type VersionOf = fn(&PomDep) -> Option<&str>; + +fn pom_version(d: &PomDep) -> Option<&str> { + d.version.as_deref() +} + +fn build_version(d: &PomDep) -> Option<&str> { + d.build_version.as_deref() +} + +/// Every GA with a versioned, classifier-less declaration Maven always +/// reads: the candidates for a pin. +fn live_gas(deps: &[PomDep]) -> BTreeSet<(String, String)> { + deps.iter() + .filter(|d| !d.in_profile && d.classifier.is_none() && d.version.is_some()) + .map(|d| (d.group.clone(), d.artifact.clone())) + .collect() +} + +/// The classifier-less declarations `in_scope` keeps that may be +/// `group:artifact`'s main jar: its own, plus every one whose groupId or +/// artifactId is a property this pom does not define and may be this GA +/// (#683, [`may_shadow`](crate::formats::maven::may_shadow)) — its version +/// then takes part in Maven's precedence, unseen under the literal key. A +/// classifier variant (`sources`, `tests`, a native build) is its own +/// artifact, managed apart from the main jar: it neither pins nor shadows +/// it ([`classifier_declarations`]). +fn main_jar_declarations<'a>( + deps: &'a [PomDep], + group: &'a str, + artifact: &'a str, + in_scope: impl Fn(&PomDep) -> bool + 'a, +) -> impl Iterator + 'a { + deps.iter().filter(move |d| { + in_scope(d) + && d.classifier.is_none() + && ((d.group == group && d.artifact == artifact) + || may_shadow( + (&d.group, &d.artifact), + d.type_text.as_deref(), + None, + (group, artifact), + )) + }) +} + +/// The executable classifier copies of `group:artifact` (tests, a native +/// or JDK-specific build; not `sources` / `javadoc`) that `in_scope` keeps, +/// with their classifier. +fn classifier_declarations<'a>( + deps: &'a [PomDep], + group: &'a str, + artifact: &'a str, + in_scope: impl Fn(&PomDep) -> bool + 'a, +) -> impl Iterator + 'a { + deps.iter() + .filter(move |d| in_scope(d) && d.group == group && d.artifact == artifact) + .filter_map(|d| { + let classifier = d.classifier.as_deref()?; + is_executable_classifier(classifier).then_some((d, classifier)) + }) +} + +/// What Maven may resolve for `group:artifact`'s main jar from the +/// declarations it always reads ([`main_jar_declarations`] outside +/// ``), each read through `version_of`. +fn ga_versions(deps: &[PomDep], group: &str, artifact: &str, version_of: VersionOf) -> GaVersions { + let mut versions = GaVersions::default(); + for dep in main_jar_declarations(deps, group, artifact, |d| !d.in_profile) { + let Some(version) = version_of(dep) else { continue; }; - let entry = map - .entry((dep.group.clone(), dep.artifact.clone())) - .or_default(); if dep.managed { - entry.managed.insert(version.clone()); + versions.managed.insert(version.to_string()); } else { - entry.direct.insert(version.clone()); + versions.direct.insert(version.to_string()); } } - map + versions } -/// The classifiers whose jars never run on a classpath. Every other -/// classifier copy of a pinned GA (tests, a native or JDK-specific build) -/// executes, so it must resolve the patched release too. -const NON_EXECUTABLE_CLASSIFIERS: &[&str] = &["sources", "javadoc"]; - -/// The first live executable classifier copy of `group:artifact` that does -/// not resolve `pinned`, with the version it resolves (`None` when this pom -/// gives it none). Its version is its own literal, else a -/// `` entry with the same classifier — Maven keys -/// management by classifier, so the classifier-less pin never applies. -fn unpatched_executable_classifier<'a>( - deps: &'a [PomDep], +/// Which wiring serves a GA's pin, for [`bypasses`]. +#[derive(Debug, Clone, Copy)] +enum Wiring { + /// The hosted `-socket.` pin: the grant serves the main + /// jar; a classifier copy AT the pin exists nowhere, so the build fails + /// closed rather than run unpatched code. + Hosted, + /// The pre-v5 single-pom vendored repository: the original GAV's main + /// jar only, so every classifier copy comes from the next repository. + Vendored, +} + +/// Every way the build may bypass the wired `group:artifact` pin `pinned` +/// (the hosted suffixed version, or the vendored repository's original +/// one), with the [`UnattestedKind`] it is reported as: the ref stays (so +/// rollback, remove and list still find the wiring) but is not attested. +fn bypasses( + deps: &[PomDep], group: &str, artifact: &str, pinned: &str, -) -> Option<(&'a str, Option<&'a str>)> { - let of_ga = |d: &&PomDep| !d.in_profile && d.group == group && d.artifact == artifact; - deps.iter() - .filter(of_ga) - .filter(|d| !d.managed) - .filter_map(|d| { - let classifier = d.classifier.as_deref()?; - (!NON_EXECUTABLE_CLASSIFIERS.contains(&classifier)).then_some((d, classifier)) - }) - .find_map(|(d, classifier)| { - let version = d.version.as_deref().or_else(|| { - deps.iter() - .filter(of_ga) - .find(|m| m.managed && m.classifier.as_deref() == Some(classifier)) - .and_then(|m| m.version.as_deref()) - }); - (version != Some(pinned)).then_some((classifier, version)) - }) + wiring: Wiring, +) -> Vec<(UnattestedKind, String)> { + let ga = format!("{group}:{artifact}"); + let mut found = Vec::new(); + // `.mvn/maven.config`'s `-D` user properties, which Maven reads before + // the pom's ``, redefine what the pin is spelled through. + let spelled = ga_versions(deps, group, artifact, pom_version); + let built = ga_versions(deps, group, artifact, build_version); + if built.effective() != spelled.effective() { + found.push(( + UnattestedKind::MavenConfigOverride, + format!( + "{POM}: {ga} is wired to {pinned}, but a -D user property in {MVN_CONFIG} \ + (which Maven reads before the pom's ) makes it resolve {:?}", + built.effective() + ), + )); + } + if let Some(detail) = unpatched_classifier_copy(deps, group, artifact, pinned, wiring) { + found.push((UnattestedKind::MavenClassifierUnpatched, detail)); + } + if let Some(detail) = profile_override(deps, group, artifact, pinned, built.direct.is_empty()) { + found.push((UnattestedKind::MavenProfileUnpatched, detail)); + } + found } -/// Why a `` declaration of `group:artifact` moves it off -/// `pinned` whenever that profile is active, for the first such one: a -/// main-jar declaration (or one an undefined coordinate property may make -/// the main jar, [`may_shadow`]) at another version, or an executable -/// classifier copy that does not name `pinned` itself. A profile's -/// `` and `` merge over the pom's own, -/// so either overrides the pin; activation is not evaluated, so every -/// profile counts, as in vendored mode. A main-jar declaration without a -/// version takes the live one and moves nothing. -fn profile_override(deps: &[PomDep], group: &str, artifact: &str, pinned: &str) -> Option { - deps.iter().filter(|d| d.in_profile).find_map(|d| { - let literal = d.group == group && d.artifact == artifact; - let what = match d.classifier.as_deref() { - None if literal - || may_shadow( - (&d.group, &d.artifact), - d.type_text.as_deref(), - None, - (group, artifact), - ) => - { - let version = d.version.as_deref().filter(|v| *v != pinned)?; - format!("{version}") - } - Some(c) if literal && !NON_EXECUTABLE_CLASSIFIERS.contains(&c) => { - match d.version.as_deref() { - Some(v) if v == pinned => return None, - Some(v) => format!("classifier {c} at {v}"), - None => format!("classifier {c} with no of its own"), +/// Why the first executable classifier copy of `group:artifact` — live or +/// inside `` — runs code the wiring does not serve, if one does. +/// A classifier copy is its own coordinate: the classifier-less pin does +/// not retarget it. Its version is its own ``, else a +/// `` entry with the same classifier (Maven keys +/// management by classifier); a profile's entry overrides the live one +/// while that profile is active, and with no live entry the version comes +/// from outside this pom (fail closed). A hosted copy is fine only at the +/// pin itself ([`Wiring::Hosted`]); a vendored one never is. +fn unpatched_classifier_copy( + deps: &[PomDep], + group: &str, + artifact: &str, + pinned: &str, + wiring: Wiring, +) -> Option { + let served = match wiring { + Wiring::Hosted => Some(pinned), + Wiring::Vendored => None, + }; + let managed = |classifier: &str, in_profile: bool| -> Vec<(Option<&str>, bool)> { + classifier_declarations(deps, group, artifact, move |d| { + d.managed && d.in_profile == in_profile + }) + .filter(|(_, c)| *c == classifier) + .map(|(d, _)| (build_version(d), in_profile)) + .collect() + }; + classifier_declarations(deps, group, artifact, |d| !d.managed).find_map(|(d, classifier)| { + let resolved = match build_version(d) { + Some(version) => vec![(Some(version), false)], + None => { + let mut live = managed(classifier, false); + if live.is_empty() { + live.push((None, false)); } + live.extend(managed(classifier, true)); + live } - _ => return None, - }; - let section = if d.managed { - " " - } else { - "" - }; - let named = if literal { - format!("{group}:{artifact}") - } else { - format!( - "{}:{} (which may be {group}:{artifact})", - d.group, d.artifact - ) }; + let (version, from_profile) = resolved + .into_iter() + .find(|(version, _)| version.is_none() || *version != served)?; Some(format!( - "{POM}: a {section}declaration of {named} carries {what}, not the \ - patched {pinned}; whenever that profile is active Maven resolves it instead of \ - the pin" + "{POM}: {group}:{artifact}:{classifier}{} resolves {}, {}", + if d.in_profile || from_profile { + " (inside )" + } else { + "" + }, + version.unwrap_or("(none in this pom)"), + match wiring { + Wiring::Hosted => format!( + "not the patched {pinned}; the Socket patch covers only the main jar, so \ + that classifier copy keeps the unpatched code on the classpath" + ), + Wiring::Vendored => "which the vendored repository does not serve (it holds \ + only the main jar), so Maven fetches the public \ + classifier jar" + .to_string(), + } )) }) } +/// ``s whose artifact carries no code onto a classpath. +const NON_CODE_TYPES: &[&str] = &["pom", "java-source", "javadoc"]; + +/// Why a `` declaration of `group:artifact`'s main jar +/// ([`main_jar_declarations`]) moves it off `pinned` whenever that profile +/// is active, for the first such one: a version other than the pin. A +/// profile's `` merge over the pom's own, so a direct one +/// always overrides the pin; its `` overrides only +/// what management governs, which `pin_managed` (no live direct version) +/// says. Activation is not evaluated, so every profile counts, as in +/// vendored mode. A declaration without a version takes the live one and +/// moves nothing, and a pom (or sources / javadoc) typed one carries no +/// code. +fn profile_override( + deps: &[PomDep], + group: &str, + artifact: &str, + pinned: &str, + pin_managed: bool, +) -> Option { + main_jar_declarations(deps, group, artifact, |d| d.in_profile) + .filter(|d| { + !d.type_text + .as_deref() + .is_some_and(|t| NON_CODE_TYPES.contains(&t)) + }) + .filter(|d| !d.managed || pin_managed) + .find_map(|d| { + let version = build_version(d).filter(|v| *v != pinned)?; + let section = if d.managed { + " " + } else { + "" + }; + let literal = d.group == group && d.artifact == artifact; + let named = if literal { + format!("{group}:{artifact}") + } else { + format!( + "{}:{} (which may be {group}:{artifact})", + d.group, d.artifact + ) + }; + Some(format!( + "{POM}: a {section}declaration of {named} carries \ + {version}, not the patched {pinned}; whenever that profile \ + is active Maven resolves it instead of the pin" + )) + }) +} + #[cfg(test)] mod tests { use super::super::testing::*; @@ -1550,115 +1654,207 @@ mod tests { } /// Maven gives `.mvn/maven.config`'s `-D` user properties precedence - /// over the pom's `` for a `` too: a pin the pom - /// spells through a property the build config redefines to the base - /// release resolves the unpatched jar, so it is no attested pin. + /// over the pom's `` for a `` too (but not over + /// the model's own `${project.*}`). A pin the pom spells through a + /// property the build config redefines stays a ref — rollback / remove + /// / list must still find the pom's wiring — but is not attested: the + /// build resolves the unpatched jar. A pin only the build config + /// supplies is no pom.xml wiring at all (hosted mode never writes one, + /// and rollback / remove never edit `.mvn/maven.config`): diagnosed, + /// never a ref. #[tokio::test] async fn version_property_reads_maven_config_overrides() { + #[derive(Debug, PartialEq)] + enum Expect { + Attested, + Overridden, + ConfigOnly, + NoRef, + } let repo = hosted_repo(&format!("socket-patch-{UUID_A}"), ®istry_url(UUID_A)); let pin = suffixed(UUID_A); - for (value, config, attested) in [ + let by_prop = dep("org.slf4j", "slf4j-api", Some("${slf4j.version}")); + let by_model = dep("org.slf4j", "slf4j-api", Some("${project.version}")); + for (value, declared, config, expect) in [ // The build config moves the pin back to the base release. - (pin.as_str(), "-Dslf4j.version=1.7.36\n", false), - (pin.as_str(), "--define slf4j.version=1.7.30\n", false), - // The build config supplies the pin itself. - ("1.7.36", &format!("-Dslf4j.version={pin}\n"), true), + ( + pin.as_str(), + &by_prop, + "-Dslf4j.version=1.7.36\n".to_string(), + Expect::Overridden, + ), + ( + pin.as_str(), + &by_prop, + "--define slf4j.version=1.7.30\n".to_string(), + Expect::Overridden, + ), + // The build config alone supplies the pin. + ( + "1.7.36", + &by_prop, + format!("-Dslf4j.version={pin}\n"), + Expect::ConfigOnly, + ), + // `${project.version}` is the model's (1.0.0), whatever -D says. + ( + "1.7.36", + &by_model, + format!("-Dproject.version={pin}\n"), + Expect::NoRef, + ), // Unrelated user properties leave the pom's own. - (pin.as_str(), "-Dother=1\n", true), + ( + pin.as_str(), + &by_prop, + "-Dother=1\n".to_string(), + Expect::Attested, + ), ] { let p = Project::new(); p.write( "pom.xml", pom(&format!( "{value}\n\ - \n{}\n\n{repo}\n", - dep("org.slf4j", "slf4j-api", Some("${slf4j.version}")), + \n{declared}\n\n{repo}\n", )), ); - p.write(".mvn/maven.config", config); + p.write(".mvn/maven.config", &config); let out = run(&p).await; - let attested_refs: Vec<_> = out - .refs - .iter() - .filter(|r| { - r.purl == FX_PURL - && !out - .unattested - .iter() - .any(|u| u.purl == r.purl && u.uuid == r.uuid) - }) - .collect(); - assert_eq!( - !attested_refs.is_empty(), - attested, + let what = format!( "{config}: {:?} {:?} {:?}", - out.refs, - out.unattested, - out.diagnostics + out.refs, out.unattested, out.diagnostics ); + match expect { + Expect::Attested | Expect::Overridden => { + assert_refs(&out, &[(FX_PURL, UUID_A, WiringMode::Hosted)]); + if expect == Expect::Attested { + assert!(out.unattested.is_empty(), "{what}"); + } else { + assert_eq!(out.unattested.len(), 1, "{what}"); + let u = &out.unattested[0]; + assert_eq!(u.kind, UnattestedKind::MavenConfigOverride, "{what}"); + assert!(u.detail.contains(".mvn/maven.config"), "{}", u.detail); + } + } + Expect::ConfigOnly | Expect::NoRef => { + assert_refs(&out, &[]); + assert!(out.unattested.is_empty(), "{what}"); + assert_eq!( + out.diagnostics + .iter() + .any(|d| d.detail.contains(".mvn/maven.config")), + expect == Expect::ConfigOnly, + "{what}" + ); + } + } } } /// A `` declaration of the pinned GA applies whenever that /// profile is active, and the hosted rewriter leaves it as written /// (`redirect_maven_profile_dependency_unpatched`); vendored mode treats - /// every profile as live. One at any version other than the pin keeps - /// the ref (rollback / remove / list still find it) but is never - /// attested: with the profile on, Maven resolves the unpatched jar. + /// every profile as live. One that moves the GA's main jar off the pin + /// keeps the ref (rollback / remove / list still find it) but is never + /// attested: with the profile on, Maven resolves the unpatched jar. A + /// profile's `` moves it only where management + /// governs (a direct literal `` beats it), and an executable + /// classifier copy — live or in a profile — is judged by the version it + /// resolves, its own or its classifier-keyed managed one. #[tokio::test] async fn profile_declaration_off_the_pin_is_not_attested() { let repo = hosted_repo(&format!("socket-patch-{UUID_A}"), ®istry_url(UUID_A)); let pin = suffixed(UUID_A); - let project = |profile: &str| { + let ga = |v: Option<&str>| dep("org.slf4j", "slf4j-api", v); + let extra = |tag: &str, c: &str, v: Option<&str>| { + let v = v.map_or(String::new(), |v| format!("{v}")); + format!( + "org.slf4jslf4j-api\ + {v}<{tag}>{c}\n" + ) + }; + let classified = |c: &str, v: Option<&str>| extra("classifier", c, v); + let typed = |t: &str, v: Option<&str>| extra("type", t, v); + let deps = |d: &str| format!("\n{d}\n"); + let mgmt = |d: &str| format!("{}\n", deps(d)); + // The live pin: a direct literal, a managed one (governing a + // version-less declaration), or a direct literal beside a + // classifier-keyed managed entry at the pin. + let direct_pin = deps(&ga(Some(&pin))); + let managed_pin = format!("{}{}", mgmt(&ga(Some(&pin))), deps(&ga(None))); + let classifier_managed_pin = format!( + "{}{}", + mgmt(&classified("tests", Some(&pin))), + deps(&ga(Some(&pin))) + ); + let project = |live: &str, profile: &str| { let p = Project::new(); p.write( "pom.xml", pom(&format!( - "\n{}\n\n{repo}\n\ + "{live}\n{repo}\n\ p\n{profile}\n", - dep("org.slf4j", "slf4j-api", Some(&pin)), )), ); p }; - let direct = |v: Option<&str>| { - format!( - "\n{}\n", - dep("org.slf4j", "slf4j-api", v) - ) - }; - let managed = |v: Option<&str>| { - format!( - "\n{}\n", - dep("org.slf4j", "slf4j-api", v) - ) - }; - let classified = |c: &str, v: Option<&str>| { - let v = v.map_or(String::new(), |v| format!("{v}")); - format!( - "org.slf4j\ - slf4j-api{v}{c}\ - \n" - ) - }; - for profile in [ - // The base release, directly or managed. - direct(Some("1.7.36")), - managed(Some("1.7.36")), - // Another release, or one this pom cannot resolve. - direct(Some("1.7.30")), - direct(Some("${undefined}")), - // Another Socket pin. - direct(Some("1.7.36-socket.88888888")), - // An executable classifier copy off the pin. - classified("tests", Some("1.7.36")), - classified("tests", None), - ] { - let out = run(&project(&profile)).await; + let profile_kind = UnattestedKind::MavenProfileUnpatched; + let classifier_kind = UnattestedKind::MavenClassifierUnpatched; + let tests_unversioned = classified("tests", None); + let unattested = [ + // The main jar at the base release, another release, one this + // pom cannot resolve, another Socket pin. + (&direct_pin, deps(&ga(Some("1.7.36"))), profile_kind), + (&direct_pin, deps(&ga(Some("1.7.30"))), profile_kind), + (&direct_pin, deps(&ga(Some("${undefined}"))), profile_kind), + ( + &direct_pin, + deps(&ga(Some("1.7.36-socket.88888888"))), + profile_kind, + ), + // Management that governs: the live pin is managed too. + (&managed_pin, mgmt(&ga(Some("1.7.36"))), profile_kind), + // A code-carrying type is not the pin's coordinate either. + ( + &direct_pin, + deps(&typed("test-jar", Some("1.7.36"))), + profile_kind, + ), + // An executable classifier copy off the pin: its own version, + // none at all, or a classifier-keyed managed one. + ( + &direct_pin, + deps(&classified("tests", Some("1.7.36"))), + classifier_kind, + ), + (&direct_pin, deps(&tests_unversioned), classifier_kind), + ( + &direct_pin, + format!( + "{}{}", + mgmt(&classified("tests", Some("1.7.36"))), + deps(&tests_unversioned) + ), + classifier_kind, + ), + // The profile's classifier management overrides the live one. + ( + &classifier_managed_pin, + format!( + "{}{}", + mgmt(&classified("tests", Some("1.7.36"))), + deps(&tests_unversioned) + ), + classifier_kind, + ), + ]; + for (live, profile, kind) in &unattested { + let out = run(&project(live, profile)).await; assert_refs(&out, &[(FX_PURL, UUID_A, WiringMode::Hosted)]); assert_eq!(out.unattested.len(), 1, "{profile}: {:?}", out.unattested); let u = &out.unattested[0]; - assert_eq!(u.kind, UnattestedKind::MavenProfileUnpatched, "{profile}"); + assert_eq!(u.kind, *kind, "{profile}"); assert_eq!( (u.purl.as_str(), u.uuid.as_str()), (FX_PURL, UUID_A), @@ -1671,23 +1867,36 @@ mod tests { ); } - for profile in [ + let attested = [ // No version: the live pin applies. - direct(None), + (&direct_pin, deps(&ga(None))), // The pin itself. - direct(Some(&pin)), + (&direct_pin, deps(&ga(Some(&pin)))), + (&managed_pin, mgmt(&ga(Some(&pin)))), + // Management a direct literal version beats. + (&direct_pin, mgmt(&ga(Some("1.7.36")))), // Another GA. - format!( - "\n{}\n", - dep("org.slf4j", "slf4j-simple", Some("1.7.36")) + ( + &direct_pin, + deps(&dep("org.slf4j", "slf4j-simple", Some("1.7.36"))), ), // A non-executable classifier copy, or one on the pin. - classified("sources", Some("1.7.36")), - classified("tests", Some(&pin)), + (&direct_pin, deps(&classified("sources", Some("1.7.36")))), + (&direct_pin, deps(&classified("tests", Some(&pin)))), + // Classifier management no declaration takes. + (&direct_pin, mgmt(&classified("tests", Some("1.7.36")))), + // A version-less classifier copy the live management pins. + (&classifier_managed_pin, deps(&tests_unversioned)), + // A pom-typed declaration carries no code. + (&direct_pin, deps(&typed("pom", Some("1.7.36")))), // Profile properties only. - "1\n".to_string(), - ] { - let out = run(&project(&profile)).await; + ( + &direct_pin, + "1\n".to_string(), + ), + ]; + for (live, profile) in &attested { + let out = run(&project(live, profile)).await; assert_refs(&out, &[(FX_PURL, UUID_A, WiringMode::Hosted)]); assert!(out.unattested.is_empty(), "{profile}: {:?}", out.unattested); } @@ -1910,35 +2119,55 @@ mod tests { assert!(!r.lockfile_basis_ok()); } - /// The retired vendored wiring serves the ORIGINAL GAV: a profile that - /// moves the GA to another version bypasses it whenever it is active, - /// so the ref stays but is not attested. + /// The retired vendored wiring serves the ORIGINAL GAV, main jar only: + /// a profile that moves the GA to another version bypasses it whenever + /// it is active, and an executable classifier copy (live or in a + /// profile, at any version) resolves from the next repository — the + /// public jar. The ref stays but is not attested. #[tokio::test] - async fn vendored_ref_under_a_profile_version_is_not_attested() { - for (version, attested) in [("1.10.1", false), ("1.10.0", true)] { + async fn vendored_ref_bypassed_by_a_profile_or_classifier_is_not_attested() { + let classified = |c: &str, v: &str| { + format!( + "org.apache.commonscommons-text\ + {v}{c}\n" + ) + }; + let in_profile = |d: &str| { + format!( + "p\n{d}\n" + ) + }; + let live = |d: &str| format!("\n{d}\n"); + for (extra, kind) in [ + ( + in_profile(&dep("org.apache.commons", "commons-text", Some("1.10.1"))), + Some(UnattestedKind::MavenProfileUnpatched), + ), + ( + in_profile(&dep("org.apache.commons", "commons-text", Some("1.10.0"))), + None, + ), + ( + live(&classified("tests", "1.10.0")), + Some(UnattestedKind::MavenClassifierUnpatched), + ), + ( + in_profile(&classified("tests", "1.10.0")), + Some(UnattestedKind::MavenClassifierUnpatched), + ), + (live(&classified("sources", "1.10.0")), None), + ] { let p = vendored_project(UUID_A); let wired = std::fs::read_to_string(p.root().join("pom.xml")).expect("read pom"); let at = wired.rfind("").expect("project close"); p.write( "pom.xml", - format!( - "{}p\n{}\n{}", - &wired[..at], - dep("org.apache.commons", "commons-text", Some(version)), - &wired[at..] - ), + format!("{}{extra}{}", &wired[..at], &wired[at..]), ); let out = run(&p).await; assert_refs(&out, &[(V_PURL, UUID_A, WiringMode::Vendored)]); - if attested { - assert!(out.unattested.is_empty(), "{version}: {:?}", out.unattested); - } else { - assert_eq!(out.unattested.len(), 1, "{version}: {:?}", out.unattested); - assert_eq!( - out.unattested[0].kind, - UnattestedKind::MavenProfileUnpatched - ); - } + let kinds: Vec<_> = out.unattested.iter().map(|u| u.kind).collect(); + assert_eq!(kinds, kind.into_iter().collect::>(), "{extra}"); } } diff --git a/crates/socket-patch-core/src/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index 1e663b93a..ca636edcb 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -601,8 +601,8 @@ pub enum UnattestedKind { /// #261): a module's own literal `` overrides it, and only the /// root pom is read, so the reactor may build the upstream jar. MavenReactorRoot, - /// A hosted Maven pin whose GA also has a live executable classifier - /// copy (`tests`, a native build; not `sources` / `javadoc`) at another + /// A Maven pin whose GA also has an executable classifier copy (live + /// or inside ``) (`tests`, a native build; not `sources` / `javadoc`) at another /// 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, @@ -612,6 +612,10 @@ pub enum UnattestedKind { /// unresolved `${property}`): whenever that profile is active it /// overrides the pin, so the build resolves the unpatched jar. MavenProfileUnpatched, + /// A hosted Maven pin the root pom spells through a `${property}` that + /// `.mvn/maven.config` redefines (`-D`, which Maven reads before the + /// pom's ``): the build resolves that version, not the pin. + MavenConfigOverride, } /// A ref discovery emits (so rollback, remove and list find the wiring, From ec89bd92d95bc55cb8312e7147755934647cc084 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 4 Nov 2026 11:58:11 -0600 Subject: [PATCH 03/20] Resolve Maven pin properties through profiles and nesting Closes the two Maven false-attestation gaps #2005 left open in VEX discovery. 1. Profile redefining a pin's property. A live ${v} that the root resolve to the Socket pin resolves something else whenever an active profile's redefine v (or a property v names): Maven merges active profile properties over the pom's own before interpolating. Vendored mode refuses to pin over such a property (version_override); discovery attested it. PomScope now reads each profile's , and every PomDep carries version_alternatives: the other versions its build_version takes under any profile or set of profiles active together (user properties and project.* model references stay fixed; a value some set leaves unresolved, cyclic or too deep counts as the text as written). profile_override now walks live main-jar declarations too (same main_jar_declarations scope and management-governs filter): one whose build version is the pin but has an alternative off it marks the ref unattested with the existing UnattestedKind::MavenProfileUnpatched (CLI note and CLI_CONTRACT.md updated), in hosted and the pre-v5 vendored wiring alike. Profile declarations and the shared classifier check (unpatched_classifier_copy) count every alternative as a version the declaration may resolve. A declaration's own ${v} reads its own profile's first. 2. Property versions resolved one level deep. ${a} with ${b} now resolves through every property it names, in both views PomDep keeps: `version` (as the pom spells it: model, own profile, root properties) and `build_version` (.mvn/maven.config user properties first at every level; project.* stays the model's). Limits are the vendored reactor's: at most 16 levels, and a cycle, undefined name or unterminated ${ never resolves (the version stays as written, which is never a pin). A partly literal version (${a}.${b}-x) resolves too, and ${project.version} resolves from the model in both views. The vendored reactor's interpolator (interpolate_by and the duplicate body of Reactor::interpolate_with) moves to formats/maven as the pure interpolate_strict, built on interpolate_each, which enumerates the values a name may take (capped at 16 results). Both now detect cycles explicitly and bound work (1024 lookups, 4096 bytes), so a pom whose properties each name the next many times over fails closed instead of expanding exponentially. Reactor::interpolate drops its always-zero depth argument. Vendored behavior is otherwise unchanged. Tests (each fails before this change): - vex::discover::maven::tests::profile_properties_redefining_a_pin_property_are_not_attested - vex::discover::maven::tests::profile_declaration_reads_its_own_profile_properties - vex::discover::maven::tests::version_property_resolves_through_nested_properties (16-deep chain resolves, 17 does not; two cycles; nested user overrides) - vex::discover::maven::tests::vendored_ref_under_profile_properties_is_not_attested - formats::maven::tests::{version_properties_resolve_recursively_and_fail_closed, profile_properties_give_version_alternatives, interpolation_is_bounded} The golden corpus is unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 4 +- .../src/commands/vex_sources.rs | 10 +- .../src/formats/maven/mod.rs | 570 ++++++++++++++++-- .../src/vendor/jvm/maven_reactor.rs | 75 +-- .../src/vex/discover/maven.rs | 319 +++++++++- .../socket-patch-core/src/vex/discover/mod.rs | 7 +- 6 files changed, 840 insertions(+), 145 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 7d9a61c76..d1e97dfe2 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -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`. Six 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 classifier copy other than `sources` / `javadoc` (`tests`, a native or JDK-specific build), live or inside ``, resolves any version other than the suffixed pin — its own literal ``, a `` entry with the same classifier (a profile's overriding the live one), or no version in the root pom — the pin is omitted. A pre-v5 single-pom vendored repository holds only the main jar, so any such copy omits its reference. `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. **Maven profile declarations** (`vex_maven_profile_unpatched`): a `` declaration of a pinned GA's main jar in the root `pom.xml` at any version other than the pin (the base release, another release, a `${property}` the pom does not resolve) overrides the pin whenever that profile is active, so the reference is omitted. A profile `` entry always counts; a profile `` entry counts only when management governs the pin (no direct `` outside ``), since a direct literal version beats management. A declaration without a `` takes the live pin, and a `pom` (or `java-source` / `javadoc`) typed one carries no code; neither withholds the statement. Activation is not evaluated: every profile counts, as in vendored mode, which rewrites profile declarations too; hosted scan leaves them as written (`redirect_maven_profile_dependency_unpatched`). The same holds for a pre-v5 single-pom vendored repository, which serves only the original version. **Maven user properties** (`vex_maven_config_override`): Maven resolves a `${property}` `` from `.mvn/maven.config`'s `-D` user properties before the pom's `` (`${project.*}` / `${pom.*}` always come from the model). A pin the pom spells through a property that `.mvn/maven.config` redefines resolves that other version, so the reference is omitted. A Socket version that only a `-D` user property supplies is not `pom.xml` wiring (hosted scan never writes one, and `rollback` / `remove` never edit `.mvn/maven.config`): it is no reference at all, only a diagnostic. +* **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`. Six 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 classifier copy other than `sources` / `javadoc` (`tests`, a native or JDK-specific build), live or inside ``, resolves any version other than the suffixed pin — its own literal ``, a `` entry with the same classifier (a profile's overriding the live one), or no version in the root pom — the pin is omitted. A pre-v5 single-pom vendored repository holds only the main jar, so any such copy omits its reference. `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. **Maven profile declarations** (`vex_maven_profile_unpatched`): a `` declaration of a pinned GA's main jar in the root `pom.xml` at any version other than the pin (the base release, another release, a `${property}` the pom does not resolve) overrides the pin whenever that profile is active, so the reference is omitted. A profile `` entry always counts; a profile `` entry counts only when management governs the pin (no direct `` outside ``), since a direct literal version beats management. A declaration without a `` takes the live pin, and a `pom` (or `java-source` / `javadoc`) typed one carries no code; neither withholds the statement. Activation is not evaluated: every profile counts, as in vendored mode, which rewrites profile declarations too; hosted scan leaves them as written (`redirect_maven_profile_dependency_unpatched`). A pin spelled through a property counts the same way: a `${property}` `` is interpolated through every property it names (at most 16 levels deep; a cycle or an undefined name never resolves to a pin), and when any profile's `` — or several profiles' together — redefine one of those properties so that it resolves anything other than the pin (or nothing), the reference is omitted too; a `.mvn/maven.config` user property wins over every profile's. A profile declaration reads its own profile's `` first. The same holds for a pre-v5 single-pom vendored repository, which serves only the original version. **Maven user properties** (`vex_maven_config_override`): Maven resolves a `${property}` `` from `.mvn/maven.config`'s `-D` user properties before the pom's `` (`${project.*}` / `${pom.*}` always come from the model). A pin the pom spells through a property that `.mvn/maven.config` redefines resolves that other version, so the reference is omitted. A Socket version that only a `-D` user property supplies is not `pom.xml` wiring (hosted scan never writes one, and `rollback` / `remove` never edit `.mvn/maven.config`): it is no reference at all, only a diagnostic. * **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,7 +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 an executable `` copy (not `sources` / `javadoc`; live or inside ``) 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_maven_profile_unpatched` | run warning and `failed[].reason` | A Maven pin's GA main jar is also declared inside `` in the root `pom.xml` at a version other than the pin (a profile `` entry only where management governs the pin), which overrides the pin whenever that profile is active; no statement while it does. `list`, `rollback` and `remove` still find the pin. | +| `vex_maven_profile_unpatched` | run warning and `failed[].reason` | A Maven pin's GA main jar is also declared inside `` in the root `pom.xml` at a version other than the pin (a profile `` entry only where management governs the pin), or a profile's `` redefine a property the pin's `${property}` version names so it resolves another version, which overrides the pin whenever that profile is active; no statement while it does. `list`, `rollback` and `remove` still find the pin. | | `vex_maven_config_override` | run warning and `failed[].reason` | A hosted Maven pin is spelled through a `${property}` that a `-D` user property in `.mvn/maven.config` redefines, so Maven resolves another version; no statement while it does. `list`, `rollback` and `remove` still find the pin. | | `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. | diff --git a/crates/socket-patch-cli/src/commands/vex_sources.rs b/crates/socket-patch-cli/src/commands/vex_sources.rs index 322b05f49..51c98e35b 100644 --- a/crates/socket-patch-cli/src/commands/vex_sources.rs +++ b/crates/socket-patch-cli/src/commands/vex_sources.rs @@ -233,7 +233,8 @@ const NOTE_MAVEN_REACTOR_ROOT: &str = "vex_maven_reactor_root"; /// (`vex::Unattested`). const NOTE_MAVEN_CLASSIFIER_UNPATCHED: &str = "vex_maven_classifier_unpatched"; /// Omission tag and note: a hosted Maven pin's GA is also declared inside -/// `` at another version, which overrides the pin whenever that +/// `` at another version, or a profile's `` move +/// the pin's `${property}` version, which overrides the pin whenever that /// profile is active (`vex::Unattested`). const NOTE_MAVEN_PROFILE_UNPATCHED: &str = "vex_maven_profile_unpatched"; /// Omission tag and note: a hosted Maven pin is spelled through a @@ -278,9 +279,10 @@ fn unattested_note(kind: UnattestedKind) -> (&'static str, &'static str) { ), UnattestedKind::MavenProfileUnpatched => ( NOTE_MAVEN_PROFILE_UNPATCHED, - "not attested while a profile declares it at another version; the wiring itself is \ - intact, so re-running `scan` does not change this (drop that profile or \ - set it to the pin, or re-patch with `scan --mode vendored`)", + "not attested while a profile declares it at, or its move it to, \ + another version; the wiring itself is intact, so re-running `scan` does not change \ + this (drop that profile or property or set it to the pin, or re-patch \ + with `scan --mode vendored`)", ), UnattestedKind::MavenConfigOverride => ( NOTE_MAVEN_CONFIG_OVERRIDE, diff --git a/crates/socket-patch-core/src/formats/maven/mod.rs b/crates/socket-patch-core/src/formats/maven/mod.rs index 6625d3e1e..6c8f99209 100644 --- a/crates/socket-patch-core/src/formats/maven/mod.rs +++ b/crates/socket-patch-core/src/formats/maven/mod.rs @@ -2,7 +2,7 @@ //! [`parse_pom`]), the Maven coordinate grammars and the hosted //! `-socket.` version split. Pure: text in, structure out. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use super::xml::{blank_non_markup, child_text, elements, open_tags, Element}; @@ -41,15 +41,27 @@ pub(crate) struct PomRepo { pub(crate) struct PomDep { pub(crate) group: String, pub(crate) artifact: String, - /// Trimmed literal version, `${prop}` resolved (one level) from the - /// root `` when possible: the version the pom itself - /// spells, which the hosted rewriter writes and rolls back. `None` when - /// the declaration has none. + /// Trimmed version, every `${prop}` interpolated + /// ([`interpolate_strict`]) from the project model (`project.*` / + /// `pom.*`), the `` of the profile holding the declaration + /// (if any), then the root ``: the version the pom itself + /// spells, which the hosted rewriter writes and rolls back. As written + /// when that fails (undefined, cyclic, too deep); `None` when the + /// declaration has none. pub(crate) version: Option, - /// [`Self::version`] as the build resolves it: `${prop}` resolved from - /// the build's user properties first (`.mvn/maven.config`'s `-D`), - /// else the root `` — Maven's own lookup order. + /// [`Self::version`] as the build resolves it: the build's user + /// properties (`.mvn/maven.config`'s `-D`) come before the profile's + /// and the root `` at every level — Maven's own lookup + /// order (the model still comes first). pub(crate) build_version: Option, + /// The trimmed `` text as written. + pub(crate) version_text: Option, + /// The other versions [`Self::build_version`] becomes when some set of + /// profiles is active whose `` redefine a property it + /// names (transitively): a superset ([`interpolate_each`]), plus the + /// text as written when some such set leaves it unresolved. Empty when + /// no profile can move it. + pub(crate) version_alternatives: Vec, /// The non-empty ``: a sibling artifact of the GA (sources, /// tests, a native build), managed and resolved apart from the main jar. pub(crate) classifier: Option, @@ -60,6 +72,10 @@ pub(crate) struct PomDep { pub(crate) in_profile: bool, } +/// One `` inside `` (`name -> trimmed value`), with +/// the `` element that holds it when the scan finds one. +type ProfileProperties = (Option, BTreeMap); + /// Where Maven reads a root pom's model from: its text with comments and /// CDATA blanked (offsets kept), and the sections that are not the /// project's own resolution. `` / `` hold plugin @@ -75,14 +91,18 @@ pub(crate) struct PomScope { profiles: Vec, /// The root `` (see [`properties`]). properties: BTreeMap, - /// [`Self::properties`] with the build's user properties laid over - /// them, except `project.*` / `pom.*`, which Maven reads from the model - /// before any user property: what a `${name}` version resolves to in - /// the build. - build_properties: BTreeMap, - /// [`Self::properties`] plus the `${project.*}` model references a - /// coordinate may name (see [`model_refs`]). + /// The build's user properties, except `project.*` / `pom.*`, which + /// Maven reads from the model before any user property. + user: BTreeMap, + /// The `${project.*}` model references (see [`model_refs`]). + model: BTreeMap, + /// [`Self::properties`] plus the user properties and model references + /// a coordinate may name. coordinate_refs: BTreeMap, + /// Each `` inside ``, with the `` that + /// holds it (when the scan finds one): merged over the root's while + /// that profile is active. + profile_properties: Vec, } impl PomScope { @@ -115,33 +135,125 @@ impl PomScope { .concat(); let profiles = elements(&masked, "profiles")?; let properties = properties(&masked, &ignored, &profiles)?; - let mut build_properties = properties.clone(); - build_properties.extend( - user.iter() - .filter(|(k, _)| !is_model_reference(k)) - .map(|(k, v)| (k.clone(), v.clone())), - ); let mut coordinate_refs = properties.clone(); coordinate_refs.extend(user.iter().map(|(k, v)| (k.clone(), v.clone()))); + let user: BTreeMap = user + .iter() + .filter(|(k, _)| !is_model_reference(k)) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + let model = model_refs(&masked, &coordinate_refs)?; // A `project.*` / `pom.*` reference names the model, whatever a // same-named property says; the deprecated bare form yields to one. - for (name, value) in model_refs(&masked, &coordinate_refs.clone())? { - if is_model_reference(&name) { - coordinate_refs.insert(name, value); + for (name, value) in &model { + if is_model_reference(name) { + coordinate_refs.insert(name.clone(), value.clone()); } else { - coordinate_refs.entry(name).or_insert(value); + coordinate_refs.entry(name.clone()).or_insert(value.clone()); } } + let profile_properties = profile_properties(&masked, &ignored, &profiles)?; Ok(PomScope { masked, ignored, profiles, properties, - build_properties, + user, + model, coordinate_refs, + profile_properties, }) } + /// The indices into [`Self::profile_properties`] of the profile that + /// holds `pos` (none outside one). + fn own_profile(&self, pos: usize) -> Vec { + (0..self.profile_properties.len()) + .filter(|&i| { + self.profile_properties[i] + .0 + .is_some_and(|p| pos >= p.start && pos < p.end) + }) + .collect() + } + + /// Whether no profile's `` can change what `${name}` + /// reads: a model reference, or (in the build's view) a user property, + /// both of which Maven looks up first. + fn fixed(&self, name: &str, build: bool) -> bool { + is_model_reference(name) || (build && self.user.contains_key(name)) + } + + /// What `${name}` reads while only the profile `own` is active (if + /// any): a model reference, else (in the build's view) a user + /// property, else that profile's ``, else the root's, else + /// a deprecated bare model reference (`${version}`). + fn lookup(&self, name: &str, own: &[usize], build: bool) -> Option { + if is_model_reference(name) { + if let Some(v) = self.model.get(name) { + return Some(v.clone()); + } + } + if build { + if let Some(v) = self.user.get(name) { + return Some(v.clone()); + } + } + if !self.fixed(name, build) { + if let Some(v) = own + .iter() + .find_map(|&i| self.profile_properties[i].1.get(name)) + { + return Some(v.clone()); + } + } + self.properties + .get(name) + .or_else(|| self.model.get(name)) + .cloned() + } + + /// Every value `${name}` may read in the build while `own` and any + /// other profiles are active: [`Self::lookup`]'s, then every other + /// profile's definition. Empty when [`Self::lookup`] finds none: some + /// set of profiles leaves it undefined. + fn lookup_any(&self, name: &str, own: &[usize]) -> Vec { + let Some(base) = self.lookup(name, own, true) else { + return Vec::new(); + }; + let mut values = vec![base]; + if !self.fixed(name, true) { + values.extend( + self.profile_properties + .iter() + .enumerate() + .filter(|(i, _)| !own.contains(i)) + .filter_map(|(_, (_, props))| props.get(name).cloned()), + ); + } + values + } + + /// The `` text of the declaration at `pos`, resolved as + /// [`PomDep::version`], [`PomDep::build_version`] and + /// [`PomDep::version_alternatives`] describe. + fn resolve_version(&self, text: &str, pos: usize) -> (String, String, Vec) { + if !text.contains("${") { + return (text.to_string(), text.to_string(), Vec::new()); + } + let own = self.own_profile(pos); + let spelled = interpolate_strict(text, &|name| self.lookup(name, &own, false)) + .unwrap_or_else(|| text.to_string()); + let Some(build) = interpolate_strict(text, &|name| self.lookup(name, &own, true)) else { + return (spelled, text.to_string(), Vec::new()); + }; + let alternatives = match interpolate_each(text, &|name| self.lookup_any(name, &own)) { + Some(all) => all.into_iter().filter(|v| *v != build).collect(), + None => vec![text.to_string()], + }; + (spelled, build, alternatives) + } + /// Inside a plugin / deploy section (never project resolution). pub(crate) fn is_ignored(&self, pos: usize) -> bool { inside(pos, &self.ignored) @@ -261,17 +373,20 @@ pub(crate) fn parse_pom_with(raw: &str, user: &BTreeMap) -> Resu let (Some(group), Some(artifact)) = (dep.group, dep.artifact) else { continue; }; + let (version, build_version, version_alternatives) = match &dep.version_text { + Some(text) => { + let (spelled, build, alternatives) = scope.resolve_version(text, dep.element.start); + (Some(spelled), Some(build), alternatives) + } + None => (None, None, Vec::new()), + }; pom.deps.push(PomDep { group, artifact, - version: dep - .version_text - .as_deref() - .map(|v| resolve_property(v, &scope.properties)), - build_version: dep - .version_text - .as_deref() - .map(|v| resolve_property(v, &scope.build_properties)), + version, + build_version, + version_text: dep.version_text, + version_alternatives, classifier: dep.classifier, type_text: dep.type_text, managed: inside(dep.element.start, &managed), @@ -298,40 +413,175 @@ fn properties( { continue; } - let body = props.inner(text); - let mut from = 0; - while let Some(rel) = body[from..].find('<') { - let lt = from + rel; - let Some(gt) = body[lt..].find('>').map(|r| lt + r) else { - return Err("unterminated tag in ".to_string()); - }; - let name = &body[lt + 1..gt]; - if !is_property_name(name) { - from = gt + 1; - continue; - } - let close = format!(""); - let Some(end) = body[gt + 1..].find(&close).map(|r| gt + 1 + r) else { - return Err(format!("unterminated <{name}> in ")); - }; - map.entry(name.to_string()) - .or_insert_with(|| body[gt + 1..end].trim().to_string()); - from = end + close.len(); - } + read_properties(props.inner(text), &mut map)?; } Ok(map) } -/// `${name}` → the property's value (one level, no recursion); anything -/// else, or an undefined property, is returned unchanged. -fn resolve_property(version: &str, properties: &BTreeMap) -> String { - version - .strip_prefix("${") - .and_then(|rest| rest.strip_suffix('}')) - .filter(|name| is_property_name(name)) - .and_then(|name| properties.get(name)) - .cloned() - .unwrap_or_else(|| version.to_string()) +/// Each `` inside `` (outside ignored sections) as +/// `name -> trimmed value`, with the `` element that holds it. +fn profile_properties( + text: &str, + ignored: &[Element], + profiles: &[Element], +) -> Result, String> { + let live = |pos: usize| inside(pos, profiles) && !inside(pos, ignored); + // A `` no `` is found around (a scan this simple + // cannot place) belongs to no profile: it is then only ever an + // alternative, never a declaration's own, which reads more versions + // into [`PomDep::version_alternatives`], not fewer. + let holders: Vec = elements(text, "profile") + .unwrap_or_default() + .into_iter() + .filter(|p| live(p.start)) + .collect(); + let mut out = Vec::new(); + for props in elements(text, "properties")? { + if !live(props.start) { + continue; + } + let holder = holders + .iter() + .find(|p| props.start >= p.start && props.start < p.end) + .copied(); + let mut map = BTreeMap::new(); + read_properties(props.inner(text), &mut map)?; + out.push((holder, map)); + } + Ok(out) +} + +/// The `name -> trimmed value` pairs of a `` body, into `map` +/// (a name's first definition wins). +fn read_properties(body: &str, map: &mut BTreeMap) -> Result<(), String> { + let mut from = 0; + while let Some(rel) = body[from..].find('<') { + let lt = from + rel; + let Some(gt) = body[lt..].find('>').map(|r| lt + r) else { + return Err("unterminated tag in ".to_string()); + }; + let name = &body[lt + 1..gt]; + if !is_property_name(name) { + from = gt + 1; + continue; + } + let close = format!(""); + let Some(end) = body[gt + 1..].find(&close).map(|r| gt + 1 + r) else { + return Err(format!("unterminated <{name}> in ")); + }; + map.entry(name.to_string()) + .or_insert_with(|| body[gt + 1..end].trim().to_string()); + from = end + close.len(); + } + Ok(()) +} + +/// Nesting depth past which [`interpolate_strict`] gives up: a property +/// named through more than this many others (Maven itself fails on a +/// cycle, which this bounds too). +pub(crate) const MAX_INTERPOLATION_DEPTH: usize = 16; + +/// Most distinct results [`interpolate_each`] enumerates before giving up. +const MAX_RESOLUTIONS: usize = 16; + +/// Most lookups one interpolation makes before giving up: a value naming +/// the next property many times over would otherwise cost exponentially +/// many with every level ("billion laughs"). +const MAX_LOOKUPS: usize = 1024; + +/// Longest text [`interpolate_each`] builds before giving up. +const MAX_RESOLVED_LEN: usize = 4096; + +/// `value` with every `${name}` replaced by `lookup(name)`, itself +/// interpolated the same way, as Maven's model interpolator resolves a +/// property defined through another. `None` when a name is undefined, a +/// `${` is unterminated, or the nesting is cyclic or deeper than +/// [`MAX_INTERPOLATION_DEPTH`] (or the work bounds are hit): an unresolved +/// value never stands for a version. Shared by the vendored reactor +/// (its `lookup` walks the parent chain) and the VEX reader +/// ([`PomScope`]). +pub(crate) fn interpolate_strict( + value: &str, + lookup: &dyn Fn(&str) -> Option, +) -> Option { + interpolate_each(value, &|name| lookup(name).into_iter().collect())? + .into_iter() + .next() +} + +/// Every text `value` may interpolate to when each `${name}` may take any +/// of the values `lookup(name)` gives (an empty list: undefined), each +/// interpolated in turn ([`interpolate_strict`] with a single value per +/// name). A name that occurs twice may take a different value each time, +/// so the set may hold texts no single assignment builds: a superset, +/// which only ever withholds an attestation. `None` when any choice leaves +/// a name undefined, a `${` is unterminated, a name is reached through +/// itself (a cycle) or through more than [`MAX_INTERPOLATION_DEPTH`] +/// others, or the result or the work exceeds its bound. +pub(crate) fn interpolate_each( + value: &str, + lookup: &dyn Fn(&str) -> Vec, +) -> Option> { + let lookups = std::cell::Cell::new(MAX_LOOKUPS); + expand(value, lookup, &mut Vec::new(), &lookups) +} + +fn expand( + value: &str, + lookup: &dyn Fn(&str) -> Vec, + stack: &mut Vec, + lookups: &std::cell::Cell, +) -> Option> { + if stack.len() > MAX_INTERPOLATION_DEPTH { + return None; + } + let mut texts = BTreeSet::from([String::new()]); + let mut rest = value; + loop { + let (literal, reference) = match rest.find("${") { + Some(at) => (&rest[..at], Some(&rest[at + 2..])), + None => (rest, None), + }; + let Some(after) = reference else { + return concat(&texts, &BTreeSet::from([literal.to_string()])); + }; + let close = after.find('}')?; + let name = &after[..close]; + if stack.iter().any(|n| n == name) { + return None; + } + lookups.set(lookups.get().checked_sub(1)?); + let raws = lookup(name); + if raws.is_empty() { + return None; + } + stack.push(name.to_string()); + let mut values = BTreeSet::new(); + for raw in &raws { + values.extend(expand(raw, lookup, stack, lookups)?); + } + stack.pop(); + let prefixed = concat(&texts, &BTreeSet::from([literal.to_string()]))?; + texts = concat(&prefixed, &values)?; + rest = &after[close + 1..]; + } +} + +/// Every `a + b`, or `None` past [`MAX_RESOLUTIONS`] / [`MAX_RESOLVED_LEN`]. +fn concat(heads: &BTreeSet, tails: &BTreeSet) -> Option> { + let mut out = BTreeSet::new(); + for head in heads { + for tail in tails { + if head.len() + tail.len() > MAX_RESOLVED_LEN { + return None; + } + out.insert(format!("{head}{tail}")); + if out.len() > MAX_RESOLUTIONS { + return None; + } + } + } + Some(out) } /// Interpolation rounds before a still-unresolved reference is given up on @@ -841,6 +1091,198 @@ mod tests { } } + /// A `${property}` version is interpolated through every property it + /// names, user properties first at each level; a cycle, an undefined + /// name or a chain deeper than [`super::MAX_INTERPOLATION_DEPTH`] + /// leaves it as written. + #[test] + fn version_properties_resolve_recursively_and_fail_closed() { + // (as the pom spells it, as the build resolves it) + let both = |props: &str, user: &[(&str, &str)], text: &str| { + let pom = format!( + "{props}\ + ga\ + {text}" + ); + let user = user + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + let dep = super::parse_pom_with(&pom, &user).unwrap().deps.remove(0); + assert!(dep.version_alternatives.is_empty(), "{props}"); + (dep.version.unwrap(), dep.build_version.unwrap()) + }; + let version = |props: &str, user: &[(&str, &str)], text: &str| { + let (spelled, built) = both(props, user, text); + if user.is_empty() { + assert_eq!(spelled, built, "{props}"); + } + built + }; + assert_eq!( + both("${b}1.0", &[("b", "2.0")], "${a}"), + ("1.0".to_string(), "2.0".to_string()), + "a nested user property moves the build's version, not the pom's" + ); + // `${p1}` ... `1.0`. + let chain = |n: usize| -> String { + (0..n) + .map(|i| match i + 1 == n { + true => format!("1.0"), + false => format!("${{p{}}}", i + 1), + }) + .collect() + }; + assert_eq!( + version("${b}${c}1.0", &[], "${a}"), + "1.0" + ); + assert_eq!( + version("${b}1.0", &[("b", "2.0")], "${a}"), + "2.0" + ); + assert_eq!(version("10", &[], "${a}.${b}-x"), "1.0-x"); + assert_eq!( + version("", &[], "${project.version}"), + "${project.version}", + "no project version here" + ); + assert_eq!(version(&chain(16), &[], "${p0}"), "1.0"); + assert_eq!(version(&chain(17), &[], "${p0}"), "${p0}", "too deep"); + for cyclic in [ + "${a}", + "${b}${a}", + "x${b}${a}y", + ] { + assert_eq!(version(cyclic, &[], "${a}"), "${a}", "{cyclic}"); + } + assert_eq!(version("${b}", &[], "${a}"), "${a}", "undefined"); + assert_eq!(version("1", &[], "${a"), "${a", "unterminated"); + } + + /// The versions a profile's `` may move a declaration to: + /// every profile, and every set of profiles active together, counts; a + /// declaration inside a profile reads that profile's own first; user + /// properties and model references no profile can redefine. + #[test] + fn profile_properties_give_version_alternatives() { + let resolve = |props: &str, profiles: &[&str], user: &[(&str, &str)], deps: &str| { + let profiles: String = profiles + .iter() + .map(|p| format!("{p}")) + .collect(); + let pom = format!( + "9{props}\ + {deps}{profiles}" + ); + let user = user + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + super::parse_pom_with(&pom, &user) + .unwrap() + .deps + .into_iter() + .map(|d| (d.build_version.unwrap(), d.version_alternatives)) + .collect::>() + }; + let dep = |v: &str| { + format!( + "ga\ + {v}" + ) + }; + let one = |props, profiles: &[&str], user: &[(&str, &str)]| { + resolve(props, profiles, user, &dep("${v}")).remove(0) + }; + let s = |v: &[&str]| v.iter().map(|v| v.to_string()).collect::>(); + assert_eq!(one("1", &["2"], &[]), ("1".into(), s(&["2"]))); + assert_eq!(one("1", &["1"], &[]), ("1".into(), s(&[]))); + assert_eq!(one("1", &["2"], &[]), ("1".into(), s(&[]))); + assert_eq!( + one("${w}1", &["2", "3"], &[]), + ("1".into(), s(&["2", "3"])) + ); + // Two profiles that move it only together. + assert_eq!( + one("11", &["${w}", "2"], &[]), + ("1".into(), s(&["2"])) + ); + // A profile value this pom cannot resolve, or a cycle. + assert_eq!( + one("1", &["${nope}"], &[]), + ("1".into(), s(&["${v}"])) + ); + assert_eq!( + one("1", &["${v}"], &[]), + ("1".into(), s(&["${v}"])) + ); + // User properties and model references win over every profile's. + assert_eq!( + one("1", &["2"], &[("v", "3")]), + ("3".into(), s(&[])) + ); + assert_eq!( + resolve( + "", + &["2"], + &[], + &dep("${project.version}") + ) + .remove(0), + ("9".into(), s(&[])) + ); + // More alternatives than are enumerated: unresolved. + let many: Vec = (0..17).map(|i| format!("{i}")).collect(); + let many: Vec<&str> = many.iter().map(String::as_str).collect(); + assert_eq!(one("x", &many, &[]), ("x".into(), s(&["${v}"]))); + + // A profile's own declaration reads its own properties first. + let pom = format!( + "1{}\ + a2\ + {}\ + b3", + dep("${v}"), + dep("${v}") + ); + let deps: Vec<_> = super::parse_pom(&pom) + .unwrap() + .deps + .into_iter() + .map(|d| { + ( + d.in_profile, + d.build_version.unwrap(), + d.version_alternatives, + ) + }) + .collect(); + assert_eq!( + deps, + [ + (false, "1".to_string(), s(&["2", "3"])), + (true, "2".to_string(), s(&["3"])), + ] + ); + } + + /// The shared interpolator's work bounds: a value naming the next + /// property many times over fails instead of growing exponentially. + #[test] + fn interpolation_is_bounded() { + let props: std::collections::BTreeMap = (0..12) + .map(|i| (format!("p{i}"), format!("${{p{}}}", i + 1).repeat(8))) + .chain([("p12".to_string(), "x".to_string())]) + .collect(); + let lookup = |name: &str| props.get(name).cloned(); + assert_eq!(super::interpolate_strict("${p0}", &lookup), None); + assert_eq!( + super::interpolate_strict("${p11}", &lookup).as_deref(), + Some("xxxxxxxx") + ); + } + #[test] fn suffix_grammar_is_exact() { assert_eq!( diff --git a/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs b/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs index 42a853fff..8b8e9b6cc 100644 --- a/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs +++ b/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs @@ -11,7 +11,9 @@ use std::ops::Range; use serde_json::{json, Value}; -use crate::formats::maven::maven_config_properties as cli_properties; +use crate::formats::maven::{ + interpolate_strict, maven_config_properties as cli_properties, MAX_INTERPOLATION_DEPTH, +}; use super::super::state::{WiringAction, WiringRecord}; use super::layout::{self, safe_coordinates}; @@ -51,8 +53,6 @@ const NESTED_MVN_FILES: &[&str] = &[ const SHAPE_UNSUPPORTED: &str = "vendor_jvm_shape_unsupported"; const UPSTREAM_UNAVAILABLE: &str = "vendor_jvm_upstream_unavailable"; const DEGRADED: &str = "vendor_jvm_degraded"; -/// Property interpolation depth cap (Maven itself fails on cycles). -const MAX_INTERPOLATION_DEPTH: usize = 16; /// Whether the pom declares a `` or `` of the project /// or of a profile (plugin configuration, comments and CDATA do not count). @@ -737,7 +737,7 @@ fn restore_versions( let resolve = |v: &str| { reactor .filter(|r| r.poms.contains_key(rel)) - .and_then(|r| r.interpolate(rel, v, 0)) + .and_then(|r| r.interpolate(rel, v)) }; let mut edits = Vec::new(); for (dep, key) in doc.keyed_declarations(c.group_id, c.artifact_id, &resolve) { @@ -1055,30 +1055,6 @@ struct ExternalPom { pom: Pom, } -/// `${name}` interpolation through `lookup`; `None` when a name is -/// undefined or the nesting is too deep. -fn interpolate_by( - value: &str, - lookup: &dyn Fn(&str) -> Option, - depth: usize, -) -> Option { - if depth > MAX_INTERPOLATION_DEPTH { - return None; - } - let mut out = String::new(); - let mut rest = value; - while let Some(at) = rest.find("${") { - out.push_str(&rest[..at]); - let after = &rest[at + 2..]; - let close = after.find('}')?; - let raw = lookup(&after[..close])?; - out.push_str(&interpolate_by(&raw, lookup, depth + 1)?); - rest = &after[close + 1..]; - } - out.push_str(rest); - Some(out) -} - impl Reactor { /// Management of `patch`'s g:a that a pin in local root `root` would /// override and that does not manage it at the patch's base version: @@ -1120,7 +1096,7 @@ impl Reactor { }) }; let resolve = |value: &str, what: &str| -> Result { - interpolate_by(value, &props, 0).ok_or_else(|| { + interpolate_strict(value, &props).ok_or_else(|| { Missing::Unresolved(format!("{rel}: {what} {value} is not defined")) }) }; @@ -1207,8 +1183,7 @@ impl Reactor { ) -> Result, Missing> { let mut chain: Vec = Vec::new(); let mut parent = self.poms[root].parent.as_ref().map(|p| { - let local = - |v: &Option| v.as_deref().and_then(|v| self.interpolate(root, v, 0)); + let local = |v: &Option| v.as_deref().and_then(|v| self.interpolate(root, v)); (local(&p.group), local(&p.artifact), local(&p.version)) }); while let Some((pg, pa, pv)) = parent { @@ -1374,7 +1349,7 @@ fn bom_manages<'b>( chain.iter().find_map(|e| e.pom.props.get(name).cloned()) }; let resolve = |value: &str| -> Result { - interpolate_by(value, &props, 0).ok_or_else(|| { + interpolate_strict(value, &props).ok_or_else(|| { Missing::Unresolved(format!( "the BOM {}:{}:{} manages {g}:{a} at {value}, which it does not define", bom.0, bom.1, bom.2 @@ -1729,13 +1704,13 @@ impl Reactor { fn keyed_declarations(&self, rel: &str, g: &str, a: &str) -> Vec<(usize, String)> { self.poms[rel] .doc - .keyed_declarations(g, a, &|v| self.interpolate(rel, v, 0)) + .keyed_declarations(g, a, &|v| self.interpolate(rel, v)) } /// Interpolate every `${name}` in `value` in the context of `rel`; /// `None` when any name is undefined in the checkout. - fn interpolate(&self, rel: &str, value: &str, depth: usize) -> Option { - self.interpolate_with(rel, value, depth, None) + fn interpolate(&self, rel: &str, value: &str) -> Option { + self.interpolate_with(rel, value, None) } /// [`Self::interpolate`] with `overlay`'s properties (a profile's) @@ -1744,24 +1719,9 @@ impl Reactor { &self, rel: &str, value: &str, - depth: usize, overlay: Option>, ) -> Option { - if depth > MAX_INTERPOLATION_DEPTH { - return None; - } - let mut out = String::new(); - let mut rest = value; - while let Some(at) = rest.find("${") { - out.push_str(&rest[..at]); - let after = &rest[at + 2..]; - let close = after.find('}')?; - let raw = self.lookup_with(rel, &after[..close], overlay)?; - out.push_str(&self.interpolate_with(rel, &raw, depth + 1, overlay)?); - rest = &after[close + 1..]; - } - out.push_str(rest); - Some(out) + interpolate_strict(value, &|name| self.lookup_with(rel, name, overlay)) } /// User properties win over model properties, as in Maven; then the @@ -1827,17 +1787,14 @@ impl Reactor { // has redefined something it names: that moves it too. let undefined = || "an undefined value".to_string(); let plain = (reader != rel) - .then(|| { - self.interpolate(reader, declared, 0) - .unwrap_or_else(undefined) - }) + .then(|| self.interpolate(reader, declared).unwrap_or_else(undefined)) .filter(|v| moved(v)) .map(|v| (v, reader.to_string())); plain.or_else(|| { self.chain(reader).find_map(|at| { self.poms[at].profile_props.iter().find_map(|(id, props)| { let v = self - .interpolate_with(reader, declared, 0, Some((at, props))) + .interpolate_with(reader, declared, Some((at, props))) .unwrap_or_else(undefined); moved(&v).then(|| (v, format!("{reader} under profile {id} of {at}"))) }) @@ -1897,7 +1854,7 @@ impl Reactor { // literal SV must not reach. if doc.has_property_coordinate(dep) { let other = self.inheritors(rel).find(|p| { - let resolve = |v: &str| self.interpolate(p, v, 0); + let resolve = |v: &str| self.interpolate(p, v); doc.coordinate(dep, "groupId", &resolve).as_deref() != Some(patch.group_id) || doc.coordinate(dep, "artifactId", &resolve).as_deref() != Some(patch.artifact_id) @@ -1924,7 +1881,7 @@ impl Reactor { }; let declared = doc.text_of(version); let resolved = if declared.contains("${") { - match self.interpolate(rel, &declared, 0) { + match self.interpolate(rel, &declared) { Some(v) => v, None => { warnings.push(degraded( @@ -2012,7 +1969,7 @@ impl Reactor { uncertain: &mut BTreeSet, ) { let doc = &self.poms[rel].doc; - let resolve = |v: &str| self.interpolate(rel, v, 0); + let resolve = |v: &str| self.interpolate(rel, v); for dep in doc.declarations() { let (Some(g), Some(a)) = ( doc.child_text(dep, "groupId"), diff --git a/crates/socket-patch-core/src/vex/discover/maven.rs b/crates/socket-patch-core/src/vex/discover/maven.rs index acb5bd0ca..2c4e7f2a2 100644 --- a/crates/socket-patch-core/src/vex/discover/maven.rs +++ b/crates/socket-patch-core/src/vex/discover/maven.rs @@ -54,14 +54,18 @@ //! pin (a profile's `` only where management governs //! the pin): the rewriter leaves it as written, and whenever that profile //! is active it overrides the pin (vendored mode treats every profile as -//! live and rewrites it too). A `${property}` version is resolved one -//! level from the root `` — the pin as the pom spells it — and -//! again with `.mvn/maven.config`'s `-D` user properties first, as the -//! build resolves it (Maven's order; `${project.*}` stays the model's): a -//! pin the build config moves keeps the ref but marks it unattested, and a -//! Socket version only the build config supplies is diagnosed, never a -//! ref (hosted mode never writes one; rollback / remove never edit -//! `.mvn/maven.config`). +//! live and rewrites it too). A `${property}` version is interpolated +//! through every property it names (the vendored reactor's interpolator: +//! at most 16 deep, a cycle never resolves) from the root `` — +//! the pin as the pom spells it — and again with `.mvn/maven.config`'s +//! `-D` user properties first at every level, as the build resolves it +//! (Maven's order; `${project.*}` stays the model's): a pin the build +//! config moves keeps the ref but marks it unattested, and a Socket +//! version only the build config supplies is diagnosed, never a ref +//! (hosted mode never writes one; rollback / remove never edit +//! `.mvn/maven.config`). A pin the build resolves but that any profile's +//! (or set of profiles') `` make resolve anything else keeps +//! the ref unattested too, as a profile declaration does. //! //! Integrity: when the pom sha256 was known the rewriter also writes Maven //! Trusted Checksums — `.mvn/checksums/checksums.sha256` lines @@ -104,7 +108,8 @@ //! (plugin classpaths) are not project resolution and are skipped silently; //! `` are only read when activated, so a Socket repository or pin //! there is diagnosed, never a ref, and a profile declaration of a pinned -//! GA at another version withholds the pin's attestation. Submodule poms +//! GA at another version, or a profile's `` that move the +//! pin's `${property}`, withholds the pin's attestation. Submodule poms //! and parent poms are not read (root-only). Gradle builds are discovered //! by `gradle.rs` (the hosted index) and the vendored JVM backend's own //! probes. @@ -698,6 +703,20 @@ fn build_version(d: &PomDep) -> Option<&str> { d.build_version.as_deref() } +/// Every version the build may resolve for `d` (`None` when it has no +/// ``), each flagged when a profile gives it: [`build_version`] +/// (a profile's when `in_profile`), then each version some set of active +/// profiles' `` moves it to ([`PomDep::version_alternatives`]). +fn resolutions(d: &PomDep, in_profile: bool) -> Vec<(Option<&str>, bool)> { + std::iter::once((build_version(d), in_profile)) + .chain( + d.version_alternatives + .iter() + .map(|v| (Some(v.as_str()), true)), + ) + .collect() +} + /// Every GA with a versioned, classifier-less declaration Maven always /// reads: the candidates for a pin. fn live_gas(deps: &[PomDep]) -> BTreeSet<(String, String)> { @@ -842,12 +861,12 @@ fn unpatched_classifier_copy( d.managed && d.in_profile == in_profile }) .filter(|(_, c)| *c == classifier) - .map(|(d, _)| (build_version(d), in_profile)) + .flat_map(|(d, _)| resolutions(d, in_profile)) .collect() }; classifier_declarations(deps, group, artifact, |d| !d.managed).find_map(|(d, classifier)| { let resolved = match build_version(d) { - Some(version) => vec![(Some(version), false)], + Some(_) => resolutions(d, false), None => { let mut live = managed(classifier, false); if live.is_empty() { @@ -894,7 +913,14 @@ const NON_CODE_TYPES: &[&str] = &["pom", "java-source", "javadoc"]; /// says. Activation is not evaluated, so every profile counts, as in /// vendored mode. A declaration without a version takes the live one and /// moves nothing, and a pom (or sources / javadoc) typed one carries no -/// code. +/// code. Every version a profile's `` may give the +/// declaration counts too ([`PomDep::version_alternatives`]). +/// +/// The same holds for a live declaration that resolves `pinned` through a +/// property some profile's `` redefine (or one it names, or +/// several profiles together) to another version: Maven merges an active +/// profile's properties over the pom's own before it interpolates. +/// Vendored mode refuses to pin over such a property (`version_override`). fn profile_override( deps: &[PomDep], group: &str, @@ -902,7 +928,7 @@ fn profile_override( pinned: &str, pin_managed: bool, ) -> Option { - main_jar_declarations(deps, group, artifact, |d| d.in_profile) + main_jar_declarations(deps, group, artifact, |_| true) .filter(|d| { !d.type_text .as_deref() @@ -910,7 +936,6 @@ fn profile_override( }) .filter(|d| !d.managed || pin_managed) .find_map(|d| { - let version = build_version(d).filter(|v| *v != pinned)?; let section = if d.managed { " " } else { @@ -925,6 +950,23 @@ fn profile_override( d.group, d.artifact ) }; + if !d.in_profile { + // Off the pin as built, it is no pin (or another bypass). + if build_version(d) != Some(pinned) { + return None; + } + let moved = d.version_alternatives.first()?; + let declared = d.version_text.as_deref().unwrap_or_default(); + return Some(format!( + "{POM}: {section}{named} {declared} resolves the \ + patched {pinned}, but a redefinition of a property \ + it names makes it {moved}; whenever that profile is active Maven resolves \ + it instead of the pin" + )); + } + let version = resolutions(d, true) + .into_iter() + .find_map(|(v, _)| v.filter(|v| *v != pinned))?; Some(format!( "{POM}: a {section}declaration of {named} carries \ {version}, not the patched {pinned}; whenever that profile \ @@ -1902,6 +1944,225 @@ mod tests { } } + /// Whether `out` attests `purl`: a ref no unattested entry withholds. + fn attests(out: &Discovery, purl: &str) -> bool { + out.refs.iter().any(|r| { + r.purl == purl + && !out + .unattested + .iter() + .any(|u| u.purl == r.purl && u.uuid == r.uuid) + }) + } + + /// A hosted project pinning slf4j-api through `decl` (a + /// `` / `` block), with the root + /// `` `props` and one `` per entry of `profiles` + /// (each a profile body). + fn property_project(props: &str, decl: &str, profiles: &[&str]) -> Project { + let repo = hosted_repo(&format!("socket-patch-{UUID_A}"), ®istry_url(UUID_A)); + let profiles: String = profiles + .iter() + .enumerate() + .map(|(i, body)| format!("p{i}\n{body}\n")) + .collect(); + let p = Project::new(); + p.write( + "pom.xml", + pom(&format!( + "{props}\n{decl}\n{repo}\n\ + \n{profiles}\n" + )), + ); + p + } + + fn direct_decl(version: &str) -> String { + format!( + "\n{}\n", + dep("org.slf4j", "slf4j-api", Some(version)) + ) + } + + /// A pin spelled through a property is what Maven resolves only while + /// no active profile's `` redefines that property (or one + /// it names) to anything else: Maven merges an active profile's + /// properties over the pom's own before it interpolates the + /// ``. Vendored mode refuses to pin over such a property + /// (`version_override`), so the ref stays (rollback / remove / list + /// find it) but is never attested. User properties + /// (`.mvn/maven.config`) win over every profile's. + #[tokio::test] + async fn profile_properties_redefining_a_pin_property_are_not_attested() { + let pin = suffixed(UUID_A); + let v = |value: &str| format!("{value}"); + let w = |value: &str| format!("{value}"); + let in_props = |s: String| format!("{s}"); + let direct = direct_decl("${slf4j.version}"); + let managed = format!( + "\n{}\n\ + \n{}\n", + dep("org.slf4j", "slf4j-api", Some("${slf4j.version}")), + dep("org.slf4j", "slf4j-api", None), + ); + let withheld = [ + // The base release. + (v(&pin), &direct, vec![in_props(v("1.7.36"))]), + (v(&pin), &managed, vec![in_props(v("1.7.36"))]), + // Through a property the pin's property names. + (v("${w}") + &w(&pin), &direct, vec![in_props(w("1.7.30"))]), + // A value this pom cannot resolve, or a cycle. + (v(&pin), &direct, vec![in_props(v("${undefined}"))]), + (v(&pin), &direct, vec![in_props(v("${slf4j.version}"))]), + // Two profiles that move the pin only together. + ( + v(&pin) + &w(&pin), + &direct, + vec![in_props(v("${w}")), in_props(w("1.7.36"))], + ), + ]; + for (root, decl, profiles) in &withheld { + let profiles: Vec<&str> = profiles.iter().map(String::as_str).collect(); + let out = run(&property_project(root, decl, &profiles)).await; + assert_refs(&out, &[(FX_PURL, UUID_A, WiringMode::Hosted)]); + assert_eq!( + out.unattested.len(), + 1, + "{root} {profiles:?}: {:?}", + out.unattested + ); + let u = &out.unattested[0]; + assert_eq!( + u.kind, + UnattestedKind::MavenProfileUnpatched, + "{profiles:?}" + ); + assert!( + u.detail.contains("org.slf4j:slf4j-api") && u.detail.contains(""), + "{}", + u.detail + ); + } + + for (root, profile, config) in [ + // The pin itself, or an unrelated property. + (v(&pin), in_props(v(&pin)), None), + (v(&pin), in_props("1".to_string()), None), + (v("${w}") + &w(&pin), in_props(w(&pin)), None), + // A user property wins over every profile's. + ( + v(&pin), + in_props(v("1.7.36")), + Some(format!("-Dslf4j.version={pin}\n")), + ), + ] { + let p = property_project(&root, &direct, &[&profile]); + if let Some(config) = &config { + p.write(".mvn/maven.config", config); + } + let out = run(&p).await; + assert_refs(&out, &[(FX_PURL, UUID_A, WiringMode::Hosted)]); + assert!( + attests(&out, FX_PURL), + "{root} {profile}: {:?}", + out.unattested + ); + } + } + + /// A `` declaration's `${property}` version reads its own + /// profile's `` first: that profile is active whenever the + /// declaration applies. + #[tokio::test] + async fn profile_declaration_reads_its_own_profile_properties() { + let pin = suffixed(UUID_A); + let profile = |value: &str| { + format!( + "{value}\n\ + \n{}\n", + dep("org.slf4j", "slf4j-api", Some("${slf4j.version}")) + ) + }; + let direct = direct_decl(&pin); + for (root, own, attested) in [ + // The root resolves the pin, the profile the base release. + (pin.as_str(), "1.7.36", false), + // The root resolves the base release, the profile the pin. + ("1.7.36", pin.as_str(), true), + ] { + let p = property_project( + &format!("{root}"), + &direct, + &[&profile(own)], + ); + let out = run(&p).await; + assert_refs(&out, &[(FX_PURL, UUID_A, WiringMode::Hosted)]); + assert_eq!( + attests(&out, FX_PURL), + attested, + "{own}: {:?}", + out.unattested + ); + } + } + + /// A `${property}` version resolves through every property it names, + /// with user properties first at each level, as Maven interpolates; a + /// cycle, or a chain deeper than the vendored reactor follows, is + /// never a pin. + #[tokio::test] + async fn version_property_resolves_through_nested_properties() { + let pin = suffixed(UUID_A); + // `${p1}` ... `{end}`. + let chain = |links: usize, end: &str| -> String { + (0..links) + .map(|i| { + let value = if i + 1 == links { + end.to_string() + } else { + format!("${{p{}}}", i + 1) + }; + format!("{value}") + }) + .collect() + }; + let split = format!( + "${{p1}}-socket.${{p2}}1.7.36{}", + &UUID_A[..8] + ); + let user_pin = format!("-Dp1={pin}\n"); + for (props, config, attested) in [ + (chain(2, &pin), None, true), + (chain(3, &pin), None, true), + (split, None, true), + // The deepest chain the vendored reactor resolves, and one more. + (chain(16, &pin), None, true), + (chain(17, &pin), None, false), + // A user property redefines a nested one: off the pin, or onto + // a Socket version only the build config supplies (never a + // pom.xml ref). + (chain(2, &pin), Some("-Dp1=1.7.36\n"), false), + (chain(2, "1.7.36"), Some(user_pin.as_str()), false), + // A cycle. + ("${p1}${p0}".to_string(), None, false), + ("${p0}".to_string(), None, false), + ] { + let p = property_project(&props, &direct_decl("${p0}"), &[]); + if let Some(config) = config { + p.write(".mvn/maven.config", config); + } + let out = run(&p).await; + assert_eq!( + attests(&out, FX_PURL), + attested, + "{props} {config:?}: {:?} {:?} {:?}", + out.refs, + out.unattested, + out.diagnostics + ); + } + } + /// A versioned declaration whose coordinate is a property this pom /// does not define may be the pinned GA, so the pin is not attested /// past it (#683). @@ -2171,6 +2432,36 @@ mod tests { } } + /// The same for a profile's `` that move a `${property}` + /// version of the vendored GA off the vendored version. + #[tokio::test] + async fn vendored_ref_under_profile_properties_is_not_attested() { + for (version, attested) in [("1.10.1", false), ("1.10.0", true)] { + let p = vendored_project(UUID_A); + let wired = std::fs::read_to_string(p.root().join("pom.xml")) + .expect("read pom") + .replace("1.10.0", "${ct}"); + let at = wired.rfind("").expect("project close"); + p.write( + "pom.xml", + format!( + "{}1.10.0\np\ + {version}\n{}", + &wired[..at], + &wired[at..] + ), + ); + let out = run(&p).await; + assert_refs(&out, &[(V_PURL, UUID_A, WiringMode::Vendored)]); + assert_eq!( + attests(&out, V_PURL), + attested, + "{version}: {:?}", + out.unattested + ); + } + } + #[tokio::test] async fn vendored_url_spellings() { for spelled in [ diff --git a/crates/socket-patch-core/src/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index ca636edcb..3a8a80b00 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -609,8 +609,11 @@ pub enum UnattestedKind { /// A Maven pin (hosted, or the pre-v5 single-pom vendored repository) /// whose GA a `` declaration of the root pom gives another /// version (the base release, another release, an - /// unresolved `${property}`): whenever that profile is active it - /// overrides the pin, so the build resolves the unpatched jar. + /// unresolved `${property}`), or whose `${property}` version a + /// profile's `` redefine (directly or through a property + /// it names) to another or an unresolvable version: whenever that + /// profile is active it overrides the pin, so the build resolves the + /// unpatched jar. MavenProfileUnpatched, /// A hosted Maven pin the root pom spells through a `${property}` that /// `.mvn/maven.config` redefines (`-D`, which Maven reads before the From 854c3f68237438dd815df6bd92878699da36663d Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 5 Nov 2026 22:15:27 -0600 Subject: [PATCH 04/20] Address review: one Maven property resolver for VEX and vendoring Review findings on the profile / nested property resolution (#2020): 1. Model references moved by properties (false attestation). The model (${project.version} from ${revision}) was bound already resolved from the root properties, and profiles were assumed unable to change it. Model references are now bound raw and resolved wherever they are read, with that read's user properties and active profiles: a profile moves ${project.version} (unattested), and a -Drevision= only .mvn/maven.config supplies makes the spelled and built versions differ, so it is diagnosed, never a ref. 2. Attributed / spaced property tags (false attestation). read_properties took the whole tag as the name, so and were skipped. The name now ends at whitespace or '/', closing tags may carry whitespace, and defines an empty value. 3. Nested references in a profile value resolved against the root. Each combination of profiles is now resolved as one property view (the profiles merged over the pom's, a later one winning), so a profile's ${w} reads its own first. 4. Profile parse errors failed the whole scope, including the hosted rewriter's. PomScope::with_user_properties no longer reads profile properties; parse_pom_with calls read_profiles(), and an unreadable one withholds every ${...} pin (MovedBy::Unreadable) while the rest of the pom is read. 5. The shared cross-product budget collapsed ordinary multi-profile poms. interpolate_each and its 16-result / 1024-lookup product are gone: profile_combinations walks the names reachable from the version, keeps only the profiles that define one (at most 8, else fail closed), and every combination of those is resolved on its own budget. 6. The detail named the lexicographically first alternative. Alternatives are listed smallest combination first and carry the profile ids (MovedBy), and the detail names them and the version they give. 7. Two precedence implementations. formats::maven::properties now holds lookup_property (Maven's order: model, user properties, each pom's properties with its active profiles merged in, bare ${version}), PomLayer and profile_combinations; PomScope and the vendored Reactor::lookup_with / version_override both use them, so vendoring now weighs the same combinations VEX does (it previously tried one profile at a time) and fails closed past the same bound. 8. Two interpolators. The round-based coordinate interpolate is gone: coordinates and model references go through the same expansion as versions, in a keep-unresolved mode (interpolate_lenient). Coordinate alternatives stay for #2026; PomScope::resolve_in_build takes the interpolation so it can reuse it. 9. Cleanups: resolutions() drops its from-profile flag (the classifier check derives it), PomScope::fixed and the profile-set guard are gone, and readers that never weigh profiles do not scan them. Tests (each failed before this commit): - vex::discover::maven::tests::model_references_move_with_the_properties_they_are_spelled_through - vex::discover::maven::tests::profile_properties_with_attributes_are_read - vex::discover::maven::tests::profile_values_resolve_through_their_own_profile - vex::discover::maven::tests::unreadable_profile_properties_withhold_only_property_pins - vex::discover::maven::tests::many_profiles_that_keep_the_pin_are_attested - vex::discover::maven::tests::profile_property_detail_names_the_profile_and_version - vendor::jvm::maven_reactor::tests::profiles_that_move_the_version_only_together_are_weighed plus formats::maven::properties unit tests and extended formats::maven::tests::profile_properties_give_version_alternatives. The golden corpus is unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../src/formats/maven/mod.rs | 612 ++++++++---------- .../src/formats/maven/properties.rs | 385 +++++++++++ .../src/vendor/jvm/maven_reactor.rs | 213 ++++-- .../src/vex/discover/maven.rs | 218 ++++++- 5 files changed, 1027 insertions(+), 403 deletions(-) create mode 100644 crates/socket-patch-core/src/formats/maven/properties.rs diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index d1e97dfe2..0dac7618e 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -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`. Six 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 classifier copy other than `sources` / `javadoc` (`tests`, a native or JDK-specific build), live or inside ``, resolves any version other than the suffixed pin — its own literal ``, a `` entry with the same classifier (a profile's overriding the live one), or no version in the root pom — the pin is omitted. A pre-v5 single-pom vendored repository holds only the main jar, so any such copy omits its reference. `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. **Maven profile declarations** (`vex_maven_profile_unpatched`): a `` declaration of a pinned GA's main jar in the root `pom.xml` at any version other than the pin (the base release, another release, a `${property}` the pom does not resolve) overrides the pin whenever that profile is active, so the reference is omitted. A profile `` entry always counts; a profile `` entry counts only when management governs the pin (no direct `` outside ``), since a direct literal version beats management. A declaration without a `` takes the live pin, and a `pom` (or `java-source` / `javadoc`) typed one carries no code; neither withholds the statement. Activation is not evaluated: every profile counts, as in vendored mode, which rewrites profile declarations too; hosted scan leaves them as written (`redirect_maven_profile_dependency_unpatched`). A pin spelled through a property counts the same way: a `${property}` `` is interpolated through every property it names (at most 16 levels deep; a cycle or an undefined name never resolves to a pin), and when any profile's `` — or several profiles' together — redefine one of those properties so that it resolves anything other than the pin (or nothing), the reference is omitted too; a `.mvn/maven.config` user property wins over every profile's. A profile declaration reads its own profile's `` first. The same holds for a pre-v5 single-pom vendored repository, which serves only the original version. **Maven user properties** (`vex_maven_config_override`): Maven resolves a `${property}` `` from `.mvn/maven.config`'s `-D` user properties before the pom's `` (`${project.*}` / `${pom.*}` always come from the model). A pin the pom spells through a property that `.mvn/maven.config` redefines resolves that other version, so the reference is omitted. A Socket version that only a `-D` user property supplies is not `pom.xml` wiring (hosted scan never writes one, and `rollback` / `remove` never edit `.mvn/maven.config`): it is no reference at all, only a diagnostic. +* **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`. Six 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 classifier copy other than `sources` / `javadoc` (`tests`, a native or JDK-specific build), live or inside ``, resolves any version other than the suffixed pin — its own literal ``, a `` entry with the same classifier (a profile's overriding the live one), or no version in the root pom — the pin is omitted. A pre-v5 single-pom vendored repository holds only the main jar, so any such copy omits its reference. `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. **Maven profile declarations** (`vex_maven_profile_unpatched`): a `` declaration of a pinned GA's main jar in the root `pom.xml` at any version other than the pin (the base release, another release, a `${property}` the pom does not resolve) overrides the pin whenever that profile is active, so the reference is omitted. A profile `` entry always counts; a profile `` entry counts only when management governs the pin (no direct `` outside ``), since a direct literal version beats management. A declaration without a `` takes the live pin, and a `pom` (or `java-source` / `javadoc`) typed one carries no code; neither withholds the statement. Activation is not evaluated: every profile counts, as in vendored mode, which rewrites profile declarations too; hosted scan leaves them as written (`redirect_maven_profile_dependency_unpatched`). A pin spelled through a property counts the same way: a `${property}` `` is interpolated through every property it names, the project's own `${project.version}` included (itself often spelled through `${revision}`), at most 16 levels deep (a cycle or an undefined name never resolves to a pin), and when any profile's `` — or any combination of profiles' together, each nested reference read with that combination active, as the vendored planner weighs them too — redefine one of those properties so that it resolves anything other than the pin (or nothing), the reference is omitted too; a `.mvn/maven.config` user property wins over every profile's. A profile declaration reads its own profile's `` first. When more than 8 profiles may change such a version, or a profile's `` cannot be parsed, the reference is omitted (fail closed); the rest of the pom is still read. The statement names the profiles that move the pin and the version they move it to. The same holds for a pre-v5 single-pom vendored repository, which serves only the original version. **Maven user properties** (`vex_maven_config_override`): Maven resolves a `${property}` `` from `.mvn/maven.config`'s `-D` user properties before the pom's `` (`${project.*}` / `${pom.*}` always come from the model). A pin the pom spells through a property that `.mvn/maven.config` redefines resolves that other version, so the reference is omitted. A Socket version that only a `-D` user property supplies is not `pom.xml` wiring (hosted scan never writes one, and `rollback` / `remove` never edit `.mvn/maven.config`): it is no reference at all, only a diagnostic. * **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). diff --git a/crates/socket-patch-core/src/formats/maven/mod.rs b/crates/socket-patch-core/src/formats/maven/mod.rs index 6c8f99209..7e5387eae 100644 --- a/crates/socket-patch-core/src/formats/maven/mod.rs +++ b/crates/socket-patch-core/src/formats/maven/mod.rs @@ -2,7 +2,15 @@ //! [`parse_pom`]), the Maven coordinate grammars and the hosted //! `-socket.` version split. Pure: text in, structure out. -use std::collections::{BTreeMap, BTreeSet}; +mod properties; + +use std::collections::BTreeMap; + +use properties::{interpolate_lenient, read_properties, Interpolate}; +pub(crate) use properties::{ + interpolate_strict, is_model_reference, lookup_property, profile_combinations, Definitions, + PomLayer, MAX_COMBINED_PROFILES, MAX_INTERPOLATION_DEPTH, +}; use super::xml::{blank_non_markup, child_text, elements, open_tags, Element}; @@ -56,12 +64,12 @@ pub(crate) struct PomDep { pub(crate) build_version: Option, /// The trimmed `` text as written. pub(crate) version_text: Option, - /// The other versions [`Self::build_version`] becomes when some set of - /// profiles is active whose `` redefine a property it - /// names (transitively): a superset ([`interpolate_each`]), plus the - /// text as written when some such set leaves it unresolved. Empty when - /// no profile can move it. - pub(crate) version_alternatives: Vec, + /// The other versions [`Self::build_version`] becomes when some + /// combination of profiles is active whose `` redefine a + /// property it names, directly, through another, or through the + /// project model ([`PomScope::resolve_in_build`]); the smallest + /// combination that moves it first. Empty when no profile can move it. + pub(crate) version_alternatives: Vec, /// The non-empty ``: a sibling artifact of the GA (sources, /// tests, a native build), managed and resolved apart from the main jar. pub(crate) classifier: Option, @@ -72,9 +80,57 @@ pub(crate) struct PomDep { pub(crate) in_profile: bool, } -/// One `` inside `` (`name -> trimmed value`), with -/// the `` element that holds it when the scan finds one. -type ProfileProperties = (Option, BTreeMap); +/// A value some combination of active profiles gives a `${…}` text other +/// than its resolution with none active, and why. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Alternative { + /// The value, or the text as written when that combination leaves it + /// unresolved (undefined, cyclic, too deep) or it could not be worked + /// out. + pub(crate) value: String, + pub(crate) by: MovedBy, +} + +/// What gives an [`Alternative`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum MovedBy { + /// These profiles (their ``s) active together. + Profiles(Vec), + /// More profiles may change it than are combined + /// ([`MAX_COMBINED_PROFILES`]): not worked out, fail closed. + TooManyProfiles, + /// A `` `` this reader cannot parse: fail closed. + Unreadable(String), +} + +impl std::fmt::Display for MovedBy { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MovedBy::Profiles(ids) => { + let s = if ids.len() == 1 { "" } else { "s" }; + write!(f, "profile{s} {}", ids.join(" + ")) + } + MovedBy::TooManyProfiles => write!( + f, + "some combination of the more than {MAX_COMBINED_PROFILES} profiles that redefine \ + what it names" + ), + MovedBy::Unreadable(e) => { + write!(f, "a section this reader cannot parse ({e})") + } + } + } +} + +/// One `` inside ``: the `` element that +/// holds it (when the scan finds one), that profile's ``, and its +/// `name -> trimmed value` pairs. +#[derive(Debug)] +struct ProfileProperties { + holder: Option, + id: String, + props: BTreeMap, +} /// Where Maven reads a root pom's model from: its text with comments and /// CDATA blanked (offsets kept), and the sections that are not the @@ -91,20 +147,21 @@ pub(crate) struct PomScope { profiles: Vec, /// The root `` (see [`properties`]). properties: BTreeMap, - /// The build's user properties, except `project.*` / `pom.*`, which - /// Maven reads from the model before any user property. + /// The build's user properties (`.mvn/maven.config`'s `-D`). user: BTreeMap, - /// The `${project.*}` model references (see [`model_refs`]). + /// The project model references, raw (see [`model_refs`]). model: BTreeMap, - /// [`Self::properties`] plus the user properties and model references - /// a coordinate may name. - coordinate_refs: BTreeMap, - /// Each `` inside ``, with the `` that - /// holds it (when the scan finds one): merged over the root's while - /// that profile is active. + /// Each `` inside ``, once [`Self::read_profiles`] + /// has read them: merged over the root's while that profile is active. profile_properties: Vec, + /// Why [`Self::read_profiles`] could not read them (every `${…}` then + /// fails closed in [`Self::resolve_in_build`]). + profiles_unreadable: Option, } +/// No user properties: the view of a version as the pom spells it. +static NO_USER_PROPERTIES: BTreeMap = BTreeMap::new(); + impl PomScope { /// Fails (fail-closed) on a pom that is not well-formed where it /// matters: no ``, or an unterminated element or CDATA. @@ -117,7 +174,8 @@ impl PomScope { /// (`.mvn/maven.config`'s `-D`, see [`maven_config_properties`]): they /// override a same-named pom property when a coordinate or a version is /// interpolated, as Maven's model interpolator looks them up before the - /// model's own. + /// pom's own. Profile `` are not read (see + /// [`Self::read_profiles`]). pub(crate) fn with_user_properties( raw: &str, user: &BTreeMap, @@ -135,123 +193,168 @@ impl PomScope { .concat(); let profiles = elements(&masked, "profiles")?; let properties = properties(&masked, &ignored, &profiles)?; - let mut coordinate_refs = properties.clone(); - coordinate_refs.extend(user.iter().map(|(k, v)| (k.clone(), v.clone()))); - let user: BTreeMap = user - .iter() - .filter(|(k, _)| !is_model_reference(k)) - .map(|(k, v)| (k.clone(), v.clone())) - .collect(); - let model = model_refs(&masked, &coordinate_refs)?; - // A `project.*` / `pom.*` reference names the model, whatever a - // same-named property says; the deprecated bare form yields to one. - for (name, value) in &model { - if is_model_reference(name) { - coordinate_refs.insert(name.clone(), value.clone()); - } else { - coordinate_refs.entry(name.clone()).or_insert(value.clone()); - } - } - let profile_properties = profile_properties(&masked, &ignored, &profiles)?; + let model = model_refs(&masked)?; Ok(PomScope { masked, ignored, profiles, properties, - user, + user: user.clone(), model, - coordinate_refs, - profile_properties, + profile_properties: Vec::new(), + profiles_unreadable: None, }) } + /// Read every `` inside ``, which only + /// [`Self::resolve_in_build`] weighs. One this reader cannot parse + /// does not fail the pom: it is recorded, and every `${…}` resolution + /// then fails closed ([`MovedBy::Unreadable`]). + pub(crate) fn read_profiles(&mut self) { + match profile_properties(&self.masked, &self.ignored, &self.profiles) { + Ok(found) => self.profile_properties = found, + Err(e) => self.profiles_unreadable = Some(e), + } + } + /// The indices into [`Self::profile_properties`] of the profile that /// holds `pos` (none outside one). fn own_profile(&self, pos: usize) -> Vec { (0..self.profile_properties.len()) .filter(|&i| { self.profile_properties[i] - .0 + .holder .is_some_and(|p| pos >= p.start && pos < p.end) }) .collect() } - /// Whether no profile's `` can change what `${name}` - /// reads: a model reference, or (in the build's view) a user property, - /// both of which Maven looks up first. - fn fixed(&self, name: &str, build: bool) -> bool { - is_model_reference(name) || (build && self.user.contains_key(name)) + /// A model reference's raw text (an empty element binds nothing). + fn model_ref(&self, name: &str) -> Option { + self.model.get(name).filter(|v| !v.is_empty()).cloned() } - /// What `${name}` reads while only the profile `own` is active (if - /// any): a model reference, else (in the build's view) a user - /// property, else that profile's ``, else the root's, else - /// a deprecated bare model reference (`${version}`). - fn lookup(&self, name: &str, own: &[usize], build: bool) -> Option { - if is_model_reference(name) { - if let Some(v) = self.model.get(name) { - return Some(v.clone()); - } - } - if build { - if let Some(v) = self.user.get(name) { - return Some(v.clone()); - } - } - if !self.fixed(name, build) { - if let Some(v) = own + /// What `${name}` reads ([`lookup_property`]) with the profiles `active` + /// (indices, in pom order) active, with or without the build's user + /// properties. + fn lookup(&self, name: &str, active: &[usize], user: bool) -> Option { + let layer = PomLayer { + own: &self.properties, + profiles: active .iter() - .find_map(|&i| self.profile_properties[i].1.get(name)) - { - return Some(v.clone()); - } - } - self.properties - .get(name) - .or_else(|| self.model.get(name)) - .cloned() + .map(|&i| &self.profile_properties[i].props) + .collect(), + }; + let user = if user { + &self.user + } else { + &NO_USER_PROPERTIES + }; + lookup_property(name, &|n| self.model_ref(n), user, [layer]) } - /// Every value `${name}` may read in the build while `own` and any - /// other profiles are active: [`Self::lookup`]'s, then every other - /// profile's definition. Empty when [`Self::lookup`] finds none: some - /// set of profiles leaves it undefined. - fn lookup_any(&self, name: &str, own: &[usize]) -> Vec { - let Some(base) = self.lookup(name, own, true) else { - return Vec::new(); - }; - let mut values = vec![base]; - if !self.fixed(name, true) { - values.extend( - self.profile_properties - .iter() - .enumerate() - .filter(|(i, _)| !own.contains(i)) - .filter_map(|(_, (_, props))| props.get(name).cloned()), - ); + /// Where `${name}` may be defined in the build, for + /// [`profile_combinations`]: the model or a user property fixes it; + /// otherwise the root's, every profile's (each but `own`, which is + /// always active, may be the one read) and a bare model reference. + fn definitions(&self, name: &str, own: &[usize]) -> Definitions { + if let Some(v) = is_model_reference(name) + .then(|| self.model_ref(name)) + .flatten() + .or_else(|| self.user.get(name).cloned()) + { + return Definitions { + texts: vec![v], + profiles: Vec::new(), + }; + } + let mut d = Definitions::default(); + d.texts.extend(self.properties.get(name).cloned()); + d.texts.extend(self.model_ref(name)); + for (i, p) in self.profile_properties.iter().enumerate() { + if let Some(v) = p.props.get(name) { + d.texts.push(v.clone()); + if !own.contains(&i) { + d.profiles.push(i); + } + } } - values + d } /// The `` text of the declaration at `pos`, resolved as /// [`PomDep::version`], [`PomDep::build_version`] and /// [`PomDep::version_alternatives`] describe. - fn resolve_version(&self, text: &str, pos: usize) -> (String, String, Vec) { + fn resolve_version(&self, text: &str, pos: usize) -> (String, String, Vec) { if !text.contains("${") { return (text.to_string(), text.to_string(), Vec::new()); } let own = self.own_profile(pos); let spelled = interpolate_strict(text, &|name| self.lookup(name, &own, false)) .unwrap_or_else(|| text.to_string()); - let Some(build) = interpolate_strict(text, &|name| self.lookup(name, &own, true)) else { - return (spelled, text.to_string(), Vec::new()); + match self.resolve_in_build(text, pos, interpolate_strict) { + (Some(build), alternatives) => (spelled, build, alternatives), + (None, _) => (spelled, text.to_string(), Vec::new()), + } + } + + /// What `text` (written in the declaration at `pos`) resolves to in the + /// build with no profile active but the one holding it, through + /// `interpolate` ([`interpolate_strict`] for a version), and each other + /// value a combination of active profiles gives it + /// ([`profile_combinations`], the vendored reactor's too), the + /// smallest combination first; a combination `interpolate` cannot + /// resolve gives the text as written. Fails closed: when the + /// combinations cannot be enumerated or a profile's `` is + /// unreadable, the text as written is an alternative. + pub(crate) fn resolve_in_build( + &self, + text: &str, + pos: usize, + interpolate: Interpolate, + ) -> (Option, Vec) { + let own = self.own_profile(pos); + let base = interpolate(text, &|name| self.lookup(name, &own, true)); + if !text.contains("${") { + return (base, Vec::new()); + } + let as_written = |by: MovedBy| Alternative { + value: text.to_string(), + by, }; - let alternatives = match interpolate_each(text, &|name| self.lookup_any(name, &own)) { - Some(all) => all.into_iter().filter(|v| *v != build).collect(), - None => vec![text.to_string()], + if let Some(e) = &self.profiles_unreadable { + return (base, vec![as_written(MovedBy::Unreadable(e.clone()))]); + } + let Some(combinations) = profile_combinations(text, &|name| self.definitions(name, &own)) + else { + return (base, vec![as_written(MovedBy::TooManyProfiles)]); }; - (spelled, build, alternatives) + let mut alternatives: Vec = Vec::new(); + for combination in combinations { + let mut active: Vec = own.iter().chain(&combination).copied().collect(); + active.sort_unstable(); + active.dedup(); + let value = interpolate(text, &|name| self.lookup(name, &active, true)) + .unwrap_or_else(|| text.to_string()); + if Some(&value) != base.as_ref() && alternatives.iter().all(|a| a.value != value) { + let ids = combination + .iter() + .map(|&i| self.profile_properties[i].id.clone()) + .collect(); + alternatives.push(Alternative { + value, + by: MovedBy::Profiles(ids), + }); + } + } + (base, alternatives) + } + + /// `text` (a coordinate) with every `${name}` the build defines with + /// no profile active interpolated, an undefined one kept as written + /// ([`interpolate_lenient`]). + fn interpolate_coordinate(&self, text: &str) -> String { + interpolate_lenient(text, &|name| self.lookup(name, &[], true)) } /// Inside a plugin / deploy section (never project resolution). @@ -297,10 +400,11 @@ fn inside(pos: usize, set: &[Element]) -> bool { #[derive(Debug)] pub(crate) struct ScopedDep { pub(crate) element: Element, - /// `` / `` with every `${prop}` the root - /// `` or the project's own coordinates define - /// interpolated (see [`interpolate`]); an - /// undefined one stays as written, so [`may_name`] can tell it apart. + /// `` / `` with every `${prop}` the build defines + /// with no profile active (user properties, the project model, the + /// root ``) interpolated + /// ([`PomScope::interpolate_coordinate`]); an undefined one stays as + /// written, so [`may_name`] can tell it apart. pub(crate) group: Option, pub(crate) artifact: Option, /// Inner-text byte range of the literal ``, when present. @@ -323,10 +427,8 @@ impl PomScope { let version = elements(&body, "version")?.into_iter().next(); out.push(ScopedDep { element: dep, - group: child_text(&body, "groupId")? - .map(|g| interpolate(&g, &self.coordinate_refs)), - artifact: child_text(&body, "artifactId")? - .map(|a| interpolate(&a, &self.coordinate_refs)), + group: child_text(&body, "groupId")?.map(|g| self.interpolate_coordinate(&g)), + artifact: child_text(&body, "artifactId")?.map(|a| self.interpolate_coordinate(&a)), version_inner: version.map(|v| { ( dep.inner_start + v.inner_start, @@ -355,7 +457,8 @@ pub(crate) fn parse_pom(raw: &str) -> Result { /// [`parse_pom`] with the build's user properties (see /// [`PomScope::with_user_properties`]). pub(crate) fn parse_pom_with(raw: &str, user: &BTreeMap) -> Result { - let scope = PomScope::with_user_properties(raw, user)?; + let mut scope = PomScope::with_user_properties(raw, user)?; + scope.read_profiles(); let text = &scope.masked; let managed = elements(text, "dependencyManagement")?; let mut pom = Pom::default(); @@ -444,218 +547,32 @@ fn profile_properties( .iter() .find(|p| props.start >= p.start && props.start < p.end) .copied(); + let id = holder + .and_then(|p| child_text(p.inner(text), "id").ok().flatten()) + .filter(|id| !id.is_empty()) + .unwrap_or_else(|| format!("#{}", out.len() + 1)); let mut map = BTreeMap::new(); read_properties(props.inner(text), &mut map)?; - out.push((holder, map)); + out.push(ProfileProperties { + holder, + id, + props: map, + }); } Ok(out) } -/// The `name -> trimmed value` pairs of a `` body, into `map` -/// (a name's first definition wins). -fn read_properties(body: &str, map: &mut BTreeMap) -> Result<(), String> { - let mut from = 0; - while let Some(rel) = body[from..].find('<') { - let lt = from + rel; - let Some(gt) = body[lt..].find('>').map(|r| lt + r) else { - return Err("unterminated tag in ".to_string()); - }; - let name = &body[lt + 1..gt]; - if !is_property_name(name) { - from = gt + 1; - continue; - } - let close = format!(""); - let Some(end) = body[gt + 1..].find(&close).map(|r| gt + 1 + r) else { - return Err(format!("unterminated <{name}> in ")); - }; - map.entry(name.to_string()) - .or_insert_with(|| body[gt + 1..end].trim().to_string()); - from = end + close.len(); - } - Ok(()) -} - -/// Nesting depth past which [`interpolate_strict`] gives up: a property -/// named through more than this many others (Maven itself fails on a -/// cycle, which this bounds too). -pub(crate) const MAX_INTERPOLATION_DEPTH: usize = 16; - -/// Most distinct results [`interpolate_each`] enumerates before giving up. -const MAX_RESOLUTIONS: usize = 16; - -/// Most lookups one interpolation makes before giving up: a value naming -/// the next property many times over would otherwise cost exponentially -/// many with every level ("billion laughs"). -const MAX_LOOKUPS: usize = 1024; - -/// Longest text [`interpolate_each`] builds before giving up. -const MAX_RESOLVED_LEN: usize = 4096; - -/// `value` with every `${name}` replaced by `lookup(name)`, itself -/// interpolated the same way, as Maven's model interpolator resolves a -/// property defined through another. `None` when a name is undefined, a -/// `${` is unterminated, or the nesting is cyclic or deeper than -/// [`MAX_INTERPOLATION_DEPTH`] (or the work bounds are hit): an unresolved -/// value never stands for a version. Shared by the vendored reactor -/// (its `lookup` walks the parent chain) and the VEX reader -/// ([`PomScope`]). -pub(crate) fn interpolate_strict( - value: &str, - lookup: &dyn Fn(&str) -> Option, -) -> Option { - interpolate_each(value, &|name| lookup(name).into_iter().collect())? - .into_iter() - .next() -} - -/// Every text `value` may interpolate to when each `${name}` may take any -/// of the values `lookup(name)` gives (an empty list: undefined), each -/// interpolated in turn ([`interpolate_strict`] with a single value per -/// name). A name that occurs twice may take a different value each time, -/// so the set may hold texts no single assignment builds: a superset, -/// which only ever withholds an attestation. `None` when any choice leaves -/// a name undefined, a `${` is unterminated, a name is reached through -/// itself (a cycle) or through more than [`MAX_INTERPOLATION_DEPTH`] -/// others, or the result or the work exceeds its bound. -pub(crate) fn interpolate_each( - value: &str, - lookup: &dyn Fn(&str) -> Vec, -) -> Option> { - let lookups = std::cell::Cell::new(MAX_LOOKUPS); - expand(value, lookup, &mut Vec::new(), &lookups) -} - -fn expand( - value: &str, - lookup: &dyn Fn(&str) -> Vec, - stack: &mut Vec, - lookups: &std::cell::Cell, -) -> Option> { - if stack.len() > MAX_INTERPOLATION_DEPTH { - return None; - } - let mut texts = BTreeSet::from([String::new()]); - let mut rest = value; - loop { - let (literal, reference) = match rest.find("${") { - Some(at) => (&rest[..at], Some(&rest[at + 2..])), - None => (rest, None), - }; - let Some(after) = reference else { - return concat(&texts, &BTreeSet::from([literal.to_string()])); - }; - let close = after.find('}')?; - let name = &after[..close]; - if stack.iter().any(|n| n == name) { - return None; - } - lookups.set(lookups.get().checked_sub(1)?); - let raws = lookup(name); - if raws.is_empty() { - return None; - } - stack.push(name.to_string()); - let mut values = BTreeSet::new(); - for raw in &raws { - values.extend(expand(raw, lookup, stack, lookups)?); - } - stack.pop(); - let prefixed = concat(&texts, &BTreeSet::from([literal.to_string()]))?; - texts = concat(&prefixed, &values)?; - rest = &after[close + 1..]; - } -} - -/// Every `a + b`, or `None` past [`MAX_RESOLUTIONS`] / [`MAX_RESOLVED_LEN`]. -fn concat(heads: &BTreeSet, tails: &BTreeSet) -> Option> { - let mut out = BTreeSet::new(); - for head in heads { - for tail in tails { - if head.len() + tail.len() > MAX_RESOLVED_LEN { - return None; - } - out.insert(format!("{head}{tail}")); - if out.len() > MAX_RESOLUTIONS { - return None; - } - } - } - Some(out) -} - -/// Interpolation rounds before a still-unresolved reference is given up on -/// (a property defined through another, or a cycle). -const MAX_INTERPOLATION: usize = 8; - -/// Longest text interpolation builds. A real coordinate is far shorter; a -/// pom whose properties each name the previous one many times would -/// otherwise grow exponentially with every round ("billion laughs"). -const MAX_INTERPOLATED_LEN: usize = 1024; - -/// `text` with every `${name}` the root `` or project model -/// defines replaced by its value, as Maven interpolates a coordinate such as -/// `org.${vendor}` (a value naming another property is resolved too, up to -/// [`MAX_INTERPOLATION`] rounds); an undefined or malformed reference is -/// kept as written, and a round that would exceed [`MAX_INTERPOLATED_LEN`] -/// is not taken, leaving its references unresolved (which fails closed). -fn interpolate(text: &str, properties: &BTreeMap) -> String { - let mut text = text.to_string(); - for _ in 0..MAX_INTERPOLATION { - let Some(next) = interpolate_once(&text, properties) else { - break; - }; - if next == text { - break; - } - text = next; - } - text -} - -/// One round of [`interpolate`], or `None` once the result would exceed -/// [`MAX_INTERPOLATED_LEN`]. -fn interpolate_once(text: &str, properties: &BTreeMap) -> Option { - let mut out = String::with_capacity(text.len()); - let mut rest = text; - while let Some(open) = rest.find("${") { - if out.len() > MAX_INTERPOLATED_LEN { - return None; - } - out.push_str(&rest[..open]); - let after = &rest[open + 2..]; - match after - .find('}') - .map(|close| (&after[..close], close)) - .filter(|(name, _)| is_property_name(name)) - .and_then(|(name, close)| properties.get(name).map(|v| (v, close))) - { - Some((value, close)) => { - out.push_str(value); - rest = &after[close + 1..]; - } - None => { - out.push_str("${"); - rest = after; - } - } - } - out.push_str(rest); - (out.len() <= MAX_INTERPOLATED_LEN).then_some(out) -} - /// The model references a sibling declaration commonly uses /// (`${project.groupId}`, `${pom.version}`, the deprecated bare /// `${groupId}`, `${project.parent.groupId}`), read from the project's own /// `` / `` / `` and its ``'s; groupId /// and version fall back to the parent's, which the project inherits when -/// it omits them. A coordinate written through a property is resolved from -/// `properties` (never inherited instead: Maven interpolates a present -/// element), and one that stays unresolved is not bound. -fn model_refs( - masked: &str, - properties: &BTreeMap, -) -> Result, String> { +/// it omits them. Each is bound raw: one written through a property +/// (`${revision}`) is interpolated wherever it is read, +/// with the user properties and active profiles of that read, as Maven +/// interpolates the model itself (never inherited instead: Maven +/// interpolates a present element). +fn model_refs(masked: &str) -> Result, String> { let Some(project) = elements(masked, "project")?.into_iter().next() else { return Ok(BTreeMap::new()); }; @@ -680,15 +597,11 @@ fn model_refs( ] { own = blank_elements(&own, section)?; } - let usable = |v: Option| { - v.map(|v| interpolate(&v, properties)) - .filter(|v| !v.is_empty() && !v.contains("${")) - }; let mut refs = BTreeMap::new(); for (field, inherited) in [("groupId", true), ("artifactId", false), ("version", true)] { - let parent_value = usable(parent_text(field)?); + let parent_value = parent_text(field)?; let value = match child_text(&own, field)? { - Some(v) => usable(Some(v)), + Some(v) => Some(v), None if inherited => parent_value.clone(), None => None, }; @@ -837,12 +750,6 @@ pub(crate) fn is_executable_classifier(classifier: &str) -> bool { !NON_EXECUTABLE_CLASSIFIERS.contains(&classifier) } -/// A `project.*` / `pom.*` reference: Maven reads it from the model, ahead -/// of any user or pom property of the same name. -fn is_model_reference(name: &str) -> bool { - name.starts_with("project.") || name.starts_with("pom.") -} - fn is_property_name(name: &str) -> bool { !name.is_empty() && name @@ -876,6 +783,8 @@ fn blank_elements(text: &str, name: &str) -> Result { #[cfg(test)] mod tests { + use std::collections::BTreeMap; + use super::PomScope; #[test] @@ -939,7 +848,7 @@ mod tests { let deps = PomScope::new(&pom).unwrap().dependencies().unwrap(); let group = deps[0].group.as_deref().unwrap(); assert!( - group.len() <= super::MAX_INTERPOLATED_LEN, + group.len() <= super::properties::MAX_RESOLVED_LEN, "{}", group.len() ); @@ -1022,7 +931,12 @@ mod tests { let scope = PomScope::new(pom).unwrap(); let deps = scope.dependencies().unwrap(); assert_eq!(deps[0].group.as_deref(), Some("com.acme")); - assert!(!scope.coordinate_refs.contains_key("project.version")); + let dep = super::parse_pom(pom).unwrap().deps.remove(0); + assert_eq!( + dep.build_version.as_deref(), + Some("${project.version}"), + "an unresolvable project version is not the parent's" + ); } /// Maven's model interpolator reads user properties (`.mvn/maven.config`'s @@ -1166,6 +1080,9 @@ mod tests { /// properties and model references no profile can redefine. #[test] fn profile_properties_give_version_alternatives() { + fn values(alternatives: Vec) -> Vec { + alternatives.into_iter().map(|a| a.value).collect() + } let resolve = |props: &str, profiles: &[&str], user: &[(&str, &str)], deps: &str| { let profiles: String = profiles .iter() @@ -1183,7 +1100,7 @@ mod tests { .unwrap() .deps .into_iter() - .map(|d| (d.build_version.unwrap(), d.version_alternatives)) + .map(|d| (d.build_version.unwrap(), values(d.version_alternatives))) .collect::>() }; let dep = |v: &str| { @@ -1258,13 +1175,58 @@ mod tests { ) }) .collect(); + let moved = |value: &str, ids: &[&str]| super::Alternative { + value: value.to_string(), + by: super::MovedBy::Profiles(s(ids)), + }; assert_eq!( deps, [ - (false, "1".to_string(), s(&["2", "3"])), - (true, "2".to_string(), s(&["3"])), + ( + false, + "1".to_string(), + vec![moved("2", &["a"]), moved("3", &["b"])] + ), + (true, "2".to_string(), vec![moved("3", &["b"])]), ] ); + + // `${project.version}` spelled through a property moves with it. + let pom = format!( + "${{revision}}1\ + {}ci\ + 2", + dep("${project.version}") + ); + let user = [("revision".to_string(), "3".to_string())].into(); + for (user, built, alternatives) in [ + (BTreeMap::new(), "1", vec![moved("2", &["ci"])]), + (user, "3", vec![]), + ] { + let d = super::parse_pom_with(&pom, &user).unwrap().deps.remove(0); + assert_eq!(d.version.as_deref(), Some("1")); + assert_eq!(d.build_version.as_deref(), Some(built)); + assert_eq!(d.version_alternatives, alternatives); + } + + // An unreadable profile `` fails a `${…}` closed, and + // only for the reader that weighs profiles. + let pom = format!( + "1{}{}\ + 2", + dep("${v}"), + dep("1") + ); + assert!(PomScope::new(&pom).unwrap().dependencies().is_ok()); + let deps = super::parse_pom(&pom).unwrap().deps; + assert!(matches!( + deps[0].version_alternatives[..], + [super::Alternative { + by: super::MovedBy::Unreadable(_), + .. + }] + )); + assert!(deps[1].version_alternatives.is_empty()); } /// The shared interpolator's work bounds: a value naming the next diff --git a/crates/socket-patch-core/src/formats/maven/properties.rs b/crates/socket-patch-core/src/formats/maven/properties.rs new file mode 100644 index 000000000..f28cd8ebc --- /dev/null +++ b/crates/socket-patch-core/src/formats/maven/properties.rs @@ -0,0 +1,385 @@ +//! Maven property interpolation, shared by VEX discovery ([`super::PomScope`]) +//! and the vendored reactor planner, so both read a `${…}` the same way: +//! Maven's lookup order ([`lookup_property`]), one bounded interpolator +//! ([`interpolate_strict`] / [`interpolate_lenient`]), and the profile +//! combinations that may change a value ([`profile_combinations`]). Pure. + +use std::collections::{BTreeMap, BTreeSet}; + +/// Nesting depth past which interpolation gives up: a property named +/// through more than this many others (Maven itself fails on a cycle, +/// which this bounds too). +pub(crate) const MAX_INTERPOLATION_DEPTH: usize = 16; + +/// Most lookups one interpolation makes before giving up: a value naming +/// the next property many times over would otherwise cost exponentially +/// many with every level ("billion laughs"). +const MAX_LOOKUPS: usize = 1024; + +/// Longest text one interpolation builds before giving up. +pub(crate) const MAX_RESOLVED_LEN: usize = 4096; + +/// Most profiles whose every combination [`profile_combinations`] lists +/// (2^8 - 1 combinations); a value more profiles may change is not +/// enumerated (the caller fails closed). +pub(crate) const MAX_COMBINED_PROFILES: usize = 8; + +/// A `project.*` / `pom.*` reference: Maven reads it from the model, ahead +/// of any user or pom property of the same name. +pub(crate) fn is_model_reference(name: &str) -> bool { + name.starts_with("project.") || name.starts_with("pom.") +} + +/// One pom's `` as Maven reads them with `profiles` active: +/// each active profile's merged over the pom's own, a later one (in pom +/// order) winning. +pub(crate) struct PomLayer<'a> { + pub(crate) own: &'a BTreeMap, + pub(crate) profiles: Vec<&'a BTreeMap>, +} + +impl PomLayer<'_> { + fn get(&self, name: &str) -> Option<&String> { + self.profiles + .iter() + .rev() + .find_map(|p| p.get(name)) + .or_else(|| self.own.get(name)) + } +} + +/// What `${name}` reads, in Maven's model interpolator's order: a +/// `project.*` / `pom.*` reference the model (`model`) answers, then the +/// build's user properties (`.mvn/maven.config`'s `-D`), then each pom's +/// properties with its active profiles merged in (`layers`, nearest pom +/// first — a child's beat its parent's), then a deprecated bare model +/// reference (`${version}`) `model` answers. The value is returned raw: +/// [`interpolate_strict`] resolves what it names in turn. +pub(crate) fn lookup_property<'a>( + name: &str, + model: &dyn Fn(&str) -> Option, + user: &BTreeMap, + layers: impl IntoIterator>, +) -> Option { + if is_model_reference(name) { + if let Some(v) = model(name) { + return Some(v); + } + } + if let Some(v) = user.get(name) { + return Some(v.clone()); + } + for layer in layers { + if let Some(v) = layer.get(name) { + return Some(v.clone()); + } + } + if is_model_reference(name) { + None + } else { + model(name) + } +} + +/// An interpolation of a text through a lookup: [`interpolate_strict`], +/// or a keep-unresolved one built on [`interpolate_lenient`]. +pub(crate) type Interpolate = fn(&str, &dyn Fn(&str) -> Option) -> Option; + +/// `value` with every `${name}` replaced by `lookup(name)`, itself +/// interpolated the same way, as Maven's model interpolator resolves a +/// property defined through another. `None` when a name is undefined, a +/// `${` is unterminated, a name is reached through itself (a cycle) or +/// through more than [`MAX_INTERPOLATION_DEPTH`] others, or the work +/// bounds are hit: an unresolved value never stands for a version. +pub(crate) fn interpolate_strict( + value: &str, + lookup: &dyn Fn(&str) -> Option, +) -> Option { + let lookups = std::cell::Cell::new(MAX_LOOKUPS); + expand(value, lookup, &mut Vec::new(), &lookups, false) +} + +/// [`interpolate_strict`] for a coordinate, where an unresolved reference +/// must stay visible: an undefined, cyclic or too deeply nested `${name}` +/// (or an unterminated `${`) is kept as written, so +/// [`may_name`](super::may_name) can treat it as unknown text; past the +/// work bounds the whole value is returned as written. +pub(crate) fn interpolate_lenient(value: &str, lookup: &dyn Fn(&str) -> Option) -> String { + let lookups = std::cell::Cell::new(MAX_LOOKUPS); + expand(value, lookup, &mut Vec::new(), &lookups, true).unwrap_or_else(|| value.to_string()) +} + +fn expand( + value: &str, + lookup: &dyn Fn(&str) -> Option, + stack: &mut Vec, + lookups: &std::cell::Cell, + keep: bool, +) -> Option { + let mut out = String::new(); + let mut rest = value; + while let Some(at) = rest.find("${") { + out.push_str(&rest[..at]); + let after = &rest[at + 2..]; + let Some(close) = after.find('}') else { + if !keep { + return None; + } + out.push_str(&rest[at..]); + rest = ""; + break; + }; + let name = &after[..close]; + let reference = &rest[at..at + close + 3]; + rest = &after[close + 1..]; + let resolved = if stack.len() >= MAX_INTERPOLATION_DEPTH || stack.iter().any(|n| n == name) + { + None + } else { + lookups.set(lookups.get().checked_sub(1)?); + match lookup(name) { + Some(raw) => { + stack.push(name.to_string()); + let v = expand(&raw, lookup, stack, lookups, keep); + stack.pop(); + Some(v?) + } + None => None, + } + }; + match resolved { + Some(v) => out.push_str(&v), + None if keep => out.push_str(reference), + None => return None, + } + if out.len() > MAX_RESOLVED_LEN { + return None; + } + } + out.push_str(rest); + (out.len() <= MAX_RESOLVED_LEN).then_some(out) +} + +/// The names `text` references (`${name}`), in order. +fn references(text: &str) -> impl Iterator { + let mut rest = text; + std::iter::from_fn(move || { + let at = rest.find("${")?; + let after = &rest[at + 2..]; + let close = after.find('}')?; + rest = &after[close + 1..]; + Some(&after[..close]) + }) +} + +/// Where `${name}` may be defined, for [`profile_combinations`]: every raw +/// text it may read under some set of active profiles (the model's, a +/// user property, a pom's, a profile's), and the profiles whose own +/// definition may be the one read (none when something Maven reads first, +/// a model reference or a user property, fixes it). +#[derive(Default)] +pub(crate) struct Definitions { + pub(crate) texts: Vec, + pub(crate) profiles: Vec, +} + +/// Every nonempty combination of the profiles that may change what +/// `value` interpolates to — those defining a name reachable from it +/// through any definition ([`Definitions`]) — smallest first, each sorted: +/// Maven activates any number of profiles together, so a value two +/// profiles move only jointly is caught. Empty when no profile can change +/// it. `None` when more than [`MAX_COMBINED_PROFILES`] may, or the walk +/// exceeds its bound: the caller fails closed. +pub(crate) fn profile_combinations( + value: &str, + defs: &dyn Fn(&str) -> Definitions, +) -> Option>> { + let mut seen: BTreeSet = BTreeSet::new(); + let mut queue: Vec = references(value).map(str::to_string).collect(); + let mut relevant: BTreeSet = BTreeSet::new(); + while let Some(name) = queue.pop() { + if !seen.insert(name.clone()) { + continue; + } + if seen.len() > MAX_LOOKUPS { + return None; + } + let d = defs(&name); + relevant.extend(d.profiles); + for text in &d.texts { + queue.extend(references(text).map(str::to_string)); + } + } + if relevant.len() > MAX_COMBINED_PROFILES { + return None; + } + let relevant: Vec = relevant.into_iter().collect(); + let mut masks: Vec = (1..(1u32 << relevant.len())).collect(); + masks.sort_by_key(|m| (m.count_ones(), *m)); + Some( + masks + .into_iter() + .map(|m| { + (0..relevant.len()) + .filter(|i| m & (1 << i) != 0) + .map(|i| relevant[i]) + .collect() + }) + .collect(), + ) +} + +/// The `name -> trimmed value` pairs of a `` body, into `map` +/// (a name's first definition wins). An element's name is its tag up to +/// whitespace or `/`, so `` and `` define +/// `v`, and `` defines it empty; a comment-blanked or malformed child +/// is an error (fail closed). +pub(crate) fn read_properties( + body: &str, + map: &mut BTreeMap, +) -> Result<(), String> { + let mut from = 0; + while let Some(rel) = body[from..].find('<') { + let lt = from + rel; + let Some(gt) = body[lt..].find('>').map(|r| lt + r) else { + return Err("unterminated tag in ".to_string()); + }; + let tag = &body[lt + 1..gt]; + let name_end = tag + .find(|c: char| c.is_ascii_whitespace() || c == '/') + .unwrap_or(tag.len()); + let name = &tag[..name_end]; + if !super::is_property_name(name) { + from = gt + 1; + continue; + } + if tag.ends_with('/') { + map.entry(name.to_string()).or_default(); + from = gt + 1; + continue; + } + // ``, with optional whitespace before the `>`. + let close = format!(" in ")); + }; + let tail = &body[at + close.len()..]; + let ws = tail.len() - tail.trim_start().len(); + if tail[ws..].starts_with('>') { + break (at, at + close.len() + ws + 1); + } + search = at + close.len(); + }; + map.entry(name.to_string()) + .or_insert_with(|| body[gt + 1..end].trim().to_string()); + from = after; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn map(pairs: &[(&str, &str)]) -> BTreeMap { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() + } + + #[test] + fn strict_and_lenient_share_one_expansion() { + let props = map(&[("a", "${b}"), ("b", "1"), ("c", "${c}"), ("d", "x${e}")]); + let lookup = |n: &str| props.get(n).cloned(); + assert_eq!(interpolate_strict("v${a}", &lookup).as_deref(), Some("v1")); + assert_eq!(interpolate_strict("${c}", &lookup), None, "cycle"); + assert_eq!(interpolate_strict("${d}", &lookup), None, "undefined"); + assert_eq!(interpolate_strict("${a", &lookup), None, "unterminated"); + assert_eq!(interpolate_lenient("v${a}", &lookup), "v1"); + assert_eq!(interpolate_lenient("${c}-${a}", &lookup), "${c}-1"); + assert_eq!(interpolate_lenient("${d}", &lookup), "x${e}"); + assert_eq!(interpolate_lenient("${a", &lookup), "${a"); + } + + #[test] + fn lookup_follows_mavens_order() { + let model = |n: &str| match n { + "project.version" => Some("9".to_string()), + "version" => Some("bare".to_string()), + _ => None, + }; + let user = map(&[("u", "user"), ("project.version", "no"), ("p", "user")]); + let own = map(&[("p", "pom"), ("q", "pom"), ("version", "prop")]); + let first = map(&[("q", "first"), ("r", "first")]); + let second = map(&[("r", "second")]); + let layer = || PomLayer { + own: &own, + profiles: vec![&first, &second], + }; + let get = |n: &str| lookup_property(n, &model, &user, [layer()]); + assert_eq!(get("project.version").as_deref(), Some("9")); + assert_eq!(get("p").as_deref(), Some("user")); + assert_eq!(get("q").as_deref(), Some("first")); + assert_eq!(get("r").as_deref(), Some("second"), "a later profile wins"); + assert_eq!(get("version").as_deref(), Some("prop")); + let empty = BTreeMap::new(); + let bare = lookup_property("version", &model, &empty, []); + assert_eq!(bare.as_deref(), Some("bare")); + assert_eq!(get("project.other"), None); + } + + #[test] + fn combinations_cover_the_relevant_profiles_only() { + // v -> w; profile 0 defines v, profile 1 w, profile 2 only x. + let defs = |n: &str| match n { + "v" => Definitions { + texts: vec!["${w}".into(), "1".into()], + profiles: vec![0], + }, + "w" => Definitions { + texts: vec!["1".into()], + profiles: vec![1], + }, + _ => Definitions { + texts: vec![], + profiles: vec![2], + }, + }; + assert_eq!( + profile_combinations("${v}", &defs), + Some(vec![vec![0], vec![1], vec![0, 1]]) + ); + assert_eq!(profile_combinations("1.0", &defs), Some(vec![])); + let many = |_: &str| Definitions { + texts: vec![], + profiles: (0..=MAX_COMBINED_PROFILES).collect(), + }; + assert_eq!(profile_combinations("${v}", &many), None); + } + + #[test] + fn properties_read_attributed_and_spaced_tags() { + let mut m = BTreeMap::new(); + read_properties( + "123\ + 45", + &mut m, + ) + .unwrap(); + assert_eq!( + m, + map(&[ + ("a", "1"), + ("b", "2"), + ("c", "3"), + ("d", ""), + ("e", ""), + ("f", "4") + ]) + ); + assert!(read_properties("1", &mut BTreeMap::new()).is_err()); + } +} diff --git a/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs b/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs index 8b8e9b6cc..0b9239a69 100644 --- a/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs +++ b/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs @@ -12,7 +12,9 @@ use std::ops::Range; use serde_json::{json, Value}; use crate::formats::maven::{ - interpolate_strict, maven_config_properties as cli_properties, MAX_INTERPOLATION_DEPTH, + interpolate_strict, is_model_reference, lookup_property, + maven_config_properties as cli_properties, profile_combinations, Definitions, PomLayer, + MAX_COMBINED_PROFILES, MAX_INTERPOLATION_DEPTH, }; use super::super::state::{WiringAction, WiringRecord}; @@ -1710,68 +1712,64 @@ impl Reactor { /// Interpolate every `${name}` in `value` in the context of `rel`; /// `None` when any name is undefined in the checkout. fn interpolate(&self, rel: &str, value: &str) -> Option { - self.interpolate_with(rel, value, None) + self.interpolate_with(rel, value, &[]) } - /// [`Self::interpolate`] with `overlay`'s properties (a profile's) - /// layered over its pom's own, as when that profile is active. - fn interpolate_with( - &self, - rel: &str, - value: &str, - overlay: Option>, - ) -> Option { - interpolate_strict(value, &|name| self.lookup_with(rel, name, overlay)) + /// [`Self::interpolate`] with the `active` profiles' properties layered + /// over their poms' own, as when those profiles are active. + fn interpolate_with(&self, rel: &str, value: &str, active: &[Overlay<'_>]) -> Option { + interpolate_strict(value, &|name| self.lookup_with(rel, name, active)) } - /// User properties win over model properties, as in Maven; then the - /// model's own version expressions, then `` up the local - /// chain. + /// Maven's lookup order ([`lookup_property`], shared with VEX + /// discovery): the model's own coordinates, user properties, then + /// `` up the local chain with the `active` profiles merged + /// over their poms', then the deprecated bare `${version}`. fn lookup(&self, rel: &str, name: &str) -> Option { - self.lookup_with(rel, name, None) + self.lookup_with(rel, name, &[]) + } + + fn lookup_with(&self, rel: &str, name: &str, active: &[Overlay<'_>]) -> Option { + let layers = self.chain(rel).map(|p| PomLayer { + own: &self.poms[p].props, + profiles: active + .iter() + .filter(|(at, _)| *at == p) + .map(|(_, props)| *props) + .collect(), + }); + lookup_property(name, &|n| self.model_ref(rel, n), &self.cli_props, layers) } - fn lookup_with(&self, rel: &str, name: &str, overlay: Option>) -> Option { + /// The project model reference `name` of `rel` (`project.version`, + /// `project.groupId`, ...; the deprecated bare `version`), raw. + fn model_ref(&self, rel: &str, name: &str) -> Option { let pom = &self.poms[rel]; // A Maven 4 child may omit its parent's coordinates too: they come // from the inferred local parent. - let version = || { - self.chain(rel) - .find_map(|p| self.poms[p].effective_version()) - .map(str::to_string) - }; match name { - "project.version" | "pom.version" => return version(), - "project.groupId" | "pom.groupId" => { - return self - .chain(rel) - .find_map(|p| self.poms[p].effective_group()) - .map(str::to_string); - } - "project.artifactId" | "pom.artifactId" => return pom.artifact.clone(), - "project.parent.version" => return pom.parent.as_ref()?.version.clone(), - "project.parent.groupId" => return pom.parent.as_ref()?.group.clone(), - _ => {} - } - if let Some(v) = self.cli_props.get(name) { - return Some(v.clone()); - } - if let Some(v) = self.chain(rel).find_map(|p| { - overlay - .filter(|(at, _)| *at == p) - .and_then(|(_, props)| props.get(name)) - .or_else(|| self.poms[p].props.get(name)) - }) { - return Some(v.clone()); + "project.version" | "pom.version" | "version" => self + .chain(rel) + .find_map(|p| self.poms[p].effective_version()) + .map(str::to_string), + "project.groupId" | "pom.groupId" => self + .chain(rel) + .find_map(|p| self.poms[p].effective_group()) + .map(str::to_string), + "project.artifactId" | "pom.artifactId" => pom.artifact.clone(), + "project.parent.version" => pom.parent.as_ref()?.version.clone(), + "project.parent.groupId" => pom.parent.as_ref()?.group.clone(), + _ => None, } - (name == "version").then(version).flatten() } /// A version `declared` (with `${...}`) reaches in a pom that reads - /// it, an inheritor of `rel` or `rel` itself with a profile on its - /// chain active, that is neither the base nor `sv`: the version and - /// where. Profile activation is not evaluated, so every profile counts - /// (one at a time). + /// it, an inheritor of `rel` or `rel` itself with some combination of + /// the profiles on its chain active, that is neither the base nor `sv`: + /// the version and where. Profile activation is not evaluated, so every + /// combination of profiles that may change it counts + /// ([`profile_combinations`], shared with VEX discovery); more of them + /// than are combined fails closed. fn version_override( &self, rel: &str, @@ -1790,17 +1788,80 @@ impl Reactor { .then(|| self.interpolate(reader, declared).unwrap_or_else(undefined)) .filter(|v| moved(v)) .map(|v| (v, reader.to_string())); - plain.or_else(|| { - self.chain(reader).find_map(|at| { - self.poms[at].profile_props.iter().find_map(|(id, props)| { - let v = self - .interpolate_with(reader, declared, Some((at, props))) - .unwrap_or_else(undefined); - moved(&v).then(|| (v, format!("{reader} under profile {id} of {at}"))) - }) - }) - }) + plain.or_else(|| self.profile_override(reader, declared, &moved)) + }) + } + + /// The first combination of the profiles on `reader`'s chain under + /// which `declared` resolves a version `moved` accepts, with the + /// version and where; fails closed (an undetermined version) when more + /// profiles may change it than are combined. + fn profile_override( + &self, + reader: &str, + declared: &str, + moved: &dyn Fn(&str) -> bool, + ) -> Option<(String, String)> { + let profiles: Vec<(&str, &str, &BTreeMap)> = self + .chain(reader) + .flat_map(|at| { + self.poms[at] + .profile_props + .iter() + .map(move |(id, props)| (at, id.as_str(), props)) + }) + .collect(); + let definitions = |name: &str| { + if let Some(v) = is_model_reference(name) + .then(|| self.model_ref(reader, name)) + .flatten() + .or_else(|| self.cli_props.get(name).cloned()) + { + return Definitions { + texts: vec![v], + profiles: Vec::new(), + }; + } + let mut d = Definitions::default(); + d.texts.extend( + self.chain(reader) + .filter_map(|p| self.poms[p].props.get(name).cloned()), + ); + d.texts.extend(self.model_ref(reader, name)); + for (i, (_, _, props)) in profiles.iter().enumerate() { + if let Some(v) = props.get(name) { + d.texts.push(v.clone()); + d.profiles.push(i); + } + } + d + }; + let Some(combinations) = profile_combinations(declared, &definitions) else { + return Some(( + "an undetermined value".to_string(), + format!( + "{reader} under some combination of the more than {MAX_COMBINED_PROFILES} \ + profiles on its chain that redefine what it names" + ), + )); + }; + combinations.into_iter().find_map(|combination| { + let active: Vec> = combination + .iter() + .map(|&i| (profiles[i].0, profiles[i].2)) + .collect(); + let v = self + .interpolate_with(reader, declared, &active) + .unwrap_or_else(|| "an undefined value".to_string()); + moved(&v).then(|| { + let under = combination + .iter() + .map(|&i| format!("profile {} of {}", profiles[i].1, profiles[i].0)) + .collect::>() + .join(" + "); + (v, format!("{reader} under {under}")) }) + }) } /// Every pom in scope that inherits from `rel` (has it on its chain). @@ -5790,6 +5851,42 @@ mod tests { } } + /// Profiles that move the version only when active together (one + /// points the property at another, the other redefines that one) are + /// weighed like a single one: VEX discovery and this planner share the + /// combinations (`formats::maven::profile_combinations`). + #[test] + fn profiles_that_move_the_version_only_together_are_weighed() { + let root = with_profile( + &ROOT.replace( + " ", + " 1.10.01.10.0\n \ + ", + ), + "indirect", + "${w}", + ); + let a = with_profile( + &module("a", &dep("${ct.version}")), + "raise", + "1.11.0", + ); + let files = fs(&[ + ("pom.xml", &root), + ("a/pom.xml", &a), + ("b/pom.xml", &module("b", "")), + ]); + let plan = run(&files).unwrap(); + assert_eq!(reasons(&plan), ["conflicting_literal_version"]); + let detail = &plan.warnings[0].detail; + assert!( + detail.contains("profile indirect") && detail.contains("profile raise"), + "{detail}" + ); + let after = applied(&files, &plan); + assert_eq!(text(&after, "a/pom.xml"), a); + } + /// #459: a module's profile overriding the property an inherited /// management declaration reads keeps that declaration. #[test] diff --git a/crates/socket-patch-core/src/vex/discover/maven.rs b/crates/socket-patch-core/src/vex/discover/maven.rs index 2c4e7f2a2..4198782de 100644 --- a/crates/socket-patch-core/src/vex/discover/maven.rs +++ b/crates/socket-patch-core/src/vex/discover/maven.rs @@ -704,17 +704,15 @@ fn build_version(d: &PomDep) -> Option<&str> { } /// Every version the build may resolve for `d` (`None` when it has no -/// ``), each flagged when a profile gives it: [`build_version`] -/// (a profile's when `in_profile`), then each version some set of active -/// profiles' `` moves it to ([`PomDep::version_alternatives`]). -fn resolutions(d: &PomDep, in_profile: bool) -> Vec<(Option<&str>, bool)> { - std::iter::once((build_version(d), in_profile)) - .chain( - d.version_alternatives - .iter() - .map(|v| (Some(v.as_str()), true)), - ) - .collect() +/// ``): [`build_version`], then each version a combination of +/// active profiles' `` moves it to +/// ([`PomDep::version_alternatives`]). +fn resolutions(d: &PomDep) -> impl Iterator> { + std::iter::once(build_version(d)).chain( + d.version_alternatives + .iter() + .map(|a| Some(a.value.as_str())), + ) } /// Every GA with a versioned, classifier-less declaration Maven always @@ -856,17 +854,26 @@ fn unpatched_classifier_copy( Wiring::Hosted => Some(pinned), Wiring::Vendored => None, }; + // Each version a declaration may resolve, flagged when a profile gives + // it: the declaration's own profile, or one whose `` move + // it (every resolution after the first). + fn flagged(d: &PomDep, in_profile: bool) -> Vec<(Option<&str>, bool)> { + resolutions(d) + .enumerate() + .map(|(i, v)| (v, in_profile || i > 0)) + .collect() + } let managed = |classifier: &str, in_profile: bool| -> Vec<(Option<&str>, bool)> { classifier_declarations(deps, group, artifact, move |d| { d.managed && d.in_profile == in_profile }) .filter(|(_, c)| *c == classifier) - .flat_map(|(d, _)| resolutions(d, in_profile)) + .flat_map(|(d, _)| flagged(d, in_profile)) .collect() }; classifier_declarations(deps, group, artifact, |d| !d.managed).find_map(|(d, classifier)| { let resolved = match build_version(d) { - Some(_) => resolutions(d, false), + Some(_) => flagged(d, false), None => { let mut live = managed(classifier, false); if live.is_empty() { @@ -955,18 +962,22 @@ fn profile_override( if build_version(d) != Some(pinned) { return None; } + // The smallest combination of profiles that moves it. let moved = d.version_alternatives.first()?; let declared = d.version_text.as_deref().unwrap_or_default(); + let value = if moved.value.contains("${") { + format!("nothing it can resolve ({})", moved.value) + } else { + moved.value.clone() + }; return Some(format!( "{POM}: {section}{named} {declared} resolves the \ - patched {pinned}, but a redefinition of a property \ - it names makes it {moved}; whenever that profile is active Maven resolves \ - it instead of the pin" + patched {pinned}, but the of {} make it {value}; whenever that \ + applies Maven resolves it instead of the pin", + moved.by )); } - let version = resolutions(d, true) - .into_iter() - .find_map(|(v, _)| v.filter(|v| *v != pinned))?; + let version = resolutions(d).find_map(|v| v.filter(|v| *v != pinned))?; Some(format!( "{POM}: a {section}declaration of {named} carries \ {version}, not the patched {pinned}; whenever that profile \ @@ -2462,6 +2473,175 @@ mod tests { } } + /// A `${project.version}` is the project's own ``, which may + /// itself be spelled through a property (`${revision}`, Maven's + /// CI-friendly versions): a profile or a `-D` user property that + /// redefines that property moves the model reference too. + #[tokio::test] + async fn model_references_move_with_the_properties_they_are_spelled_through() { + let pin = suffixed(UUID_A); + let repo = hosted_repo(&format!("socket-patch-{UUID_A}"), ®istry_url(UUID_A)); + let project = |revision: &str, profile: &str| { + let p = Project::new(); + p.write( + "pom.xml", + format!( + "\n\n4.0.0\n\ + com.exampleapp\ + ${{revision}}\n\ + {revision}\n\ + \n{}\n\n{repo}\n\ + ci{profile}\n\n", + dep("org.slf4j", "slf4j-api", Some("${project.version}")), + ), + ); + p + }; + // A profile that redefines what the project version is spelled + // through moves the pin. + let out = run(&project( + &pin, + "1.7.36", + )) + .await; + assert_refs(&out, &[(FX_PURL, UUID_A, WiringMode::Hosted)]); + assert_eq!(out.unattested.len(), 1, "{:?}", out.unattested); + assert_eq!( + out.unattested[0].kind, + UnattestedKind::MavenProfileUnpatched + ); + // One that keeps it does not. + let out = run(&project( + &pin, + &format!("{pin}"), + )) + .await; + assert!(attests(&out, FX_PURL), "{:?}", out.unattested); + // A Socket version only `-Drevision=` supplies is no pom.xml ref. + let p = project("1.7.36", ""); + p.write(".mvn/maven.config", format!("-Drevision={pin}\n")); + let out = run(&p).await; + assert!(!attests(&out, FX_PURL), "{:?}", out.refs); + assert!( + out.diagnostics.iter().any( + |d| d.code == DIAG_REF_UNATTRIBUTABLE && d.detail.contains(".mvn/maven.config") + ), + "{:?}", + out.diagnostics + ); + } + + /// A profile property written with attributes (`combine.self`) or + /// whitespace in its tag is still a definition Maven reads. + #[tokio::test] + async fn profile_properties_with_attributes_are_read() { + let pin = suffixed(UUID_A); + for profile in [ + "1.7.36", + "1.7.36", + "\n1.7.36", + ] { + let p = property_project( + &format!("{pin}"), + &direct_decl("${slf4j.version}"), + &[profile], + ); + let out = run(&p).await; + assert_refs(&out, &[(FX_PURL, UUID_A, WiringMode::Hosted)]); + assert!(!attests(&out, FX_PURL), "{profile}: {:?}", out.unattested); + } + } + + /// A nested `${…}` in a profile's value reads that profile's own + /// properties first, as Maven merges the whole profile before it + /// interpolates: a profile that keeps the pin through its own + /// property does not withhold it. + #[tokio::test] + async fn profile_values_resolve_through_their_own_profile() { + let pin = suffixed(UUID_A); + let p = property_project( + &format!("{pin}1.7.36"), + &direct_decl("${slf4j.version}"), + &[&format!( + "${{w}}{pin}" + )], + ); + let out = run(&p).await; + assert!(attests(&out, FX_PURL), "{:?}", out.unattested); + } + + /// A profile `` this reader cannot parse withholds a + /// `${property}` pin (fail closed) instead of failing the whole pom; a + /// literal pin, which no property moves, is still attested. + #[tokio::test] + async fn unreadable_profile_properties_withhold_only_property_pins() { + let pin = suffixed(UUID_A); + let broken = "1.7.36"; + let p = property_project( + &format!("{pin}"), + &direct_decl("${slf4j.version}"), + &[broken], + ); + let out = run(&p).await; + assert_refs(&out, &[(FX_PURL, UUID_A, WiringMode::Hosted)]); + assert_eq!(out.unattested.len(), 1, "{:?}", out.unattested); + assert_eq!( + out.unattested[0].kind, + UnattestedKind::MavenProfileUnpatched + ); + let p = property_project("", &direct_decl(&pin), &[broken]); + let out = run(&p).await; + assert!( + attests(&out, FX_PURL), + "{:?} {:?}", + out.unattested, + out.diagnostics + ); + } + + /// Profiles that each keep the pin, however many and however deep the + /// chain they spell it through, never withhold it: every combination + /// is checked, not a capped cross product. + #[tokio::test] + async fn many_profiles_that_keep_the_pin_are_attested() { + let pin = suffixed(UUID_A); + // `${p1}` ... `{pin}`. + let chain: String = (0..5) + .map(|i| match i { + 4 => format!("{pin}"), + i => format!("${{p{}}}", i + 1), + }) + .collect(); + let profile = format!("{chain}"); + let profiles = [profile.as_str(); 5]; + let p = property_project(&chain, &direct_decl("${p0}"), &profiles); + let out = run(&p).await; + assert_refs(&out, &[(FX_PURL, UUID_A, WiringMode::Hosted)]); + assert!(attests(&out, FX_PURL), "{:?}", out.unattested); + } + + /// The withheld statement names the profiles that move the pin and + /// the version they move it to. + #[tokio::test] + async fn profile_property_detail_names_the_profile_and_version() { + let pin = suffixed(UUID_A); + let p = property_project( + &format!("{pin}"), + &direct_decl("${slf4j.version}"), + &[ + "1", + "1.7.30", + ], + ); + let out = run(&p).await; + assert_eq!(out.unattested.len(), 1, "{:?}", out.unattested); + let detail = &out.unattested[0].detail; + assert!( + detail.contains("profile p1") && detail.contains("1.7.30") && !detail.contains("p0"), + "{detail}" + ); + } + #[tokio::test] async fn vendored_url_spellings() { for spelled in [ From e458517f046eebbf510f1967e54c3abaa583a480 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 5 Nov 2026 23:00:50 -0600 Subject: [PATCH 05/20] Withhold Maven pins whose coordinates a profile redefines Closes the last Maven property false-attestation gap in VEX discovery after #2020: a / written as ${prop} that a profile's redefine. Coordinates were read only with no profile active, so discovery attested two shapes the build may not resolve: (a) The declaration carrying the pin names another package whenever a profile redefining its coordinate property is active; the pin then no longer applies to the patched GA, which resolves from its other declarations or transitively, unpinned. (b) Another declaration (live, or inside a profile whose own properties retarget it) becomes the patched GA under a profile, at its own version off the pin. Both readers now resolve coordinates through #2020's shared helpers (formats::maven::properties): the groupId and artifactId are joined with COORDINATE_SEPARATOR (U+0001, which XML 1.0 forbids) so one profile_combinations walk and one interpolation per combination read both under the same active profiles, through interpolate_keeping (the keep-unresolved interpolate_lenient as an Interpolate), and split_coordinates splits the result. formats/maven: PomScope::coordinate_alternatives runs the joined text through resolve_in_build; PomDep carries group_text / artifact_text and coordinate_alternatives (CoordinateAlternative: group, artifact, MovedBy), every pair some combination gives other than the no-profile reading (ScopedDep.group / artifact, unchanged), plus a profile declaration's own-profile reading. More than 8 relevant profiles or an unreadable profile fail closed (the text as written, which may_name reads as any text). vex/discover/maven: bypasses() runs coordinate_override after profile_override, reported as MavenProfileUnpatched (detail names the profiles), for (a) the live main-jar declaration carrying the pin (build_version == pin) with any alternative, and (b) any declaration with an alternative that may be the GA's main jar (literal or may_shadow; pom / sources / javadoc types skipped) at a version other than the pin among its resolutions(), or literally an executable classifier copy off the pin or with no version of its own. As in profile_override, a managed declaration counts only where management governs the pin, and a versionless main jar takes the live pin. CLI note and CLI_CONTRACT.md updated. vendor/jvm/maven_reactor: the v5 planner had the same gap. interpolate_with and profile_override take the Interpolate to use (interpolate_strict for versions, as before), and coordinates_elsewhere reads the joined coordinates in each inheritor and under each profile combination on the chain through them. (a) The existing coordinates_overridden check now weighs profiles, not only inheritors. (b) New warn_moved_coordinates: a declaration that names another artifact in its pom but may be the GA in an inheritor or under a profile combination is warned (coordinates_overridden) and, when versioned, unpins its root and is uncertain, so attestable_checked no longer attests it. This also covers the inheritor form of (b). Tests (each fails before this change): - vex::discover::maven::tests::profile_properties_moving_the_pinned_coordinates_are_not_attested - vex::discover::maven::tests::profile_properties_turning_a_declaration_into_the_pinned_ga_are_not_attested - vendor::jvm::maven_reactor::tests::a_profile_moving_the_declared_coordinates_is_left_alone - vendor::jvm::maven_reactor::tests::a_profile_turning_a_declaration_into_the_patched_artifact_is_weighed - formats::maven::tests::profile_properties_give_coordinate_alternatives Guard: vex::discover::maven::tests::unrelated_coordinate_properties_do_not_withhold. User-property variants (a chain through -D, -D beating the profile) are in each. The golden corpus is unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 4 +- .../src/commands/vex_sources.rs | 8 +- .../src/formats/maven/mod.rs | 239 +++++++++- .../src/formats/maven/properties.rs | 21 + .../src/vendor/jvm/maven_reactor.rs | 344 +++++++++++++- .../src/vex/discover/maven.rs | 436 +++++++++++++++++- 6 files changed, 1019 insertions(+), 33 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 0dac7618e..4b154b730 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -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`. Six 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 classifier copy other than `sources` / `javadoc` (`tests`, a native or JDK-specific build), live or inside ``, resolves any version other than the suffixed pin — its own literal ``, a `` entry with the same classifier (a profile's overriding the live one), or no version in the root pom — the pin is omitted. A pre-v5 single-pom vendored repository holds only the main jar, so any such copy omits its reference. `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. **Maven profile declarations** (`vex_maven_profile_unpatched`): a `` declaration of a pinned GA's main jar in the root `pom.xml` at any version other than the pin (the base release, another release, a `${property}` the pom does not resolve) overrides the pin whenever that profile is active, so the reference is omitted. A profile `` entry always counts; a profile `` entry counts only when management governs the pin (no direct `` outside ``), since a direct literal version beats management. A declaration without a `` takes the live pin, and a `pom` (or `java-source` / `javadoc`) typed one carries no code; neither withholds the statement. Activation is not evaluated: every profile counts, as in vendored mode, which rewrites profile declarations too; hosted scan leaves them as written (`redirect_maven_profile_dependency_unpatched`). A pin spelled through a property counts the same way: a `${property}` `` is interpolated through every property it names, the project's own `${project.version}` included (itself often spelled through `${revision}`), at most 16 levels deep (a cycle or an undefined name never resolves to a pin), and when any profile's `` — or any combination of profiles' together, each nested reference read with that combination active, as the vendored planner weighs them too — redefine one of those properties so that it resolves anything other than the pin (or nothing), the reference is omitted too; a `.mvn/maven.config` user property wins over every profile's. A profile declaration reads its own profile's `` first. When more than 8 profiles may change such a version, or a profile's `` cannot be parsed, the reference is omitted (fail closed); the rest of the pom is still read. The statement names the profiles that move the pin and the version they move it to. The same holds for a pre-v5 single-pom vendored repository, which serves only the original version. **Maven user properties** (`vex_maven_config_override`): Maven resolves a `${property}` `` from `.mvn/maven.config`'s `-D` user properties before the pom's `` (`${project.*}` / `${pom.*}` always come from the model). A pin the pom spells through a property that `.mvn/maven.config` redefines resolves that other version, so the reference is omitted. A Socket version that only a `-D` user property supplies is not `pom.xml` wiring (hosted scan never writes one, and `rollback` / `remove` never edit `.mvn/maven.config`): it is no reference at all, only a diagnostic. +* **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`. Six 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 classifier copy other than `sources` / `javadoc` (`tests`, a native or JDK-specific build), live or inside ``, resolves any version other than the suffixed pin — its own literal ``, a `` entry with the same classifier (a profile's overriding the live one), or no version in the root pom — the pin is omitted. A pre-v5 single-pom vendored repository holds only the main jar, so any such copy omits its reference. `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. **Maven profile declarations** (`vex_maven_profile_unpatched`): a `` declaration of a pinned GA's main jar in the root `pom.xml` at any version other than the pin (the base release, another release, a `${property}` the pom does not resolve) overrides the pin whenever that profile is active, so the reference is omitted. A profile `` entry always counts; a profile `` entry counts only when management governs the pin (no direct `` outside ``), since a direct literal version beats management. A declaration without a `` takes the live pin, and a `pom` (or `java-source` / `javadoc`) typed one carries no code; neither withholds the statement. Activation is not evaluated: every profile counts, as in vendored mode, which rewrites profile declarations too; hosted scan leaves them as written (`redirect_maven_profile_dependency_unpatched`). A pin spelled through a property counts the same way: a `${property}` `` is interpolated through every property it names, the project's own `${project.version}` included (itself often spelled through `${revision}`), at most 16 levels deep (a cycle or an undefined name never resolves to a pin), and when any profile's `` — or any combination of profiles' together, each nested reference read with that combination active, as the vendored planner weighs them too — redefine one of those properties so that it resolves anything other than the pin (or nothing), the reference is omitted too; a `.mvn/maven.config` user property wins over every profile's. A profile declaration reads its own profile's `` first. When more than 8 profiles may change such a version, or a profile's `` cannot be parsed, the reference is omitted (fail closed); the rest of the pom is still read. The statement names the profiles that move the pin and the version they move it to. Coordinates count the same way: when a profile's `` (or several profiles' together, or the profile holding the declaration) redefine a property a `${property}` `` / `` names, so that the declaration carrying the pin names another package (the pin then no longer applies to the patched GA) or another declaration names the patched GA (its main jar, or an executable classifier copy) at a version other than the pin, or as a classifier copy with no version of its own, the reference is omitted; a profile value the pom cannot resolve may name any package, a user property wins over every profile's, and the same 8-profile and unreadable-profile limits fail closed. The same holds for a pre-v5 single-pom vendored repository, which serves only the original version. **Maven user properties** (`vex_maven_config_override`): Maven resolves a `${property}` `` from `.mvn/maven.config`'s `-D` user properties before the pom's `` (`${project.*}` / `${pom.*}` always come from the model). A pin the pom spells through a property that `.mvn/maven.config` redefines resolves that other version, so the reference is omitted. A Socket version that only a `-D` user property supplies is not `pom.xml` wiring (hosted scan never writes one, and `rollback` / `remove` never edit `.mvn/maven.config`): it is no reference at all, only a diagnostic. * **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,7 +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 an executable `` copy (not `sources` / `javadoc`; live or inside ``) 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_maven_profile_unpatched` | run warning and `failed[].reason` | A Maven pin's GA main jar is also declared inside `` in the root `pom.xml` at a version other than the pin (a profile `` entry only where management governs the pin), or a profile's `` redefine a property the pin's `${property}` version names so it resolves another version, which overrides the pin whenever that profile is active; no statement while it does. `list`, `rollback` and `remove` still find the pin. | +| `vex_maven_profile_unpatched` | run warning and `failed[].reason` | A Maven pin's GA main jar is also declared inside `` in the root `pom.xml` at a version other than the pin (a profile `` entry only where management governs the pin), or a profile's `` redefine a property the pin's `${property}` version names so it resolves another version, or a property a `${property}` groupId / artifactId names so the pin names another package or another declaration becomes the pinned GA off the pin, which overrides the pin whenever that profile is active; no statement while it does. `list`, `rollback` and `remove` still find the pin. | | `vex_maven_config_override` | run warning and `failed[].reason` | A hosted Maven pin is spelled through a `${property}` that a `-D` user property in `.mvn/maven.config` redefines, so Maven resolves another version; no statement while it does. `list`, `rollback` and `remove` still find the pin. | | `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. | diff --git a/crates/socket-patch-cli/src/commands/vex_sources.rs b/crates/socket-patch-cli/src/commands/vex_sources.rs index 51c98e35b..6dc982a2d 100644 --- a/crates/socket-patch-cli/src/commands/vex_sources.rs +++ b/crates/socket-patch-cli/src/commands/vex_sources.rs @@ -280,9 +280,11 @@ fn unattested_note(kind: UnattestedKind) -> (&'static str, &'static str) { UnattestedKind::MavenProfileUnpatched => ( NOTE_MAVEN_PROFILE_UNPATCHED, "not attested while a profile declares it at, or its move it to, \ - another version; the wiring itself is intact, so re-running `scan` does not change \ - this (drop that profile or property or set it to the pin, or re-patch \ - with `scan --mode vendored`)", + another version, or its redefine a groupId / artifactId property so \ + the pin names another package or another declaration names this one; the wiring \ + itself is intact, so re-running `scan` does not change this (drop that profile \ + or property or set it to the pin, or re-patch with \ + `scan --mode vendored`)", ), UnattestedKind::MavenConfigOverride => ( NOTE_MAVEN_CONFIG_OVERRIDE, diff --git a/crates/socket-patch-core/src/formats/maven/mod.rs b/crates/socket-patch-core/src/formats/maven/mod.rs index 7e5387eae..44adad231 100644 --- a/crates/socket-patch-core/src/formats/maven/mod.rs +++ b/crates/socket-patch-core/src/formats/maven/mod.rs @@ -6,11 +6,12 @@ mod properties; use std::collections::BTreeMap; -use properties::{interpolate_lenient, read_properties, Interpolate}; pub(crate) use properties::{ - interpolate_strict, is_model_reference, lookup_property, profile_combinations, Definitions, - PomLayer, MAX_COMBINED_PROFILES, MAX_INTERPOLATION_DEPTH, + interpolate_keeping, interpolate_strict, is_model_reference, lookup_property, + profile_combinations, split_coordinates, Definitions, Interpolate, PomLayer, + COORDINATE_SEPARATOR, MAX_COMBINED_PROFILES, MAX_INTERPOLATION_DEPTH, }; +use properties::{interpolate_lenient, read_properties}; use super::xml::{blank_non_markup, child_text, elements, open_tags, Element}; @@ -47,8 +48,20 @@ pub(crate) struct PomRepo { #[derive(Debug)] pub(crate) struct PomDep { + /// `` / `` as [`ScopedDep::group`] reads them: + /// with no profile active, an undefined `${…}` kept as written. pub(crate) group: String, pub(crate) artifact: String, + /// The trimmed `` / `` text as written. + pub(crate) group_text: String, + pub(crate) artifact_text: String, + /// The other `(groupId, artifactId)` pairs the declaration names when + /// some combination of profiles is active whose `` redefine + /// a property its coordinates name, or, for one inside a profile, while + /// its own profile is ([`PomScope::coordinate_alternatives`]); a + /// reference left unresolved stays as written ([`may_name`] reads it as + /// any text). Empty when no profile can move it. + pub(crate) coordinate_alternatives: Vec, /// Trimmed version, every `${prop}` interpolated /// ([`interpolate_strict`]) from the project model (`project.*` / /// `pom.*`), the `` of the profile holding the declaration @@ -91,6 +104,15 @@ pub(crate) struct Alternative { pub(crate) by: MovedBy, } +/// A `(groupId, artifactId)` some combination of active profiles gives a +/// declaration other than [`PomDep::group`] / [`PomDep::artifact`], and why. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CoordinateAlternative { + pub(crate) group: String, + pub(crate) artifact: String, + pub(crate) by: MovedBy, +} + /// What gives an [`Alternative`]. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum MovedBy { @@ -350,6 +372,54 @@ impl PomScope { (base, alternatives) } + /// [`PomDep::coordinate_alternatives`] of the declaration at `pos` + /// whose coordinates are written `text` and read `base` with no profile + /// active ([`Self::interpolate_coordinate`]): both resolved together + /// ([`COORDINATE_SEPARATOR`]) through [`Self::resolve_in_build`], + /// keeping an unresolved reference ([`interpolate_keeping`]). A + /// profile declaration's own profile is always active with it, so what + /// it reads there is an alternative too. + fn coordinate_alternatives( + &self, + text: (&str, &str), + base: (&str, &str), + pos: usize, + ) -> Vec { + if !text.0.contains("${") && !text.1.contains("${") { + return Vec::new(); + } + let joined = format!("{}{COORDINATE_SEPARATOR}{}", text.0, text.1); + let (own_base, alternatives) = self.resolve_in_build(&joined, pos, interpolate_keeping); + let own_ids = || { + self.own_profile(pos) + .into_iter() + .map(|i| self.profile_properties[i].id.clone()) + .collect() + }; + let mut out: Vec = Vec::new(); + let candidates = own_base + .map(|v| (v, MovedBy::Profiles(own_ids()))) + .into_iter() + .chain(alternatives.into_iter().map(|a| (a.value, a.by))); + for (value, by) in candidates { + let Some((group, artifact)) = split_coordinates(&value) else { + continue; + }; + if (group.as_str(), artifact.as_str()) != base + && out + .iter() + .all(|o| (&o.group, &o.artifact) != (&group, &artifact)) + { + out.push(CoordinateAlternative { + group, + artifact, + by, + }); + } + } + out + } + /// `text` (a coordinate) with every `${name}` the build defines with /// no profile active interpolated, an undefined one kept as written /// ([`interpolate_lenient`]). @@ -407,6 +477,9 @@ pub(crate) struct ScopedDep { /// written, so [`may_name`] can tell it apart. pub(crate) group: Option, pub(crate) artifact: Option, + /// The trimmed `` / `` text as written. + pub(crate) group_text: Option, + pub(crate) artifact_text: Option, /// Inner-text byte range of the literal ``, when present. pub(crate) version_inner: Option<(usize, usize)>, /// Trimmed `` text as written (no property resolution). @@ -425,10 +498,18 @@ impl PomScope { for dep in self.elements("dependency")? { let body = blank_elements(dep.inner(&self.masked), "exclusions")?; let version = elements(&body, "version")?.into_iter().next(); + let group_text = child_text(&body, "groupId")?; + let artifact_text = child_text(&body, "artifactId")?; out.push(ScopedDep { element: dep, - group: child_text(&body, "groupId")?.map(|g| self.interpolate_coordinate(&g)), - artifact: child_text(&body, "artifactId")?.map(|a| self.interpolate_coordinate(&a)), + group: group_text + .as_deref() + .map(|g| self.interpolate_coordinate(g)), + artifact: artifact_text + .as_deref() + .map(|a| self.interpolate_coordinate(a)), + group_text, + artifact_text, version_inner: version.map(|v| { ( dep.inner_start + v.inner_start, @@ -473,9 +554,16 @@ pub(crate) fn parse_pom_with(raw: &str, user: &BTreeMap) -> Resu } for dep in scope.dependencies()? { - let (Some(group), Some(artifact)) = (dep.group, dep.artifact) else { + let (Some(group), Some(artifact), Some(group_text), Some(artifact_text)) = + (dep.group, dep.artifact, dep.group_text, dep.artifact_text) + else { continue; }; + let coordinate_alternatives = scope.coordinate_alternatives( + (&group_text, &artifact_text), + (&group, &artifact), + dep.element.start, + ); let (version, build_version, version_alternatives) = match &dep.version_text { Some(text) => { let (spelled, build, alternatives) = scope.resolve_version(text, dep.element.start); @@ -486,6 +574,9 @@ pub(crate) fn parse_pom_with(raw: &str, user: &BTreeMap) -> Resu pom.deps.push(PomDep { group, artifact, + group_text, + artifact_text, + coordinate_alternatives, version, build_version, version_text: dep.version_text, @@ -1245,6 +1336,142 @@ mod tests { ); } + /// The `(groupId, artifactId)` pairs a profile's `` may move + /// a `${property}` coordinate to: every combination of profiles counts, + /// both coordinates read under the same one, a declaration inside a + /// profile reads its own first, a value left unresolved stays as + /// written, and user properties and model references no profile can + /// redefine. + #[test] + fn profile_properties_give_coordinate_alternatives() { + let pairs = |alternatives: Vec| { + alternatives + .into_iter() + .map(|c| (c.group, c.artifact)) + .collect::>() + }; + let alternatives = |props: &str, profiles: &[&str], user: &[(&str, &str)], deps: &str| { + let profiles: String = profiles + .iter() + .map(|p| format!("{p}")) + .collect(); + let pom = format!( + "org.self{props}\ + {deps}{profiles}" + ); + let user = user + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + super::parse_pom_with(&pom, &user) + .unwrap() + .deps + .into_iter() + .map(|d| ((d.group, d.artifact), pairs(d.coordinate_alternatives))) + .collect::>() + }; + let dep = |g: &str, a: &str| { + format!( + "{g}{a}\ + 1" + ) + }; + let pair = |g: &str, a: &str| (g.to_string(), a.to_string()); + let one = |props, profiles: &[&str], user: &[(&str, &str)], g: &str, a: &str| { + alternatives(props, profiles, user, &dep(g, a)).remove(0) + }; + assert_eq!( + one("org.a", &["org.b"], &[], "${g}", "x"), + (pair("org.a", "x"), vec![pair("org.b", "x")]) + ); + assert_eq!( + one("org.a", &["org.a"], &[], "${g}", "x"), + (pair("org.a", "x"), vec![]) + ); + assert_eq!( + one("org.a", &["org.b"], &[], "${g}", "x"), + (pair("org.a", "x"), vec![]) + ); + // Both coordinates, read under the same profiles. + assert_eq!( + one("a", &["b"], &[], "org.${v}", "${v}-api").1, + [pair("org.b", "b-api")] + ); + // Nested, and two profiles together. + assert_eq!( + one( + "${w}org.a", + &["org.b", "${w}.c"], + &[], + "${g}", + "x" + ) + .1, + [ + pair("org.b", "x"), + pair("org.a.c", "x"), + pair("org.b.c", "x") + ] + ); + // Unresolved under a profile, or a cycle: kept as written. + assert_eq!( + one("org.a", &["${nope}"], &[], "${g}", "x").1, + [pair("${nope}", "x")] + ); + assert_eq!( + one("org.a", &["${g}"], &[], "${g}", "x").1, + [pair("${g}", "x")] + ); + // Undefined with no profile involved: no alternative. + assert_eq!( + one("org.a", &[], &[], "${g}.${nope}", "x"), + (pair("org.a.${nope}", "x"), vec![]) + ); + // User properties and model references win over every profile's. + assert_eq!( + one( + "org.a", + &["org.b"], + &[("g", "org.c")], + "${g}", + "x" + ), + (pair("org.c", "x"), vec![]) + ); + assert_eq!( + one( + "", + &["org.b"], + &[], + "${project.groupId}", + "x" + ), + (pair("org.self", "x"), vec![]) + ); + + // A profile's own declaration reads its own properties first. + let pom = format!( + "org.a{}\ + porg.b\ + {}", + dep("${g}", "x"), + dep("${g}", "x") + ); + let deps: Vec<_> = super::parse_pom(&pom) + .unwrap() + .deps + .into_iter() + .map(|d| (d.in_profile, d.group, pairs(d.coordinate_alternatives))) + .collect(); + assert_eq!( + deps, + [ + (false, "org.a".to_string(), vec![pair("org.b", "x")]), + (true, "org.a".to_string(), vec![pair("org.b", "x")]), + ] + ); + } + #[test] fn suffix_grammar_is_exact() { assert_eq!( diff --git a/crates/socket-patch-core/src/formats/maven/properties.rs b/crates/socket-patch-core/src/formats/maven/properties.rs index f28cd8ebc..06d28a1af 100644 --- a/crates/socket-patch-core/src/formats/maven/properties.rs +++ b/crates/socket-patch-core/src/formats/maven/properties.rs @@ -109,6 +109,27 @@ pub(crate) fn interpolate_lenient(value: &str, lookup: &dyn Fn(&str) -> Option Option, +) -> Option { + Some(interpolate_lenient(value, lookup)) +} + +/// Joins a `` and an `` into one text, so that one +/// interpolation (and one [`profile_combinations`] walk) reads both under +/// the same active profiles; a control character neither may hold (XML +/// 1.0 forbids it in a document). [`split_coordinates`] undoes it. +pub(crate) const COORDINATE_SEPARATOR: char = '\u{1}'; + +/// The `(groupId, artifactId)` a [`COORDINATE_SEPARATOR`]-joined text +/// holds. +pub(crate) fn split_coordinates(joined: &str) -> Option<(String, String)> { + let (g, a) = joined.split_once(COORDINATE_SEPARATOR)?; + Some((g.to_string(), a.to_string())) +} + fn expand( value: &str, lookup: &dyn Fn(&str) -> Option, diff --git a/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs b/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs index 0b9239a69..17fed1a22 100644 --- a/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs +++ b/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs @@ -12,9 +12,10 @@ use std::ops::Range; use serde_json::{json, Value}; use crate::formats::maven::{ - interpolate_strict, is_model_reference, lookup_property, - maven_config_properties as cli_properties, profile_combinations, Definitions, PomLayer, - MAX_COMBINED_PROFILES, MAX_INTERPOLATION_DEPTH, + interpolate_keeping, interpolate_strict, is_model_reference, lookup_property, + maven_config_properties as cli_properties, profile_combinations, split_coordinates, + Definitions, Interpolate, PomLayer, COORDINATE_SEPARATOR, MAX_COMBINED_PROFILES, + MAX_INTERPOLATION_DEPTH, }; use super::super::state::{WiringAction, WiringRecord}; @@ -1712,13 +1713,21 @@ impl Reactor { /// Interpolate every `${name}` in `value` in the context of `rel`; /// `None` when any name is undefined in the checkout. fn interpolate(&self, rel: &str, value: &str) -> Option { - self.interpolate_with(rel, value, &[]) + self.interpolate_with(rel, value, &[], interpolate_strict) } /// [`Self::interpolate`] with the `active` profiles' properties layered - /// over their poms' own, as when those profiles are active. - fn interpolate_with(&self, rel: &str, value: &str, active: &[Overlay<'_>]) -> Option { - interpolate_strict(value, &|name| self.lookup_with(rel, name, active)) + /// over their poms' own, as when those profiles are active, through + /// `interpolate` ([`interpolate_strict`] for a version, + /// [`interpolate_keeping`] for coordinates). + fn interpolate_with( + &self, + rel: &str, + value: &str, + active: &[Overlay<'_>], + interpolate: Interpolate, + ) -> Option { + interpolate(value, &|name| self.lookup_with(rel, name, active)) } /// Maven's lookup order ([`lookup_property`], shared with VEX @@ -1788,18 +1797,20 @@ impl Reactor { .then(|| self.interpolate(reader, declared).unwrap_or_else(undefined)) .filter(|v| moved(v)) .map(|v| (v, reader.to_string())); - plain.or_else(|| self.profile_override(reader, declared, &moved)) + plain + .or_else(|| self.profile_override(reader, declared, interpolate_strict, &moved)) }) } /// The first combination of the profiles on `reader`'s chain under - /// which `declared` resolves a version `moved` accepts, with the - /// version and where; fails closed (an undetermined version) when more - /// profiles may change it than are combined. + /// which `declared` resolves (through `interpolate`) a value `moved` + /// accepts, with the value and where; fails closed (an undetermined + /// value) when more profiles may change it than are combined. fn profile_override( &self, reader: &str, declared: &str, + interpolate: Interpolate, moved: &dyn Fn(&str) -> bool, ) -> Option<(String, String)> { let profiles: Vec<(&str, &str, &BTreeMap)> = self @@ -1851,7 +1862,7 @@ impl Reactor { .map(|&i| (profiles[i].0, profiles[i].2)) .collect(); let v = self - .interpolate_with(reader, declared, &active) + .interpolate_with(reader, declared, &active, interpolate) .unwrap_or_else(|| "an undefined value".to_string()); moved(&v).then(|| { let under = combination @@ -1864,6 +1875,44 @@ impl Reactor { }) } + /// Where `dep` of `rel`, a declaration named through `${...}` + /// coordinates, reads a groupId and artifactId that `hit` accepts, + /// other than `rel` with no profile active: an inheritor of `rel`, or + /// `rel` or an inheritor with some combination of the profiles on its + /// chain active ([`Self::profile_override`], which covers the profile + /// holding `dep`). Both are read together, an unresolved reference kept + /// as written ([`interpolate_keeping`]): Maven interpolates after + /// inheritance and with the active profiles' `` merged in, + /// then matches management and pins. More profiles than are combined + /// fail closed (accepted whatever `hit` says). + fn coordinates_elsewhere( + &self, + rel: &str, + dep: usize, + hit: &dyn Fn(&str, &str) -> bool, + ) -> Option { + let doc = &self.poms[rel].doc; + let joined = format!( + "{}{COORDINATE_SEPARATOR}{}", + doc.child_text(dep, "groupId")?, + doc.child_text(dep, "artifactId")? + ); + let accepts = |v: &str| split_coordinates(v).is_some_and(|(g, a)| hit(&g, &a)); + std::iter::once(rel) + .chain(self.inheritors(rel)) + .find_map(|reader| { + let plain = (reader != rel) + .then(|| self.interpolate_with(reader, &joined, &[], interpolate_keeping)) + .flatten() + .filter(|v| accepts(v)) + .map(|_| reader.to_string()); + plain.or_else(|| { + self.profile_override(reader, &joined, interpolate_keeping, &accepts) + .map(|(_, at)| at) + }) + }) + } + /// Every pom in scope that inherits from `rel` (has it on its chain). fn inheritors<'s>(&'s self, rel: &'s str) -> impl Iterator + 's { self.scope @@ -1891,6 +1940,7 @@ impl Reactor { let doc = &self.poms[rel].doc; let ga = format!("{}:{}", patch.group_id, patch.artifact_id); self.warn_unresolved_coordinates(rel, patch, warnings, unpinned, uncertain); + self.warn_moved_coordinates(rel, patch, warnings, unpinned, uncertain); for (dep, key) in self.keyed_declarations(rel, patch.group_id, patch.artifact_id) { if doc .child_text(dep, "type") @@ -1910,15 +1960,13 @@ impl Reactor { continue; } let root = self.local_root(rel); - // Maven interpolates after inheritance: a pom that inherits this - // declaration may name another artifact through it, which the - // literal SV must not reach. + // Maven interpolates after inheritance and with the active + // profiles: a pom that inherits this declaration, or a profile + // redefining a property it names, may make it another artifact, + // which the literal SV must not reach. if doc.has_property_coordinate(dep) { - let other = self.inheritors(rel).find(|p| { - let resolve = |v: &str| self.interpolate(p, v); - doc.coordinate(dep, "groupId", &resolve).as_deref() != Some(patch.group_id) - || doc.coordinate(dep, "artifactId", &resolve).as_deref() - != Some(patch.artifact_id) + let other = self.coordinates_elsewhere(rel, dep, &|g, a| { + g != patch.group_id || a != patch.artifact_id }); if let Some(p) = other { warnings.push(degraded( @@ -2088,6 +2136,83 @@ impl Reactor { } } +impl Reactor { + /// Warn for each declaration of `rel` whose `${...}` groupId or + /// artifactId reads another artifact in `rel` itself but may read + /// `patch`'s g:a in an inheritor or with a profile active + /// ([`Self::coordinates_elsewhere`]): it is left as is, and there it + /// declares the g:a at its own version. One with a version beats any + /// pin, so its local root is not pinned. + fn warn_moved_coordinates( + &self, + rel: &str, + patch: &JvmPatch<'_>, + warnings: &mut Vec, + unpinned: &mut BTreeSet, + uncertain: &mut BTreeSet, + ) { + let doc = &self.poms[rel].doc; + let resolve = |v: &str| self.interpolate(rel, v); + for dep in doc.declarations() { + let (Some(g), Some(a)) = ( + doc.child_text(dep, "groupId"), + doc.child_text(dep, "artifactId"), + ) else { + continue; + }; + let other_variant = doc + .child_text(dep, "type") + .is_some_and(|t| !t.is_empty() && t != "jar") + || doc + .child_text(dep, "classifier") + .is_some_and(|c| !c.is_empty()); + // Undefined in `rel`: `warn_unresolved_coordinates` weighs it; + // `patch`'s g:a in `rel`: `rewrite_declarations` does. + let (Some(here_g), Some(here_a)) = ( + doc.coordinate(dep, "groupId", &resolve), + doc.coordinate(dep, "artifactId", &resolve), + ) else { + continue; + }; + if other_variant + || !doc.has_property_coordinate(dep) + || doc.pin_at(dep).is_some() + || (here_g == patch.group_id && here_a == patch.artifact_id) + { + continue; + } + // A reference left unresolved there may read any text. + let Some(there) = self.coordinates_elsewhere(rel, dep, &|rg, ra| { + may_interpolate_to(rg, patch.group_id) && may_interpolate_to(ra, patch.artifact_id) + }) else { + continue; + }; + let at = format!("{rel}:{}", doc.line_of(doc.nodes[dep].start)); + let versioned = doc + .child_text(dep, "version") + .is_some_and(|v| !v.is_empty()); + let root = self.local_root(rel); + let unpin = if versioned { + format!(", and {root} is not pinned") + } else { + String::new() + }; + warnings.push(degraded( + "coordinates_overridden", + format!( + "{at}: {g}:{a} is {here_g}:{here_a} here but may be {}:{} in {there}; \ + left as is, probably unpatched there{unpin}", + patch.group_id, patch.artifact_id, + ), + )); + if versioned { + unpinned.insert(root); + uncertain.insert(at); + } + } + } +} + /// Whether `raw`, with each `${...}` standing for any text, can read /// `want`. fn may_interpolate_to(raw: &str, want: &str) -> bool { @@ -5912,6 +6037,185 @@ mod tests { assert!(unwired(&applied(&files, &plan))); } + /// `root` with `{props}` and a profile `corp` + /// whose `` are `profile` (none when empty), before its + /// ``. + fn root_with(props: &str, profile: &str) -> String { + let root = ROOT.replace( + " ", + &format!(" {props}\n "), + ); + match profile { + "" => root, + p => root.replace( + " ", + &format!( + " corp{p}\ + \n " + ), + ), + } + } + + /// A declaration whose `${...}` groupId a profile redefines names + /// another artifact whenever that profile is active, which the literal + /// SV must not reach: it is left as is and its root unpinned, as for + /// an inheritor that resolves it differently. A `.mvn/maven.config` + /// user property wins over the profile's. + #[test] + fn a_profile_moving_the_declared_coordinates_is_left_alone() { + let decl = "${ct.group}\ + commons-text1.10.0"; + let a = module("a", decl); + let props = "org.apache.commons"; + for profile in [ + "org.other", + "${nope}", + ] { + let files = fs(&[ + ("pom.xml", &root_with(props, profile)), + ("a/pom.xml", &a), + ("b/pom.xml", &module("b", "")), + ]); + let plan = run(&files).unwrap(); + assert_eq!(reasons(&plan), ["coordinates_overridden"], "{profile}"); + assert!( + plan.warnings[0].detail.contains("profile corp"), + "{:?}", + plan.warnings + ); + let after = applied(&files, &plan); + assert_eq!(text(&after, "a/pom.xml"), a); + assert!(!root_pinned(&plan, &files)); + assert!(unwired(&after), "{profile}"); + } + + // A vendored reactor a profile is added to later is not attestable. + let files = fs(&[ + ("pom.xml", &root_with(props, "")), + ("a/pom.xml", &a), + ("b/pom.xml", &module("b", "")), + ]); + let (_, mut after) = vendor(&files); + assert_eq!(attestable(&after), (true, true)); + let root = text(&after, "pom.xml").replace( + " ", + " corporg.other\ + \n ", + ); + after.insert("pom.xml".to_string(), root.into_bytes()); + assert_eq!(attestable(&after), (true, false)); + + // The same profile, beaten by a user property, or agreeing. + for (profile, config) in [ + ( + "org.other", + "-Dct.group=org.apache.commons\n", + ), + ("org.apache.commons", ""), + ] { + let files = fs(&[ + ("pom.xml", &root_with(props, profile)), + ("a/pom.xml", &a), + ("b/pom.xml", &module("b", "")), + (".mvn/maven.config", config), + ]); + let (plan, after) = vendor(&files); + assert!(plan.warnings.is_empty(), "{:?}", plan.warnings); + assert_eq!(text(&after, "a/pom.xml"), a.replace("1.10.0", SV)); + assert_eq!(attestable(&after), (true, true), "{profile}"); + } + } + + /// A declaration whose `${...}` groupId names another artifact with no + /// profile active, but the patched one under a profile, declares the + /// patched artifact at its own version there, beating the root pin: + /// it is left as is, warned about, and its root unpinned, so a reactor + /// vendored before that profile was added is not attestable. Unrelated + /// coordinate properties change nothing. + #[test] + fn a_profile_turning_a_declaration_into_the_patched_artifact_is_weighed() { + let other = |v: &str| { + format!( + "${{lib.group}}\ + commons-text{v}" + ) + }; + let props = "org.other"; + let a = module( + "a", + &format!("{}{}", dep("1.10.0"), other("1.9")), + ); + for (profile, warned) in [ + ("org.apache.commons", true), + ("${nope}", true), + ("org.third", false), + ("org.apache.commons", false), + ] { + let files = fs(&[ + ("pom.xml", &root_with(props, profile)), + ("a/pom.xml", &a), + ("b/pom.xml", &module("b", "")), + ]); + let plan = run(&files).unwrap(); + if warned { + assert_eq!(reasons(&plan), ["coordinates_overridden"], "{profile}"); + assert!( + plan.warnings[0].detail.contains("profile corp") + && plan.warnings[0] + .detail + .contains("org.apache.commons:commons-text"), + "{:?}", + plan.warnings + ); + assert!(!root_pinned(&plan, &files), "{profile}"); + } else { + assert!(plan.warnings.is_empty(), "{profile}: {:?}", plan.warnings); + assert!(root_pinned(&plan, &files), "{profile}"); + } + } + + // No version of its own: the pin applies, so the root stays pinned. + let files = fs(&[ + ( + "pom.xml", + &root_with(props, "org.apache.commons"), + ), + ( + "a/pom.xml", + &module("a", &format!("{}{}", dep("1.10.0"), other(""))), + ), + ("b/pom.xml", &module("b", "")), + ]); + let plan = run(&files).unwrap(); + assert_eq!(reasons(&plan), ["coordinates_overridden"]); + assert!(root_pinned(&plan, &files)); + + // Vendored first, the profile added later: not attestable; a user + // property fixing the group keeps it attestable. + let files = fs(&[ + ("pom.xml", &root_with(props, "")), + ("a/pom.xml", &a), + ("b/pom.xml", &module("b", "")), + ]); + let (_, after) = vendor(&files); + assert_eq!(attestable(&after), (true, true)); + let profiled = text(&after, "pom.xml").replace( + " ", + " corp\ + org.apache.commons\n \ + ", + ); + let mut later = after.clone(); + later.insert("pom.xml".to_string(), profiled.into_bytes()); + assert_eq!(attestable(&later), (true, false)); + later.insert( + ".mvn/maven.config".to_string(), + b"-Dlib.group=org.other\n".to_vec(), + ); + assert_eq!(attestable(&later), (true, true)); + } + fn attestable(files: &Fs) -> (bool, bool) { let read = |p: &str| files.get(p).cloned(); let c = Coords { diff --git a/crates/socket-patch-core/src/vex/discover/maven.rs b/crates/socket-patch-core/src/vex/discover/maven.rs index 4198782de..893b98a11 100644 --- a/crates/socket-patch-core/src/vex/discover/maven.rs +++ b/crates/socket-patch-core/src/vex/discover/maven.rs @@ -65,7 +65,13 @@ //! (hosted mode never writes one; rollback / remove never edit //! `.mvn/maven.config`). A pin the build resolves but that any profile's //! (or set of profiles') `` make resolve anything else keeps -//! the ref unattested too, as a profile declaration does. +//! the ref unattested too, as a profile declaration does. So do +//! `${property}` coordinates a profile's `` redefine (Maven +//! merges them in before it interpolates a groupId / artifactId, then +//! matches pins): the declaration carrying the pin then names another +//! package, or another declaration becomes the pinned GA at a version off +//! the pin. Coordinates keep an undefined reference as written, so a +//! profile value this pom cannot resolve may name anything. //! //! Integrity: when the pom sha256 was known the rewriter also writes Maven //! Trusted Checksums — `.mvn/checksums/checksums.sha256` lines @@ -828,7 +834,10 @@ fn bypasses( if let Some(detail) = unpatched_classifier_copy(deps, group, artifact, pinned, wiring) { found.push((UnattestedKind::MavenClassifierUnpatched, detail)); } - if let Some(detail) = profile_override(deps, group, artifact, pinned, built.direct.is_empty()) { + let pin_managed = built.direct.is_empty(); + if let Some(detail) = profile_override(deps, group, artifact, pinned, pin_managed) + .or_else(|| coordinate_override(deps, group, artifact, pinned, pin_managed)) + { found.push((UnattestedKind::MavenProfileUnpatched, detail)); } found @@ -986,6 +995,100 @@ fn profile_override( }) } +/// Why a profile's `` move a `${…}` groupId or artifactId so +/// that `group:artifact`'s pin `pinned` is bypassed, for the first such +/// declaration ([`PomDep::coordinate_alternatives`]: every combination of +/// active profiles, and a profile declaration's own). Maven merges an +/// active profile's properties over the pom's own before it interpolates +/// the coordinates, then matches management and pins: +/// +/// * the live main-jar declaration carrying the pin (a managed one only +/// where management governs it, `pin_managed`) then names another +/// package, so the pin no longer applies to `group:artifact`, which +/// resolves from its other declarations or a transitive path; +/// * any other declaration then becomes `group:artifact`'s main jar (as +/// [`main_jar_declarations`] reads one; a managed one again only where +/// management governs) at a version other than `pinned` +/// ([`resolutions`]), or literally one of its executable classifier +/// copies off the pin or with no `` of its own. A main jar +/// without a version takes the live pin and moves nothing. +fn coordinate_override( + deps: &[PomDep], + group: &str, + artifact: &str, + pinned: &str, + pin_managed: bool, +) -> Option { + deps.iter() + .filter(|d| { + !d.coordinate_alternatives.is_empty() + && (!d.managed || pin_managed) + && !d + .type_text + .as_deref() + .is_some_and(|t| NON_CODE_TYPES.contains(&t)) + }) + .find_map(|d| { + let section = if d.managed { + " " + } else { + "" + }; + let place = if d.in_profile { "a " } else { "a " }; + let written = format!("{}:{}", d.group_text, d.artifact_text); + if d.group == group && d.artifact == artifact { + if d.in_profile || d.classifier.is_some() || build_version(d) != Some(pinned) { + return None; + } + let moved = &d.coordinate_alternatives[0]; + return Some(format!( + "{POM}: {place}{section}declaration of {written} names {group}:{artifact} at \ + the patched {pinned}, but the of {} redefine its coordinates \ + to {}:{}; whenever that applies the pin no longer applies to \ + {group}:{artifact}, which Maven resolves from its other declarations instead", + moved.by, moved.group, moved.artifact + )); + } + let moved = d.coordinate_alternatives.iter().find(|m| { + let literal = m.group == group && m.artifact == artifact; + match d.classifier.as_deref() { + None => { + literal + || may_shadow( + (&m.group, &m.artifact), + d.type_text.as_deref(), + None, + (group, artifact), + ) + } + Some(c) => literal && is_executable_classifier(c), + } + })?; + let version = resolutions(d).find_map(|v| v.filter(|v| *v != pinned)); + let what = match (d.classifier.as_deref(), version) { + (None, None) => return None, + (None, Some(v)) => format!("{v}"), + (Some(c), Some(v)) => format!("classifier {c} at {v}"), + (Some(c), None) if build_version(d).is_none() => { + format!("classifier {c} with no of its own") + } + (Some(_), None) => return None, + }; + let which = if moved.group == group && moved.artifact == artifact { + String::new() + } else { + format!(", which may be {group}:{artifact},") + }; + Some(format!( + "{POM}: {place}{section}declaration of {written} names {}:{} with no profile \ + active, but the of {} redefine its coordinates to {}:{}{which} \ + carrying {what}, not the patched {pinned}; whenever that applies Maven resolves \ + it instead of the pin", + d.group, d.artifact, moved.by, moved.group, moved.artifact + )) + }) +} + #[cfg(test)] mod tests { use super::super::testing::*; @@ -2117,6 +2220,335 @@ mod tests { } } + /// One `property_project` case: root ``, the declarations, + /// each profile's body, `.mvn/maven.config`. + type CoordinateCase = (String, String, Vec, Option); + + /// Runs `case` and checks the slf4j-api ref is found, and attested + /// exactly when `attested` (else withheld as a profile override naming + /// the GA and the profile ``). + async fn assert_coordinate_case(case: &CoordinateCase, attested: bool) { + let (props, decl, profiles, config) = case; + let profiles: Vec<&str> = profiles.iter().map(String::as_str).collect(); + let p = property_project(props, decl, &profiles); + if let Some(config) = config { + p.write(".mvn/maven.config", config); + } + let out = run(&p).await; + assert_refs(&out, &[(FX_PURL, UUID_A, WiringMode::Hosted)]); + if attested { + assert!( + attests(&out, FX_PURL), + "{props} {decl} {profiles:?}: {:?}", + out.unattested + ); + return; + } + assert_eq!( + out.unattested.len(), + 1, + "{props} {decl} {profiles:?}: {:?}", + out.unattested + ); + let u = &out.unattested[0]; + assert_eq!(u.kind, UnattestedKind::MavenProfileUnpatched, "{decl}"); + assert_eq!((u.purl.as_str(), u.uuid.as_str()), (FX_PURL, UUID_A)); + assert!( + u.detail.contains("org.slf4j:slf4j-api") + && u.detail.contains("") + && u.detail.contains("coordinates"), + "{}", + u.detail + ); + } + + fn in_props(body: &str) -> String { + format!("{body}\n") + } + + /// A pin declared through a `${property}` groupId or artifactId is the + /// pin of that GA only while no active profile's `` + /// redefine the property (or one it names): Maven merges them over the + /// pom's own before it interpolates the coordinates, so with such a + /// profile active the pin names another package and slf4j-api resolves + /// from wherever else it is declared (here: transitively, unpinned). + #[tokio::test] + async fn profile_properties_moving_the_pinned_coordinates_are_not_attested() { + let pin = suffixed(UUID_A); + let direct = |g: &str, a: &str| { + format!("\n{}\n", dep(g, a, Some(&pin))) + }; + let managed = format!( + "\n{}\n", + dep("${g}", "slf4j-api", Some(&pin)) + ); + let case = |props: &str, decl: &str, profiles: &[&str], config: Option<&str>| { + ( + props.to_string(), + decl.to_string(), + profiles.iter().map(|p| in_props(p)).collect::>(), + config.map(str::to_string), + ) + }; + let g = "org.slf4j"; + let by_group = direct("${g}", "slf4j-api"); + let withheld = [ + // The groupId, the artifactId, or a managed pin. + case(g, &by_group, &["org.other"], None), + case( + "slf4j-api", + &direct("org.slf4j", "${a}"), + &["slf4j-simple"], + None, + ), + case(g, &managed, &["org.other"], None), + // Through a property the coordinate's names, or partly literal. + case( + "${vendor}org.slf4j", + &by_group, + &["org.other"], + None, + ), + case( + "slf4j", + &direct("org.${v}", "${v}-api"), + &["log4j"], + None, + ), + // A value this pom cannot resolve, or a cycle. + case(g, &by_group, &["${undefined}"], None), + case(g, &by_group, &["${g}"], None), + // Two profiles that move it only together. + case( + "org.slf4jorg.slf4j", + &by_group, + &["${w}", "org.other"], + None, + ), + // A user property in the chain does not fix a name above it. + case( + "${vendor}", + &by_group, + &["org.other"], + Some("-Dvendor=org.slf4j\n"), + ), + ]; + for c in &withheld { + assert_coordinate_case(c, false).await; + } + let attested = [ + // The same GA, or an unrelated property. + case(g, &by_group, &["org.slf4j"], None), + case(g, &by_group, &["org.other"], None), + // A user property wins over every profile's. + case(g, &by_group, &["org.other"], Some("-Dg=org.slf4j\n")), + case( + "${vendor}org.other", + &by_group, + &["org.another"], + Some("-Dvendor=org.slf4j\n"), + ), + ]; + for c in &attested { + assert_coordinate_case(c, true).await; + } + } + + /// Another declaration whose `${property}` coordinates name another + /// package while no profile is active but slf4j-api once a profile's + /// `` redefine them overrides the pin with its own version + /// whenever that profile is active. So does one inside a profile whose + /// own `` make it slf4j-api. + #[tokio::test] + async fn profile_properties_turning_a_declaration_into_the_pinned_ga_are_not_attested() { + let pin = suffixed(UUID_A); + let pinned = dep("org.slf4j", "slf4j-api", Some(&pin)); + let with = |other: &str| format!("\n{pinned}{other}\n"); + let other = |v: Option<&str>| dep("${h}", "slf4j-api", v); + let classified = |c: &str, v: Option<&str>| { + let v = v.map_or(String::new(), |v| format!("{v}")); + format!( + "${{h}}slf4j-api{v}\ + {c}\n" + ) + }; + let h = "org.other"; + let to_slf4j = in_props("org.slf4j"); + let case = |props: &str, decl: String, profiles: Vec, config: Option<&str>| { + ( + props.to_string(), + decl, + profiles, + config.map(str::to_string), + ) + }; + let withheld = [ + // The base release, or another one. + case( + h, + with(&other(Some("1.7.36"))), + vec![to_slf4j.clone()], + None, + ), + case( + h, + with(&other(Some("1.7.30"))), + vec![to_slf4j.clone()], + None, + ), + // Managed, where management governs the pin. + case( + h, + format!( + "\n{}\n\ + \n{pinned}{}\n", + dep("org.slf4j", "slf4j-api", None), + other(Some("1.7.36")) + ), + vec![to_slf4j.clone()], + None, + ), + // A version the same profile moves too. + case( + &format!("{h}2.0.0"), + with(&other(Some("${v}"))), + vec![in_props("org.slf4j1.7.36")], + None, + ), + // A value the pom cannot resolve may be slf4j-api. + case( + h, + with(&other(Some("1.7.36"))), + vec![in_props("${undefined}")], + None, + ), + // An executable classifier copy, with or without a version. + case( + h, + with(&classified("tests", Some("1.7.36"))), + vec![to_slf4j.clone()], + None, + ), + case( + h, + with(&classified("tests", None)), + vec![to_slf4j.clone()], + None, + ), + // A profile declaration its own profile's properties retarget. + case( + h, + with(""), + vec![format!( + "{to_slf4j}\n{}\n", + other(Some("1.7.36")) + )], + None, + ), + // Through a user property lower in the chain. + case( + "${corp}", + with(&other(Some("1.7.36"))), + vec![to_slf4j.clone()], + Some("-Dcorp=org.other\n"), + ), + ]; + for c in &withheld { + assert_coordinate_case(c, false).await; + } + let attested = [ + // No version of its own: the live pin applies. + case(h, with(&other(None)), vec![to_slf4j.clone()], None), + // Managed, while a direct version (the pin) governs. + case( + h, + format!( + "\n{pinned}\n\ + \n{}\n", + other(Some("1.7.36")) + ), + vec![to_slf4j.clone()], + None, + ), + // A non-executable classifier copy, or another type. + case( + h, + with(&classified("sources", Some("1.7.36"))), + vec![to_slf4j.clone()], + None, + ), + case( + h, + with( + "${h}slf4j-api\ + 1.7.36pom\n", + ), + vec![to_slf4j.clone()], + None, + ), + // A user property wins over every profile's. + case( + h, + with(&other(Some("1.7.36"))), + vec![to_slf4j.clone()], + Some("-Dh=org.other\n"), + ), + ]; + for c in &attested { + assert_coordinate_case(c, true).await; + } + } + + /// Coordinate properties that cannot make any declaration the pinned + /// GA, or move the pin off it, withhold nothing: a profile moving an + /// unrelated package to another unrelated one, a partly undefined + /// coordinate no profile touches, and a profile property no coordinate + /// names. + #[tokio::test] + async fn unrelated_coordinate_properties_do_not_withhold() { + let pin = suffixed(UUID_A); + let pinned = dep("org.slf4j", "slf4j-api", Some(&pin)); + let with = |other: &str| format!("\n{pinned}{other}\n"); + let none: Option = None; + for case in [ + ( + "org.other".to_string(), + with(&dep("${h}", "slf4j-api", Some("1.7.36"))), + vec![in_props("org.third")], + none.clone(), + ), + ( + "org.other".to_string(), + with(&dep("org.slf4j", "${h}", Some("1.7.36"))), + vec![in_props("slf4j-simple")], + none.clone(), + ), + ( + "com.corp".to_string(), + with(&dep("${h}.${undefined}", "slf4j-api", Some("1.7.36"))), + vec![in_props("1")], + none.clone(), + ), + ( + "org.other1".to_string(), + with(&dep("${h}", "slf4j-api", Some("1.7.36"))), + vec![in_props("2")], + none.clone(), + ), + ( + "org.slf4j".to_string(), + format!( + "\n{}\n", + dep("${g}", "slf4j-api", Some(&pin)) + ), + vec![in_props("org.other")], + none.clone(), + ), + ] { + assert_coordinate_case(&case, true).await; + } + } + /// A `${property}` version resolves through every property it names, /// with user properties first at each level, as Maven interpolates; a /// cycle, or a chain deeper than the vendored reactor follows, is From c77d5d5b84bebb08da7176ea6c56995634cf5d0e Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 5 Nov 2026 23:27:40 -0600 Subject: [PATCH 06/20] Address review: weigh retargeted Maven coordinates with the shared checks /code-review high findings on #2026. Discovery (vex/discover/maven.rs): coordinate_override is gone. Each declaration's coordinate_alternatives now carry the version it resolves under the same profile combination, and bypasses() weighs every such reading as a declaration of its own (retargeted(): read only under those profiles, marked retargeted_by) through the existing unpatched_classifier_copy and profile_override checks; only the pinned declaration moving off the GA keeps its own small check (pin_retargeted). So: - (1) a classifier copy retargeted onto the GA is weighed with the Wiring: under the pre-v5 vendored repository (main jar only) it withholds even at the vendored version; - (6) a profile that moves coordinates and version onto the pin together no longer withholds; - (8) the reading's maps as Maven's does: test-jar is the tests classifier copy, any other non-jar type (war, pom, ...) is not the GA's jar. formats/maven: resolve_in_build is a wrapper over resolve_all_in_build, which reads several texts of one declaration under the same profile combinations but each through its own interpolation and work budget; profile_combinations takes all of them. (5) The U+0001 joined text is gone: an unterminated ${ in one coordinate stays in that one, and a reading always names a non-empty profile set. Vendored (maven_reactor.rs): profile_override / read_elsewhere read several texts under one combination, each on its own budget. (7) warn_unresolved_coordinates and warn_moved_coordinates are one pass, warn_property_coordinates, classifying each declaration as unresolved or moved with a shared warn-and-unpin tail. (2)(4) Text that can never read the patched GA (may_interpolate_to) is ruled out before any inheritor or profile is read, so a >8-profile fail-closed walk no longer unpins roots for ${g}:guava. (3) An executable classifier copy an inheritor or profile retargets onto the GA warns classifier_declared, as a literal one does. Hosted (9, back-port): PomIndex reads profile and each declaration's coordinate_alternatives, and rewrite_maven_pom refuses a dep whose pinned declaration a profile moves off the GA, or that another declaration becomes at another version (or as an executable classifier copy), with nothing written: redirect_maven_profile_coordinates_unpatched (CLI_CONTRACT.md). The vex_maven_profile_unpatched note no longer suggests re-patching such a pin with vendored mode. Tests (each fails with the fix disabled): - vex::discover::maven::tests::vendored_ref_under_a_retargeted_classifier_copy_is_not_attested - vendor::jvm::maven_reactor::tests::many_profiles_on_an_unrelated_coordinate_are_not_weighed - vendor::jvm::maven_reactor::tests::a_profile_retargeting_a_classifier_copy_warns - patch::redirect::tests::maven_pom_profile_moved_coordinates_refuse_the_dep - new cases in profile_properties_turning_a_declaration_into_the_pinned_ga_are_not_attested (test-jar withheld; war and coordinates+version onto the pin attested) and formats::maven::tests::profile_properties_give_coordinate_alternatives (an unterminated ${ in one coordinate). Redirect and VEX goldens unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 4 +- .../src/commands/vex_sources.rs | 5 +- .../src/formats/maven/mod.rs | 226 ++++++---- .../src/formats/maven/properties.rs | 39 +- .../src/patch/redirect/maven_index.rs | 87 +++- .../src/patch/redirect/mod.rs | 110 +++++ .../src/vendor/jvm/maven_reactor.rs | 387 +++++++++++------- .../src/vex/discover/maven.rs | 289 ++++++++----- 8 files changed, 791 insertions(+), 356 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 4b154b730..22616fd7e 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -329,7 +329,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Attribution gate (v5.0).** A hosted run never leaves wiring that lockfile discovery calls contested: before the rewrite writes any lockfile, the same discovery `vex`, `list`, `rollback`, `remove` and `vendor` read runs over the project as the rewrite would leave it. A candidate whose pin discovery reads but cannot attribute to one package version (a requirements `-r` include resolving the same version from the registry beside a rewired `Pipfile.lock`, #567; a Maven pin in a ``, #260) is left out of the rewrite and reported as a `skipped` event with `errorCode` `redirect_unattributable` (nothing written for it; exit code unchanged). Unchanged: a pin in a file discovery does not read (a pre-2.6 bundler `Gemfile`, locked by the next `bundle install`) keeps the rewriter's verdict, and so does a deliberate partial redirect the run already reports (a bundled or `bun patch`-ed copy left on the registry, a yarn `npm:` alias entry left on the registry with `redirect_yarn_classic_alias_skipped` / `redirect_yarn_berry_alias_skipped` while the package's direct entry is pinned, a dep withheld from the vlt rewrite while a sibling lock takes it), and so does a vendored→hosted takeover: its vendored wiring is reverted in the run's staged overlay before the rewrite plans, and it is redirected when the rewriters pin it (otherwise it is retracted and stays vendored), in a `--dry-run` preview the same. A pin whose own pnpm or yarn lock also installs an unpatched copy of the same `name@version` (a pnpm `file:` directory or tarball dependency; a yarn `file:` / URL / berry `portal:` / `link:` dependency declared under another name) is written too, with a `redirect_copy_unpatched` warning naming that entry, and is not attested. A lockless NuGet / Cargo pin (an exclusive Socket source mapping without `packages.lock.json`, a Cargo registry pin without `Cargo.lock`) is still written, with a `redirect_pin_lockless` warning: no lockfile records its version, so `vex` cannot attest it and `rollback` / `remove` / `vendor` refuse it until the lockfile exists (whether such pins should be written at all is an open decision). The rollout's recorded view uses the same discovery: a uuid a file merely mentions (a stale `package.json` field, an inactive `pdm.lock`, a comment) is not a pin and does not count as already patched. -The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate, `bun.lock` / `bun.lockb`, and `vlt-lock.json` with `vlt.json` and `node_modules/.vlt-lock.json` read only), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; the hosted redirect is a `sparse+` registry, which cargo loads only from **1.68**, so `rust-toolchain.toml` / `rust-toolchain` are read too (never edited) and a project whose declared floor — the lowest `rust-version` of the root and the member manifests read here (a `rust-version.workspace = true` inheritor reads `[workspace.package]`), else the channel rustup picks from the root (the legacy `rust-toolchain` over `rust-toolchain.toml`; a dated `nightly-`/`beta-YYYY-MM-DD` maps to its release train) — is below 1.68 is refused `redirect_cargo_old_cargo` per crate (nothing written, an earlier run's pins left in place with the `rollback` that drops them named; `--mode vendored` builds on cargo 1.41+), while an undeclared or rolling-channel floor still redirects (toolchain files in ancestor directories are not read); **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. With NO `Cargo.lock` there is no resolved graph to ask, so the dependents question is answered from the manifests instead: a crate declared beside any other dependency — anything but a path dependency on a manifest this run also pins, or a `workspace = true` inheritor of a table it scans — or beside a workspace member this run did not read (a `members` glob, or a member outside the project or behind a symbolic link, which member discovery drops) is refused `redirect_cargo_lockless_dependents`, whose detail names the remedies (commit a lockfile, or `--mode vendored`); a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json` (plus, v5.0 #353/#514, every lock a project under the root restores into: a member project's `packages.lock.json`, a per-project `packages..lock.json`, a literal `NuGetLockFilePath` — each pinned with the root config; a `NuGetLockFilePath` it cannot evaluate warns `redirect_nuget_lock_path_unresolved` and a project tree it cannot list warns `redirect_nuget_lock_unreadable`, both skipping the nuget redirect with nothing written), `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and, for a Gradle build, every settings, build, `buildSrc`, included-build, applied and plugin-source script, version catalog and lock file the script graph reaches, plus `gradle/verification-metadata.xml`, `gradle/wrapper/gradle-wrapper.properties` and the owned `.socket/gradle/` files), and the sbt build files (`socket-patch.sbt`, `socket-patch-vendor.sbt`, `build.sbt`, `project/build.properties`, `.sbtopts`, `.jvmopts`; `build.sbt.lock` and the Mill / scala-cli build files `build.mill`, `build.mill.yaml`, `build.sc`, `.mill-version`, `project.scala` for their presence only) — read, never edited; `socket-patch.sbt` is the only sbt file hosted mode writes (see **Hosted sbt** below). **npm-family flavor coverage**: package-lock / npm-shrinkwrap (a package the project patches itself with npm ≥ 12.1's native `npm patch` — a root `patchedDependencies` key, or the lock entry's `patched` record — is left on its registry entry in every npm lock, `redirect_npm_patched_dependency_skipped`), pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic (a yarn 2+ install migrates a v1 `yarn.lock` and drops its pins, so a run whose v1 lock carries a hosted pin warns `redirect_yarn_classic_berry_migration_risk` — the hosted twin of the vendored `yarn_classic_berry_migration_risk` — unless the root `package.json`, read as advisory input, declares `"packageManager": "yarn@1…"`), **yarn berry** (the pin yarn writes for a root `resolutions` entry: the root `package.json` — edited only beside a berry `yarn.lock` — gains one `"@npm:": ""` selector per locked range (`redirect_yarn_berry_resolution` edits), and only that `yarn.lock` entry is re-keyed `"@"` with the same `resolution:` + `yarnBerry10c0` checksum (`redirect_yarn_berry_entry`), moved to yarn's key order; never an `npm:` locator, whose fetcher sends npm registry auth to the patch host, nor a tarball locator under an `npm:` key, which hardened mode rejects (YN0078). An older release's `npm:::__archiveUrl=` pin is still recognized and is re-pinned on the next run; rollback rebuilds the key from the selectors and drops them. Refused, nothing written: a user-authored `resolutions` entry for the package `redirect_yarn_berry_resolutions_conflict`, no root manifest `redirect_yarn_berry_manifest_missing`, a builtin `patch:` entry wrapping the same descriptor `redirect_yarn_berry_shared_descriptor`, an artifact URL yarn cannot fetch as a tarball `redirect_yarn_berry_artifact_url_unsupported`; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found` (or `redirect_bun_non_registry_entry_skipped` when the only same-version entry is a user URL / `file:` tarball, #497), a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`), and **vlt** (`vlt-lock.json` without `lockfileVersion`, `0` or `1`; see the vlt hosted-mode contract below). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when the `repo-state.json` beside a rewritten lock exists (`common/config/rush/repo-state.json` for the common lock, `common/config/subspaces//repo-state.json` for a subspace lock; the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives), and `redirect_pnpm_trust_lockfile` carries the Rush pnpm >=11 install remedy (see the trust paragraph above). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`; when `.mvn/wrapper/maven-wrapper.properties` pins a Maven older than 3.9.4, which ignores those files, the additive warning `redirect_maven_trusted_checksums_unenforced`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a reactor root (a `` / `` declaration, a profile's included, or a Maven 4 root that lists none but whose subprojects Maven discovers: model version not `4.0.0`, `pom` packaging, a direct subdirectory holding a `pom.xml`, and `maven.project.discoverSubprojects` not turned off in `.mvn/maven.config`; each such subdirectory's `pom.xml` is read for this, never edited) is refused whole with `redirect_maven_multimodule_unsupported`: `pom.xml` and `.mvn/` are left untouched and the dep is not counted as redirected (a Gradle build beside it is still planned by the Gradle rewriter, but a dep in a root with a `pom.xml` is confirmed only when the pom pins it too), since a module's own literal `` would shadow a root pin (use `--mode vendored`; `vex` omits a hosted pin in a reactor root as `vex_maven_reactor_root`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), a `` variant (sources, tests, a native build) left at its version (`redirect_maven_classifier_unsupported`; the grant serves the main jar only, and the dep is skipped when a variant other than `sources` / `javadoc` names the patched release), a versioned declaration inside `` (a literal or a `${property}`) left as-is (`redirect_maven_profile_dependency_unpatched`; the dep is skipped when a profile holds its only declaration), a `${property}` groupId / artifactId resolved from the pom's own `` before matching (one the pom does not define, which may be the patched GA and whose own version would then win, refuses the dep with nothing written: `redirect_maven_coordinate_unresolved`), and a pom not readable as one left untouched (`redirect_maven_pom_unreadable`). Matches and anchors skip comments, CDATA, ``, ``, ``, `` and `` (the scope `vex` reads), and an existing self-closed `` / `` is expanded in place, never duplicated; an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). **gradle** (v5.0) is automated wiring, no longer a pasted snippet: the owned settings script `.socket/gradle/socket-patch.hosted.settings.gradle` with its index `.socket/gradle/hosted-index.tsv`, one apply line per build's settings file, every lock entry of the GA moved to the suffixed version, and the suffixed component in an existing `gradle/verification-metadata.xml`. A refused dep writes nothing and keeps `redirect_gradle_manual_snippet` as its fallback; same-GAV grants are refused (`redirect_gradle_same_gav_unsupported`). Rules, refusals and codes: [Gradle builds](#gradle-builds-v50). +The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate, `bun.lock` / `bun.lockb`, and `vlt-lock.json` with `vlt.json` and `node_modules/.vlt-lock.json` read only), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; the hosted redirect is a `sparse+` registry, which cargo loads only from **1.68**, so `rust-toolchain.toml` / `rust-toolchain` are read too (never edited) and a project whose declared floor — the lowest `rust-version` of the root and the member manifests read here (a `rust-version.workspace = true` inheritor reads `[workspace.package]`), else the channel rustup picks from the root (the legacy `rust-toolchain` over `rust-toolchain.toml`; a dated `nightly-`/`beta-YYYY-MM-DD` maps to its release train) — is below 1.68 is refused `redirect_cargo_old_cargo` per crate (nothing written, an earlier run's pins left in place with the `rollback` that drops them named; `--mode vendored` builds on cargo 1.41+), while an undeclared or rolling-channel floor still redirects (toolchain files in ancestor directories are not read); **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. With NO `Cargo.lock` there is no resolved graph to ask, so the dependents question is answered from the manifests instead: a crate declared beside any other dependency — anything but a path dependency on a manifest this run also pins, or a `workspace = true` inheritor of a table it scans — or beside a workspace member this run did not read (a `members` glob, or a member outside the project or behind a symbolic link, which member discovery drops) is refused `redirect_cargo_lockless_dependents`, whose detail names the remedies (commit a lockfile, or `--mode vendored`); a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json` (plus, v5.0 #353/#514, every lock a project under the root restores into: a member project's `packages.lock.json`, a per-project `packages..lock.json`, a literal `NuGetLockFilePath` — each pinned with the root config; a `NuGetLockFilePath` it cannot evaluate warns `redirect_nuget_lock_path_unresolved` and a project tree it cannot list warns `redirect_nuget_lock_unreadable`, both skipping the nuget redirect with nothing written), `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and, for a Gradle build, every settings, build, `buildSrc`, included-build, applied and plugin-source script, version catalog and lock file the script graph reaches, plus `gradle/verification-metadata.xml`, `gradle/wrapper/gradle-wrapper.properties` and the owned `.socket/gradle/` files), and the sbt build files (`socket-patch.sbt`, `socket-patch-vendor.sbt`, `build.sbt`, `project/build.properties`, `.sbtopts`, `.jvmopts`; `build.sbt.lock` and the Mill / scala-cli build files `build.mill`, `build.mill.yaml`, `build.sc`, `.mill-version`, `project.scala` for their presence only) — read, never edited; `socket-patch.sbt` is the only sbt file hosted mode writes (see **Hosted sbt** below). **npm-family flavor coverage**: package-lock / npm-shrinkwrap (a package the project patches itself with npm ≥ 12.1's native `npm patch` — a root `patchedDependencies` key, or the lock entry's `patched` record — is left on its registry entry in every npm lock, `redirect_npm_patched_dependency_skipped`), pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic (a yarn 2+ install migrates a v1 `yarn.lock` and drops its pins, so a run whose v1 lock carries a hosted pin warns `redirect_yarn_classic_berry_migration_risk` — the hosted twin of the vendored `yarn_classic_berry_migration_risk` — unless the root `package.json`, read as advisory input, declares `"packageManager": "yarn@1…"`), **yarn berry** (the pin yarn writes for a root `resolutions` entry: the root `package.json` — edited only beside a berry `yarn.lock` — gains one `"@npm:": ""` selector per locked range (`redirect_yarn_berry_resolution` edits), and only that `yarn.lock` entry is re-keyed `"@"` with the same `resolution:` + `yarnBerry10c0` checksum (`redirect_yarn_berry_entry`), moved to yarn's key order; never an `npm:` locator, whose fetcher sends npm registry auth to the patch host, nor a tarball locator under an `npm:` key, which hardened mode rejects (YN0078). An older release's `npm:::__archiveUrl=` pin is still recognized and is re-pinned on the next run; rollback rebuilds the key from the selectors and drops them. Refused, nothing written: a user-authored `resolutions` entry for the package `redirect_yarn_berry_resolutions_conflict`, no root manifest `redirect_yarn_berry_manifest_missing`, a builtin `patch:` entry wrapping the same descriptor `redirect_yarn_berry_shared_descriptor`, an artifact URL yarn cannot fetch as a tarball `redirect_yarn_berry_artifact_url_unsupported`; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found` (or `redirect_bun_non_registry_entry_skipped` when the only same-version entry is a user URL / `file:` tarball, #497), a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`), and **vlt** (`vlt-lock.json` without `lockfileVersion`, `0` or `1`; see the vlt hosted-mode contract below). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when the `repo-state.json` beside a rewritten lock exists (`common/config/rush/repo-state.json` for the common lock, `common/config/subspaces//repo-state.json` for a subspace lock; the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives), and `redirect_pnpm_trust_lockfile` carries the Rush pnpm >=11 install remedy (see the trust paragraph above). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`; when `.mvn/wrapper/maven-wrapper.properties` pins a Maven older than 3.9.4, which ignores those files, the additive warning `redirect_maven_trusted_checksums_unenforced`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a reactor root (a `` / `` declaration, a profile's included, or a Maven 4 root that lists none but whose subprojects Maven discovers: model version not `4.0.0`, `pom` packaging, a direct subdirectory holding a `pom.xml`, and `maven.project.discoverSubprojects` not turned off in `.mvn/maven.config`; each such subdirectory's `pom.xml` is read for this, never edited) is refused whole with `redirect_maven_multimodule_unsupported`: `pom.xml` and `.mvn/` are left untouched and the dep is not counted as redirected (a Gradle build beside it is still planned by the Gradle rewriter, but a dep in a root with a `pom.xml` is confirmed only when the pom pins it too), since a module's own literal `` would shadow a root pin (use `--mode vendored`; `vex` omits a hosted pin in a reactor root as `vex_maven_reactor_root`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), a `` variant (sources, tests, a native build) left at its version (`redirect_maven_classifier_unsupported`; the grant serves the main jar only, and the dep is skipped when a variant other than `sources` / `javadoc` names the patched release), a versioned declaration inside `` (a literal or a `${property}`) left as-is (`redirect_maven_profile_dependency_unpatched`; the dep is skipped when a profile holds its only declaration), a `${property}` groupId / artifactId resolved from the pom's own `` before matching (one the pom does not define, which may be the patched GA and whose own version would then win, refuses the dep with nothing written: `redirect_maven_coordinate_unresolved`), and a `${property}` groupId / artifactId some profile's `` (or several profiles' together) redefine so that the declaration a pin would go into names another package, or another declaration names the GA's main jar at another version (or one of its executable classifier copies, a `test-jar` included), refuses the dep with nothing written too (`redirect_maven_profile_coordinates_unpatched`): `vex` would never attest that pin (`vex_maven_profile_unpatched`), and a pom not readable as one left untouched (`redirect_maven_pom_unreadable`). Matches and anchors skip comments, CDATA, ``, ``, ``, `` and `` (the scope `vex` reads), and an existing self-closed `` / `` is expanded in place, never duplicated; an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). **gradle** (v5.0) is automated wiring, no longer a pasted snippet: the owned settings script `.socket/gradle/socket-patch.hosted.settings.gradle` with its index `.socket/gradle/hosted-index.tsv`, one apply line per build's settings file, every lock entry of the GA moved to the suffixed version, and the suffixed component in an existing `gradle/verification-metadata.xml`. A refused dep writes nothing and keeps `redirect_gradle_manual_snippet` as its fallback; same-GAV grants are refused (`redirect_gradle_same_gav_unsupported`). Rules, refusals and codes: [Gradle builds](#gradle-builds-v50). **Hosted sbt (v5.0, additive)**: an sbt build root (`project/build.properties` naming an `sbt.version`, 0.13.18 or later) is wired through ONE generated root file, `socket-patch.sbt` — no user file is edited. It pins every granted Maven patch build-wide (a `ThisBuild` `dependencyOverrides +=` of the Socket-only `-socket.` version plus a `file:` resolver over `.socket/sbt-hosted/maven2/`, moved ahead of the default repositories on sbt 0.13 / 1.x so an unreachable one never blocks it offline), downloads the pinned pom and jar there on the first sbt load (sha256-checked, gitignored by the file itself), and installs a load-time verifier that fails `update` when any project resolves another version or a pinned artifact whose bytes are not pinned. Edits: `redirect_sbt_pin` (added), `redirect_sbt_pin_updated` (an existing row replaced: same GA and base under a new uuid, or the same uuid with new served values; `original` names the previous uuid and version), `redirect_sbt_pin_rechecked` (an existing row re-verified after the build's dependencies changed: its dependency digest is recorded anew, `original`/`new` are `{deps}`). The load-time verifier also fails `update` when a project declares a pinned GA at a version newer than the pin's base (the build-wide override would otherwise force it back down). A new pin is gated on sbt's own resolution records under `target/` (never the machine-wide cache): run-level stops wire nothing, warn once and exit 0 — `redirect_sbt_no_resolution_evidence` (none; run `sbt update` first; always the in-memory engine's answer), `redirect_sbt_resolution_incomplete` (a declared project left no evidence, or the project definitions cannot be read statically), `redirect_sbt_resolution_stale` (a build source is newer than some project's evidence: each project is dated by its own newest record, so a partial `sbt /update` does not vouch for the others). Per-patch refusals (never confirmed): `redirect_sbt_missing_override` (no `maven2` override or no suffixed version), `redirect_sbt_integrity_missing` (jar or pom sha256 missing), `redirect_sbt_unsafe_value` (a value unsafe in a Scala literal, or an index URL not naming the uuid), `redirect_sbt_version_conflict` (some project resolves another version, or a build source declares the GA newer than the patch's base), `redirect_sbt_override_conflict` (two patches for one GA in a run, or another base already pinned), `redirect_sbt_vendored_conflict` (the GA is pinned by `socket-patch-vendor.sbt`, or that file cannot be parsed — then every Maven patch), `redirect_sbt_owned_file_modified` / `redirect_sbt_owned_file_foreign` (`socket-patch.sbt` edited, or not socket-patch's — every Maven patch), `redirect_sbt_owned_file_unreadable` (a whole-run refusal: `socket-patch.sbt` is on disk but cannot be read as UTF-8 text, so writing it would replace it; nothing is written), `redirect_sbt_unsupported_version`, `redirect_sbt_build_root_unknown` (sbt files but no versioned build root — every Maven patch), `redirect_sbt_overrides_assignment` / `redirect_sbt_resolvers_assignment` (a build source reassigns `dependencyOverrides` / `resolvers` with `:=`, `~=` or `--=`), `redirect_sbt_dependency_lock_present` (a `build.sbt.lock`), `redirect_sbt_scala_runtime_unsupported` (`org.scala-lang`), `redirect_sbt_classifier_unsupported`; a GA no library configuration resolves is skipped silently (`redirect_sbt_meta_build_only` when only the meta-build resolves it). Advisories: `redirect_sbt_version_untested` (sbt 2.1+, still wired), `redirect_sbt_override_build_repos` (`sbt.override.build.repos=true`), `redirect_maven_pom_ignored_sbt_build` (a `pom.xml` beside the sbt build, which sbt never reads; the Maven rewriter still edits it for the Maven build). A re-run keeps an existing row and re-checks it. When the build's dependency digest changed since the pin, evidence resolved after the change (fresh, newer than the generated file) re-verifies it and the row's digest is refreshed (`redirect_sbt_pin_rechecked`); the uuid is NOT confirmed on `redirect_sbt_pin_declared_newer` (a build source now declares the GA newer than the pin's base; the row stays, sbt's load-time verifier fails the build, and the remedy is `socket-patch rollback` or declaring the base again), `redirect_sbt_pin_unverifiable` (the digest changed and the evidence predates the change, or the digest cannot be computed: run `sbt update`, then re-run socket-patch), `redirect_sbt_override_shadowed` (the evidence still resolves the base version) or `redirect_sbt_resolved_elsewhere` (the pinned version resolves from outside the pin repository from a file whose sha256 is not the pinned jar's; a copy holding the pinned bytes, such as the Ivy cache a second checkout reads, is fine — at most 64 pinned artifact files of up to 256 MiB are hashed, anything else counts as elsewhere), and also when a build source now reassigns `dependencyOverrides` / `resolvers` or a `build.sbt.lock` appeared (the same `redirect_sbt_overrides_assignment` / `redirect_sbt_resolvers_assignment` / `redirect_sbt_dependency_lock_present` codes; the row stays and sbt's load-time verifier fails the build). For a pure sbt root (no `pom.xml` / Gradle script beside it), maven confirmation is decided only by the sbt rewriter's report; on a mixed root a uuid the sbt rewriter refused is still confirmed by the Maven rewriter's own `pom.xml` pin (the generated sbt files never prove a pin by substring). **Mill and scala-cli** are guidance only: per Maven patch `redirect_mill_manual_snippet` / `redirect_scala_cli_manual_snippet` carry a paste-able snippet (repository + forced suffixed version), nothing is written or confirmed, and a pure Mill / scala-cli root gets no `redirect_maven_no_pom`; there, a Maven patch the server sent without a `maven2` registry override gets `redirect_maven_missing_override` instead of a snippet (with a `pom.xml` beside the Mill / scala-cli files the pom rewriter reports it). `rollback` / `remove` restore `socket-patch.sbt` offline (the rows removed, the file deleted with its last pin; the gitignored downloads are left). Manifest-less VEX reads every strictly parsed pin as a hosted reference but grants it the lockfile basis only when the local evidence shows every recorded version of the GA is the pinned one and every recorded artifact hashes to a pinned sha256 (else `sbt_resolution_unverified`). @@ -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`. Six 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 classifier copy other than `sources` / `javadoc` (`tests`, a native or JDK-specific build), live or inside ``, resolves any version other than the suffixed pin — its own literal ``, a `` entry with the same classifier (a profile's overriding the live one), or no version in the root pom — the pin is omitted. A pre-v5 single-pom vendored repository holds only the main jar, so any such copy omits its reference. `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. **Maven profile declarations** (`vex_maven_profile_unpatched`): a `` declaration of a pinned GA's main jar in the root `pom.xml` at any version other than the pin (the base release, another release, a `${property}` the pom does not resolve) overrides the pin whenever that profile is active, so the reference is omitted. A profile `` entry always counts; a profile `` entry counts only when management governs the pin (no direct `` outside ``), since a direct literal version beats management. A declaration without a `` takes the live pin, and a `pom` (or `java-source` / `javadoc`) typed one carries no code; neither withholds the statement. Activation is not evaluated: every profile counts, as in vendored mode, which rewrites profile declarations too; hosted scan leaves them as written (`redirect_maven_profile_dependency_unpatched`). A pin spelled through a property counts the same way: a `${property}` `` is interpolated through every property it names, the project's own `${project.version}` included (itself often spelled through `${revision}`), at most 16 levels deep (a cycle or an undefined name never resolves to a pin), and when any profile's `` — or any combination of profiles' together, each nested reference read with that combination active, as the vendored planner weighs them too — redefine one of those properties so that it resolves anything other than the pin (or nothing), the reference is omitted too; a `.mvn/maven.config` user property wins over every profile's. A profile declaration reads its own profile's `` first. When more than 8 profiles may change such a version, or a profile's `` cannot be parsed, the reference is omitted (fail closed); the rest of the pom is still read. The statement names the profiles that move the pin and the version they move it to. Coordinates count the same way: when a profile's `` (or several profiles' together, or the profile holding the declaration) redefine a property a `${property}` `` / `` names, so that the declaration carrying the pin names another package (the pin then no longer applies to the patched GA) or another declaration names the patched GA (its main jar, or an executable classifier copy) at a version other than the pin, or as a classifier copy with no version of its own, the reference is omitted; a profile value the pom cannot resolve may name any package, a user property wins over every profile's, and the same 8-profile and unreadable-profile limits fail closed. The same holds for a pre-v5 single-pom vendored repository, which serves only the original version. **Maven user properties** (`vex_maven_config_override`): Maven resolves a `${property}` `` from `.mvn/maven.config`'s `-D` user properties before the pom's `` (`${project.*}` / `${pom.*}` always come from the model). A pin the pom spells through a property that `.mvn/maven.config` redefines resolves that other version, so the reference is omitted. A Socket version that only a `-D` user property supplies is not `pom.xml` wiring (hosted scan never writes one, and `rollback` / `remove` never edit `.mvn/maven.config`): it is no reference at all, only a diagnostic. +* **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`. Six 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 classifier copy other than `sources` / `javadoc` (`tests`, a native or JDK-specific build), live or inside ``, resolves any version other than the suffixed pin — its own literal ``, a `` entry with the same classifier (a profile's overriding the live one), or no version in the root pom — the pin is omitted. A pre-v5 single-pom vendored repository holds only the main jar, so any such copy omits its reference. `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. **Maven profile declarations** (`vex_maven_profile_unpatched`): a `` declaration of a pinned GA's main jar in the root `pom.xml` at any version other than the pin (the base release, another release, a `${property}` the pom does not resolve) overrides the pin whenever that profile is active, so the reference is omitted. A profile `` entry always counts; a profile `` entry counts only when management governs the pin (no direct `` outside ``), since a direct literal version beats management. A declaration without a `` takes the live pin, and a `pom` (or `java-source` / `javadoc`) typed one carries no code; neither withholds the statement. Activation is not evaluated: every profile counts, as in vendored mode, which rewrites profile declarations too; hosted scan leaves them as written (`redirect_maven_profile_dependency_unpatched`). A pin spelled through a property counts the same way: a `${property}` `` is interpolated through every property it names, the project's own `${project.version}` included (itself often spelled through `${revision}`), at most 16 levels deep (a cycle or an undefined name never resolves to a pin), and when any profile's `` — or any combination of profiles' together, each nested reference read with that combination active, as the vendored planner weighs them too — redefine one of those properties so that it resolves anything other than the pin (or nothing), the reference is omitted too; a `.mvn/maven.config` user property wins over every profile's. A profile declaration reads its own profile's `` first. When more than 8 profiles may change such a version, or a profile's `` cannot be parsed, the reference is omitted (fail closed); the rest of the pom is still read. The statement names the profiles that move the pin and the version they move it to. Coordinates count the same way: when a profile's `` (or several profiles' together, or the profile holding the declaration) redefine a property a `${property}` `` / `` names, so that the declaration carrying the pin names another package (the pin then no longer applies to the patched GA) or another declaration names the patched GA (its main jar, or an executable classifier copy) at a version other than the pin, or as a classifier copy with no version of its own, the reference is omitted; a profile value the pom cannot resolve may name any package, a user property wins over every profile's, and the same 8-profile and unreadable-profile limits fail closed. Each such reading is weighed like a declaration of its own: a retargeted classifier copy (a `test-jar` is the `tests` copy) by the classifier rule (`vex_maven_classifier_unpatched`, so a pre-v5 vendored repository, which serves only the main jar, is bypassed even at the vendored version), any other non-`jar` type not at all, and a reading that moves the coordinates and the version onto the pin together does not withhold the statement. Hosted scan refuses such a dep (`redirect_maven_profile_coordinates_unpatched`), and vendored mode leaves the declaration as written and does not pin its root (`coordinates_overridden`). The same holds for a pre-v5 single-pom vendored repository, which serves only the original version. **Maven user properties** (`vex_maven_config_override`): Maven resolves a `${property}` `` from `.mvn/maven.config`'s `-D` user properties before the pom's `` (`${project.*}` / `${pom.*}` always come from the model). A pin the pom spells through a property that `.mvn/maven.config` redefines resolves that other version, so the reference is omitted. A Socket version that only a `-D` user property supplies is not `pom.xml` wiring (hosted scan never writes one, and `rollback` / `remove` never edit `.mvn/maven.config`): it is no reference at all, only a diagnostic. * **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). diff --git a/crates/socket-patch-cli/src/commands/vex_sources.rs b/crates/socket-patch-cli/src/commands/vex_sources.rs index 6dc982a2d..e6257509c 100644 --- a/crates/socket-patch-cli/src/commands/vex_sources.rs +++ b/crates/socket-patch-cli/src/commands/vex_sources.rs @@ -283,8 +283,9 @@ fn unattested_note(kind: UnattestedKind) -> (&'static str, &'static str) { another version, or its redefine a groupId / artifactId property so \ the pin names another package or another declaration names this one; the wiring \ itself is intact, so re-running `scan` does not change this (drop that profile \ - or property or set it to the pin, or re-patch with \ - `scan --mode vendored`)", + or property or set it to the pin; for a moved version, `scan --mode \ + vendored` may re-patch it, but neither mode wires a pin whose groupId / artifactId \ + a profile can move)", ), UnattestedKind::MavenConfigOverride => ( NOTE_MAVEN_CONFIG_OVERRIDE, diff --git a/crates/socket-patch-core/src/formats/maven/mod.rs b/crates/socket-patch-core/src/formats/maven/mod.rs index 44adad231..7f4d497d9 100644 --- a/crates/socket-patch-core/src/formats/maven/mod.rs +++ b/crates/socket-patch-core/src/formats/maven/mod.rs @@ -8,8 +8,8 @@ use std::collections::BTreeMap; pub(crate) use properties::{ interpolate_keeping, interpolate_strict, is_model_reference, lookup_property, - profile_combinations, split_coordinates, Definitions, Interpolate, PomLayer, - COORDINATE_SEPARATOR, MAX_COMBINED_PROFILES, MAX_INTERPOLATION_DEPTH, + profile_combinations, Definitions, Interpolate, PomLayer, MAX_COMBINED_PROFILES, + MAX_INTERPOLATION_DEPTH, }; use properties::{interpolate_lenient, read_properties}; @@ -46,7 +46,7 @@ pub(crate) struct PomRepo { pub(crate) in_profile: bool, } -#[derive(Debug)] +#[derive(Debug, Clone)] pub(crate) struct PomDep { /// `` / `` as [`ScopedDep::group`] reads them: /// with no profile active, an undefined `${…}` kept as written. @@ -55,12 +55,13 @@ pub(crate) struct PomDep { /// The trimmed `` / `` text as written. pub(crate) group_text: String, pub(crate) artifact_text: String, - /// The other `(groupId, artifactId)` pairs the declaration names when - /// some combination of profiles is active whose `` redefine - /// a property its coordinates name, or, for one inside a profile, while - /// its own profile is ([`PomScope::coordinate_alternatives`]); a - /// reference left unresolved stays as written ([`may_name`] reads it as - /// any text). Empty when no profile can move it. + /// How the declaration reads under each combination of active profiles + /// whose `` move its coordinates off + /// [`Self::group`] / [`Self::artifact`] (for one inside a profile, its + /// own profile is always active), with the version it resolves there + /// ([`PomScope::coordinate_alternatives`]); a coordinate reference left + /// unresolved stays as written ([`may_name`] reads it as any text). + /// Empty when no profile can move its coordinates. pub(crate) coordinate_alternatives: Vec, /// Trimmed version, every `${prop}` interpolated /// ([`interpolate_strict`]) from the project model (`project.*` / @@ -91,6 +92,10 @@ pub(crate) struct PomDep { /// Inside ``. pub(crate) managed: bool, pub(crate) in_profile: bool, + /// `None` for a declaration as the pom writes it ([`parse_pom`]); a + /// reader that expands one into its [`Self::coordinate_alternatives`] + /// sets the profiles that give each such reading. + pub(crate) retargeted_by: Option, } /// A value some combination of active profiles gives a `${…}` text other @@ -104,15 +109,23 @@ pub(crate) struct Alternative { pub(crate) by: MovedBy, } -/// A `(groupId, artifactId)` some combination of active profiles gives a -/// declaration other than [`PomDep::group`] / [`PomDep::artifact`], and why. +/// How a declaration reads under one combination of active profiles that +/// moves its `(groupId, artifactId)` off the no-profile reading, and why. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct CoordinateAlternative { pub(crate) group: String, pub(crate) artifact: String, + /// Its `` in the build under the same profiles + /// ([`interpolate_strict`], as written when that fails); `None` when + /// it has none. + pub(crate) version: Option, pub(crate) by: MovedBy, } +/// What each text of a declaration reads under one combination of active +/// profiles, and which ([`PomScope::resolve_all_in_build`]). +type Reading = (Vec>, MovedBy); + /// What gives an [`Alternative`]. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum MovedBy { @@ -335,86 +348,141 @@ impl PomScope { pos: usize, interpolate: Interpolate, ) -> (Option, Vec) { + let (mut base, readings) = self.resolve_all_in_build(&[(text, interpolate)], pos); + let mut alternatives: Vec = Vec::new(); + for (mut values, by) in readings { + let value = values.remove(0).unwrap_or_else(|| text.to_string()); + if alternatives.iter().all(|a| a.value != value) { + alternatives.push(Alternative { value, by }); + } + } + (base.remove(0), alternatives) + } + + /// [`Self::resolve_in_build`] for several texts of one declaration (its + /// groupId, artifactId and version), each through its own + /// interpolation and work budget but all under the same combination of + /// active profiles: what they read with none active but the one holding + /// the declaration, and each combination ([`profile_combinations`] of + /// them all) under which any reads otherwise, the smallest first, with + /// what they all read there. Fails closed: when the combinations cannot + /// be enumerated or a profile's `` is unreadable, every text + /// as written is one reading. + fn resolve_all_in_build( + &self, + texts: &[(&str, Interpolate)], + pos: usize, + ) -> (Vec>, Vec) { let own = self.own_profile(pos); - let base = interpolate(text, &|name| self.lookup(name, &own, true)); - if !text.contains("${") { + let read = |active: &[usize]| -> Vec> { + texts + .iter() + .map(|(text, interpolate)| { + interpolate(text, &|name| self.lookup(name, active, true)) + }) + .collect() + }; + let base = read(&own); + if texts.iter().all(|(text, _)| !text.contains("${")) { return (base, Vec::new()); } - let as_written = |by: MovedBy| Alternative { - value: text.to_string(), - by, + let as_written = |by: MovedBy| { + let values = texts.iter().map(|(text, _)| Some(text.to_string())); + vec![(values.collect(), by)] }; if let Some(e) = &self.profiles_unreadable { - return (base, vec![as_written(MovedBy::Unreadable(e.clone()))]); + return (base, as_written(MovedBy::Unreadable(e.clone()))); } - let Some(combinations) = profile_combinations(text, &|name| self.definitions(name, &own)) + let values: Vec<&str> = texts.iter().map(|(text, _)| *text).collect(); + let Some(combinations) = + profile_combinations(&values, &|name| self.definitions(name, &own)) else { - return (base, vec![as_written(MovedBy::TooManyProfiles)]); + return (base, as_written(MovedBy::TooManyProfiles)); }; - let mut alternatives: Vec = Vec::new(); + let mut readings: Vec = Vec::new(); for combination in combinations { let mut active: Vec = own.iter().chain(&combination).copied().collect(); active.sort_unstable(); active.dedup(); - let value = interpolate(text, &|name| self.lookup(name, &active, true)) - .unwrap_or_else(|| text.to_string()); - if Some(&value) != base.as_ref() && alternatives.iter().all(|a| a.value != value) { + // What a text cannot resolve there is read as written. + let values: Vec> = read(&active) + .into_iter() + .zip(texts) + .map(|(v, (text, _))| v.or_else(|| Some(text.to_string()))) + .collect(); + if values != base && readings.iter().all(|(v, _)| *v != values) { let ids = combination .iter() .map(|&i| self.profile_properties[i].id.clone()) .collect(); - alternatives.push(Alternative { - value, - by: MovedBy::Profiles(ids), - }); + readings.push((values, MovedBy::Profiles(ids))); } } - (base, alternatives) + (base, readings) } - /// [`PomDep::coordinate_alternatives`] of the declaration at `pos` - /// whose coordinates are written `text` and read `base` with no profile - /// active ([`Self::interpolate_coordinate`]): both resolved together - /// ([`COORDINATE_SEPARATOR`]) through [`Self::resolve_in_build`], - /// keeping an unresolved reference ([`interpolate_keeping`]). A - /// profile declaration's own profile is always active with it, so what - /// it reads there is an alternative too. - fn coordinate_alternatives( - &self, - text: (&str, &str), - base: (&str, &str), - pos: usize, - ) -> Vec { - if !text.0.contains("${") && !text.1.contains("${") { + /// [`PomDep::coordinate_alternatives`] of `dep`: its groupId and + /// artifactId (keeping an unresolved reference, + /// [`interpolate_keeping`]) and version ([`interpolate_strict`]) read + /// together ([`Self::resolve_all_in_build`]), every reading whose + /// coordinates differ from what it reads with no profile active + /// ([`ScopedDep::group`] / [`ScopedDep::artifact`]). A profile + /// declaration's own profile is always active with it, so what it reads + /// there is one too. + pub(crate) fn coordinate_alternatives(&self, dep: &ScopedDep) -> Vec { + let (Some(group_text), Some(artifact_text), Some(group), Some(artifact)) = ( + dep.group_text.as_deref(), + dep.artifact_text.as_deref(), + dep.group.as_deref(), + dep.artifact.as_deref(), + ) else { return Vec::new(); - } - let joined = format!("{}{COORDINATE_SEPARATOR}{}", text.0, text.1); - let (own_base, alternatives) = self.resolve_in_build(&joined, pos, interpolate_keeping); - let own_ids = || { - self.own_profile(pos) - .into_iter() - .map(|i| self.profile_properties[i].id.clone()) - .collect() }; - let mut out: Vec = Vec::new(); - let candidates = own_base - .map(|v| (v, MovedBy::Profiles(own_ids()))) + if !group_text.contains("${") && !artifact_text.contains("${") { + return Vec::new(); + } + let mut texts: Vec<(&str, Interpolate)> = vec![ + (group_text, interpolate_keeping), + (artifact_text, interpolate_keeping), + ]; + if let Some(version) = dep.version_text.as_deref() { + texts.push((version, interpolate_strict)); + } + let pos = dep.element.start; + let (own_base, readings) = self.resolve_all_in_build(&texts, pos); + let own: Vec = self + .own_profile(pos) .into_iter() - .chain(alternatives.into_iter().map(|a| (a.value, a.by))); - for (value, by) in candidates { - let Some((group, artifact)) = split_coordinates(&value) else { + .map(|i| self.profile_properties[i].id.clone()) + .collect(); + let own_reading = (!own.is_empty()).then(|| { + let mut values = own_base; + if let Some(v) = values.get_mut(2) { + *v = v.take().or_else(|| dep.version_text.clone()); + } + (values, MovedBy::Profiles(own)) + }); + let mut out: Vec = Vec::new(); + for (values, by) in own_reading.into_iter().chain(readings) { + let mut values = values.into_iter(); + let (Some(Some(g)), Some(Some(a))) = (values.next(), values.next()) else { continue; }; - if (group.as_str(), artifact.as_str()) != base - && out - .iter() - .all(|o| (&o.group, &o.artifact) != (&group, &artifact)) - { - out.push(CoordinateAlternative { - group, - artifact, - by, - }); + let version = values.next().flatten(); + if (g.as_str(), a.as_str()) == (group, artifact) { + continue; + } + let reading = CoordinateAlternative { + group: g, + artifact: a, + version, + by, + }; + if !out.iter().any(|o| { + (&o.group, &o.artifact, &o.version) + == (&reading.group, &reading.artifact, &reading.version) + }) { + out.push(reading); } } out @@ -554,16 +622,12 @@ pub(crate) fn parse_pom_with(raw: &str, user: &BTreeMap) -> Resu } for dep in scope.dependencies()? { + let coordinate_alternatives = scope.coordinate_alternatives(&dep); let (Some(group), Some(artifact), Some(group_text), Some(artifact_text)) = (dep.group, dep.artifact, dep.group_text, dep.artifact_text) else { continue; }; - let coordinate_alternatives = scope.coordinate_alternatives( - (&group_text, &artifact_text), - (&group, &artifact), - dep.element.start, - ); let (version, build_version, version_alternatives) = match &dep.version_text { Some(text) => { let (spelled, build, alternatives) = scope.resolve_version(text, dep.element.start); @@ -585,6 +649,7 @@ pub(crate) fn parse_pom_with(raw: &str, user: &BTreeMap) -> Resu type_text: dep.type_text, managed: inside(dep.element.start, &managed), in_profile: dep.in_profile, + retargeted_by: None, }); } Ok(pom) @@ -1422,6 +1487,25 @@ mod tests { one("org.a", &["${g}"], &[], "${g}", "x").1, [pair("${g}", "x")] ); + // An unterminated `${` in one coordinate stays in that one: each is + // read on its own budget, and every reading names its profiles. + let pom = format!( + "x{}\ + qy\ + ", + dep("${g", "${a}") + ); + let d = super::parse_pom(&pom).unwrap().deps.remove(0); + assert_eq!((d.group.as_str(), d.artifact.as_str()), ("${g", "x")); + assert_eq!( + d.coordinate_alternatives, + [super::CoordinateAlternative { + group: "${g".to_string(), + artifact: "y".to_string(), + version: Some("1".to_string()), + by: super::MovedBy::Profiles(vec!["q".to_string()]), + }] + ); // Undefined with no profile involved: no alternative. assert_eq!( one("org.a", &[], &[], "${g}.${nope}", "x"), diff --git a/crates/socket-patch-core/src/formats/maven/properties.rs b/crates/socket-patch-core/src/formats/maven/properties.rs index 06d28a1af..3eb63b4d1 100644 --- a/crates/socket-patch-core/src/formats/maven/properties.rs +++ b/crates/socket-patch-core/src/formats/maven/properties.rs @@ -117,19 +117,6 @@ pub(crate) fn interpolate_keeping( Some(interpolate_lenient(value, lookup)) } -/// Joins a `` and an `` into one text, so that one -/// interpolation (and one [`profile_combinations`] walk) reads both under -/// the same active profiles; a control character neither may hold (XML -/// 1.0 forbids it in a document). [`split_coordinates`] undoes it. -pub(crate) const COORDINATE_SEPARATOR: char = '\u{1}'; - -/// The `(groupId, artifactId)` a [`COORDINATE_SEPARATOR`]-joined text -/// holds. -pub(crate) fn split_coordinates(joined: &str) -> Option<(String, String)> { - let (g, a) = joined.split_once(COORDINATE_SEPARATOR)?; - Some((g.to_string(), a.to_string())) -} - fn expand( value: &str, lookup: &dyn Fn(&str) -> Option, @@ -204,19 +191,25 @@ pub(crate) struct Definitions { pub(crate) profiles: Vec, } -/// Every nonempty combination of the profiles that may change what -/// `value` interpolates to — those defining a name reachable from it +/// Every nonempty combination of the profiles that may change what any of +/// `values` interpolates to — those defining a name reachable from one /// through any definition ([`Definitions`]) — smallest first, each sorted: /// Maven activates any number of profiles together, so a value two -/// profiles move only jointly is caught. Empty when no profile can change -/// it. `None` when more than [`MAX_COMBINED_PROFILES`] may, or the walk -/// exceeds its bound: the caller fails closed. +/// profiles move only jointly is caught, and the texts of one declaration +/// (its groupId, artifactId and version) are read under the same +/// combinations. Empty when no profile can change them. `None` when more +/// than [`MAX_COMBINED_PROFILES`] may, or the walk exceeds its bound: the +/// caller fails closed. pub(crate) fn profile_combinations( - value: &str, + values: &[&str], defs: &dyn Fn(&str) -> Definitions, ) -> Option>> { let mut seen: BTreeSet = BTreeSet::new(); - let mut queue: Vec = references(value).map(str::to_string).collect(); + let mut queue: Vec = values + .iter() + .flat_map(|v| references(v)) + .map(str::to_string) + .collect(); let mut relevant: BTreeSet = BTreeSet::new(); while let Some(name) = queue.pop() { if !seen.insert(name.clone()) { @@ -370,15 +363,15 @@ mod tests { }, }; assert_eq!( - profile_combinations("${v}", &defs), + profile_combinations(&["${v}"], &defs), Some(vec![vec![0], vec![1], vec![0, 1]]) ); - assert_eq!(profile_combinations("1.0", &defs), Some(vec![])); + assert_eq!(profile_combinations(&["1.0"], &defs), Some(vec![])); let many = |_: &str| Definitions { texts: vec![], profiles: (0..=MAX_COMBINED_PROFILES).collect(), }; - assert_eq!(profile_combinations("${v}", &many), None); + assert_eq!(profile_combinations(&["${v}"], &many), None); } #[test] diff --git a/crates/socket-patch-core/src/patch/redirect/maven_index.rs b/crates/socket-patch-core/src/patch/redirect/maven_index.rs index 3d2a024ff..f2ec54150 100644 --- a/crates/socket-patch-core/src/patch/redirect/maven_index.rs +++ b/crates/socket-patch-core/src/patch/redirect/maven_index.rs @@ -6,7 +6,9 @@ //! shifting offsets, so a pom with many patched dependencies is scanned once, //! not once per dependency. -use crate::formats::maven::PomScope; +use crate::formats::maven::{ + is_executable_classifier, may_shadow, CoordinateAlternative, PomScope, +}; use crate::formats::xml::{child_text, children, elements, Element}; /// One `` outside ignored sections. @@ -20,6 +22,13 @@ pub(super) struct IndexedDep { pub(super) type_text: Option, pub(super) classifier: Option, pub(super) in_profile: bool, + /// The trimmed `` / `` text as written. + pub(super) group_text: Option, + pub(super) artifact_text: Option, + /// How each combination of profiles that moves its coordinates reads + /// it ([`PomScope::coordinate_alternatives`], as VEX discovery reads + /// it). + pub(super) coordinate_alternatives: Vec, } /// One live ``. @@ -126,12 +135,16 @@ impl PomIndex { pom: &str, user: &std::collections::BTreeMap, ) -> Result { - let scope = PomScope::with_user_properties(pom, user)?; + let mut scope = PomScope::with_user_properties(pom, user)?; + // Profile `` may move a `${…}` coordinate, as VEX + // discovery reads it (one it cannot read fails closed there). + scope.read_profiles(); let masked = &scope.masked; let deps = scope .dependencies()? .into_iter() .map(|d| IndexedDep { + coordinate_alternatives: scope.coordinate_alternatives(&d), group: d.group, artifact: d.artifact, version_inner: d.version_inner, @@ -139,6 +152,8 @@ impl PomIndex { type_text: d.type_text, classifier: d.classifier, in_profile: d.in_profile, + group_text: d.group_text, + artifact_text: d.artifact_text, }) .collect(); let mut repos = Vec::new(); @@ -201,6 +216,71 @@ impl PomIndex { }) } + /// Why a pin of `group:artifact` at `served` (the suffixed version) + /// would not hold whenever some combination of profiles is active whose + /// `` redefine a property a `${…}` coordinate names + /// ([`IndexedDep::coordinate_alternatives`]), as VEX discovery weighs + /// it: a live versioned declaration of it (the one a pin is written + /// into) then names another package, or another declaration then names + /// its main jar (as [`may_shadow`] reads one) at a version other than + /// `served`, or literally one of its executable classifier copies + /// (`test-jar` is the `tests` copy) off `served` or with no version of + /// its own. A main jar without a version takes the pin. + pub(super) fn retargeted( + &self, + group: &str, + artifact: &str, + served: Option<&str>, + ) -> Option { + self.deps.iter().find_map(|d| { + let written = format!( + "{}:{}", + d.group_text.as_deref().unwrap_or_default(), + d.artifact_text.as_deref().unwrap_or_default() + ); + if d.group.as_deref() == Some(group) && d.artifact.as_deref() == Some(artifact) { + let moved = d.coordinate_alternatives.first()?; + return (!d.in_profile && d.classifier.is_none() && d.version_text.is_some()).then( + || { + format!( + "{written} names {group}:{artifact}, but the of {} \ + make it {}:{}", + moved.by, moved.group, moved.artifact + ) + }, + ); + } + let classifier = match (d.type_text.as_deref(), d.classifier.as_deref()) { + (None | Some("jar"), c) => c, + (Some(t), c) if t.contains("${") => c, + (Some("test-jar"), None) => Some("tests"), + _ => return None, + }; + d.coordinate_alternatives.iter().find_map(|r| { + let literal = r.group == group && r.artifact == artifact; + let names = match classifier { + None => { + (literal + || may_shadow((&r.group, &r.artifact), None, None, (group, artifact))) + && r.version.is_some() + } + Some(c) => literal && is_executable_classifier(c), + }; + (names && r.version.as_deref() != served).then(|| { + format!( + "{written}{} declares {}:{} at {} under the \ + of {}", + classifier.map_or(String::new(), |c| format!(" (classifier {c})")), + r.group, + r.artifact, + r.version.as_deref().unwrap_or("(none of its own)"), + r.by + ) + }) + }) + }) + } + pub(super) fn dep(&self, i: usize) -> &IndexedDep { &self.deps[i] } @@ -344,6 +424,9 @@ impl PomIndex { type_text: None, classifier: None, in_profile: false, + group_text: Some(group.to_string()), + artifact_text: Some(artifact.to_string()), + coordinate_alternatives: Vec::new(), }); } diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 0c778ac74..0dba0a674 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -8897,6 +8897,21 @@ fn rewrite_maven_pom( } } } + // A `${…}` groupId / artifactId some profile's `` + // redefine may move the pinned declaration off this GA, or make + // another declaration this GA at its own version, whenever that + // profile is active: VEX would never attest such a pin, so none is + // written and the dep is not counted as redirected. + if let Some(why) = idx.retargeted(&group_id, &artifact_id, suffixed_version.as_deref()) { + result.warnings.push(RewriteWarning { + code: "redirect_maven_profile_coordinates_unpatched".into(), + detail: format!( + "{why}; whenever that applies a pin of {group_id}:{artifact_id} would not \ + hold, so pom.xml was left as it is and the dep is not counted as redirected" + ), + }); + continue; + } let has_live_repo = idx.has_repository(&repo_id); // LEGACY same-GAV fallback: no suffixed version means the patched jar is @@ -10694,6 +10709,101 @@ mod tests { } } + /// A `${…}` groupId / artifactId some profile's `` redefine + /// may move the declaration a pin would be written into off the GA, or + /// make another declaration the GA at its own version, whenever that + /// profile is active, and VEX never attests such a pin: hosted scan + /// writes none and says why (#2026 review, back-ported from VEX + /// discovery). A profile that leaves the coordinates alone, or makes an + /// unrelated declaration another unrelated package, does not block it. + #[test] + fn maven_pom_profile_moved_coordinates_refuse_the_dep() { + let pom = |props: &str, deps: &str, profile: &str| { + format!( + "\n {props}\n \n {deps}\n \ + \n p{profile}\ + \n\n" + ) + }; + let decl = |g: &str, a: &str, v: &str| { + format!( + "{g}{a}\ + {v}" + ) + }; + let pinned = decl("org.slf4j", "slf4j-api", "1.7.36"); + for (props, deps, profile) in [ + // The declaration a pin goes into moves off the GA. + ( + "org.slf4j", + decl("${g}", "slf4j-api", "1.7.36"), + "org.other", + ), + // Another declaration becomes the GA. + ( + "org.other", + format!("{pinned}{}", decl("${h}", "slf4j-api", "1.7.30")), + "org.slf4j", + ), + // ... or its executable classifier copy. + ( + "org.other", + format!( + "{pinned}${{h}}slf4j-api\ + 1.7.36tests\ + " + ), + "org.slf4j", + ), + ] { + let mut files = BTreeMap::new(); + files.insert("pom.xml".to_string(), pom(props, &deps, profile)); + let r = rewrite_registry_redirect(&files, &[maven_override()]); + assert!( + r.files.is_empty() && r.edits.is_empty(), + "{deps}: nothing written: {:?}", + r.files + ); + assert_eq!( + warning_codes(&r), + vec!["redirect_maven_profile_coordinates_unpatched"], + "{deps}" + ); + } + for (props, deps, profile) in [ + ( + "org.slf4j", + decl("${g}", "slf4j-api", "1.7.36"), + "org.slf4j", + ), + ( + "org.other", + format!("{pinned}{}", decl("${h}", "slf4j-api", "1.7.30")), + "org.third", + ), + // Versionless: it takes the pin. + ( + "org.other", + format!( + "{pinned}${{h}}slf4j-api\ + " + ), + "org.slf4j", + ), + ] { + let mut files = BTreeMap::new(); + files.insert("pom.xml".to_string(), pom(props, &deps, profile)); + let r = rewrite_registry_redirect(&files, &[maven_override()]); + assert!( + !warning_codes(&r).contains(&"redirect_maven_profile_coordinates_unpatched"), + "{deps}: {:?}", + r.warnings + ); + let out = r.files.get("pom.xml").expect("pom rewritten"); + assert!(out.contains(MAVEN_SUFFIXED), "{deps}: pinned: {out}"); + } + } + /// A versioned `` declaration through an undefined coordinate /// property may be the GA while its profile is active: reported like a /// literal profile declaration (#683), pinned beside a top-level diff --git a/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs b/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs index 17fed1a22..ce8e73517 100644 --- a/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs +++ b/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs @@ -12,10 +12,9 @@ use std::ops::Range; use serde_json::{json, Value}; use crate::formats::maven::{ - interpolate_keeping, interpolate_strict, is_model_reference, lookup_property, - maven_config_properties as cli_properties, profile_combinations, split_coordinates, - Definitions, Interpolate, PomLayer, COORDINATE_SEPARATOR, MAX_COMBINED_PROFILES, - MAX_INTERPOLATION_DEPTH, + interpolate_keeping, interpolate_strict, is_executable_classifier, is_model_reference, + lookup_property, maven_config_properties as cli_properties, profile_combinations, Definitions, + Interpolate, PomLayer, MAX_COMBINED_PROFILES, MAX_INTERPOLATION_DEPTH, }; use super::super::state::{WiringAction, WiringRecord}; @@ -1787,32 +1786,66 @@ impl Reactor { sv: &str, ) -> Option<(String, String)> { let moved = |v: &str| !is_base_like(v, base) && v != sv; + self.read_elsewhere(rel, &[(declared, interpolate_strict)], &|v| moved(&v[0])) + .map(|(mut v, at)| (v.remove(0), at)) + } + + /// Where `texts` (one declaration's, each read through its own + /// interpolation) read values `hit` accepts, other than in `rel` with + /// no profile active: in an inheritor of `rel` (Maven interpolates + /// after inheritance), or in `rel` or an inheritor with some combination + /// of the profiles on its chain active ([`Self::profile_override`]); + /// with those values (an undefined one reads "an undefined value") and + /// where. + fn read_elsewhere( + &self, + rel: &str, + texts: &[(&str, Interpolate)], + hit: &dyn Fn(&[String]) -> bool, + ) -> Option<(Vec, String)> { std::iter::once(rel) .chain(self.inheritors(rel)) .find_map(|reader| { // `rel` resolves it, so a reader or profile that does not // has redefined something it names: that moves it too. - let undefined = || "an undefined value".to_string(); let plain = (reader != rel) - .then(|| self.interpolate(reader, declared).unwrap_or_else(undefined)) - .filter(|v| moved(v)) + .then(|| self.read_under(reader, texts, &[])) + .filter(|v| hit(v)) .map(|v| (v, reader.to_string())); - plain - .or_else(|| self.profile_override(reader, declared, interpolate_strict, &moved)) + plain.or_else(|| self.profile_override(reader, texts, hit)) + }) + } + + /// What each of `texts` reads in `reader` with the `active` profiles + /// active, through its own interpolation and work budget ("an + /// undefined value" when that fails). + fn read_under( + &self, + reader: &str, + texts: &[(&str, Interpolate)], + active: &[Overlay<'_>], + ) -> Vec { + texts + .iter() + .map(|(text, interpolate)| { + self.interpolate_with(reader, text, active, *interpolate) + .unwrap_or_else(|| "an undefined value".to_string()) }) + .collect() } /// The first combination of the profiles on `reader`'s chain under - /// which `declared` resolves (through `interpolate`) a value `moved` - /// accepts, with the value and where; fails closed (an undetermined - /// value) when more profiles may change it than are combined. + /// which `texts` (all read under the same combination, + /// [`Self::read_under`]) read values `moved` accepts, with the values + /// and where; fails closed (undetermined values, whatever `moved` says) + /// when more profiles may change them than are combined, so a caller + /// that weighs only some declarations filters them first. fn profile_override( &self, reader: &str, - declared: &str, - interpolate: Interpolate, - moved: &dyn Fn(&str) -> bool, - ) -> Option<(String, String)> { + texts: &[(&str, Interpolate)], + moved: &dyn Fn(&[String]) -> bool, + ) -> Option<(Vec, String)> { let profiles: Vec<(&str, &str, &BTreeMap)> = self .chain(reader) .flat_map(|at| { @@ -1847,9 +1880,10 @@ impl Reactor { } d }; - let Some(combinations) = profile_combinations(declared, &definitions) else { + let values: Vec<&str> = texts.iter().map(|(text, _)| *text).collect(); + let Some(combinations) = profile_combinations(&values, &definitions) else { return Some(( - "an undetermined value".to_string(), + vec!["an undetermined value".to_string(); texts.len()], format!( "{reader} under some combination of the more than {MAX_COMBINED_PROFILES} \ profiles on its chain that redefine what it names" @@ -1861,9 +1895,7 @@ impl Reactor { .iter() .map(|&i| (profiles[i].0, profiles[i].2)) .collect(); - let v = self - .interpolate_with(reader, declared, &active, interpolate) - .unwrap_or_else(|| "an undefined value".to_string()); + let v = self.read_under(reader, texts, &active); moved(&v).then(|| { let under = combination .iter() @@ -1877,14 +1909,13 @@ impl Reactor { /// Where `dep` of `rel`, a declaration named through `${...}` /// coordinates, reads a groupId and artifactId that `hit` accepts, - /// other than `rel` with no profile active: an inheritor of `rel`, or - /// `rel` or an inheritor with some combination of the profiles on its - /// chain active ([`Self::profile_override`], which covers the profile - /// holding `dep`). Both are read together, an unresolved reference kept - /// as written ([`interpolate_keeping`]): Maven interpolates after - /// inheritance and with the active profiles' `` merged in, - /// then matches management and pins. More profiles than are combined - /// fail closed (accepted whatever `hit` says). + /// other than `rel` with no profile active ([`Self::read_elsewhere`], + /// which covers the profile holding `dep`): each read under the same + /// profiles but on its own, an unresolved reference kept as written + /// ([`interpolate_keeping`]). Maven interpolates after inheritance and + /// with the active profiles' `` merged in, then matches + /// management and pins. More profiles than are combined fail closed + /// (accepted whatever `hit` says). fn coordinates_elsewhere( &self, rel: &str, @@ -1892,25 +1923,14 @@ impl Reactor { hit: &dyn Fn(&str, &str) -> bool, ) -> Option { let doc = &self.poms[rel].doc; - let joined = format!( - "{}{COORDINATE_SEPARATOR}{}", - doc.child_text(dep, "groupId")?, - doc.child_text(dep, "artifactId")? - ); - let accepts = |v: &str| split_coordinates(v).is_some_and(|(g, a)| hit(&g, &a)); - std::iter::once(rel) - .chain(self.inheritors(rel)) - .find_map(|reader| { - let plain = (reader != rel) - .then(|| self.interpolate_with(reader, &joined, &[], interpolate_keeping)) - .flatten() - .filter(|v| accepts(v)) - .map(|_| reader.to_string()); - plain.or_else(|| { - self.profile_override(reader, &joined, interpolate_keeping, &accepts) - .map(|(_, at)| at) - }) - }) + let group = doc.child_text(dep, "groupId")?; + let artifact = doc.child_text(dep, "artifactId")?; + let texts: [(&str, Interpolate); 2] = [ + (&group, interpolate_keeping), + (&artifact, interpolate_keeping), + ]; + self.read_elsewhere(rel, &texts, &|v| hit(&v[0], &v[1])) + .map(|(_, at)| at) } /// Every pom in scope that inherits from `rel` (has it on its chain). @@ -1939,8 +1959,7 @@ impl Reactor { ) { let doc = &self.poms[rel].doc; let ga = format!("{}:{}", patch.group_id, patch.artifact_id); - self.warn_unresolved_coordinates(rel, patch, warnings, unpinned, uncertain); - self.warn_moved_coordinates(rel, patch, warnings, unpinned, uncertain); + self.warn_property_coordinates(rel, patch, warnings, unpinned, uncertain); for (dep, key) in self.keyed_declarations(rel, patch.group_id, patch.artifact_id) { if doc .child_text(dep, "type") @@ -2065,11 +2084,21 @@ impl Reactor { } impl Reactor { - /// Warn for each declaration of `rel` that could be `patch`'s g:a but - /// names a `${...}` groupId or artifactId undefined in the checkout: - /// it is left as is, probably unpatched. One with its own version - /// beats any pin, so its local root is not pinned. - fn warn_unresolved_coordinates( + /// Warn for each declaration of `rel` named through `${...}` + /// coordinates that is not `patch`'s g:a in `rel` itself (those + /// `rewrite_declarations` weighs) but may be: one whose property the + /// checkout does not define (`property_unresolved`), or one an + /// inheritor or a combination of profiles makes it + /// (`coordinates_overridden`, [`Self::coordinates_elsewhere`]). It is + /// left as is, probably unpatched; one with its own version beats any + /// pin, so its local root is not pinned. A classifier copy is its own + /// coordinate, which no pin reaches: an executable one is warned about + /// as `classifier_declared`, as `rewrite_declarations` does (`sources` / + /// `javadoc` never run), and any other `` but `jar` is not the + /// g:a's jar. Text that can never + /// read the g:a, whatever its properties say ([`may_interpolate_to`]), + /// is ruled out first, before any inheritor or profile is read. + fn warn_property_coordinates( &self, rel: &str, patch: &JvmPatch<'_>, @@ -2079,6 +2108,7 @@ impl Reactor { ) { let doc = &self.poms[rel].doc; let resolve = |v: &str| self.interpolate(rel, v); + let ga = format!("{}:{}", patch.group_id, patch.artifact_id); for dep in doc.declarations() { let (Some(g), Some(a)) = ( doc.child_text(dep, "groupId"), @@ -2086,108 +2116,83 @@ impl Reactor { ) else { continue; }; - let unresolved: Vec<&str> = [&g, &a] - .into_iter() - .filter(|c| c.contains("${") && resolve(c).is_none()) - .map(String::as_str) - .collect(); - let could_be = |raw: &str, want: &str| match resolve(raw) { - Some(v) => v == want, - None => may_interpolate_to(raw, want), - }; - let other_variant = doc - .child_text(dep, "type") - .is_some_and(|t| !t.is_empty() && t != "jar") + let classifier = doc.child_text(dep, "classifier").filter(|c| !c.is_empty()); + if !doc.has_property_coordinate(dep) + || doc.pin_at(dep).is_some() + || classifier + .as_deref() + .is_some_and(|c| !is_executable_classifier(c)) + || !may_interpolate_to(&g, patch.group_id) + || !may_interpolate_to(&a, patch.artifact_id) || doc - .child_text(dep, "classifier") - .is_some_and(|c| !c.is_empty()); - if unresolved.is_empty() - || other_variant - || !could_be(&g, patch.group_id) - || !could_be(&a, patch.artifact_id) + .child_text(dep, "type") + .is_some_and(|t| !t.is_empty() && t != "jar") { continue; } - let at = format!("{rel}:{}", doc.line_of(doc.nodes[dep].start)); - let versioned = doc - .child_text(dep, "version") - .is_some_and(|v| !v.is_empty()); - let root = self.local_root(rel); - let unpin = if versioned { - format!(", and {root} is not pinned") + let (here_g, here_a) = (resolve(&g), resolve(&a)); + let here_g = if g.contains("${") { + here_g } else { - String::new() + Some(g.clone()) }; - warnings.push(degraded( - "property_unresolved", - format!( - "{at}: {g}:{a} may be {}:{} but {} is not defined in the checkout; \ - left as is, probably unpatched{unpin}", - patch.group_id, - patch.artifact_id, - unresolved.join(", ") - ), - )); - if versioned { - unpinned.insert(root); - uncertain.insert(at); - } - } - } -} - -impl Reactor { - /// Warn for each declaration of `rel` whose `${...}` groupId or - /// artifactId reads another artifact in `rel` itself but may read - /// `patch`'s g:a in an inheritor or with a profile active - /// ([`Self::coordinates_elsewhere`]): it is left as is, and there it - /// declares the g:a at its own version. One with a version beats any - /// pin, so its local root is not pinned. - fn warn_moved_coordinates( - &self, - rel: &str, - patch: &JvmPatch<'_>, - warnings: &mut Vec, - unpinned: &mut BTreeSet, - uncertain: &mut BTreeSet, - ) { - let doc = &self.poms[rel].doc; - let resolve = |v: &str| self.interpolate(rel, v); - for dep in doc.declarations() { - let (Some(g), Some(a)) = ( - doc.child_text(dep, "groupId"), - doc.child_text(dep, "artifactId"), - ) else { - continue; + let here_a = if a.contains("${") { + here_a + } else { + Some(a.clone()) }; - let other_variant = doc - .child_text(dep, "type") - .is_some_and(|t| !t.is_empty() && t != "jar") - || doc - .child_text(dep, "classifier") - .is_some_and(|c| !c.is_empty()); - // Undefined in `rel`: `warn_unresolved_coordinates` weighs it; - // `patch`'s g:a in `rel`: `rewrite_declarations` does. - let (Some(here_g), Some(here_a)) = ( - doc.coordinate(dep, "groupId", &resolve), - doc.coordinate(dep, "artifactId", &resolve), - ) else { - continue; + let why = match (&here_g, &here_a) { + (Some(hg), Some(ha)) if hg == patch.group_id && ha == patch.artifact_id => { + continue; + } + (Some(hg), Some(ha)) => { + let Some(there) = self.coordinates_elsewhere(rel, dep, &|rg, ra| { + may_interpolate_to(rg, patch.group_id) + && may_interpolate_to(ra, patch.artifact_id) + }) else { + continue; + }; + ( + "coordinates_overridden", + format!("{g}:{a} is {hg}:{ha} here but may be {ga} in {there}"), + ) + } + _ => { + let could_be = |read: &Option, raw: &str, want: &str| match read { + Some(v) => v == want, + None => may_interpolate_to(raw, want), + }; + if !could_be(&here_g, &g, patch.group_id) + || !could_be(&here_a, &a, patch.artifact_id) + { + continue; + } + let unresolved: Vec<&str> = [(&g, &here_g), (&a, &here_a)] + .into_iter() + .filter(|(_, read)| read.is_none()) + .map(|(raw, _)| raw.as_str()) + .collect(); + ( + "property_unresolved", + format!( + "{g}:{a} may be {ga} but {} is not defined in the checkout", + unresolved.join(", ") + ), + ) + } }; - if other_variant - || !doc.has_property_coordinate(dep) - || doc.pin_at(dep).is_some() - || (here_g == patch.group_id && here_a == patch.artifact_id) - { + let (reason, detail) = why; + let at = format!("{rel}:{}", doc.line_of(doc.nodes[dep].start)); + if let Some(classifier) = classifier { + warnings.push(degraded( + "classifier_declared", + format!( + "{at}: {detail}; a classifier variant ({classifier}) of {ga} bypasses \ + the pin" + ), + )); continue; } - // A reference left unresolved there may read any text. - let Some(there) = self.coordinates_elsewhere(rel, dep, &|rg, ra| { - may_interpolate_to(rg, patch.group_id) && may_interpolate_to(ra, patch.artifact_id) - }) else { - continue; - }; - let at = format!("{rel}:{}", doc.line_of(doc.nodes[dep].start)); let versioned = doc .child_text(dep, "version") .is_some_and(|v| !v.is_empty()); @@ -2198,12 +2203,8 @@ impl Reactor { String::new() }; warnings.push(degraded( - "coordinates_overridden", - format!( - "{at}: {g}:{a} is {here_g}:{here_a} here but may be {}:{} in {there}; \ - left as is, probably unpatched there{unpin}", - patch.group_id, patch.artifact_id, - ), + reason, + format!("{at}: {detail}; left as is, probably unpatched{unpin}"), )); if versioned { unpinned.insert(root); @@ -6216,6 +6217,86 @@ mod tests { assert_eq!(attestable(&later), (true, true)); } + /// Coordinates whose text can never read the patched g:a (`${g}:guava`) + /// are not weighed, however many profiles redefine `g`: more than are + /// combined would otherwise fail closed and unpin the root (#2026 + /// review). The same text naming commons-text does fail closed. + #[test] + fn many_profiles_on_an_unrelated_coordinate_are_not_weighed() { + let profiles: String = (0..=MAX_COMBINED_PROFILES) + .map(|i| { + format!("p{i}org.g{i}") + }) + .collect(); + let root = ROOT.replace( + " ", + &format!( + " com.google.guava\n \ + {profiles}\n " + ), + ); + let named = |artifact: &str| { + module( + "a", + &format!( + "{}${{g}}{artifact}\ + 1", + dep("1.10.0") + ), + ) + }; + let files = fs(&[ + ("pom.xml", &root), + ("a/pom.xml", &named("guava")), + ("b/pom.xml", &module("b", "")), + ]); + let plan = run(&files).unwrap(); + assert!(plan.warnings.is_empty(), "{:?}", plan.warnings); + assert!(root_pinned(&plan, &files)); + + let files = fs(&[ + ("pom.xml", &root), + ("a/pom.xml", &named("commons-text")), + ("b/pom.xml", &module("b", "")), + ]); + let plan = run(&files).unwrap(); + assert_eq!(reasons(&plan), ["coordinates_overridden"]); + assert!(!root_pinned(&plan, &files)); + } + + /// A classifier copy a profile retargets onto the patched g:a is its + /// own coordinate, which no pin reaches: warned about as + /// `classifier_declared`, like a literal one, without unpinning the + /// root (#2026 review). + #[test] + fn a_profile_retargeting_a_classifier_copy_warns() { + let copy = "${lib.group}\ + commons-text1.10.0\ + tests"; + let files = fs(&[ + ( + "pom.xml", + &root_with( + "org.other", + "org.apache.commons", + ), + ), + ( + "a/pom.xml", + &module("a", &format!("{}{copy}", dep("1.10.0"))), + ), + ("b/pom.xml", &module("b", "")), + ]); + let plan = run(&files).unwrap(); + assert_eq!(reasons(&plan), ["classifier_declared"]); + assert!( + plan.warnings[0].detail.contains("profile corp"), + "{:?}", + plan.warnings + ); + assert!(root_pinned(&plan, &files)); + } + fn attestable(files: &Fs) -> (bool, bool) { let read = |p: &str| files.get(p).cloned(); let c = Coords { diff --git a/crates/socket-patch-core/src/vex/discover/maven.rs b/crates/socket-patch-core/src/vex/discover/maven.rs index 893b98a11..3faa071a6 100644 --- a/crates/socket-patch-core/src/vex/discover/maven.rs +++ b/crates/socket-patch-core/src/vex/discover/maven.rs @@ -129,7 +129,7 @@ use super::{ }; use crate::formats::maven::{ is_executable_classifier, is_maven_coordinate, is_maven_version_text, maven_config_properties, - may_shadow, parse_pom_with, split_socket_version, Pom, PomDep, PomRepo, + may_shadow, parse_pom_with, split_socket_version, MovedBy, Pom, PomDep, PomRepo, }; use crate::patch::redirect::{MVN_CHECKSUMS, MVN_CONFIG, TRUSTED_CHECKSUMS_ON}; use crate::utils::digest::sha256_hex; @@ -831,18 +831,106 @@ fn bypasses( ), )); } - if let Some(detail) = unpatched_classifier_copy(deps, group, artifact, pinned, wiring) { + // Each declaration as written, then as each combination of profiles + // that moves its coordinates reads it: what it then declares is weighed + // by the same checks. + let read: Vec = deps.iter().cloned().chain(retargeted(deps)).collect(); + if let Some(detail) = unpatched_classifier_copy(&read, group, artifact, pinned, wiring) { found.push((UnattestedKind::MavenClassifierUnpatched, detail)); } let pin_managed = built.direct.is_empty(); - if let Some(detail) = profile_override(deps, group, artifact, pinned, pin_managed) - .or_else(|| coordinate_override(deps, group, artifact, pinned, pin_managed)) + if let Some(detail) = profile_override(&read, group, artifact, pinned, pin_managed) + .or_else(|| pin_retargeted(deps, group, artifact, pinned, pin_managed)) { found.push((UnattestedKind::MavenProfileUnpatched, detail)); } found } +/// Each declaration's [`PomDep::coordinate_alternatives`] as a declaration +/// of its own: what Maven reads while those profiles are active, so read +/// only then (`in_profile`), at the version it resolves there, and marked +/// with the profiles that give it ([`PomDep::retargeted_by`]). Its `` +/// is read as Maven maps it, as [`may_shadow`] does: `test-jar` is the +/// `tests` classifier copy, and any other type but `jar` (or a property) +/// is not a jar on the classpath, so no reading of it is kept. +fn retargeted(deps: &[PomDep]) -> impl Iterator + '_ { + deps.iter().flat_map(|d| { + d.coordinate_alternatives.iter().filter_map(move |r| { + let (classifier, type_text) = match d.type_text.as_deref() { + None | Some("jar") => (d.classifier.clone(), d.type_text.clone()), + Some(t) if t.contains("${") => (d.classifier.clone(), d.type_text.clone()), + Some("test-jar") if d.classifier.is_none() => (Some("tests".to_string()), None), + Some(_) => return None, + }; + Some(PomDep { + group: r.group.clone(), + artifact: r.artifact.clone(), + coordinate_alternatives: Vec::new(), + version: r.version.clone(), + build_version: r.version.clone(), + version_alternatives: Vec::new(), + classifier, + type_text, + in_profile: true, + retargeted_by: Some(r.by.clone()), + ..d.clone() + }) + }) + }) +} + +/// How a declaration [`retargeted`] reads, for a detail: the declaration as +/// written and the profiles that make it `group:artifact` (or a property +/// it leaves unresolved that may be). +fn retargeted_text(d: &PomDep, by: &MovedBy, group: &str, artifact: &str) -> String { + let maybe = if d.group == group && d.artifact == artifact { + String::new() + } else { + format!(" (which may be {group}:{artifact})") + }; + format!( + "declared {}:{}, whose coordinates the of {by} make {}:{}{maybe}", + d.group_text, d.artifact_text, d.group, d.artifact + ) +} + +/// Why the live main-jar declaration carrying `group:artifact`'s pin +/// `pinned` (a managed one only where management governs it, +/// `pin_managed`) stops naming it whenever some combination of profiles is +/// active whose `` redefine a property its coordinates name +/// ([`PomDep::coordinate_alternatives`]): the pin then no longer applies to +/// `group:artifact`, which resolves from its other declarations or a +/// transitive path. (A declaration that becomes `group:artifact` there is +/// weighed through [`retargeted`].) +fn pin_retargeted( + deps: &[PomDep], + group: &str, + artifact: &str, + pinned: &str, + pin_managed: bool, +) -> Option { + deps.iter() + .filter(|d| { + !d.in_profile + && d.classifier.is_none() + && (!d.managed || pin_managed) + && d.group == group + && d.artifact == artifact + && build_version(d) == Some(pinned) + }) + .find_map(|d| { + let moved = d.coordinate_alternatives.first()?; + Some(format!( + "{POM}: the declaration {}:{} carrying the patched {pinned} names \ + {group}:{artifact}, but the of {} redefine its coordinates to \ + {}:{}; whenever that applies the pin no longer applies to {group}:{artifact}, \ + which Maven resolves from its other declarations instead", + d.group_text, d.artifact_text, moved.by, moved.group, moved.artifact + )) + }) +} + /// Why the first executable classifier copy of `group:artifact` — live or /// inside `` — runs code the wiring does not serve, if one does. /// A classifier copy is its own coordinate: the classifier-less pin does @@ -897,10 +985,10 @@ fn unpatched_classifier_copy( .find(|(version, _)| version.is_none() || *version != served)?; Some(format!( "{POM}: {group}:{artifact}:{classifier}{} resolves {}, {}", - if d.in_profile || from_profile { - " (inside )" - } else { - "" + match &d.retargeted_by { + Some(by) => format!(" ({})", retargeted_text(d, by, group, artifact)), + None if d.in_profile || from_profile => " (inside )".to_string(), + None => String::new(), }, version.unwrap_or("(none in this pom)"), match wiring { @@ -987,104 +1075,18 @@ fn profile_override( )); } let version = resolutions(d).find_map(|v| v.filter(|v| *v != pinned))?; - Some(format!( - "{POM}: a {section}declaration of {named} carries \ - {version}, not the patched {pinned}; whenever that profile \ - is active Maven resolves it instead of the pin" - )) - }) -} - -/// Why a profile's `` move a `${…}` groupId or artifactId so -/// that `group:artifact`'s pin `pinned` is bypassed, for the first such -/// declaration ([`PomDep::coordinate_alternatives`]: every combination of -/// active profiles, and a profile declaration's own). Maven merges an -/// active profile's properties over the pom's own before it interpolates -/// the coordinates, then matches management and pins: -/// -/// * the live main-jar declaration carrying the pin (a managed one only -/// where management governs it, `pin_managed`) then names another -/// package, so the pin no longer applies to `group:artifact`, which -/// resolves from its other declarations or a transitive path; -/// * any other declaration then becomes `group:artifact`'s main jar (as -/// [`main_jar_declarations`] reads one; a managed one again only where -/// management governs) at a version other than `pinned` -/// ([`resolutions`]), or literally one of its executable classifier -/// copies off the pin or with no `` of its own. A main jar -/// without a version takes the live pin and moves nothing. -fn coordinate_override( - deps: &[PomDep], - group: &str, - artifact: &str, - pinned: &str, - pin_managed: bool, -) -> Option { - deps.iter() - .filter(|d| { - !d.coordinate_alternatives.is_empty() - && (!d.managed || pin_managed) - && !d - .type_text - .as_deref() - .is_some_and(|t| NON_CODE_TYPES.contains(&t)) - }) - .find_map(|d| { - let section = if d.managed { - " " - } else { - "" - }; - let place = if d.in_profile { "a " } else { "a " }; - let written = format!("{}:{}", d.group_text, d.artifact_text); - if d.group == group && d.artifact == artifact { - if d.in_profile || d.classifier.is_some() || build_version(d) != Some(pinned) { - return None; - } - let moved = &d.coordinate_alternatives[0]; + if let Some(by) = &d.retargeted_by { return Some(format!( - "{POM}: {place}{section}declaration of {written} names {group}:{artifact} at \ - the patched {pinned}, but the of {} redefine its coordinates \ - to {}:{}; whenever that applies the pin no longer applies to \ - {group}:{artifact}, which Maven resolves from its other declarations instead", - moved.by, moved.group, moved.artifact + "{POM}: a {section}declaration {} carries {version}, \ + not the patched {pinned}; whenever that applies Maven resolves it instead \ + of the pin", + retargeted_text(d, by, group, artifact) )); } - let moved = d.coordinate_alternatives.iter().find(|m| { - let literal = m.group == group && m.artifact == artifact; - match d.classifier.as_deref() { - None => { - literal - || may_shadow( - (&m.group, &m.artifact), - d.type_text.as_deref(), - None, - (group, artifact), - ) - } - Some(c) => literal && is_executable_classifier(c), - } - })?; - let version = resolutions(d).find_map(|v| v.filter(|v| *v != pinned)); - let what = match (d.classifier.as_deref(), version) { - (None, None) => return None, - (None, Some(v)) => format!("{v}"), - (Some(c), Some(v)) => format!("classifier {c} at {v}"), - (Some(c), None) if build_version(d).is_none() => { - format!("classifier {c} with no of its own") - } - (Some(_), None) => return None, - }; - let which = if moved.group == group && moved.artifact == artifact { - String::new() - } else { - format!(", which may be {group}:{artifact},") - }; Some(format!( - "{POM}: {place}{section}declaration of {written} names {}:{} with no profile \ - active, but the of {} redefine its coordinates to {}:{}{which} \ - carrying {what}, not the patched {pinned}; whenever that applies Maven resolves \ - it instead of the pin", - d.group, d.artifact, moved.by, moved.group, moved.artifact + "{POM}: a {section}declaration of {named} carries \ + {version}, not the patched {pinned}; whenever that profile \ + is active Maven resolves it instead of the pin" )) }) } @@ -2251,7 +2253,15 @@ mod tests { out.unattested ); let u = &out.unattested[0]; - assert_eq!(u.kind, UnattestedKind::MavenProfileUnpatched, "{decl}"); + // A retargeted classifier copy is weighed as one. + assert!( + matches!( + u.kind, + UnattestedKind::MavenProfileUnpatched | UnattestedKind::MavenClassifierUnpatched + ), + "{decl}: {:?}", + u.kind + ); assert_eq!((u.purl.as_str(), u.uuid.as_str()), (FX_PURL, UUID_A)); assert!( u.detail.contains("org.slf4j:slf4j-api") @@ -2435,6 +2445,16 @@ mod tests { vec![to_slf4j.clone()], None, ), + // A `test-jar` is the `tests` classifier copy. + case( + h, + with( + "${h}slf4j-api\ + 1.7.36test-jar\n", + ), + vec![to_slf4j.clone()], + None, + ), // A profile declaration its own profile's properties retarget. case( h, @@ -2486,6 +2506,23 @@ mod tests { vec![to_slf4j.clone()], None, ), + case( + h, + with( + "${h}slf4j-api\ + 1.7.36war\n", + ), + vec![to_slf4j.clone()], + None, + ), + // The same profile moves its coordinates and its version onto + // the pin: read together, it declares the pin there. + case( + &format!("{h}2.0.0"), + with(&other(Some("${v}"))), + vec![in_props(&format!("org.slf4j{pin}"))], + None, + ), // A user property wins over every profile's. case( h, @@ -2905,6 +2942,52 @@ mod tests { } } + /// The pre-v5 vendored repository serves only the original GAV's main + /// jar, so an executable classifier copy a profile's `` + /// retarget onto the vendored GA comes from the next repository even at + /// the vendored version (#2026 review); a non-executable one does not + /// withhold. + #[tokio::test] + async fn vendored_ref_under_a_retargeted_classifier_copy_is_not_attested() { + for (classifier, attested) in [("tests", false), ("sources", true)] { + let p = vendored_project(UUID_A); + let wired = std::fs::read_to_string(p.root().join("pom.xml")).expect("read pom"); + let copy = format!( + "${{h}}commons-text\ + 1.10.0{classifier}\n \ + " + ); + let wired = wired.replacen("", ©, 1); + let at = wired.rfind("").expect("project close"); + p.write( + "pom.xml", + format!( + "{}org.other\np\ + org.apache.commons\n{}", + &wired[..at], + &wired[at..] + ), + ); + let out = run(&p).await; + assert_refs(&out, &[(V_PURL, UUID_A, WiringMode::Vendored)]); + assert_eq!( + attests(&out, V_PURL), + attested, + "{classifier}: {:?}", + out.unattested + ); + if !attested { + let u = &out.unattested[0]; + assert_eq!(u.kind, UnattestedKind::MavenClassifierUnpatched); + assert!( + u.detail.contains("coordinates") && u.detail.contains("profile p"), + "{}", + u.detail + ); + } + } + } + /// A `${project.version}` is the project's own ``, which may /// itself be spelled through a property (`${revision}`, Maven's /// CI-friendly versions): a profile or a `-D` user property that From 21fc6302afe8ab54ebfcdee40a6c0e0d29ad32ae Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 5 Nov 2026 23:38:12 -0600 Subject: [PATCH 07/20] Write no hosted Maven pin a profile's version bypasses Back-port rule: hosted must not write a pin VEX can never attest. Hosted scan already refuses a live ${property} version, so the shape left was a versioned declaration of the GA beside a live one: hosted warned redirect_maven_profile_dependency_unpatched but still pinned the live declaration, and discovery's profile_override withheld that pin forever. That covers a profile literal at the base or another release, a profile ${property} (undefined, or one its own or another profile's move off the pin, alone or combined), a profile declaration through an undefined coordinate property, and a profile entry where management governs the pin (the pin hosted adds when no live direct declaration carries a version). PomIndex now records each declaration's managed flag and every version the build may resolve for it (PomScope::resolve_in_build with interpolate_strict, the same data discovery reads), and PomIndex::profile_bypass mirrors discovery's profile check. When it finds one, rewrite_maven_pom refuses the dep with nothing written, under the existing redirect_maven_profile_dependency_unpatched code (detail: the declaration, the version it resolves, the suffixed pin). A versionless profile declaration, one resolving the pin, or profile management under a direct pin still lets the pin land; the old informational warning now fires only when nothing live is pinned or on the legacy same-GAV path. CLI_CONTRACT.md and the vex_maven_profile_unpatched note say so. Tests (each fails with the check disabled): - patch::redirect::tests::maven_profile_versions_are_weighed_before_pinning (new) - patch::redirect::tests::maven_versioned_profile_declarations_are_never_pinned_over and maven_pom_undefined_coordinate_property_in_a_profile_is_reported, which asserted the old pin-beside-a-profile behavior. Redirect golden (profile-literal keeps its warning code) and the VEX golden corpus are unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 4 +- .../src/commands/vex_sources.rs | 6 +- .../src/patch/redirect/maven_index.rs | 63 +++++++- .../src/patch/redirect/mod.rs | 146 ++++++++++++++++-- 4 files changed, 200 insertions(+), 19 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 22616fd7e..ed389097d 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -329,7 +329,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Attribution gate (v5.0).** A hosted run never leaves wiring that lockfile discovery calls contested: before the rewrite writes any lockfile, the same discovery `vex`, `list`, `rollback`, `remove` and `vendor` read runs over the project as the rewrite would leave it. A candidate whose pin discovery reads but cannot attribute to one package version (a requirements `-r` include resolving the same version from the registry beside a rewired `Pipfile.lock`, #567; a Maven pin in a ``, #260) is left out of the rewrite and reported as a `skipped` event with `errorCode` `redirect_unattributable` (nothing written for it; exit code unchanged). Unchanged: a pin in a file discovery does not read (a pre-2.6 bundler `Gemfile`, locked by the next `bundle install`) keeps the rewriter's verdict, and so does a deliberate partial redirect the run already reports (a bundled or `bun patch`-ed copy left on the registry, a yarn `npm:` alias entry left on the registry with `redirect_yarn_classic_alias_skipped` / `redirect_yarn_berry_alias_skipped` while the package's direct entry is pinned, a dep withheld from the vlt rewrite while a sibling lock takes it), and so does a vendored→hosted takeover: its vendored wiring is reverted in the run's staged overlay before the rewrite plans, and it is redirected when the rewriters pin it (otherwise it is retracted and stays vendored), in a `--dry-run` preview the same. A pin whose own pnpm or yarn lock also installs an unpatched copy of the same `name@version` (a pnpm `file:` directory or tarball dependency; a yarn `file:` / URL / berry `portal:` / `link:` dependency declared under another name) is written too, with a `redirect_copy_unpatched` warning naming that entry, and is not attested. A lockless NuGet / Cargo pin (an exclusive Socket source mapping without `packages.lock.json`, a Cargo registry pin without `Cargo.lock`) is still written, with a `redirect_pin_lockless` warning: no lockfile records its version, so `vex` cannot attest it and `rollback` / `remove` / `vendor` refuse it until the lockfile exists (whether such pins should be written at all is an open decision). The rollout's recorded view uses the same discovery: a uuid a file merely mentions (a stale `package.json` field, an inactive `pdm.lock`, a comment) is not a pin and does not count as already patched. -The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate, `bun.lock` / `bun.lockb`, and `vlt-lock.json` with `vlt.json` and `node_modules/.vlt-lock.json` read only), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; the hosted redirect is a `sparse+` registry, which cargo loads only from **1.68**, so `rust-toolchain.toml` / `rust-toolchain` are read too (never edited) and a project whose declared floor — the lowest `rust-version` of the root and the member manifests read here (a `rust-version.workspace = true` inheritor reads `[workspace.package]`), else the channel rustup picks from the root (the legacy `rust-toolchain` over `rust-toolchain.toml`; a dated `nightly-`/`beta-YYYY-MM-DD` maps to its release train) — is below 1.68 is refused `redirect_cargo_old_cargo` per crate (nothing written, an earlier run's pins left in place with the `rollback` that drops them named; `--mode vendored` builds on cargo 1.41+), while an undeclared or rolling-channel floor still redirects (toolchain files in ancestor directories are not read); **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. With NO `Cargo.lock` there is no resolved graph to ask, so the dependents question is answered from the manifests instead: a crate declared beside any other dependency — anything but a path dependency on a manifest this run also pins, or a `workspace = true` inheritor of a table it scans — or beside a workspace member this run did not read (a `members` glob, or a member outside the project or behind a symbolic link, which member discovery drops) is refused `redirect_cargo_lockless_dependents`, whose detail names the remedies (commit a lockfile, or `--mode vendored`); a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json` (plus, v5.0 #353/#514, every lock a project under the root restores into: a member project's `packages.lock.json`, a per-project `packages..lock.json`, a literal `NuGetLockFilePath` — each pinned with the root config; a `NuGetLockFilePath` it cannot evaluate warns `redirect_nuget_lock_path_unresolved` and a project tree it cannot list warns `redirect_nuget_lock_unreadable`, both skipping the nuget redirect with nothing written), `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and, for a Gradle build, every settings, build, `buildSrc`, included-build, applied and plugin-source script, version catalog and lock file the script graph reaches, plus `gradle/verification-metadata.xml`, `gradle/wrapper/gradle-wrapper.properties` and the owned `.socket/gradle/` files), and the sbt build files (`socket-patch.sbt`, `socket-patch-vendor.sbt`, `build.sbt`, `project/build.properties`, `.sbtopts`, `.jvmopts`; `build.sbt.lock` and the Mill / scala-cli build files `build.mill`, `build.mill.yaml`, `build.sc`, `.mill-version`, `project.scala` for their presence only) — read, never edited; `socket-patch.sbt` is the only sbt file hosted mode writes (see **Hosted sbt** below). **npm-family flavor coverage**: package-lock / npm-shrinkwrap (a package the project patches itself with npm ≥ 12.1's native `npm patch` — a root `patchedDependencies` key, or the lock entry's `patched` record — is left on its registry entry in every npm lock, `redirect_npm_patched_dependency_skipped`), pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic (a yarn 2+ install migrates a v1 `yarn.lock` and drops its pins, so a run whose v1 lock carries a hosted pin warns `redirect_yarn_classic_berry_migration_risk` — the hosted twin of the vendored `yarn_classic_berry_migration_risk` — unless the root `package.json`, read as advisory input, declares `"packageManager": "yarn@1…"`), **yarn berry** (the pin yarn writes for a root `resolutions` entry: the root `package.json` — edited only beside a berry `yarn.lock` — gains one `"@npm:": ""` selector per locked range (`redirect_yarn_berry_resolution` edits), and only that `yarn.lock` entry is re-keyed `"@"` with the same `resolution:` + `yarnBerry10c0` checksum (`redirect_yarn_berry_entry`), moved to yarn's key order; never an `npm:` locator, whose fetcher sends npm registry auth to the patch host, nor a tarball locator under an `npm:` key, which hardened mode rejects (YN0078). An older release's `npm:::__archiveUrl=` pin is still recognized and is re-pinned on the next run; rollback rebuilds the key from the selectors and drops them. Refused, nothing written: a user-authored `resolutions` entry for the package `redirect_yarn_berry_resolutions_conflict`, no root manifest `redirect_yarn_berry_manifest_missing`, a builtin `patch:` entry wrapping the same descriptor `redirect_yarn_berry_shared_descriptor`, an artifact URL yarn cannot fetch as a tarball `redirect_yarn_berry_artifact_url_unsupported`; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found` (or `redirect_bun_non_registry_entry_skipped` when the only same-version entry is a user URL / `file:` tarball, #497), a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`), and **vlt** (`vlt-lock.json` without `lockfileVersion`, `0` or `1`; see the vlt hosted-mode contract below). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when the `repo-state.json` beside a rewritten lock exists (`common/config/rush/repo-state.json` for the common lock, `common/config/subspaces//repo-state.json` for a subspace lock; the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives), and `redirect_pnpm_trust_lockfile` carries the Rush pnpm >=11 install remedy (see the trust paragraph above). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`; when `.mvn/wrapper/maven-wrapper.properties` pins a Maven older than 3.9.4, which ignores those files, the additive warning `redirect_maven_trusted_checksums_unenforced`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a reactor root (a `` / `` declaration, a profile's included, or a Maven 4 root that lists none but whose subprojects Maven discovers: model version not `4.0.0`, `pom` packaging, a direct subdirectory holding a `pom.xml`, and `maven.project.discoverSubprojects` not turned off in `.mvn/maven.config`; each such subdirectory's `pom.xml` is read for this, never edited) is refused whole with `redirect_maven_multimodule_unsupported`: `pom.xml` and `.mvn/` are left untouched and the dep is not counted as redirected (a Gradle build beside it is still planned by the Gradle rewriter, but a dep in a root with a `pom.xml` is confirmed only when the pom pins it too), since a module's own literal `` would shadow a root pin (use `--mode vendored`; `vex` omits a hosted pin in a reactor root as `vex_maven_reactor_root`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), a `` variant (sources, tests, a native build) left at its version (`redirect_maven_classifier_unsupported`; the grant serves the main jar only, and the dep is skipped when a variant other than `sources` / `javadoc` names the patched release), a versioned declaration inside `` (a literal or a `${property}`) left as-is (`redirect_maven_profile_dependency_unpatched`; the dep is skipped when a profile holds its only declaration), a `${property}` groupId / artifactId resolved from the pom's own `` before matching (one the pom does not define, which may be the patched GA and whose own version would then win, refuses the dep with nothing written: `redirect_maven_coordinate_unresolved`), and a `${property}` groupId / artifactId some profile's `` (or several profiles' together) redefine so that the declaration a pin would go into names another package, or another declaration names the GA's main jar at another version (or one of its executable classifier copies, a `test-jar` included), refuses the dep with nothing written too (`redirect_maven_profile_coordinates_unpatched`): `vex` would never attest that pin (`vex_maven_profile_unpatched`), and a pom not readable as one left untouched (`redirect_maven_pom_unreadable`). Matches and anchors skip comments, CDATA, ``, ``, ``, `` and `` (the scope `vex` reads), and an existing self-closed `` / `` is expanded in place, never duplicated; an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). **gradle** (v5.0) is automated wiring, no longer a pasted snippet: the owned settings script `.socket/gradle/socket-patch.hosted.settings.gradle` with its index `.socket/gradle/hosted-index.tsv`, one apply line per build's settings file, every lock entry of the GA moved to the suffixed version, and the suffixed component in an existing `gradle/verification-metadata.xml`. A refused dep writes nothing and keeps `redirect_gradle_manual_snippet` as its fallback; same-GAV grants are refused (`redirect_gradle_same_gav_unsupported`). Rules, refusals and codes: [Gradle builds](#gradle-builds-v50). +The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate, `bun.lock` / `bun.lockb`, and `vlt-lock.json` with `vlt.json` and `node_modules/.vlt-lock.json` read only), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; the hosted redirect is a `sparse+` registry, which cargo loads only from **1.68**, so `rust-toolchain.toml` / `rust-toolchain` are read too (never edited) and a project whose declared floor — the lowest `rust-version` of the root and the member manifests read here (a `rust-version.workspace = true` inheritor reads `[workspace.package]`), else the channel rustup picks from the root (the legacy `rust-toolchain` over `rust-toolchain.toml`; a dated `nightly-`/`beta-YYYY-MM-DD` maps to its release train) — is below 1.68 is refused `redirect_cargo_old_cargo` per crate (nothing written, an earlier run's pins left in place with the `rollback` that drops them named; `--mode vendored` builds on cargo 1.41+), while an undeclared or rolling-channel floor still redirects (toolchain files in ancestor directories are not read); **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. With NO `Cargo.lock` there is no resolved graph to ask, so the dependents question is answered from the manifests instead: a crate declared beside any other dependency — anything but a path dependency on a manifest this run also pins, or a `workspace = true` inheritor of a table it scans — or beside a workspace member this run did not read (a `members` glob, or a member outside the project or behind a symbolic link, which member discovery drops) is refused `redirect_cargo_lockless_dependents`, whose detail names the remedies (commit a lockfile, or `--mode vendored`); a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json` (plus, v5.0 #353/#514, every lock a project under the root restores into: a member project's `packages.lock.json`, a per-project `packages..lock.json`, a literal `NuGetLockFilePath` — each pinned with the root config; a `NuGetLockFilePath` it cannot evaluate warns `redirect_nuget_lock_path_unresolved` and a project tree it cannot list warns `redirect_nuget_lock_unreadable`, both skipping the nuget redirect with nothing written), `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and, for a Gradle build, every settings, build, `buildSrc`, included-build, applied and plugin-source script, version catalog and lock file the script graph reaches, plus `gradle/verification-metadata.xml`, `gradle/wrapper/gradle-wrapper.properties` and the owned `.socket/gradle/` files), and the sbt build files (`socket-patch.sbt`, `socket-patch-vendor.sbt`, `build.sbt`, `project/build.properties`, `.sbtopts`, `.jvmopts`; `build.sbt.lock` and the Mill / scala-cli build files `build.mill`, `build.mill.yaml`, `build.sc`, `.mill-version`, `project.scala` for their presence only) — read, never edited; `socket-patch.sbt` is the only sbt file hosted mode writes (see **Hosted sbt** below). **npm-family flavor coverage**: package-lock / npm-shrinkwrap (a package the project patches itself with npm ≥ 12.1's native `npm patch` — a root `patchedDependencies` key, or the lock entry's `patched` record — is left on its registry entry in every npm lock, `redirect_npm_patched_dependency_skipped`), pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic (a yarn 2+ install migrates a v1 `yarn.lock` and drops its pins, so a run whose v1 lock carries a hosted pin warns `redirect_yarn_classic_berry_migration_risk` — the hosted twin of the vendored `yarn_classic_berry_migration_risk` — unless the root `package.json`, read as advisory input, declares `"packageManager": "yarn@1…"`), **yarn berry** (the pin yarn writes for a root `resolutions` entry: the root `package.json` — edited only beside a berry `yarn.lock` — gains one `"@npm:": ""` selector per locked range (`redirect_yarn_berry_resolution` edits), and only that `yarn.lock` entry is re-keyed `"@"` with the same `resolution:` + `yarnBerry10c0` checksum (`redirect_yarn_berry_entry`), moved to yarn's key order; never an `npm:` locator, whose fetcher sends npm registry auth to the patch host, nor a tarball locator under an `npm:` key, which hardened mode rejects (YN0078). An older release's `npm:::__archiveUrl=` pin is still recognized and is re-pinned on the next run; rollback rebuilds the key from the selectors and drops them. Refused, nothing written: a user-authored `resolutions` entry for the package `redirect_yarn_berry_resolutions_conflict`, no root manifest `redirect_yarn_berry_manifest_missing`, a builtin `patch:` entry wrapping the same descriptor `redirect_yarn_berry_shared_descriptor`, an artifact URL yarn cannot fetch as a tarball `redirect_yarn_berry_artifact_url_unsupported`; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found` (or `redirect_bun_non_registry_entry_skipped` when the only same-version entry is a user URL / `file:` tarball, #497), a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`), and **vlt** (`vlt-lock.json` without `lockfileVersion`, `0` or `1`; see the vlt hosted-mode contract below). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when the `repo-state.json` beside a rewritten lock exists (`common/config/rush/repo-state.json` for the common lock, `common/config/subspaces//repo-state.json` for a subspace lock; the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives), and `redirect_pnpm_trust_lockfile` carries the Rush pnpm >=11 install remedy (see the trust paragraph above). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`; when `.mvn/wrapper/maven-wrapper.properties` pins a Maven older than 3.9.4, which ignores those files, the additive warning `redirect_maven_trusted_checksums_unenforced`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a reactor root (a `` / `` declaration, a profile's included, or a Maven 4 root that lists none but whose subprojects Maven discovers: model version not `4.0.0`, `pom` packaging, a direct subdirectory holding a `pom.xml`, and `maven.project.discoverSubprojects` not turned off in `.mvn/maven.config`; each such subdirectory's `pom.xml` is read for this, never edited) is refused whole with `redirect_maven_multimodule_unsupported`: `pom.xml` and `.mvn/` are left untouched and the dep is not counted as redirected (a Gradle build beside it is still planned by the Gradle rewriter, but a dep in a root with a `pom.xml` is confirmed only when the pom pins it too), since a module's own literal `` would shadow a root pin (use `--mode vendored`; `vex` omits a hosted pin in a reactor root as `vex_maven_reactor_root`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), a `` variant (sources, tests, a native build) left at its version (`redirect_maven_classifier_unsupported`; the grant serves the main jar only, and the dep is skipped when a variant other than `sources` / `javadoc` names the patched release), a versioned declaration inside `` (a literal or a `${property}`) left as-is (`redirect_maven_profile_dependency_unpatched`): when any version it may resolve — its own, or what its own profile's or any combination of profiles' `` make of it, read as `vex` reads it — is not the suffixed pin, the dep is refused with nothing written, since it overrides the pin whenever that profile is active and `vex` would never attest it (a profile `` entry only when management governs the pin, i.e. no live direct declaration carries a version); a versionless one takes the pin, and the dep is also skipped when a profile holds its only declaration, a `${property}` groupId / artifactId resolved from the pom's own `` before matching (one the pom does not define, which may be the patched GA and whose own version would then win, refuses the dep with nothing written: `redirect_maven_coordinate_unresolved`), and a `${property}` groupId / artifactId some profile's `` (or several profiles' together) redefine so that the declaration a pin would go into names another package, or another declaration names the GA's main jar at another version (or one of its executable classifier copies, a `test-jar` included), refuses the dep with nothing written too (`redirect_maven_profile_coordinates_unpatched`): `vex` would never attest that pin (`vex_maven_profile_unpatched`), and a pom not readable as one left untouched (`redirect_maven_pom_unreadable`). Matches and anchors skip comments, CDATA, ``, ``, ``, `` and `` (the scope `vex` reads), and an existing self-closed `` / `` is expanded in place, never duplicated; an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). **gradle** (v5.0) is automated wiring, no longer a pasted snippet: the owned settings script `.socket/gradle/socket-patch.hosted.settings.gradle` with its index `.socket/gradle/hosted-index.tsv`, one apply line per build's settings file, every lock entry of the GA moved to the suffixed version, and the suffixed component in an existing `gradle/verification-metadata.xml`. A refused dep writes nothing and keeps `redirect_gradle_manual_snippet` as its fallback; same-GAV grants are refused (`redirect_gradle_same_gav_unsupported`). Rules, refusals and codes: [Gradle builds](#gradle-builds-v50). **Hosted sbt (v5.0, additive)**: an sbt build root (`project/build.properties` naming an `sbt.version`, 0.13.18 or later) is wired through ONE generated root file, `socket-patch.sbt` — no user file is edited. It pins every granted Maven patch build-wide (a `ThisBuild` `dependencyOverrides +=` of the Socket-only `-socket.` version plus a `file:` resolver over `.socket/sbt-hosted/maven2/`, moved ahead of the default repositories on sbt 0.13 / 1.x so an unreachable one never blocks it offline), downloads the pinned pom and jar there on the first sbt load (sha256-checked, gitignored by the file itself), and installs a load-time verifier that fails `update` when any project resolves another version or a pinned artifact whose bytes are not pinned. Edits: `redirect_sbt_pin` (added), `redirect_sbt_pin_updated` (an existing row replaced: same GA and base under a new uuid, or the same uuid with new served values; `original` names the previous uuid and version), `redirect_sbt_pin_rechecked` (an existing row re-verified after the build's dependencies changed: its dependency digest is recorded anew, `original`/`new` are `{deps}`). The load-time verifier also fails `update` when a project declares a pinned GA at a version newer than the pin's base (the build-wide override would otherwise force it back down). A new pin is gated on sbt's own resolution records under `target/` (never the machine-wide cache): run-level stops wire nothing, warn once and exit 0 — `redirect_sbt_no_resolution_evidence` (none; run `sbt update` first; always the in-memory engine's answer), `redirect_sbt_resolution_incomplete` (a declared project left no evidence, or the project definitions cannot be read statically), `redirect_sbt_resolution_stale` (a build source is newer than some project's evidence: each project is dated by its own newest record, so a partial `sbt /update` does not vouch for the others). Per-patch refusals (never confirmed): `redirect_sbt_missing_override` (no `maven2` override or no suffixed version), `redirect_sbt_integrity_missing` (jar or pom sha256 missing), `redirect_sbt_unsafe_value` (a value unsafe in a Scala literal, or an index URL not naming the uuid), `redirect_sbt_version_conflict` (some project resolves another version, or a build source declares the GA newer than the patch's base), `redirect_sbt_override_conflict` (two patches for one GA in a run, or another base already pinned), `redirect_sbt_vendored_conflict` (the GA is pinned by `socket-patch-vendor.sbt`, or that file cannot be parsed — then every Maven patch), `redirect_sbt_owned_file_modified` / `redirect_sbt_owned_file_foreign` (`socket-patch.sbt` edited, or not socket-patch's — every Maven patch), `redirect_sbt_owned_file_unreadable` (a whole-run refusal: `socket-patch.sbt` is on disk but cannot be read as UTF-8 text, so writing it would replace it; nothing is written), `redirect_sbt_unsupported_version`, `redirect_sbt_build_root_unknown` (sbt files but no versioned build root — every Maven patch), `redirect_sbt_overrides_assignment` / `redirect_sbt_resolvers_assignment` (a build source reassigns `dependencyOverrides` / `resolvers` with `:=`, `~=` or `--=`), `redirect_sbt_dependency_lock_present` (a `build.sbt.lock`), `redirect_sbt_scala_runtime_unsupported` (`org.scala-lang`), `redirect_sbt_classifier_unsupported`; a GA no library configuration resolves is skipped silently (`redirect_sbt_meta_build_only` when only the meta-build resolves it). Advisories: `redirect_sbt_version_untested` (sbt 2.1+, still wired), `redirect_sbt_override_build_repos` (`sbt.override.build.repos=true`), `redirect_maven_pom_ignored_sbt_build` (a `pom.xml` beside the sbt build, which sbt never reads; the Maven rewriter still edits it for the Maven build). A re-run keeps an existing row and re-checks it. When the build's dependency digest changed since the pin, evidence resolved after the change (fresh, newer than the generated file) re-verifies it and the row's digest is refreshed (`redirect_sbt_pin_rechecked`); the uuid is NOT confirmed on `redirect_sbt_pin_declared_newer` (a build source now declares the GA newer than the pin's base; the row stays, sbt's load-time verifier fails the build, and the remedy is `socket-patch rollback` or declaring the base again), `redirect_sbt_pin_unverifiable` (the digest changed and the evidence predates the change, or the digest cannot be computed: run `sbt update`, then re-run socket-patch), `redirect_sbt_override_shadowed` (the evidence still resolves the base version) or `redirect_sbt_resolved_elsewhere` (the pinned version resolves from outside the pin repository from a file whose sha256 is not the pinned jar's; a copy holding the pinned bytes, such as the Ivy cache a second checkout reads, is fine — at most 64 pinned artifact files of up to 256 MiB are hashed, anything else counts as elsewhere), and also when a build source now reassigns `dependencyOverrides` / `resolvers` or a `build.sbt.lock` appeared (the same `redirect_sbt_overrides_assignment` / `redirect_sbt_resolvers_assignment` / `redirect_sbt_dependency_lock_present` codes; the row stays and sbt's load-time verifier fails the build). For a pure sbt root (no `pom.xml` / Gradle script beside it), maven confirmation is decided only by the sbt rewriter's report; on a mixed root a uuid the sbt rewriter refused is still confirmed by the Maven rewriter's own `pom.xml` pin (the generated sbt files never prove a pin by substring). **Mill and scala-cli** are guidance only: per Maven patch `redirect_mill_manual_snippet` / `redirect_scala_cli_manual_snippet` carry a paste-able snippet (repository + forced suffixed version), nothing is written or confirmed, and a pure Mill / scala-cli root gets no `redirect_maven_no_pom`; there, a Maven patch the server sent without a `maven2` registry override gets `redirect_maven_missing_override` instead of a snippet (with a `pom.xml` beside the Mill / scala-cli files the pom rewriter reports it). `rollback` / `remove` restore `socket-patch.sbt` offline (the rows removed, the file deleted with its last pin; the gitignored downloads are left). Manifest-less VEX reads every strictly parsed pin as a hosted reference but grants it the lockfile basis only when the local evidence shows every recorded version of the GA is the pinned one and every recorded artifact hashes to a pinned sha256 (else `sbt_resolution_unverified`). @@ -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`. Six 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 classifier copy other than `sources` / `javadoc` (`tests`, a native or JDK-specific build), live or inside ``, resolves any version other than the suffixed pin — its own literal ``, a `` entry with the same classifier (a profile's overriding the live one), or no version in the root pom — the pin is omitted. A pre-v5 single-pom vendored repository holds only the main jar, so any such copy omits its reference. `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. **Maven profile declarations** (`vex_maven_profile_unpatched`): a `` declaration of a pinned GA's main jar in the root `pom.xml` at any version other than the pin (the base release, another release, a `${property}` the pom does not resolve) overrides the pin whenever that profile is active, so the reference is omitted. A profile `` entry always counts; a profile `` entry counts only when management governs the pin (no direct `` outside ``), since a direct literal version beats management. A declaration without a `` takes the live pin, and a `pom` (or `java-source` / `javadoc`) typed one carries no code; neither withholds the statement. Activation is not evaluated: every profile counts, as in vendored mode, which rewrites profile declarations too; hosted scan leaves them as written (`redirect_maven_profile_dependency_unpatched`). A pin spelled through a property counts the same way: a `${property}` `` is interpolated through every property it names, the project's own `${project.version}` included (itself often spelled through `${revision}`), at most 16 levels deep (a cycle or an undefined name never resolves to a pin), and when any profile's `` — or any combination of profiles' together, each nested reference read with that combination active, as the vendored planner weighs them too — redefine one of those properties so that it resolves anything other than the pin (or nothing), the reference is omitted too; a `.mvn/maven.config` user property wins over every profile's. A profile declaration reads its own profile's `` first. When more than 8 profiles may change such a version, or a profile's `` cannot be parsed, the reference is omitted (fail closed); the rest of the pom is still read. The statement names the profiles that move the pin and the version they move it to. Coordinates count the same way: when a profile's `` (or several profiles' together, or the profile holding the declaration) redefine a property a `${property}` `` / `` names, so that the declaration carrying the pin names another package (the pin then no longer applies to the patched GA) or another declaration names the patched GA (its main jar, or an executable classifier copy) at a version other than the pin, or as a classifier copy with no version of its own, the reference is omitted; a profile value the pom cannot resolve may name any package, a user property wins over every profile's, and the same 8-profile and unreadable-profile limits fail closed. Each such reading is weighed like a declaration of its own: a retargeted classifier copy (a `test-jar` is the `tests` copy) by the classifier rule (`vex_maven_classifier_unpatched`, so a pre-v5 vendored repository, which serves only the main jar, is bypassed even at the vendored version), any other non-`jar` type not at all, and a reading that moves the coordinates and the version onto the pin together does not withhold the statement. Hosted scan refuses such a dep (`redirect_maven_profile_coordinates_unpatched`), and vendored mode leaves the declaration as written and does not pin its root (`coordinates_overridden`). The same holds for a pre-v5 single-pom vendored repository, which serves only the original version. **Maven user properties** (`vex_maven_config_override`): Maven resolves a `${property}` `` from `.mvn/maven.config`'s `-D` user properties before the pom's `` (`${project.*}` / `${pom.*}` always come from the model). A pin the pom spells through a property that `.mvn/maven.config` redefines resolves that other version, so the reference is omitted. A Socket version that only a `-D` user property supplies is not `pom.xml` wiring (hosted scan never writes one, and `rollback` / `remove` never edit `.mvn/maven.config`): it is no reference at all, only a diagnostic. +* **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`. Six 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 classifier copy other than `sources` / `javadoc` (`tests`, a native or JDK-specific build), live or inside ``, resolves any version other than the suffixed pin — its own literal ``, a `` entry with the same classifier (a profile's overriding the live one), or no version in the root pom — the pin is omitted. A pre-v5 single-pom vendored repository holds only the main jar, so any such copy omits its reference. `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. **Maven profile declarations** (`vex_maven_profile_unpatched`): a `` declaration of a pinned GA's main jar in the root `pom.xml` at any version other than the pin (the base release, another release, a `${property}` the pom does not resolve) overrides the pin whenever that profile is active, so the reference is omitted. A profile `` entry always counts; a profile `` entry counts only when management governs the pin (no direct `` outside ``), since a direct literal version beats management. A declaration without a `` takes the live pin, and a `pom` (or `java-source` / `javadoc`) typed one carries no code; neither withholds the statement. Activation is not evaluated: every profile counts, as in vendored mode, which rewrites profile declarations too; hosted scan leaves them as written and writes no pin they bypass (`redirect_maven_profile_dependency_unpatched`). A pin spelled through a property counts the same way: a `${property}` `` is interpolated through every property it names, the project's own `${project.version}` included (itself often spelled through `${revision}`), at most 16 levels deep (a cycle or an undefined name never resolves to a pin), and when any profile's `` — or any combination of profiles' together, each nested reference read with that combination active, as the vendored planner weighs them too — redefine one of those properties so that it resolves anything other than the pin (or nothing), the reference is omitted too; a `.mvn/maven.config` user property wins over every profile's. A profile declaration reads its own profile's `` first. When more than 8 profiles may change such a version, or a profile's `` cannot be parsed, the reference is omitted (fail closed); the rest of the pom is still read. The statement names the profiles that move the pin and the version they move it to. Coordinates count the same way: when a profile's `` (or several profiles' together, or the profile holding the declaration) redefine a property a `${property}` `` / `` names, so that the declaration carrying the pin names another package (the pin then no longer applies to the patched GA) or another declaration names the patched GA (its main jar, or an executable classifier copy) at a version other than the pin, or as a classifier copy with no version of its own, the reference is omitted; a profile value the pom cannot resolve may name any package, a user property wins over every profile's, and the same 8-profile and unreadable-profile limits fail closed. Each such reading is weighed like a declaration of its own: a retargeted classifier copy (a `test-jar` is the `tests` copy) by the classifier rule (`vex_maven_classifier_unpatched`, so a pre-v5 vendored repository, which serves only the main jar, is bypassed even at the vendored version), any other non-`jar` type not at all, and a reading that moves the coordinates and the version onto the pin together does not withhold the statement. Hosted scan refuses such a dep (`redirect_maven_profile_coordinates_unpatched`), and vendored mode leaves the declaration as written and does not pin its root (`coordinates_overridden`). The same holds for a pre-v5 single-pom vendored repository, which serves only the original version. **Maven user properties** (`vex_maven_config_override`): Maven resolves a `${property}` `` from `.mvn/maven.config`'s `-D` user properties before the pom's `` (`${project.*}` / `${pom.*}` always come from the model). A pin the pom spells through a property that `.mvn/maven.config` redefines resolves that other version, so the reference is omitted. A Socket version that only a `-D` user property supplies is not `pom.xml` wiring (hosted scan never writes one, and `rollback` / `remove` never edit `.mvn/maven.config`): it is no reference at all, only a diagnostic. * **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). diff --git a/crates/socket-patch-cli/src/commands/vex_sources.rs b/crates/socket-patch-cli/src/commands/vex_sources.rs index e6257509c..384d9753b 100644 --- a/crates/socket-patch-cli/src/commands/vex_sources.rs +++ b/crates/socket-patch-cli/src/commands/vex_sources.rs @@ -283,9 +283,9 @@ fn unattested_note(kind: UnattestedKind) -> (&'static str, &'static str) { another version, or its redefine a groupId / artifactId property so \ the pin names another package or another declaration names this one; the wiring \ itself is intact, so re-running `scan` does not change this (drop that profile \ - or property or set it to the pin; for a moved version, `scan --mode \ - vendored` may re-patch it, but neither mode wires a pin whose groupId / artifactId \ - a profile can move)", + or property or set it to the pin; `scan --mode vendored` rewrites a \ + literal profile , but neither mode wires a pin a profile's \ + can move, and hosted `scan` now writes no pin a profile bypasses)", ), UnattestedKind::MavenConfigOverride => ( NOTE_MAVEN_CONFIG_OVERRIDE, diff --git a/crates/socket-patch-core/src/patch/redirect/maven_index.rs b/crates/socket-patch-core/src/patch/redirect/maven_index.rs index f2ec54150..d622ef0cc 100644 --- a/crates/socket-patch-core/src/patch/redirect/maven_index.rs +++ b/crates/socket-patch-core/src/patch/redirect/maven_index.rs @@ -7,7 +7,7 @@ //! not once per dependency. use crate::formats::maven::{ - is_executable_classifier, may_shadow, CoordinateAlternative, PomScope, + interpolate_strict, is_executable_classifier, may_shadow, CoordinateAlternative, PomScope, }; use crate::formats::xml::{child_text, children, elements, Element}; @@ -29,6 +29,14 @@ pub(super) struct IndexedDep { /// it ([`PomScope::coordinate_alternatives`], as VEX discovery reads /// it). pub(super) coordinate_alternatives: Vec, + /// Inside a ``. + pub(super) managed: bool, + /// Every version the build may resolve for it, as VEX discovery reads + /// one ([`PomScope::resolve_in_build`]): with no profile active but the + /// one holding it, then each combination of profiles whose + /// `` move it (as written where one leaves it unresolved). + /// Empty when it has no ``. + pub(super) build_versions: Vec, } /// One live ``. @@ -136,15 +144,26 @@ impl PomIndex { user: &std::collections::BTreeMap, ) -> Result { let mut scope = PomScope::with_user_properties(pom, user)?; - // Profile `` may move a `${…}` coordinate, as VEX - // discovery reads it (one it cannot read fails closed there). + // Profile `` may move a `${…}` coordinate or version, as + // VEX discovery reads it (one it cannot read fails closed there). scope.read_profiles(); let masked = &scope.masked; + let managed = elements(masked, "dependencyManagement")?; let deps = scope .dependencies()? .into_iter() .map(|d| IndexedDep { coordinate_alternatives: scope.coordinate_alternatives(&d), + managed: managed + .iter() + .any(|m| d.element.start >= m.start && d.element.start < m.end), + build_versions: d.version_text.as_deref().map_or_else(Vec::new, |text| { + let (base, alternatives) = + scope.resolve_in_build(text, d.element.start, interpolate_strict); + std::iter::once(base.unwrap_or_else(|| text.to_string())) + .chain(alternatives.into_iter().map(|a| a.value)) + .collect() + }), group: d.group, artifact: d.artifact, version_inner: d.version_inner, @@ -281,6 +300,42 @@ impl PomIndex { }) } + /// A `` declaration of `group:artifact`'s main jar (literal, + /// or through a coordinate property this pom does not define, + /// [`may_shadow`]) and a version it may resolve other than `served` + /// ([`IndexedDep::build_versions`]: its own profile's ``, + /// or any combination of profiles'), as VEX discovery weighs one: an + /// active profile's `` merge over the pom's own, so it + /// overrides any pin, and its `` overrides one + /// only where management governs the pin (`pin_managed`). A pom / + /// sources / javadoc typed one carries no code, and one without a + /// version takes the pin. + pub(super) fn profile_bypass( + &self, + group: &str, + artifact: &str, + served: &str, + pin_managed: bool, + ) -> Option<(&IndexedDep, &str)> { + self.deps.iter().find_map(|d| { + let (Some(g), Some(a)) = (d.group.as_deref(), d.artifact.as_deref()) else { + return None; + }; + let main_jar = d.classifier.is_none() + && !d + .type_text + .as_deref() + .is_some_and(|t| matches!(t, "pom" | "java-source" | "javadoc")) + && ((g == group && a == artifact) + || may_shadow((g, a), d.type_text.as_deref(), None, (group, artifact))); + if !d.in_profile || !main_jar || (d.managed && !pin_managed) { + return None; + } + let version = d.build_versions.iter().find(|v| *v != served)?; + Some((d, version.as_str())) + }) + } + pub(super) fn dep(&self, i: usize) -> &IndexedDep { &self.deps[i] } @@ -427,6 +482,8 @@ impl PomIndex { group_text: Some(group.to_string()), artifact_text: Some(artifact.to_string()), coordinate_alternatives: Vec::new(), + managed: true, + build_versions: vec![version.to_string()], }); } diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 0dba0a674..0fa606262 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -8831,7 +8831,47 @@ fn rewrite_maven_pom( // skipped rather than reported redirected. A versionless profile // declaration takes the managed version, so the pin covers it. let (profiled, matches): (Vec<_>, Vec<_>) = live.into_iter().partition(|m| m.in_profile); - let profile_version = profiled.iter().find_map(|m| m.version_text.as_deref()); + // One that resolves anything but the suffixed pin (its own literal + // or `${property}`, or what a profile's `` make of it) + // overrides the pin whenever its profile is active, and VEX never + // attests such a pin: none is written. A profile's management + // overrides only a pin management governs — the one written when no + // live direct declaration carries a version. + if let Some(served) = suffixed_version.as_deref() { + let pin_managed = !matches + .iter() + .any(|m| m.version_text.is_some() && !idx.dep(m.idx).managed); + if let Some((d, version)) = + idx.profile_bypass(&group_id, &artifact_id, served, pin_managed) + { + result.warnings.push(RewriteWarning { + code: "redirect_maven_profile_dependency_unpatched".into(), + detail: format!( + "{}:{} {} inside {} resolves {version}, not \ + the suffixed {served}; while that profile is active it overrides any \ + pin, so pom.xml was left as it is and the dep is not counted as \ + redirected", + d.group.as_deref().unwrap_or_default(), + d.artifact.as_deref().unwrap_or_default(), + d.version_text.as_deref().unwrap_or_default(), + if d.managed { + " ()" + } else { + "" + }, + ), + }); + continue; + } + } + // Past that check, a versioned profile declaration beside a live one + // takes (or cannot override) the suffixed pin; it is still reported + // when nothing live is pinned, or on the legacy same-GAV path. + let report_profiles = suffixed_version.is_none() || matches.is_empty(); + let profile_version = profiled + .iter() + .find_map(|m| m.version_text.as_deref()) + .filter(|_| report_profiles); if let Some(profile_version) = profile_version { result.warnings.push(RewriteWarning { code: "redirect_maven_profile_dependency_unpatched".into(), @@ -8875,7 +8915,7 @@ fn rewrite_maven_pom( }); continue; } - if profile_version.is_none() { + if profile_version.is_none() && report_profiles { if let Some(shadow) = idx.placeholder_shadow(&group_id, &artifact_id, true) { result.warnings.push(RewriteWarning { code: "redirect_maven_profile_dependency_unpatched".into(), @@ -10316,20 +10356,104 @@ mod tests { r.warnings ); + // Beside a top-level declaration the pin would be overridden + // whenever the profile is active, and VEX never attests it: + // nothing is written either (#2026 review). let both = pom_with_dep("\n 1.7.36", "") .replace("\n", &profile(version)); let files = BTreeMap::from([("pom.xml".to_string(), both)]); let r = rewrite_registry_redirect(&files, &[maven_override()]); - let out = r.files.get("pom.xml").expect("top-level pinned"); - assert!( - out.contains(&format!("{version}")), - "{out}" + assert!(r.files.is_empty(), "{version}: {:?}", r.files); + assert_eq!( + warning_codes(&r), + vec!["redirect_maven_profile_dependency_unpatched"], + "{version}" + ); + } + } + + /// The versions a `` declaration may resolve are read as VEX + /// discovery reads them (its own profile's ``, then every + /// combination of profiles'), and a pin is written only when none of + /// them bypasses it: a profile's `` bypasses only + /// a pin management governs (the one written when no live direct + /// declaration carries a version), and a versionless profile + /// declaration takes the pin. + #[test] + fn maven_profile_versions_are_weighed_before_pinning() { + let pom = |top: &str, profiles: &str| { + format!( + "\n \n {top}\n \n \ + {profiles}\n\n" + ) + }; + let dep = |v: Option<&str>| { + let v = v.map_or(String::new(), |v| format!("{v}")); + format!( + "org.slf4jslf4j-api{v}\ + " + ) + }; + let profile = |id: &str, props: &str, body: &str| { + format!("{id}{props}{body}") + }; + let direct = |v: Option<&str>| format!("{}", dep(v)); + let managed = |v: Option<&str>| { + format!( + "{}", + dep(v) + ) + }; + let own_pin = format!("{MAVEN_SUFFIXED}"); + let refused = [ + // A profile's management, where management governs the pin. + pom(&dep(None), &profile("p", "", &managed(Some("1.7.36")))), + // Its own profile's property resolves the pin, but another + // profile active with it redefines that property. + pom( + &dep(Some("1.7.36")), + &format!( + "{}{}", + profile("p", &own_pin, &direct(Some("${v}"))), + profile("q", "1.7.36", "") + ), + ), + ]; + for pom in &refused { + let files = BTreeMap::from([("pom.xml".to_string(), pom.clone())]); + let r = rewrite_registry_redirect(&files, &[maven_override()]); + assert!(r.files.is_empty(), "{pom}: {:?}", r.files); + assert_eq!( + warning_codes(&r), + vec!["redirect_maven_profile_dependency_unpatched"], + "{pom}" ); + } + let pinned = [ + // A profile's management under a direct pin. + pom( + &dep(Some("1.7.36")), + &profile("p", "", &managed(Some("1.7.36"))), + ), + // Versionless: it takes the pin. + pom(&dep(Some("1.7.36")), &profile("p", "", &direct(None))), + // Its own profile's property resolves the pin, and nothing + // else redefines it. + pom( + &dep(Some("1.7.36")), + &profile("p", &own_pin, &direct(Some("${v}"))), + ), + ]; + for pom in &pinned { + let files = BTreeMap::from([("pom.xml".to_string(), pom.clone())]); + let r = rewrite_registry_redirect(&files, &[maven_override()]); assert!( - warning_codes(&r).contains(&"redirect_maven_profile_dependency_unpatched"), - "{version}: {:?}", + !warning_codes(&r).contains(&"redirect_maven_profile_dependency_unpatched"), + "{pom}: {:?}", r.warnings ); + let out = r.files.get("pom.xml").expect("pinned"); + assert!(out.contains(MAVEN_SUFFIXED), "{out}"); } } @@ -10806,8 +10930,8 @@ mod tests { /// A versioned `` declaration through an undefined coordinate /// property may be the GA while its profile is active: reported like a - /// literal profile declaration (#683), pinned beside a top-level - /// literal and skipped when it is the only declaration. + /// literal profile declaration (#683), and skipped beside a top-level + /// literal too, since that pin would never be attested (#2026 review). #[test] fn maven_pom_undefined_coordinate_property_in_a_profile_is_reported() { let profile = "p\ @@ -10816,7 +10940,7 @@ mod tests { for (top, pinned) in [ ( "org.slf4jslf4j-api1.7.36", - true, + false, ), ("", false), ] { From 402fccf517de69078bb2a0a620485cee92f75e64 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 5 Nov 2026 23:50:16 -0600 Subject: [PATCH 08/20] Write no hosted Maven pin an executable classifier copy bypasses Back-port rule: hosted must not write a pin VEX can never attest. Hosted scan skipped a dep only when an executable classifier copy (not sources / javadoc) named the patched release; one at another release, through a ${property}, or with no version in the pom let the main jar's pin land, and discovery's unpatched_classifier_copy then withheld it forever (vex_maven_classifier_unpatched). PomIndex::unserved_classifier_copy mirrors that check with the shared is_executable_classifier and the per-declaration build_versions (every version the build may resolve, profile included): a non-managed executable copy of the GA, live or inside , whose own versions, or else its classifier-keyed entries' (live; a profile's too; none live = no version in this pom), include anything but the suffixed pin. rewrite_maven_pom then refuses the dep with nothing written under the existing redirect_maven_classifier_unsupported code (detail: the copy, the version it resolves, the suffixed pin). sources / javadoc copies still only warn, and the legacy same-GAV path keeps its release check. A test-jar typed declaration has no classifier and stays refused as an unsupported packaging (live) or a profile bypass. CLI_CONTRACT.md says so in both the hosted and the vex_maven_classifier_unpatched sections. Test (fails with the check disabled): - patch::redirect::tests::maven_executable_classifier_off_the_pin_blocks_the_redirect Redirect golden and the VEX golden corpus are unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 4 +- .../src/patch/redirect/maven_index.rs | 56 +++++++++ .../src/patch/redirect/mod.rs | 112 +++++++++++++++++- 3 files changed, 167 insertions(+), 5 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index ed389097d..ffb6f9ad8 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -329,7 +329,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Attribution gate (v5.0).** A hosted run never leaves wiring that lockfile discovery calls contested: before the rewrite writes any lockfile, the same discovery `vex`, `list`, `rollback`, `remove` and `vendor` read runs over the project as the rewrite would leave it. A candidate whose pin discovery reads but cannot attribute to one package version (a requirements `-r` include resolving the same version from the registry beside a rewired `Pipfile.lock`, #567; a Maven pin in a ``, #260) is left out of the rewrite and reported as a `skipped` event with `errorCode` `redirect_unattributable` (nothing written for it; exit code unchanged). Unchanged: a pin in a file discovery does not read (a pre-2.6 bundler `Gemfile`, locked by the next `bundle install`) keeps the rewriter's verdict, and so does a deliberate partial redirect the run already reports (a bundled or `bun patch`-ed copy left on the registry, a yarn `npm:` alias entry left on the registry with `redirect_yarn_classic_alias_skipped` / `redirect_yarn_berry_alias_skipped` while the package's direct entry is pinned, a dep withheld from the vlt rewrite while a sibling lock takes it), and so does a vendored→hosted takeover: its vendored wiring is reverted in the run's staged overlay before the rewrite plans, and it is redirected when the rewriters pin it (otherwise it is retracted and stays vendored), in a `--dry-run` preview the same. A pin whose own pnpm or yarn lock also installs an unpatched copy of the same `name@version` (a pnpm `file:` directory or tarball dependency; a yarn `file:` / URL / berry `portal:` / `link:` dependency declared under another name) is written too, with a `redirect_copy_unpatched` warning naming that entry, and is not attested. A lockless NuGet / Cargo pin (an exclusive Socket source mapping without `packages.lock.json`, a Cargo registry pin without `Cargo.lock`) is still written, with a `redirect_pin_lockless` warning: no lockfile records its version, so `vex` cannot attest it and `rollback` / `remove` / `vendor` refuse it until the lockfile exists (whether such pins should be written at all is an open decision). The rollout's recorded view uses the same discovery: a uuid a file merely mentions (a stale `package.json` field, an inactive `pdm.lock`, a comment) is not a pin and does not count as already patched. -The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate, `bun.lock` / `bun.lockb`, and `vlt-lock.json` with `vlt.json` and `node_modules/.vlt-lock.json` read only), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; the hosted redirect is a `sparse+` registry, which cargo loads only from **1.68**, so `rust-toolchain.toml` / `rust-toolchain` are read too (never edited) and a project whose declared floor — the lowest `rust-version` of the root and the member manifests read here (a `rust-version.workspace = true` inheritor reads `[workspace.package]`), else the channel rustup picks from the root (the legacy `rust-toolchain` over `rust-toolchain.toml`; a dated `nightly-`/`beta-YYYY-MM-DD` maps to its release train) — is below 1.68 is refused `redirect_cargo_old_cargo` per crate (nothing written, an earlier run's pins left in place with the `rollback` that drops them named; `--mode vendored` builds on cargo 1.41+), while an undeclared or rolling-channel floor still redirects (toolchain files in ancestor directories are not read); **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. With NO `Cargo.lock` there is no resolved graph to ask, so the dependents question is answered from the manifests instead: a crate declared beside any other dependency — anything but a path dependency on a manifest this run also pins, or a `workspace = true` inheritor of a table it scans — or beside a workspace member this run did not read (a `members` glob, or a member outside the project or behind a symbolic link, which member discovery drops) is refused `redirect_cargo_lockless_dependents`, whose detail names the remedies (commit a lockfile, or `--mode vendored`); a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json` (plus, v5.0 #353/#514, every lock a project under the root restores into: a member project's `packages.lock.json`, a per-project `packages..lock.json`, a literal `NuGetLockFilePath` — each pinned with the root config; a `NuGetLockFilePath` it cannot evaluate warns `redirect_nuget_lock_path_unresolved` and a project tree it cannot list warns `redirect_nuget_lock_unreadable`, both skipping the nuget redirect with nothing written), `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and, for a Gradle build, every settings, build, `buildSrc`, included-build, applied and plugin-source script, version catalog and lock file the script graph reaches, plus `gradle/verification-metadata.xml`, `gradle/wrapper/gradle-wrapper.properties` and the owned `.socket/gradle/` files), and the sbt build files (`socket-patch.sbt`, `socket-patch-vendor.sbt`, `build.sbt`, `project/build.properties`, `.sbtopts`, `.jvmopts`; `build.sbt.lock` and the Mill / scala-cli build files `build.mill`, `build.mill.yaml`, `build.sc`, `.mill-version`, `project.scala` for their presence only) — read, never edited; `socket-patch.sbt` is the only sbt file hosted mode writes (see **Hosted sbt** below). **npm-family flavor coverage**: package-lock / npm-shrinkwrap (a package the project patches itself with npm ≥ 12.1's native `npm patch` — a root `patchedDependencies` key, or the lock entry's `patched` record — is left on its registry entry in every npm lock, `redirect_npm_patched_dependency_skipped`), pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic (a yarn 2+ install migrates a v1 `yarn.lock` and drops its pins, so a run whose v1 lock carries a hosted pin warns `redirect_yarn_classic_berry_migration_risk` — the hosted twin of the vendored `yarn_classic_berry_migration_risk` — unless the root `package.json`, read as advisory input, declares `"packageManager": "yarn@1…"`), **yarn berry** (the pin yarn writes for a root `resolutions` entry: the root `package.json` — edited only beside a berry `yarn.lock` — gains one `"@npm:": ""` selector per locked range (`redirect_yarn_berry_resolution` edits), and only that `yarn.lock` entry is re-keyed `"@"` with the same `resolution:` + `yarnBerry10c0` checksum (`redirect_yarn_berry_entry`), moved to yarn's key order; never an `npm:` locator, whose fetcher sends npm registry auth to the patch host, nor a tarball locator under an `npm:` key, which hardened mode rejects (YN0078). An older release's `npm:::__archiveUrl=` pin is still recognized and is re-pinned on the next run; rollback rebuilds the key from the selectors and drops them. Refused, nothing written: a user-authored `resolutions` entry for the package `redirect_yarn_berry_resolutions_conflict`, no root manifest `redirect_yarn_berry_manifest_missing`, a builtin `patch:` entry wrapping the same descriptor `redirect_yarn_berry_shared_descriptor`, an artifact URL yarn cannot fetch as a tarball `redirect_yarn_berry_artifact_url_unsupported`; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found` (or `redirect_bun_non_registry_entry_skipped` when the only same-version entry is a user URL / `file:` tarball, #497), a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`), and **vlt** (`vlt-lock.json` without `lockfileVersion`, `0` or `1`; see the vlt hosted-mode contract below). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when the `repo-state.json` beside a rewritten lock exists (`common/config/rush/repo-state.json` for the common lock, `common/config/subspaces//repo-state.json` for a subspace lock; the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives), and `redirect_pnpm_trust_lockfile` carries the Rush pnpm >=11 install remedy (see the trust paragraph above). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`; when `.mvn/wrapper/maven-wrapper.properties` pins a Maven older than 3.9.4, which ignores those files, the additive warning `redirect_maven_trusted_checksums_unenforced`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a reactor root (a `` / `` declaration, a profile's included, or a Maven 4 root that lists none but whose subprojects Maven discovers: model version not `4.0.0`, `pom` packaging, a direct subdirectory holding a `pom.xml`, and `maven.project.discoverSubprojects` not turned off in `.mvn/maven.config`; each such subdirectory's `pom.xml` is read for this, never edited) is refused whole with `redirect_maven_multimodule_unsupported`: `pom.xml` and `.mvn/` are left untouched and the dep is not counted as redirected (a Gradle build beside it is still planned by the Gradle rewriter, but a dep in a root with a `pom.xml` is confirmed only when the pom pins it too), since a module's own literal `` would shadow a root pin (use `--mode vendored`; `vex` omits a hosted pin in a reactor root as `vex_maven_reactor_root`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), a `` variant (sources, tests, a native build) left at its version (`redirect_maven_classifier_unsupported`; the grant serves the main jar only, and the dep is skipped when a variant other than `sources` / `javadoc` names the patched release), a versioned declaration inside `` (a literal or a `${property}`) left as-is (`redirect_maven_profile_dependency_unpatched`): when any version it may resolve — its own, or what its own profile's or any combination of profiles' `` make of it, read as `vex` reads it — is not the suffixed pin, the dep is refused with nothing written, since it overrides the pin whenever that profile is active and `vex` would never attest it (a profile `` entry only when management governs the pin, i.e. no live direct declaration carries a version); a versionless one takes the pin, and the dep is also skipped when a profile holds its only declaration, a `${property}` groupId / artifactId resolved from the pom's own `` before matching (one the pom does not define, which may be the patched GA and whose own version would then win, refuses the dep with nothing written: `redirect_maven_coordinate_unresolved`), and a `${property}` groupId / artifactId some profile's `` (or several profiles' together) redefine so that the declaration a pin would go into names another package, or another declaration names the GA's main jar at another version (or one of its executable classifier copies, a `test-jar` included), refuses the dep with nothing written too (`redirect_maven_profile_coordinates_unpatched`): `vex` would never attest that pin (`vex_maven_profile_unpatched`), and a pom not readable as one left untouched (`redirect_maven_pom_unreadable`). Matches and anchors skip comments, CDATA, ``, ``, ``, `` and `` (the scope `vex` reads), and an existing self-closed `` / `` is expanded in place, never duplicated; an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). **gradle** (v5.0) is automated wiring, no longer a pasted snippet: the owned settings script `.socket/gradle/socket-patch.hosted.settings.gradle` with its index `.socket/gradle/hosted-index.tsv`, one apply line per build's settings file, every lock entry of the GA moved to the suffixed version, and the suffixed component in an existing `gradle/verification-metadata.xml`. A refused dep writes nothing and keeps `redirect_gradle_manual_snippet` as its fallback; same-GAV grants are refused (`redirect_gradle_same_gav_unsupported`). Rules, refusals and codes: [Gradle builds](#gradle-builds-v50). +The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate, `bun.lock` / `bun.lockb`, and `vlt-lock.json` with `vlt.json` and `node_modules/.vlt-lock.json` read only), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; the hosted redirect is a `sparse+` registry, which cargo loads only from **1.68**, so `rust-toolchain.toml` / `rust-toolchain` are read too (never edited) and a project whose declared floor — the lowest `rust-version` of the root and the member manifests read here (a `rust-version.workspace = true` inheritor reads `[workspace.package]`), else the channel rustup picks from the root (the legacy `rust-toolchain` over `rust-toolchain.toml`; a dated `nightly-`/`beta-YYYY-MM-DD` maps to its release train) — is below 1.68 is refused `redirect_cargo_old_cargo` per crate (nothing written, an earlier run's pins left in place with the `rollback` that drops them named; `--mode vendored` builds on cargo 1.41+), while an undeclared or rolling-channel floor still redirects (toolchain files in ancestor directories are not read); **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. With NO `Cargo.lock` there is no resolved graph to ask, so the dependents question is answered from the manifests instead: a crate declared beside any other dependency — anything but a path dependency on a manifest this run also pins, or a `workspace = true` inheritor of a table it scans — or beside a workspace member this run did not read (a `members` glob, or a member outside the project or behind a symbolic link, which member discovery drops) is refused `redirect_cargo_lockless_dependents`, whose detail names the remedies (commit a lockfile, or `--mode vendored`); a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json` (plus, v5.0 #353/#514, every lock a project under the root restores into: a member project's `packages.lock.json`, a per-project `packages..lock.json`, a literal `NuGetLockFilePath` — each pinned with the root config; a `NuGetLockFilePath` it cannot evaluate warns `redirect_nuget_lock_path_unresolved` and a project tree it cannot list warns `redirect_nuget_lock_unreadable`, both skipping the nuget redirect with nothing written), `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and, for a Gradle build, every settings, build, `buildSrc`, included-build, applied and plugin-source script, version catalog and lock file the script graph reaches, plus `gradle/verification-metadata.xml`, `gradle/wrapper/gradle-wrapper.properties` and the owned `.socket/gradle/` files), and the sbt build files (`socket-patch.sbt`, `socket-patch-vendor.sbt`, `build.sbt`, `project/build.properties`, `.sbtopts`, `.jvmopts`; `build.sbt.lock` and the Mill / scala-cli build files `build.mill`, `build.mill.yaml`, `build.sc`, `.mill-version`, `project.scala` for their presence only) — read, never edited; `socket-patch.sbt` is the only sbt file hosted mode writes (see **Hosted sbt** below). **npm-family flavor coverage**: package-lock / npm-shrinkwrap (a package the project patches itself with npm ≥ 12.1's native `npm patch` — a root `patchedDependencies` key, or the lock entry's `patched` record — is left on its registry entry in every npm lock, `redirect_npm_patched_dependency_skipped`), pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic (a yarn 2+ install migrates a v1 `yarn.lock` and drops its pins, so a run whose v1 lock carries a hosted pin warns `redirect_yarn_classic_berry_migration_risk` — the hosted twin of the vendored `yarn_classic_berry_migration_risk` — unless the root `package.json`, read as advisory input, declares `"packageManager": "yarn@1…"`), **yarn berry** (the pin yarn writes for a root `resolutions` entry: the root `package.json` — edited only beside a berry `yarn.lock` — gains one `"@npm:": ""` selector per locked range (`redirect_yarn_berry_resolution` edits), and only that `yarn.lock` entry is re-keyed `"@"` with the same `resolution:` + `yarnBerry10c0` checksum (`redirect_yarn_berry_entry`), moved to yarn's key order; never an `npm:` locator, whose fetcher sends npm registry auth to the patch host, nor a tarball locator under an `npm:` key, which hardened mode rejects (YN0078). An older release's `npm:::__archiveUrl=` pin is still recognized and is re-pinned on the next run; rollback rebuilds the key from the selectors and drops them. Refused, nothing written: a user-authored `resolutions` entry for the package `redirect_yarn_berry_resolutions_conflict`, no root manifest `redirect_yarn_berry_manifest_missing`, a builtin `patch:` entry wrapping the same descriptor `redirect_yarn_berry_shared_descriptor`, an artifact URL yarn cannot fetch as a tarball `redirect_yarn_berry_artifact_url_unsupported`; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found` (or `redirect_bun_non_registry_entry_skipped` when the only same-version entry is a user URL / `file:` tarball, #497), a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`), and **vlt** (`vlt-lock.json` without `lockfileVersion`, `0` or `1`; see the vlt hosted-mode contract below). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when the `repo-state.json` beside a rewritten lock exists (`common/config/rush/repo-state.json` for the common lock, `common/config/subspaces//repo-state.json` for a subspace lock; the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives), and `redirect_pnpm_trust_lockfile` carries the Rush pnpm >=11 install remedy (see the trust paragraph above). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`; when `.mvn/wrapper/maven-wrapper.properties` pins a Maven older than 3.9.4, which ignores those files, the additive warning `redirect_maven_trusted_checksums_unenforced`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a reactor root (a `` / `` declaration, a profile's included, or a Maven 4 root that lists none but whose subprojects Maven discovers: model version not `4.0.0`, `pom` packaging, a direct subdirectory holding a `pom.xml`, and `maven.project.discoverSubprojects` not turned off in `.mvn/maven.config`; each such subdirectory's `pom.xml` is read for this, never edited) is refused whole with `redirect_maven_multimodule_unsupported`: `pom.xml` and `.mvn/` are left untouched and the dep is not counted as redirected (a Gradle build beside it is still planned by the Gradle rewriter, but a dep in a root with a `pom.xml` is confirmed only when the pom pins it too), since a module's own literal `` would shadow a root pin (use `--mode vendored`; `vex` omits a hosted pin in a reactor root as `vex_maven_reactor_root`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), a `` variant (sources, tests, a native build) left at its version (`redirect_maven_classifier_unsupported`; the grant serves the main jar only, and the dep is skipped, with nothing written, when a variant other than `sources` / `javadoc`, live or inside ``, may resolve any version but the suffixed pin — the patched release, another one, a `${property}` a profile moves, or no version in the pom once its classifier-keyed `` (a profile's too) is consulted — since `vex` would never attest that pin: `vex_maven_classifier_unpatched`), a versioned declaration inside `` (a literal or a `${property}`) left as-is (`redirect_maven_profile_dependency_unpatched`): when any version it may resolve — its own, or what its own profile's or any combination of profiles' `` make of it, read as `vex` reads it — is not the suffixed pin, the dep is refused with nothing written, since it overrides the pin whenever that profile is active and `vex` would never attest it (a profile `` entry only when management governs the pin, i.e. no live direct declaration carries a version); a versionless one takes the pin, and the dep is also skipped when a profile holds its only declaration, a `${property}` groupId / artifactId resolved from the pom's own `` before matching (one the pom does not define, which may be the patched GA and whose own version would then win, refuses the dep with nothing written: `redirect_maven_coordinate_unresolved`), and a `${property}` groupId / artifactId some profile's `` (or several profiles' together) redefine so that the declaration a pin would go into names another package, or another declaration names the GA's main jar at another version (or one of its executable classifier copies, a `test-jar` included), refuses the dep with nothing written too (`redirect_maven_profile_coordinates_unpatched`): `vex` would never attest that pin (`vex_maven_profile_unpatched`), and a pom not readable as one left untouched (`redirect_maven_pom_unreadable`). Matches and anchors skip comments, CDATA, ``, ``, ``, `` and `` (the scope `vex` reads), and an existing self-closed `` / `` is expanded in place, never duplicated; an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). **gradle** (v5.0) is automated wiring, no longer a pasted snippet: the owned settings script `.socket/gradle/socket-patch.hosted.settings.gradle` with its index `.socket/gradle/hosted-index.tsv`, one apply line per build's settings file, every lock entry of the GA moved to the suffixed version, and the suffixed component in an existing `gradle/verification-metadata.xml`. A refused dep writes nothing and keeps `redirect_gradle_manual_snippet` as its fallback; same-GAV grants are refused (`redirect_gradle_same_gav_unsupported`). Rules, refusals and codes: [Gradle builds](#gradle-builds-v50). **Hosted sbt (v5.0, additive)**: an sbt build root (`project/build.properties` naming an `sbt.version`, 0.13.18 or later) is wired through ONE generated root file, `socket-patch.sbt` — no user file is edited. It pins every granted Maven patch build-wide (a `ThisBuild` `dependencyOverrides +=` of the Socket-only `-socket.` version plus a `file:` resolver over `.socket/sbt-hosted/maven2/`, moved ahead of the default repositories on sbt 0.13 / 1.x so an unreachable one never blocks it offline), downloads the pinned pom and jar there on the first sbt load (sha256-checked, gitignored by the file itself), and installs a load-time verifier that fails `update` when any project resolves another version or a pinned artifact whose bytes are not pinned. Edits: `redirect_sbt_pin` (added), `redirect_sbt_pin_updated` (an existing row replaced: same GA and base under a new uuid, or the same uuid with new served values; `original` names the previous uuid and version), `redirect_sbt_pin_rechecked` (an existing row re-verified after the build's dependencies changed: its dependency digest is recorded anew, `original`/`new` are `{deps}`). The load-time verifier also fails `update` when a project declares a pinned GA at a version newer than the pin's base (the build-wide override would otherwise force it back down). A new pin is gated on sbt's own resolution records under `target/` (never the machine-wide cache): run-level stops wire nothing, warn once and exit 0 — `redirect_sbt_no_resolution_evidence` (none; run `sbt update` first; always the in-memory engine's answer), `redirect_sbt_resolution_incomplete` (a declared project left no evidence, or the project definitions cannot be read statically), `redirect_sbt_resolution_stale` (a build source is newer than some project's evidence: each project is dated by its own newest record, so a partial `sbt /update` does not vouch for the others). Per-patch refusals (never confirmed): `redirect_sbt_missing_override` (no `maven2` override or no suffixed version), `redirect_sbt_integrity_missing` (jar or pom sha256 missing), `redirect_sbt_unsafe_value` (a value unsafe in a Scala literal, or an index URL not naming the uuid), `redirect_sbt_version_conflict` (some project resolves another version, or a build source declares the GA newer than the patch's base), `redirect_sbt_override_conflict` (two patches for one GA in a run, or another base already pinned), `redirect_sbt_vendored_conflict` (the GA is pinned by `socket-patch-vendor.sbt`, or that file cannot be parsed — then every Maven patch), `redirect_sbt_owned_file_modified` / `redirect_sbt_owned_file_foreign` (`socket-patch.sbt` edited, or not socket-patch's — every Maven patch), `redirect_sbt_owned_file_unreadable` (a whole-run refusal: `socket-patch.sbt` is on disk but cannot be read as UTF-8 text, so writing it would replace it; nothing is written), `redirect_sbt_unsupported_version`, `redirect_sbt_build_root_unknown` (sbt files but no versioned build root — every Maven patch), `redirect_sbt_overrides_assignment` / `redirect_sbt_resolvers_assignment` (a build source reassigns `dependencyOverrides` / `resolvers` with `:=`, `~=` or `--=`), `redirect_sbt_dependency_lock_present` (a `build.sbt.lock`), `redirect_sbt_scala_runtime_unsupported` (`org.scala-lang`), `redirect_sbt_classifier_unsupported`; a GA no library configuration resolves is skipped silently (`redirect_sbt_meta_build_only` when only the meta-build resolves it). Advisories: `redirect_sbt_version_untested` (sbt 2.1+, still wired), `redirect_sbt_override_build_repos` (`sbt.override.build.repos=true`), `redirect_maven_pom_ignored_sbt_build` (a `pom.xml` beside the sbt build, which sbt never reads; the Maven rewriter still edits it for the Maven build). A re-run keeps an existing row and re-checks it. When the build's dependency digest changed since the pin, evidence resolved after the change (fresh, newer than the generated file) re-verifies it and the row's digest is refreshed (`redirect_sbt_pin_rechecked`); the uuid is NOT confirmed on `redirect_sbt_pin_declared_newer` (a build source now declares the GA newer than the pin's base; the row stays, sbt's load-time verifier fails the build, and the remedy is `socket-patch rollback` or declaring the base again), `redirect_sbt_pin_unverifiable` (the digest changed and the evidence predates the change, or the digest cannot be computed: run `sbt update`, then re-run socket-patch), `redirect_sbt_override_shadowed` (the evidence still resolves the base version) or `redirect_sbt_resolved_elsewhere` (the pinned version resolves from outside the pin repository from a file whose sha256 is not the pinned jar's; a copy holding the pinned bytes, such as the Ivy cache a second checkout reads, is fine — at most 64 pinned artifact files of up to 256 MiB are hashed, anything else counts as elsewhere), and also when a build source now reassigns `dependencyOverrides` / `resolvers` or a `build.sbt.lock` appeared (the same `redirect_sbt_overrides_assignment` / `redirect_sbt_resolvers_assignment` / `redirect_sbt_dependency_lock_present` codes; the row stays and sbt's load-time verifier fails the build). For a pure sbt root (no `pom.xml` / Gradle script beside it), maven confirmation is decided only by the sbt rewriter's report; on a mixed root a uuid the sbt rewriter refused is still confirmed by the Maven rewriter's own `pom.xml` pin (the generated sbt files never prove a pin by substring). **Mill and scala-cli** are guidance only: per Maven patch `redirect_mill_manual_snippet` / `redirect_scala_cli_manual_snippet` carry a paste-able snippet (repository + forced suffixed version), nothing is written or confirmed, and a pure Mill / scala-cli root gets no `redirect_maven_no_pom`; there, a Maven patch the server sent without a `maven2` registry override gets `redirect_maven_missing_override` instead of a snippet (with a `pom.xml` beside the Mill / scala-cli files the pom rewriter reports it). `rollback` / `remove` restore `socket-patch.sbt` offline (the rows removed, the file deleted with its last pin; the gitignored downloads are left). Manifest-less VEX reads every strictly parsed pin as a hosted reference but grants it the lockfile basis only when the local evidence shows every recorded version of the GA is the pinned one and every recorded artifact hashes to a pinned sha256 (else `sbt_resolution_unverified`). @@ -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`. Six 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 classifier copy other than `sources` / `javadoc` (`tests`, a native or JDK-specific build), live or inside ``, resolves any version other than the suffixed pin — its own literal ``, a `` entry with the same classifier (a profile's overriding the live one), or no version in the root pom — the pin is omitted. A pre-v5 single-pom vendored repository holds only the main jar, so any such copy omits its reference. `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. **Maven profile declarations** (`vex_maven_profile_unpatched`): a `` declaration of a pinned GA's main jar in the root `pom.xml` at any version other than the pin (the base release, another release, a `${property}` the pom does not resolve) overrides the pin whenever that profile is active, so the reference is omitted. A profile `` entry always counts; a profile `` entry counts only when management governs the pin (no direct `` outside ``), since a direct literal version beats management. A declaration without a `` takes the live pin, and a `pom` (or `java-source` / `javadoc`) typed one carries no code; neither withholds the statement. Activation is not evaluated: every profile counts, as in vendored mode, which rewrites profile declarations too; hosted scan leaves them as written and writes no pin they bypass (`redirect_maven_profile_dependency_unpatched`). A pin spelled through a property counts the same way: a `${property}` `` is interpolated through every property it names, the project's own `${project.version}` included (itself often spelled through `${revision}`), at most 16 levels deep (a cycle or an undefined name never resolves to a pin), and when any profile's `` — or any combination of profiles' together, each nested reference read with that combination active, as the vendored planner weighs them too — redefine one of those properties so that it resolves anything other than the pin (or nothing), the reference is omitted too; a `.mvn/maven.config` user property wins over every profile's. A profile declaration reads its own profile's `` first. When more than 8 profiles may change such a version, or a profile's `` cannot be parsed, the reference is omitted (fail closed); the rest of the pom is still read. The statement names the profiles that move the pin and the version they move it to. Coordinates count the same way: when a profile's `` (or several profiles' together, or the profile holding the declaration) redefine a property a `${property}` `` / `` names, so that the declaration carrying the pin names another package (the pin then no longer applies to the patched GA) or another declaration names the patched GA (its main jar, or an executable classifier copy) at a version other than the pin, or as a classifier copy with no version of its own, the reference is omitted; a profile value the pom cannot resolve may name any package, a user property wins over every profile's, and the same 8-profile and unreadable-profile limits fail closed. Each such reading is weighed like a declaration of its own: a retargeted classifier copy (a `test-jar` is the `tests` copy) by the classifier rule (`vex_maven_classifier_unpatched`, so a pre-v5 vendored repository, which serves only the main jar, is bypassed even at the vendored version), any other non-`jar` type not at all, and a reading that moves the coordinates and the version onto the pin together does not withhold the statement. Hosted scan refuses such a dep (`redirect_maven_profile_coordinates_unpatched`), and vendored mode leaves the declaration as written and does not pin its root (`coordinates_overridden`). The same holds for a pre-v5 single-pom vendored repository, which serves only the original version. **Maven user properties** (`vex_maven_config_override`): Maven resolves a `${property}` `` from `.mvn/maven.config`'s `-D` user properties before the pom's `` (`${project.*}` / `${pom.*}` always come from the model). A pin the pom spells through a property that `.mvn/maven.config` redefines resolves that other version, so the reference is omitted. A Socket version that only a `-D` user property supplies is not `pom.xml` wiring (hosted scan never writes one, and `rollback` / `remove` never edit `.mvn/maven.config`): it is no reference at all, only a diagnostic. +* **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`. Six 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 classifier copy other than `sources` / `javadoc` (`tests`, a native or JDK-specific build), live or inside ``, resolves any version other than the suffixed pin — its own literal ``, a `` entry with the same classifier (a profile's overriding the live one), or no version in the root pom — the pin is omitted. A pre-v5 single-pom vendored repository holds only the main jar, so any such copy omits its reference. `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, writing nothing, when an executable copy may resolve anything but the suffixed pin (so it never writes a pin this rule withholds). **Maven profile declarations** (`vex_maven_profile_unpatched`): a `` declaration of a pinned GA's main jar in the root `pom.xml` at any version other than the pin (the base release, another release, a `${property}` the pom does not resolve) overrides the pin whenever that profile is active, so the reference is omitted. A profile `` entry always counts; a profile `` entry counts only when management governs the pin (no direct `` outside ``), since a direct literal version beats management. A declaration without a `` takes the live pin, and a `pom` (or `java-source` / `javadoc`) typed one carries no code; neither withholds the statement. Activation is not evaluated: every profile counts, as in vendored mode, which rewrites profile declarations too; hosted scan leaves them as written and writes no pin they bypass (`redirect_maven_profile_dependency_unpatched`). A pin spelled through a property counts the same way: a `${property}` `` is interpolated through every property it names, the project's own `${project.version}` included (itself often spelled through `${revision}`), at most 16 levels deep (a cycle or an undefined name never resolves to a pin), and when any profile's `` — or any combination of profiles' together, each nested reference read with that combination active, as the vendored planner weighs them too — redefine one of those properties so that it resolves anything other than the pin (or nothing), the reference is omitted too; a `.mvn/maven.config` user property wins over every profile's. A profile declaration reads its own profile's `` first. When more than 8 profiles may change such a version, or a profile's `` cannot be parsed, the reference is omitted (fail closed); the rest of the pom is still read. The statement names the profiles that move the pin and the version they move it to. Coordinates count the same way: when a profile's `` (or several profiles' together, or the profile holding the declaration) redefine a property a `${property}` `` / `` names, so that the declaration carrying the pin names another package (the pin then no longer applies to the patched GA) or another declaration names the patched GA (its main jar, or an executable classifier copy) at a version other than the pin, or as a classifier copy with no version of its own, the reference is omitted; a profile value the pom cannot resolve may name any package, a user property wins over every profile's, and the same 8-profile and unreadable-profile limits fail closed. Each such reading is weighed like a declaration of its own: a retargeted classifier copy (a `test-jar` is the `tests` copy) by the classifier rule (`vex_maven_classifier_unpatched`, so a pre-v5 vendored repository, which serves only the main jar, is bypassed even at the vendored version), any other non-`jar` type not at all, and a reading that moves the coordinates and the version onto the pin together does not withhold the statement. Hosted scan refuses such a dep (`redirect_maven_profile_coordinates_unpatched`), and vendored mode leaves the declaration as written and does not pin its root (`coordinates_overridden`). The same holds for a pre-v5 single-pom vendored repository, which serves only the original version. **Maven user properties** (`vex_maven_config_override`): Maven resolves a `${property}` `` from `.mvn/maven.config`'s `-D` user properties before the pom's `` (`${project.*}` / `${pom.*}` always come from the model). A pin the pom spells through a property that `.mvn/maven.config` redefines resolves that other version, so the reference is omitted. A Socket version that only a `-D` user property supplies is not `pom.xml` wiring (hosted scan never writes one, and `rollback` / `remove` never edit `.mvn/maven.config`): it is no reference at all, only a diagnostic. * **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). diff --git a/crates/socket-patch-core/src/patch/redirect/maven_index.rs b/crates/socket-patch-core/src/patch/redirect/maven_index.rs index d622ef0cc..2e731a589 100644 --- a/crates/socket-patch-core/src/patch/redirect/maven_index.rs +++ b/crates/socket-patch-core/src/patch/redirect/maven_index.rs @@ -336,6 +336,62 @@ impl PomIndex { }) } + /// The first executable classifier copy of `group:artifact` (not + /// `sources` / `javadoc`; live or inside ``) that may resolve + /// a version other than `served`, with its classifier and that version + /// (`None`: none in this pom), as VEX discovery weighs one: the grant + /// serves only the main jar, and a classifier copy is its own + /// coordinate, which the pin does not retarget. Its versions are its + /// own ([`IndexedDep::build_versions`]), else those of a + /// `` entry with the same classifier (Maven keys + /// management by classifier; a profile's entry while that profile is + /// active), and with no live entry the version comes from outside this + /// pom. A `test-jar` typed declaration has no classifier and is refused + /// as an unsupported packaging (or a profile bypass) instead. + pub(super) fn unserved_classifier_copy( + &self, + group: &str, + artifact: &str, + served: &str, + ) -> Option<(&str, Option<&str>)> { + let copies = |managed: bool| { + self.deps.iter().filter_map(move |d| { + let c = d.classifier.as_deref()?; + (d.managed == managed + && d.group.as_deref() == Some(group) + && d.artifact.as_deref() == Some(artifact) + && is_executable_classifier(c)) + .then_some((d, c)) + }) + }; + // Every version a declaration may resolve (`None`: it has none). + fn versions(d: &IndexedDep) -> Vec> { + match d.build_versions.as_slice() { + [] => vec![None], + all => all.iter().map(|v| Some(v.as_str())).collect(), + } + } + copies(false).find_map(|(d, classifier)| { + let resolved: Vec> = if d.version_text.is_some() { + versions(d) + } else { + let entries = |in_profile: bool| { + copies(true) + .filter(move |(m, c)| *c == classifier && m.in_profile == in_profile) + .flat_map(|(m, _)| versions(m)) + }; + let mut live: Vec> = entries(false).collect(); + if live.is_empty() { + live.push(None); + } + live.extend(entries(true)); + live + }; + let version = resolved.into_iter().find(|v| *v != Some(served))?; + Some((classifier, version)) + }) + } + pub(super) fn dep(&self, i: usize) -> &IndexedDep { &self.deps[i] } diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 0fa606262..11c92845f 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -8791,6 +8791,28 @@ fn rewrite_maven_pom( // unpatched bytes there while the GA counts as redirected and VEX // attests it: the dep is skipped. `sources` / `javadoc` jars are // never executed, so they only warn. + // + // With a suffixed pin, every executable copy is weighed as VEX + // discovery weighs it: one that may resolve anything but the pin + // (another release, a `${property}` a profile moves, or no version + // in this pom) keeps unpatched code on the classpath, so no pin is + // written that VEX could never attest. + let unserved = suffixed_version + .as_deref() + .and_then(|s| idx.unserved_classifier_copy(&group_id, &artifact_id, s)); + if let (Some((classifier, version)), Some(served)) = (unserved, &suffixed_version) { + result.warnings.push(RewriteWarning { + code: "redirect_maven_classifier_unsupported".into(), + detail: format!( + "{group_id}:{artifact_id}:{classifier} resolves {}, not \ + the suffixed {served}; the Socket patch covers only the main jar, so it is \ + left as-is and the dep is not redirected (that variant would keep the \ + unpatched code on the classpath)", + version.unwrap_or("(none in this pom)") + ), + }); + continue; + } let mut executable_variant = false; for variant in &variants { let Some(v) = variant.version_text.as_deref() else { @@ -8799,9 +8821,10 @@ fn rewrite_maven_pom( let names_release = v == dep.version || crate::formats::maven::split_socket_version(v) .is_some_and(|(base, _)| base == dep.version); - if names_release { - let classifier = variant.classifier.as_deref().unwrap_or_default(); - let executable = !matches!(classifier, "sources" | "javadoc"); + let classifier = variant.classifier.as_deref().unwrap_or_default(); + let executable = crate::formats::maven::is_executable_classifier(classifier); + // Weighed above when the pin is suffixed. + if names_release && (suffixed_version.is_none() || !executable) { executable_variant |= executable; result.warnings.push(RewriteWarning { code: "redirect_maven_classifier_unsupported".into(), @@ -10486,6 +10509,89 @@ mod tests { } } + /// An executable classifier copy that resolves anything but the + /// suffixed pin — another release, a `${property}` a profile moves, no + /// version in this pom (its classifier-keyed management consulted, a + /// profile's too) — keeps unpatched code on the classpath, and VEX never + /// attests that pin: nothing is written (#2026 review). One the + /// management pins to the suffixed version, or a `sources` / `javadoc` + /// one at any version, lets the main jar's pin land. + #[test] + fn maven_executable_classifier_off_the_pin_blocks_the_redirect() { + let pom = |copies: &str, profile: &str| { + format!( + "\n 1.7.30\n \n \ + org.slf4jslf4j-api\ + 1.7.36\n {copies}\n \n \ + p{profile}\n\n" + ) + }; + let copy = |classifier: &str, version: Option<&str>| { + let v = version.map_or(String::new(), |v| format!("{v}")); + format!( + "org.slf4jslf4j-api{v}\ + {classifier}" + ) + }; + let managed = |version: &str| { + format!( + "{}", + copy("tests", Some(version)) + ) + }; + let refused = [ + pom(©("tests", Some("1.7.30")), ""), + pom(©("linux-x86_64", Some("${v}")), ""), + pom(©("tests", None), ""), + // A profile's management of a versionless copy, over a live one + // at the pin. + pom(©("tests", None), &managed("1.7.30")).replace( + " ", + &format!(" {}\n ", managed(MAVEN_SUFFIXED)), + ), + // A version a profile's `` move. + pom( + ©("tests", Some("${w}")), + "1.7.30", + ) + .replace( + "1.7.30", + &format!("1.7.30{MAVEN_SUFFIXED}"), + ), + ]; + for pom in &refused { + let files = BTreeMap::from([("pom.xml".to_string(), pom.clone())]); + let r = rewrite_registry_redirect(&files, &[maven_override()]); + assert!(r.files.is_empty(), "{pom}: {:?}", r.files); + assert_eq!( + warning_codes(&r), + vec!["redirect_maven_classifier_unsupported"], + "{pom}" + ); + } + let pinned = [ + pom(©("sources", Some("1.7.30")), ""), + pom(©("javadoc", None), ""), + pom(©("tests", Some(MAVEN_SUFFIXED)), ""), + // A versionless copy its live management pins to the pin. + pom(©("tests", None), "").replace( + " ", + &format!(" {}\n ", managed(MAVEN_SUFFIXED)), + ), + ]; + for pom in &pinned { + let files = BTreeMap::from([("pom.xml".to_string(), pom.clone())]); + let r = rewrite_registry_redirect(&files, &[maven_override()]); + let out = r.files.get("pom.xml").expect("main jar pinned"); + assert!( + out.contains(&format!( + "slf4j-api{MAVEN_SUFFIXED}" + )), + "{out}" + ); + } + } + /// Fail-closed literal pin: the `` is rewritten to the suffixed /// value, the repository + trusted-checksum files are emitted, and a re-run /// over the fully-pinned output records nothing (idempotent). From 64a07dae3fdea89793ab5d4dc395cd903ff17dcf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 12:29:50 +0000 Subject: [PATCH 09/20] Address Bugbot: Maven config-only diagnostic and property-coordinate classifiers The "only through .mvn/maven.config" diagnostic fired whenever the build resolved a different Socket version than the pom spelled, even when the pom spelling was itself a Socket pin. That pin is pom.xml wiring: pin detection keeps its ref and reports MavenConfigOverride, so the diagnostic contradicted it. Skip it when the pom spelling is a pin. The executable-classifier scan matched only a literal groupId/artifactId, so a classifier copy declared through an undefined ${property} coordinate was ignored and the main-jar pin could still be attested while Maven might put that unpatched classifier jar on the classpath. Match coordinates with may_name, as main-jar grouping already does. Co-Authored-By: Claude --- .../src/vex/discover/maven.rs | 42 +++++++++++++++++-- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/crates/socket-patch-core/src/vex/discover/maven.rs b/crates/socket-patch-core/src/vex/discover/maven.rs index acb5bd0ca..23cdab524 100644 --- a/crates/socket-patch-core/src/vex/discover/maven.rs +++ b/crates/socket-patch-core/src/vex/discover/maven.rs @@ -118,7 +118,7 @@ use super::{ }; use crate::formats::maven::{ is_executable_classifier, is_maven_coordinate, is_maven_version_text, maven_config_properties, - may_shadow, parse_pom_with, split_socket_version, Pom, PomDep, PomRepo, + may_name, may_shadow, parse_pom_with, split_socket_version, Pom, PomDep, PomRepo, }; use crate::patch::redirect::{MVN_CHECKSUMS, MVN_CONFIG, TRUSTED_CHECKSUMS_ON}; use crate::utils::digest::sha256_hex; @@ -233,6 +233,17 @@ async fn extract_hosted( else { continue; }; + // A pom spelling that is itself a Socket pin is pom.xml wiring: + // pin detection keeps its ref and reports the -D override + // (`MavenConfigOverride`) instead. + if dep + .version + .as_deref() + .and_then(split_socket_version) + .is_some() + { + continue; + } out.diag( DIAG_REF_UNATTRIBUTABLE, POM, @@ -736,7 +747,8 @@ fn main_jar_declarations<'a>( /// The executable classifier copies of `group:artifact` (tests, a native /// or JDK-specific build; not `sources` / `javadoc`) that `in_scope` keeps, -/// with their classifier. +/// with their classifier. A groupId or artifactId that is a property this +/// pom does not define may be this GA ([`may_name`]), so it counts too. fn classifier_declarations<'a>( deps: &'a [PomDep], group: &'a str, @@ -744,7 +756,9 @@ fn classifier_declarations<'a>( in_scope: impl Fn(&PomDep) -> bool + 'a, ) -> impl Iterator + 'a { deps.iter() - .filter(move |d| in_scope(d) && d.group == group && d.artifact == artifact) + .filter(move |d| { + in_scope(d) && may_name(&d.group, group) && may_name(&d.artifact, artifact) + }) .filter_map(|d| { let classifier = d.classifier.as_deref()?; is_executable_classifier(classifier).then_some((d, classifier)) @@ -1241,6 +1255,14 @@ mod tests { ), // No version this pom gives it (a parent's): fail closed. (classified("tests", None), String::new()), + // A coordinate property this pom does not define may be the GA. + ( + "${undefined.group}\ + slf4j-api1.7.36\ + tests\n" + .to_string(), + String::new(), + ), ]; for (deps, managed) in &unattested { let out = run(&project(deps, managed)).await; @@ -1689,6 +1711,14 @@ mod tests { "--define slf4j.version=1.7.30\n".to_string(), Expect::Overridden, ), + // The build config swaps one Socket pin for another: the pom's + // pin is still pom.xml wiring, so no config-only diagnostic. + ( + pin.as_str(), + &by_prop, + format!("-Dslf4j.version={}\n", suffixed(UUID_B)), + Expect::Overridden, + ), // The build config alone supplies the pin. ( "1.7.36", @@ -1736,6 +1766,12 @@ mod tests { assert_eq!(u.kind, UnattestedKind::MavenConfigOverride, "{what}"); assert!(u.detail.contains(".mvn/maven.config"), "{}", u.detail); } + assert!( + !out.diagnostics + .iter() + .any(|d| d.detail.contains("only through")), + "{what}" + ); } Expect::ConfigOnly | Expect::NoRef => { assert_refs(&out, &[]); From 2af00b2bf553e9ab38cb580a0582f60347b1332b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 13:37:47 +0000 Subject: [PATCH 10/20] Fail closed on classifier copies managed only through a property A version-less executable classifier copy took its version from any managed classifier entry `may_name` matched, including one whose groupId or artifactId is an undefined property. That entry may manage another GA, so when it was the only one and sat at the pin, discovery attested while Maven could resolve the copy from outside this pom, unpatched. Such entries still count as possible versions, but without a live entry that names the GA literally the copy's version is treated as unknown. Addresses Bugbot's classifier-management finding on this PR. Co-Authored-By: Claude --- .../src/vex/discover/maven.rs | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/crates/socket-patch-core/src/vex/discover/maven.rs b/crates/socket-patch-core/src/vex/discover/maven.rs index 23cdab524..ee6bf531f 100644 --- a/crates/socket-patch-core/src/vex/discover/maven.rs +++ b/crates/socket-patch-core/src/vex/discover/maven.rs @@ -851,24 +851,32 @@ fn unpatched_classifier_copy( Wiring::Hosted => Some(pinned), Wiring::Vendored => None, }; - let managed = |classifier: &str, in_profile: bool| -> Vec<(Option<&str>, bool)> { + // Each entry carries whether it names `group:artifact` literally: one + // reached only through an undefined property ([`may_name`]) may manage + // another GA, so it never stands in for this copy's version alone. + let managed = |classifier: &str, in_profile: bool| -> Vec<(Option<&str>, bool, bool)> { classifier_declarations(deps, group, artifact, move |d| { d.managed && d.in_profile == in_profile }) .filter(|(_, c)| *c == classifier) - .map(|(d, _)| (build_version(d), in_profile)) + .map(|(d, _)| { + let literal = d.group == group && d.artifact == artifact; + (build_version(d), in_profile, literal) + }) .collect() }; classifier_declarations(deps, group, artifact, |d| !d.managed).find_map(|(d, classifier)| { - let resolved = match build_version(d) { + let resolved: Vec<(Option<&str>, bool)> = match build_version(d) { Some(version) => vec![(Some(version), false)], None => { let mut live = managed(classifier, false); - if live.is_empty() { - live.push((None, false)); + // Without a live entry that surely manages this GA, the + // version may come from outside this pom: fail closed. + if !live.iter().any(|(_, _, literal)| *literal) { + live.push((None, false, true)); } live.extend(managed(classifier, true)); - live + live.into_iter().map(|(v, p, _)| (v, p)).collect() } }; let (version, from_profile) = resolved @@ -1263,6 +1271,17 @@ mod tests { .to_string(), String::new(), ), + // Version-less copy whose only managed entry reaches the GA + // through an undefined property: it may manage another GA, so + // the copy's version may come from outside this pom. + ( + classified("tests", None), + format!( + "${{undefined.group}}\ + slf4j-api{pin}\ + tests\n" + ), + ), ]; for (deps, managed) in &unattested { let out = run(&project(deps, managed)).await; From 139ea9dfa0771a648ddda9470761506a038c6307 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 19:36:35 +0000 Subject: [PATCH 11/20] Judge a Maven config override against the pin, not the spelling Second review round on Maven discovery false attestation. - `MavenConfigOverride` compared the build-resolved versions with the pom-spelled ones, so a property that only `.mvn/maven.config` defines AT the pinned version (a pre-v5 vendored `${commons.version}`) was reported as an override. It is one only when the build resolves something other than the pin; the detail lists versions plainly instead of a debug-printed set. - A version-less profile `` entry for a classifier copy (scope or exclusions only) counted as an unpatched copy; it overrides no version and is skipped, as `profile_override` already does for the main jar. - The config-only Socket-version diagnostic is emitted once per GA and version, not once per declaration. - The hosted rewriter uses the shared `is_executable_classifier`; the contract rows, `UnattestedKind` doc and CLI notes say pre-v5 vendored refs get these kinds too. Co-Authored-By: Claude Opus 5.5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 4 +- .../src/commands/vex_sources.rs | 8 +- .../src/patch/redirect/mod.rs | 2 +- .../src/vex/discover/maven.rs | 93 ++++++++++++++++++- .../socket-patch-core/src/vex/discover/mod.rs | 3 +- 5 files changed, 98 insertions(+), 12 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 7d9a61c76..fcc0e6443 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -2677,9 +2677,9 @@ match it, withholds the statement: | `vex_pnpm_bundled_copy` | run warning and `failed[].reason` | An npm pin is wired, but its pnpm lock names a package whose `bundledDependencies` may ship an unpatched copy of it (the lock does not record that copy's version); no statement while that package bundles it. `vendor --check` and `scan` still treat the wiring as live. | | `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 an executable `` copy (not `sources` / `javadoc`; live or inside ``) 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_maven_classifier_unpatched` | run warning and `failed[].reason` | A hosted (or pre-v5 vendored) Maven pin's GA also has an executable `` copy (not `sources` / `javadoc`; live or inside ``) 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_maven_profile_unpatched` | run warning and `failed[].reason` | A Maven pin's GA main jar is also declared inside `` in the root `pom.xml` at a version other than the pin (a profile `` entry only where management governs the pin), which overrides the pin whenever that profile is active; no statement while it does. `list`, `rollback` and `remove` still find the pin. | -| `vex_maven_config_override` | run warning and `failed[].reason` | A hosted Maven pin is spelled through a `${property}` that a `-D` user property in `.mvn/maven.config` redefines, so Maven resolves another version; no statement while it does. `list`, `rollback` and `remove` still find the pin. | +| `vex_maven_config_override` | run warning and `failed[].reason` | A hosted (or pre-v5 vendored) Maven pin is spelled through a `${property}` that a `-D` user property in `.mvn/maven.config` redefines, so Maven resolves another version; no statement while it does. `list`, `rollback` and `remove` still find the pin. | | `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 322b05f49..2040be50c 100644 --- a/crates/socket-patch-cli/src/commands/vex_sources.rs +++ b/crates/socket-patch-cli/src/commands/vex_sources.rs @@ -228,16 +228,16 @@ const NOTE_NPM_SHRINKWRAP_ONLY: &str = "vex_npm_shrinkwrap_only"; /// Omission tag and note: a hosted Maven pin sits in a reactor root, where a /// module's own `` may override it (`vex::Unattested`). const NOTE_MAVEN_REACTOR_ROOT: &str = "vex_maven_reactor_root"; -/// Omission tag and note: a hosted Maven pin's GA also has an executable -/// classifier copy at another version, which the pin does not reach +/// Omission tag and note: a hosted (or pre-v5 vendored) Maven pin's GA also +/// has an executable 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 hosted Maven pin's GA is also declared inside /// `` at another version, which overrides the pin whenever that /// profile is active (`vex::Unattested`). const NOTE_MAVEN_PROFILE_UNPATCHED: &str = "vex_maven_profile_unpatched"; -/// Omission tag and note: a hosted Maven pin is spelled through a -/// `${property}` that `.mvn/maven.config` redefines, so the build resolves +/// Omission tag and note: a hosted (or pre-v5 vendored) Maven pin is spelled +/// through a `${property}` that `.mvn/maven.config` redefines, so the build resolves /// another version (`vex::Unattested`). const NOTE_MAVEN_CONFIG_OVERRIDE: &str = "vex_maven_config_override"; diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 0c778ac74..bb0e0f24e 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -8801,7 +8801,7 @@ fn rewrite_maven_pom( .is_some_and(|(base, _)| base == dep.version); if names_release { let classifier = variant.classifier.as_deref().unwrap_or_default(); - let executable = !matches!(classifier, "sources" | "javadoc"); + let executable = crate::formats::maven::is_executable_classifier(classifier); executable_variant |= executable; result.warnings.push(RewriteWarning { code: "redirect_maven_classifier_unsupported".into(), diff --git a/crates/socket-patch-core/src/vex/discover/maven.rs b/crates/socket-patch-core/src/vex/discover/maven.rs index ee6bf531f..eae4a6b6e 100644 --- a/crates/socket-patch-core/src/vex/discover/maven.rs +++ b/crates/socket-patch-core/src/vex/discover/maven.rs @@ -225,6 +225,7 @@ async fn extract_hosted( // A Socket version only a `-D` user property supplies is no pom.xml // wiring: hosted mode never writes one, and rollback / remove never // edit `.mvn/maven.config`. + let mut reported = BTreeSet::new(); for dep in pom.deps.iter().filter(|d| !d.in_profile) { let Some(built) = dep .build_version @@ -244,6 +245,11 @@ async fn extract_hosted( { continue; } + // Once per GA and version: its managed entry and classifier copies + // spell the same property. + if !reported.insert((dep.group.clone(), dep.artifact.clone(), built.to_string())) { + continue; + } out.diag( DIAG_REF_UNATTRIBUTABLE, POM, @@ -810,15 +816,26 @@ fn bypasses( let mut found = Vec::new(); // `.mvn/maven.config`'s `-D` user properties, which Maven reads before // the pom's ``, redefine what the pin is spelled through. + // Only a property that moves the build OFF the pin is an override: one + // `.mvn/maven.config` alone defines, at the pinned version, is the pin. let spelled = ga_versions(deps, group, artifact, pom_version); let built = ga_versions(deps, group, artifact, build_version); - if built.effective() != spelled.effective() { + let resolved = built.effective(); + if resolved != spelled.effective() && !(resolved.len() == 1 && resolved.contains(pinned)) { + let listed = if resolved.is_empty() { + "no version this pom gives".to_string() + } else { + resolved + .iter() + .map(|v| v.as_str()) + .collect::>() + .join(", ") + }; found.push(( UnattestedKind::MavenConfigOverride, format!( "{POM}: {ga} is wired to {pinned}, but a -D user property in {MVN_CONFIG} \ - (which Maven reads before the pom's ) makes it resolve {:?}", - built.effective() + (which Maven reads before the pom's ) makes it resolve {listed}" ), )); } @@ -875,7 +892,13 @@ fn unpatched_classifier_copy( if !live.iter().any(|(_, _, literal)| *literal) { live.push((None, false, true)); } - live.extend(managed(classifier, true)); + // A profile entry with no `` (scope or exclusions + // only) overrides no version. + live.extend( + managed(classifier, true) + .into_iter() + .filter(|(v, _, _)| v.is_some()), + ); live.into_iter().map(|(v, p, _)| (v, p)).collect() } }; @@ -2226,6 +2249,68 @@ mod tests { } } + /// Review on #2005 (round 2): a `-D` property `.mvn/maven.config` alone + /// defines AT the vendored version is the pin, not an override. + #[tokio::test] + async fn vendored_version_from_maven_config_at_the_pin_is_attested() { + let p = vendored_project(UUID_A); + let wired = std::fs::read_to_string(p.root().join("pom.xml")).expect("read pom"); + let spelled = wired.replacen( + "1.10.0", + "${commons.version}", + 1, + ); + assert_ne!(spelled, wired); + p.write("pom.xml", &spelled); + p.write(".mvn/maven.config", "-Dcommons.version=1.10.0\n"); + let out = run(&p).await; + assert_refs(&out, &[(V_PURL, UUID_A, WiringMode::Vendored)]); + assert!(out.unattested.is_empty(), "{:?}", out.unattested); + p.write(".mvn/maven.config", "-Dcommons.version=1.9\n"); + let out = run(&p).await; + let kinds: Vec<_> = out.unattested.iter().map(|u| u.kind).collect(); + assert_eq!(kinds, [UnattestedKind::MavenConfigOverride]); + assert!( + out.unattested[0].detail.ends_with("makes it resolve 1.9"), + "{}", + out.unattested[0].detail + ); + } + + /// Review on #2005 (round 2): a profile `` entry + /// for a classifier copy that sets no `` (scope, exclusions) + /// overrides no version: the live managed pin still governs. + #[tokio::test] + async fn a_versionless_profile_management_entry_overrides_nothing() { + let pin = suffixed(UUID_A); + let tests = |v: Option<&str>| { + let v = v.map_or(String::new(), |v| format!("{v}")); + format!( + "org.slf4jslf4j-api\ + {v}teststest\n" + ) + }; + let p = Project::new(); + p.write( + "pom.xml", + pom(&format!( + "\n\n{}\n\n\ + \n{}{}\n\ + p\n{}\ + \n\ + \n{}\n", + tests(Some(&pin)), + dep("org.slf4j", "slf4j-api", Some(&pin)), + tests(None), + tests(None), + hosted_repo(&format!("socket-patch-{UUID_A}"), ®istry_url(UUID_A)), + )), + ); + let out = run(&p).await; + assert_refs(&out, &[(FX_PURL, UUID_A, WiringMode::Hosted)]); + assert!(out.unattested.is_empty(), "{:?}", out.unattested); + } + #[tokio::test] async fn vendored_url_spellings() { for spelled in [ diff --git a/crates/socket-patch-core/src/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index ca636edcb..2f931a87e 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -612,7 +612,8 @@ pub enum UnattestedKind { /// unresolved `${property}`): whenever that profile is active it /// overrides the pin, so the build resolves the unpatched jar. MavenProfileUnpatched, - /// A hosted Maven pin the root pom spells through a `${property}` that + /// A hosted (or pre-v5 vendored) Maven pin the root pom spells through a + /// `${property}` that /// `.mvn/maven.config` redefines (`-D`, which Maven reads before the /// pom's ``): the build resolves that version, not the pin. MavenConfigOverride, From 3389c349ee564d31310ee17d6a06c233a20a83bb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 20:02:37 +0000 Subject: [PATCH 12/20] Read Maven properties last-wins; profile coordinates and ids Second review round on Maven property resolution. - `read_properties` kept the FIRST definition of a duplicated property; Maven's model reader keeps the last (`Properties.put`). A pom defining `slf4j.version` as the Socket pin and then as the base release was attested while Maven built the base. Last wins now, for the root, the profiles and the reactor alike. - A profile declaration's `` / `` were interpolated with no profile active while its version read the profile's own ``, so a profile dependency naming the pinned GA only through a profile property was missed by `profile_override`. Coordinates now read the declaration's own profile first. - A profile was named by the first `` anywhere under it (a nested ``'s); it is named by its own direct `` child. - An undetermined profile alternative (unreadable profile properties, too many profiles) no longer reads as "nothing it can resolve (${v})" but as a version this reader cannot work out. Co-Authored-By: Claude Opus 5.5 --- .../src/formats/maven/mod.rs | 46 +++++++++++++--- .../src/formats/maven/properties.rs | 9 ++-- .../src/vex/discover/maven.rs | 53 ++++++++++++++++++- 3 files changed, 96 insertions(+), 12 deletions(-) diff --git a/crates/socket-patch-core/src/formats/maven/mod.rs b/crates/socket-patch-core/src/formats/maven/mod.rs index 7e5387eae..ff8ccd27c 100644 --- a/crates/socket-patch-core/src/formats/maven/mod.rs +++ b/crates/socket-patch-core/src/formats/maven/mod.rs @@ -350,11 +350,14 @@ impl PomScope { (base, alternatives) } - /// `text` (a coordinate) with every `${name}` the build defines with - /// no profile active interpolated, an undefined one kept as written + /// `text` (a coordinate of the declaration at `pos`) with every + /// `${name}` the build defines interpolated, reading the declaration's + /// own profile `` first when it sits in a profile (as its + /// version is), an undefined one kept as written /// ([`interpolate_lenient`]). - fn interpolate_coordinate(&self, text: &str) -> String { - interpolate_lenient(text, &|name| self.lookup(name, &[], true)) + fn interpolate_coordinate(&self, text: &str, pos: usize) -> String { + let own = self.own_profile(pos); + interpolate_lenient(text, &|name| self.lookup(name, &own, true)) } /// Inside a plugin / deploy section (never project resolution). @@ -427,8 +430,10 @@ impl PomScope { let version = elements(&body, "version")?.into_iter().next(); out.push(ScopedDep { element: dep, - group: child_text(&body, "groupId")?.map(|g| self.interpolate_coordinate(&g)), - artifact: child_text(&body, "artifactId")?.map(|a| self.interpolate_coordinate(&a)), + group: child_text(&body, "groupId")? + .map(|g| self.interpolate_coordinate(&g, dep.start)), + artifact: child_text(&body, "artifactId")? + .map(|a| self.interpolate_coordinate(&a, dep.start)), version_inner: version.map(|v| { ( dep.inner_start + v.inner_start, @@ -521,6 +526,33 @@ fn properties( Ok(map) } +/// The trimmed text of `body`'s own `` child (depth 0), never one +/// nested deeper (a ``'s `` inside a ``). +fn direct_child_text(body: &str, name: &str) -> Option { + let mut depth = 0usize; + let mut from = 0; + while let Some(rel) = body[from..].find('<') { + let lt = from + rel; + let gt = lt + body[lt..].find('>')?; + let tag = &body[lt + 1..gt]; + from = gt + 1; + if tag.starts_with('!') || tag.starts_with('?') || tag.ends_with('/') { + continue; + } + if tag.starts_with('/') { + depth = depth.saturating_sub(1); + continue; + } + let tag_name = tag.split(|c: char| c.is_ascii_whitespace()).next()?; + if depth == 0 && tag_name == name { + let end = from + body[from..].find(&format!("` inside `` (outside ignored sections) as /// `name -> trimmed value`, with the `` element that holds it. fn profile_properties( @@ -548,7 +580,7 @@ fn profile_properties( .find(|p| props.start >= p.start && props.start < p.end) .copied(); let id = holder - .and_then(|p| child_text(p.inner(text), "id").ok().flatten()) + .and_then(|p| direct_child_text(p.inner(text), "id")) .filter(|id| !id.is_empty()) .unwrap_or_else(|| format!("#{}", out.len() + 1)); let mut map = BTreeMap::new(); diff --git a/crates/socket-patch-core/src/formats/maven/properties.rs b/crates/socket-patch-core/src/formats/maven/properties.rs index f28cd8ebc..1f9cc3343 100644 --- a/crates/socket-patch-core/src/formats/maven/properties.rs +++ b/crates/socket-patch-core/src/formats/maven/properties.rs @@ -253,8 +253,10 @@ pub(crate) fn read_properties( from = gt + 1; continue; } + // A later definition replaces an earlier one, as Maven's model + // reader's `Properties.put` does. if tag.ends_with('/') { - map.entry(name.to_string()).or_default(); + map.insert(name.to_string(), String::new()); from = gt + 1; continue; } @@ -272,8 +274,7 @@ pub(crate) fn read_properties( } search = at + close.len(); }; - map.entry(name.to_string()) - .or_insert_with(|| body[gt + 1..end].trim().to_string()); + map.insert(name.to_string(), body[gt + 1..end].trim().to_string()); from = after; } Ok(()) @@ -377,7 +378,7 @@ mod tests { ("c", "3"), ("d", ""), ("e", ""), - ("f", "4") + ("f", "5") ]) ); assert!(read_properties("1", &mut BTreeMap::new()).is_err()); diff --git a/crates/socket-patch-core/src/vex/discover/maven.rs b/crates/socket-patch-core/src/vex/discover/maven.rs index a132bd8cc..c94398d20 100644 --- a/crates/socket-patch-core/src/vex/discover/maven.rs +++ b/crates/socket-patch-core/src/vex/discover/maven.rs @@ -1012,7 +1012,9 @@ fn profile_override( // The smallest combination of profiles that moves it. let moved = d.version_alternatives.first()?; let declared = d.version_text.as_deref().unwrap_or_default(); - let value = if moved.value.contains("${") { + let value = if !matches!(moved.by, crate::formats::maven::MovedBy::Profiles(_)) { + "resolve a version this reader cannot work out".to_string() + } else if moved.value.contains("${") { format!("nothing it can resolve ({})", moved.value) } else { moved.value.clone() @@ -2611,6 +2613,55 @@ mod tests { ); } + /// Review on #2020 (round 2): a property defined twice is Maven's LAST + /// definition; a profile declaration's coordinates read its own + /// profile's `` as its version does; a profile is named by + /// its own ``, not one nested in it. + #[tokio::test] + async fn duplicate_properties_profile_coordinates_and_profile_ids() { + let pin = suffixed(UUID_A); + let p = property_project( + &format!("{pin}1.7.36"), + &direct_decl("${slf4j.version}"), + &[], + ); + let out = run(&p).await; + assert!(!attests(&out, FX_PURL), "{:?}", out.refs); + + let p = property_project( + "org.other", + &direct_decl(&pin), + &[ + "org.slf4j\n\n\ + ${g}slf4j-api\ + 1.7.30\n\n", + ], + ); + let out = run(&p).await; + assert_refs(&out, &[(FX_PURL, UUID_A, WiringMode::Hosted)]); + assert!(!attests(&out, FX_PURL), "{:?}", out.unattested); + + let repo = hosted_repo(&format!("socket-patch-{UUID_A}"), ®istry_url(UUID_A)); + let p = Project::new(); + p.write( + "pom.xml", + pom(&format!( + "{pin}\n{}\n{repo}\n\ + mirror\ + https://example.test/m2\ + ci1.7.30\n", + direct_decl("${v}"), + )), + ); + let out = run(&p).await; + assert_eq!(out.unattested.len(), 1, "{:?}", out.unattested); + let detail = &out.unattested[0].detail; + assert!( + detail.contains("profile ci") && !detail.contains("mirror"), + "{detail}" + ); + } + /// A profile property written with attributes (`combine.self`) or /// whitespace in its tag is still a definition Maven reads. #[tokio::test] From 4487fa6ef96414f00672dba5e06a2c181f91b826 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 20:35:59 +0000 Subject: [PATCH 13/20] Weigh a retargeted typed classifier copy in hosted and discovery Second review round on Maven coordinate overrides. FALSE ATTESTATION: a profile-retargeted declaration with both a non-jar `` and an explicit `` (`test-jar` + `tests`) was dropped by both readers' `retargeted` (its match fell to "any other type: not weighed"), so hosted scan wrote the pin and VEX attested it while the active profile put the unpatched tests jar on the classpath. An explicit classifier now names the copy whatever the type, as it does for a literal declaration. The contract also states that the vendored reactor reports a retargeted classifier copy as `classifier_declared` with its root still pinned. Co-Authored-By: Claude Opus 5.5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../src/patch/redirect/maven_index.rs | 2 ++ crates/socket-patch-core/src/patch/redirect/mod.rs | 10 ++++++++++ crates/socket-patch-core/src/vex/discover/maven.rs | 14 ++++++++++++++ 4 files changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 15f24b078..8f8e5d3b0 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -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`. Six 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 classifier copy other than `sources` / `javadoc` (`tests`, a native or JDK-specific build), live or inside ``, resolves any version other than the suffixed pin — its own literal ``, a `` entry with the same classifier (a profile's overriding the live one), or no version in the root pom — the pin is omitted. A pre-v5 single-pom vendored repository holds only the main jar, so any such copy omits its reference. `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, writing nothing, when an executable copy may resolve anything but the suffixed pin (so it never writes a pin this rule withholds). **Maven profile declarations** (`vex_maven_profile_unpatched`): a `` declaration of a pinned GA's main jar in the root `pom.xml` at any version other than the pin (the base release, another release, a `${property}` the pom does not resolve) overrides the pin whenever that profile is active, so the reference is omitted. A profile `` entry always counts; a profile `` entry counts only when management governs the pin (no direct `` outside ``), since a direct literal version beats management. A declaration without a `` takes the live pin, and a `pom` (or `java-source` / `javadoc`) typed one carries no code; neither withholds the statement. Activation is not evaluated: every profile counts, as in vendored mode, which rewrites profile declarations too; hosted scan leaves them as written and writes no pin they bypass (`redirect_maven_profile_dependency_unpatched`). A pin spelled through a property counts the same way: a `${property}` `` is interpolated through every property it names, the project's own `${project.version}` included (itself often spelled through `${revision}`), at most 16 levels deep (a cycle or an undefined name never resolves to a pin), and when any profile's `` — or any combination of profiles' together, each nested reference read with that combination active, as the vendored planner weighs them too — redefine one of those properties so that it resolves anything other than the pin (or nothing), the reference is omitted too; a `.mvn/maven.config` user property wins over every profile's. A profile declaration reads its own profile's `` first. When more than 8 profiles may change such a version, or a profile's `` cannot be parsed, the reference is omitted (fail closed); the rest of the pom is still read. The statement names the profiles that move the pin and the version they move it to. Coordinates count the same way: when a profile's `` (or several profiles' together, or the profile holding the declaration) redefine a property a `${property}` `` / `` names, so that the declaration carrying the pin names another package (the pin then no longer applies to the patched GA) or another declaration names the patched GA (its main jar, or an executable classifier copy) at a version other than the pin, or as a classifier copy with no version of its own, the reference is omitted; a profile value the pom cannot resolve may name any package, a user property wins over every profile's, and the same 8-profile and unreadable-profile limits fail closed. Each such reading is weighed like a declaration of its own: a retargeted classifier copy (a `test-jar` is the `tests` copy) by the classifier rule (`vex_maven_classifier_unpatched`, so a pre-v5 vendored repository, which serves only the main jar, is bypassed even at the vendored version), any other non-`jar` type not at all, and a reading that moves the coordinates and the version onto the pin together does not withhold the statement. Hosted scan refuses such a dep (`redirect_maven_profile_coordinates_unpatched`), and vendored mode leaves the declaration as written and does not pin its root (`coordinates_overridden`). The same holds for a pre-v5 single-pom vendored repository, which serves only the original version. **Maven user properties** (`vex_maven_config_override`): Maven resolves a `${property}` `` from `.mvn/maven.config`'s `-D` user properties before the pom's `` (`${project.*}` / `${pom.*}` always come from the model). A pin the pom spells through a property that `.mvn/maven.config` redefines resolves that other version, so the reference is omitted. A Socket version that only a `-D` user property supplies is not `pom.xml` wiring (hosted scan never writes one, and `rollback` / `remove` never edit `.mvn/maven.config`): it is no reference at all, only a diagnostic. +* **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`. Six 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 classifier copy other than `sources` / `javadoc` (`tests`, a native or JDK-specific build), live or inside ``, resolves any version other than the suffixed pin — its own literal ``, a `` entry with the same classifier (a profile's overriding the live one), or no version in the root pom — the pin is omitted. A pre-v5 single-pom vendored repository holds only the main jar, so any such copy omits its reference. `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, writing nothing, when an executable copy may resolve anything but the suffixed pin (so it never writes a pin this rule withholds). **Maven profile declarations** (`vex_maven_profile_unpatched`): a `` declaration of a pinned GA's main jar in the root `pom.xml` at any version other than the pin (the base release, another release, a `${property}` the pom does not resolve) overrides the pin whenever that profile is active, so the reference is omitted. A profile `` entry always counts; a profile `` entry counts only when management governs the pin (no direct `` outside ``), since a direct literal version beats management. A declaration without a `` takes the live pin, and a `pom` (or `java-source` / `javadoc`) typed one carries no code; neither withholds the statement. Activation is not evaluated: every profile counts, as in vendored mode, which rewrites profile declarations too; hosted scan leaves them as written and writes no pin they bypass (`redirect_maven_profile_dependency_unpatched`). A pin spelled through a property counts the same way: a `${property}` `` is interpolated through every property it names, the project's own `${project.version}` included (itself often spelled through `${revision}`), at most 16 levels deep (a cycle or an undefined name never resolves to a pin), and when any profile's `` — or any combination of profiles' together, each nested reference read with that combination active, as the vendored planner weighs them too — redefine one of those properties so that it resolves anything other than the pin (or nothing), the reference is omitted too; a `.mvn/maven.config` user property wins over every profile's. A profile declaration reads its own profile's `` first. When more than 8 profiles may change such a version, or a profile's `` cannot be parsed, the reference is omitted (fail closed); the rest of the pom is still read. The statement names the profiles that move the pin and the version they move it to. Coordinates count the same way: when a profile's `` (or several profiles' together, or the profile holding the declaration) redefine a property a `${property}` `` / `` names, so that the declaration carrying the pin names another package (the pin then no longer applies to the patched GA) or another declaration names the patched GA (its main jar, or an executable classifier copy) at a version other than the pin, or as a classifier copy with no version of its own, the reference is omitted; a profile value the pom cannot resolve may name any package, a user property wins over every profile's, and the same 8-profile and unreadable-profile limits fail closed. Each such reading is weighed like a declaration of its own: a retargeted classifier copy (a `test-jar` is the `tests` copy; an explicit `` names the copy whatever the type) by the classifier rule (`vex_maven_classifier_unpatched`, so a pre-v5 vendored repository, which serves only the main jar, is bypassed even at the vendored version), any other non-`jar` type not at all, and a reading that moves the coordinates and the version onto the pin together does not withhold the statement. Hosted scan refuses such a dep (`redirect_maven_profile_coordinates_unpatched`), and vendored mode leaves the declaration as written and does not pin its root (`coordinates_overridden`), except a retargeted classifier copy, which it reports as `classifier_declared` with the root still pinned. The same holds for a pre-v5 single-pom vendored repository, which serves only the original version. **Maven user properties** (`vex_maven_config_override`): Maven resolves a `${property}` `` from `.mvn/maven.config`'s `-D` user properties before the pom's `` (`${project.*}` / `${pom.*}` always come from the model). A pin the pom spells through a property that `.mvn/maven.config` redefines resolves that other version, so the reference is omitted. A Socket version that only a `-D` user property supplies is not `pom.xml` wiring (hosted scan never writes one, and `rollback` / `remove` never edit `.mvn/maven.config`): it is no reference at all, only a diagnostic. * **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). diff --git a/crates/socket-patch-core/src/patch/redirect/maven_index.rs b/crates/socket-patch-core/src/patch/redirect/maven_index.rs index 2e731a589..d877d0c51 100644 --- a/crates/socket-patch-core/src/patch/redirect/maven_index.rs +++ b/crates/socket-patch-core/src/patch/redirect/maven_index.rs @@ -273,6 +273,8 @@ impl PomIndex { (None | Some("jar"), c) => c, (Some(t), c) if t.contains("${") => c, (Some("test-jar"), None) => Some("tests"), + // An explicit classifier names the copy whatever the type. + (Some(_), Some(c)) => Some(c), _ => return None, }; d.coordinate_alternatives.iter().find_map(|r| { diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 11c92845f..259031c6c 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -10985,6 +10985,16 @@ mod tests { ), "org.slf4j", ), + // ... a type with an explicit classifier included. + ( + "org.other", + format!( + "{pinned}${{h}}slf4j-api\ + 1.7.30test-jar\ + tests" + ), + "org.slf4j", + ), ] { let mut files = BTreeMap::new(); files.insert("pom.xml".to_string(), pom(props, &deps, profile)); diff --git a/crates/socket-patch-core/src/vex/discover/maven.rs b/crates/socket-patch-core/src/vex/discover/maven.rs index 34fd4744a..1f089e50a 100644 --- a/crates/socket-patch-core/src/vex/discover/maven.rs +++ b/crates/socket-patch-core/src/vex/discover/maven.rs @@ -892,6 +892,9 @@ fn retargeted(deps: &[PomDep]) -> impl Iterator + '_ { None | Some("jar") => (d.classifier.clone(), d.type_text.clone()), Some(t) if t.contains("${") => (d.classifier.clone(), d.type_text.clone()), Some("test-jar") if d.classifier.is_none() => (Some("tests".to_string()), None), + // An explicit classifier names the copy whatever the type + // (`test-jar` + `tests`): weighed as that classifier. + Some(_) if d.classifier.is_some() => (d.classifier.clone(), None), Some(_) => return None, }; Some(PomDep { @@ -2537,6 +2540,17 @@ mod tests { vec![to_slf4j.clone()], None, ), + // A type with an explicit classifier: that classifier's copy. + case( + h, + with( + "${h}slf4j-api\ + 1.7.30test-jartests\ + \n", + ), + vec![to_slf4j.clone()], + None, + ), // A profile declaration its own profile's properties retarget. case( h, From 935a66b291d8bbb9f39ac26b89bba83620f0467f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 09:30:49 +0000 Subject: [PATCH 14/20] Weigh retargeted Maven classifier copies as discovery does Two readings of a classifier copy that a profile's move disagreed with VEX discovery: - Hosted: `retargeted` counted a moved classifier copy as the patched GA only when its interpolated coordinates equal it exactly. Discovery reads them through `may_name`, so a coordinate the profile leaves on a property the pom does not define may still be the GA. Scan could write a pin that vex then withholds (`vex_maven_classifier_unpatched`). Use `may_name` here too. - Vendored: `warn_property_coordinates` dropped every declaration whose is not `jar`, test-jar included. Discovery and hosted map test-jar to the `tests` classifier copy, so a profile that turns one into the patched GA now gets `classifier_declared` like a tests copy does. Addresses Bugbot 4501092184 and 4501092187 on #2026. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_011f9aePC6sLFkqRM2XPWq4P --- .../src/patch/redirect/maven_index.rs | 15 ++-- .../src/patch/redirect/mod.rs | 12 ++++ .../src/vendor/jvm/maven_reactor.rs | 71 +++++++++++-------- 3 files changed, 66 insertions(+), 32 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/maven_index.rs b/crates/socket-patch-core/src/patch/redirect/maven_index.rs index 20437faae..7f23941f5 100644 --- a/crates/socket-patch-core/src/patch/redirect/maven_index.rs +++ b/crates/socket-patch-core/src/patch/redirect/maven_index.rs @@ -7,8 +7,8 @@ //! not once per dependency. use crate::formats::maven::{ - interpolate_strict, is_executable_classifier, is_executable_variant, is_variant, may_shadow, - variant_label, CoordinateAlternative, PomScope, + interpolate_strict, is_executable_classifier, is_executable_variant, is_variant, may_name, + may_shadow, variant_label, CoordinateAlternative, PomScope, }; use crate::formats::xml::{child_text, children, elements, Element}; @@ -243,7 +243,8 @@ impl PomIndex { /// it: a live versioned declaration of it (the one a pin is written /// into) then names another package, or another declaration then names /// its main jar (as [`may_shadow`] reads one) at a version other than - /// `served`, or literally one of its executable classifier copies + /// `served`, or one of its executable classifier copies (literally, or + /// through a property this pom does not define, [`may_name`]) /// (`test-jar` is the `tests` copy) off `served` or with no version of /// its own. A main jar without a version takes the pin. pub(super) fn retargeted( @@ -286,7 +287,13 @@ impl PomIndex { || may_shadow((&r.group, &r.artifact), None, None, (group, artifact))) && r.version.is_some() } - Some(c) => literal && is_executable_classifier(c), + // A coordinate still holding a property this pom does + // not define may be this copy, as discovery weighs it. + Some(c) => { + may_name(&r.group, group) + && may_name(&r.artifact, artifact) + && is_executable_classifier(c) + } }; (names && r.version.as_deref() != served).then(|| { format!( diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index ba09d156a..5d7fcb530 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -11037,6 +11037,18 @@ mod tests { ), "org.slf4j", ), + // ... or a classifier copy the profile leaves on a property this + // pom does not define, which may be the GA (as discovery weighs + // it, #2026 review). + ( + "org.other", + format!( + "{pinned}${{h}}slf4j-api\ + 1.7.30tests\ + " + ), + "${parent.g}", + ), ] { let mut files = BTreeMap::new(); files.insert("pom.xml".to_string(), pom(props, &deps, profile)); diff --git a/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs b/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs index 79f61a9e3..a7d14aaf7 100644 --- a/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs +++ b/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs @@ -2273,8 +2273,8 @@ impl Reactor { /// pin, so its local root is not pinned. A classifier copy is its own /// coordinate, which no pin reaches: an executable one is warned about /// as `classifier_declared`, as `rewrite_declarations` does (`sources` / - /// `javadoc` never run), and any other `` but `jar` is not the - /// g:a's jar. Text that can never + /// `javadoc` never run; `test-jar` is the `tests` copy), and any other + /// `` but `jar` is not the g:a's jar. Text that can never /// read the g:a, whatever its properties say ([`may_interpolate_to`]), /// is ruled out first, before any inheritor or profile is read. fn warn_property_coordinates( @@ -2295,7 +2295,16 @@ impl Reactor { ) else { continue; }; - let classifier = doc.child_text(dep, "classifier").filter(|c| !c.is_empty()); + let type_text = doc.child_text(dep, "type").filter(|t| !t.is_empty()); + // `test-jar` is the `tests` classifier copy, as discovery and + // the hosted rewriter map it. + let classifier = match ( + type_text.as_deref(), + doc.child_text(dep, "classifier").filter(|c| !c.is_empty()), + ) { + (Some("test-jar"), None) => Some("tests".to_string()), + (_, classifier) => classifier, + }; if !doc.has_property_coordinate(dep) || doc.pin_at(dep).is_some() || classifier @@ -2303,9 +2312,9 @@ impl Reactor { .is_some_and(|c| !is_executable_classifier(c)) || !may_interpolate_to(&g, patch.group_id) || !may_interpolate_to(&a, patch.artifact_id) - || doc - .child_text(dep, "type") - .is_some_and(|t| !t.is_empty() && t != "jar") + || type_text + .as_deref() + .is_some_and(|t| t != "jar" && t != "test-jar") { continue; } @@ -6479,31 +6488,37 @@ mod tests { /// root (#2026 review). #[test] fn a_profile_retargeting_a_classifier_copy_warns() { - let copy = "${lib.group}\ + let classified = "${lib.group}\ commons-text1.10.0\ tests"; - let files = fs(&[ - ( - "pom.xml", - &root_with( - "org.other", - "org.apache.commons", + // `test-jar` is the `tests` copy too. + let test_jar = "${lib.group}\ + commons-text1.10.0\ + test-jar"; + for copy in [classified, test_jar] { + let files = fs(&[ + ( + "pom.xml", + &root_with( + "org.other", + "org.apache.commons", + ), ), - ), - ( - "a/pom.xml", - &module("a", &format!("{}{copy}", dep("1.10.0"))), - ), - ("b/pom.xml", &module("b", "")), - ]); - let plan = run(&files).unwrap(); - assert_eq!(reasons(&plan), ["classifier_declared"]); - assert!( - plan.warnings[0].detail.contains("profile corp"), - "{:?}", - plan.warnings - ); - assert!(root_pinned(&plan, &files)); + ( + "a/pom.xml", + &module("a", &format!("{}{copy}", dep("1.10.0"))), + ), + ("b/pom.xml", &module("b", "")), + ]); + let plan = run(&files).unwrap(); + assert_eq!(reasons(&plan), ["classifier_declared"], "{copy}"); + assert!( + plan.warnings[0].detail.contains("profile corp"), + "{:?}", + plan.warnings + ); + assert!(root_pinned(&plan, &files)); + } } fn attestable(files: &Fs) -> (bool, bool) { From ddf48089bdabf054478d2d9d068631e0ed4e3efc Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Mon, 9 Nov 2026 15:26:31 -0500 Subject: [PATCH 15/20] Weigh profile versions of a parent-resolved pin property /code-review high on #2020: - A live `${prop}` this pom leaves unresolved (a parent pom defines it) dropped every profile alternative, so a pre-v5 vendored ref spelled that way stayed attested while a profile's `` moved the build to a public release. The profile values are now kept, and the live check weighs an unresolved declaration as one that may be the pin. - A profile without `` is named `default`, as Maven and the vendored reactor name it (it was numbered by `` block). - Docs: read_properties is last-wins; ScopedDep coordinates read a profile declaration's own `` only once read_profiles ran, which the hosted rewriter does not do. Co-Authored-By: Claude Opus 5.5 --- .../src/formats/maven/mod.rs | 32 +++++++++++++---- .../src/formats/maven/properties.rs | 2 +- .../src/vex/discover/maven.rs | 35 +++++++++++++++---- 3 files changed, 54 insertions(+), 15 deletions(-) diff --git a/crates/socket-patch-core/src/formats/maven/mod.rs b/crates/socket-patch-core/src/formats/maven/mod.rs index d05946875..fbc4ebf2d 100644 --- a/crates/socket-patch-core/src/formats/maven/mod.rs +++ b/crates/socket-patch-core/src/formats/maven/mod.rs @@ -141,7 +141,8 @@ struct ProfileProperties { /// classpaths, `` / `` plugin /// and deploy repositories; `` apply only when activated. Shared /// by the VEX reader ([`parse_pom`]) and the hosted rewriter, so the -/// rewriter edits exactly the markup VEX later attests. +/// rewriter edits exactly the markup VEX later attests. Only the VEX +/// reader weighs profile `` ([`Self::read_profiles`]). #[derive(Debug)] pub(crate) struct PomScope { /// The pom with comments and CDATA blanked byte-for-byte. @@ -297,7 +298,17 @@ impl PomScope { .unwrap_or_else(|| text.to_string()); match self.resolve_in_build(text, pos, interpolate_strict) { (Some(build), alternatives) => (spelled, build, alternatives), - (None, _) => (spelled, text.to_string(), Vec::new()), + // Unresolved here (a parent pom this reader never sees may + // define it): the build version stays as written, but a value + // some profiles give it is still one the build may resolve. + (None, alternatives) => ( + spelled, + text.to_string(), + alternatives + .into_iter() + .filter(|a| a.value != text) + .collect(), + ), } } @@ -407,10 +418,15 @@ fn inside(pos: usize, set: &[Element]) -> bool { pub(crate) struct ScopedDep { pub(crate) element: Element, /// `` / `` with every `${prop}` the build defines - /// with no profile active (user properties, the project model, the - /// root ``) interpolated - /// ([`PomScope::interpolate_coordinate`]); an undefined one stays as - /// written, so [`may_name`] can tell it apart. + /// (user properties, the project model, the root ``) + /// interpolated ([`PomScope::interpolate_coordinate`]); an undefined + /// one stays as written, so [`may_name`] can tell it apart. No profile + /// is active but, for a `` declaration, its own, whose + /// `` count only once [`PomScope::read_profiles`] has run: + /// the VEX reader runs it, the hosted rewriter does not, so a profile + /// declaration whose coordinates only its own profile's `` + /// define is the pinned GA to VEX (which then withholds the pin) and + /// not to the rewriter. pub(crate) group: Option, pub(crate) artifact: Option, /// Inner-text byte range of the literal ``, when present. @@ -582,10 +598,12 @@ fn profile_properties( .iter() .find(|p| props.start >= p.start && props.start < p.end) .copied(); + // Maven names a profile without an `` `default`, as the + // vendored reactor does. let id = holder .and_then(|p| direct_child_text(p.inner(text), "id")) .filter(|id| !id.is_empty()) - .unwrap_or_else(|| format!("#{}", out.len() + 1)); + .unwrap_or_else(|| "default".to_string()); let mut map = BTreeMap::new(); read_properties(props.inner(text), &mut map)?; out.push(ProfileProperties { diff --git a/crates/socket-patch-core/src/formats/maven/properties.rs b/crates/socket-patch-core/src/formats/maven/properties.rs index 1f9cc3343..0c6a96df4 100644 --- a/crates/socket-patch-core/src/formats/maven/properties.rs +++ b/crates/socket-patch-core/src/formats/maven/properties.rs @@ -230,7 +230,7 @@ pub(crate) fn profile_combinations( } /// The `name -> trimmed value` pairs of a `` body, into `map` -/// (a name's first definition wins). An element's name is its tag up to +/// (a name's last definition wins, as in Maven's model reader). An element's name is its tag up to /// whitespace or `/`, so `` and `` define /// `v`, and `` defines it empty; a comment-blanked or malformed child /// is an error (fail closed). diff --git a/crates/socket-patch-core/src/vex/discover/maven.rs b/crates/socket-patch-core/src/vex/discover/maven.rs index c93eb0526..c22991cc4 100644 --- a/crates/socket-patch-core/src/vex/discover/maven.rs +++ b/crates/socket-patch-core/src/vex/discover/maven.rs @@ -1004,11 +1004,19 @@ fn profile_override( }; if !d.in_profile { // Off the pin as built, it is no pin (or another bypass). - if build_version(d) != Some(pinned) { + // One this pom leaves unresolved (a parent pom's property) + // may be the pin. + let built = build_version(d)?; + let unresolved = built.contains("${"); + if built != pinned && !unresolved { return None; } - // The smallest combination of profiles that moves it. - let moved = d.version_alternatives.first()?; + // The smallest combination of profiles that moves it: for + // an unresolved one, to a version this pom does give. + let moved = d + .version_alternatives + .iter() + .find(|a| a.value != pinned && !(unresolved && a.value.contains("${")))?; let declared = d.version_text.as_deref().unwrap_or_default(); let value = if !matches!(moved.by, crate::formats::maven::MovedBy::Profiles(_)) { "resolve a version this reader cannot work out".to_string() @@ -1018,9 +1026,14 @@ fn profile_override( moved.value.clone() }; return Some(format!( - "{POM}: {section}{named} {declared} resolves the \ + "{POM}: {section}{named} {declared} {} the \ patched {pinned}, but the of {} make it {value}; whenever that \ applies Maven resolves it instead of the pin", + if unresolved { + "may resolve" + } else { + "resolves" + }, moved.by )); } @@ -2610,7 +2623,15 @@ mod tests { /// version of the vendored GA off the vendored version. #[tokio::test] async fn vendored_ref_under_profile_properties_is_not_attested() { - for (version, attested) in [("1.10.1", false), ("1.10.0", true)] { + // `root` is the pom's own ``; none leaves `${ct}` to a + // parent pom this reader never sees, which may give the vendored + // version, while the profile's value is known. + for (root, version, attested) in [ + ("1.10.0", "1.10.1", false), + ("1.10.0", "1.10.0", true), + ("", "1.10.1", false), + ("", "1.10.0", true), + ] { let p = vendored_project(UUID_A); let wired = std::fs::read_to_string(p.root().join("pom.xml")) .expect("read pom") @@ -2619,7 +2640,7 @@ mod tests { p.write( "pom.xml", format!( - "{}1.10.0\np\ + "{}{root}\np\ {version}\n{}", &wired[..at], &wired[at..] @@ -2630,7 +2651,7 @@ mod tests { assert_eq!( attests(&out, V_PURL), attested, - "{version}: {:?}", + "{root:?} {version}: {:?}", out.unattested ); } From 237307e738088c5727b89beb7bc76c0127f2e709 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Mon, 9 Nov 2026 15:27:39 -0500 Subject: [PATCH 16/20] Keep coordinate docs on the no-profile reading after the #2020 merge ScopedDep coordinates here are the no-profile value, with every profile reading carried by coordinate_alternatives, and the hosted index reads profile too: restate both docs #2020's round-3 merge brought in for its own branch. Co-Authored-By: Claude Opus 5.5 --- .../src/formats/maven/mod.rs | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/crates/socket-patch-core/src/formats/maven/mod.rs b/crates/socket-patch-core/src/formats/maven/mod.rs index b9c4a1898..9d732d4fd 100644 --- a/crates/socket-patch-core/src/formats/maven/mod.rs +++ b/crates/socket-patch-core/src/formats/maven/mod.rs @@ -176,8 +176,9 @@ struct ProfileProperties { /// classpaths, `` / `` plugin /// and deploy repositories; `` apply only when activated. Shared /// by the VEX reader ([`parse_pom`]) and the hosted rewriter, so the -/// rewriter edits exactly the markup VEX later attests. Only the VEX -/// reader weighs profile `` ([`Self::read_profiles`]). +/// rewriter edits exactly the markup VEX later attests. Both the VEX +/// reader and the hosted rewriter's index weigh profile `` +/// ([`Self::read_profiles`]). #[derive(Debug)] pub(crate) struct PomScope { /// The pom with comments and CDATA blanked byte-for-byte. @@ -555,15 +556,12 @@ fn inside(pos: usize, set: &[Element]) -> bool { pub(crate) struct ScopedDep { pub(crate) element: Element, /// `` / `` with every `${prop}` the build defines - /// (user properties, the project model, the root ``) - /// interpolated ([`PomScope::interpolate_coordinate`]); an undefined - /// one stays as written, so [`may_name`] can tell it apart. No profile - /// is active but, for a `` declaration, its own, whose - /// `` count only once [`PomScope::read_profiles`] has run: - /// the VEX reader runs it, the hosted rewriter does not, so a profile - /// declaration whose coordinates only its own profile's `` - /// define is the pinned GA to VEX (which then withholds the pin) and - /// not to the rewriter. + /// with no profile active (user properties, the project model, the + /// root ``) interpolated + /// ([`PomScope::interpolate_coordinate`]); an undefined one stays as + /// written, so [`may_name`] can tell it apart. What a profile (a + /// `` declaration's own included) makes of them is + /// [`PomScope::coordinate_alternatives`]' job. pub(crate) group: Option, pub(crate) artifact: Option, /// The trimmed `` / `` text as written. From 37d9fd9fb2fc16219c8d0e2d17f2ff8348cde0dd Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Mon, 9 Nov 2026 15:52:05 -0500 Subject: [PATCH 17/20] Read profile declarations' coordinates under their own profile /code-review high on #2026: - A declaration's ${...} groupId / artifactId was read with no profile active, its own profile's reading only an alternative. A coordinate only its own define stayed "${g}", which may_name reads as any text, so VEX withheld and hosted scan refused a valid pin beside a profile dependency its own profile names as another group (a regression from #2020). Coordinates read the declaration's own profile again, as its version does; other profiles stay alternatives. - The vendored reactor's warn_property_coordinates skipped a ${...} , which discovery and the hosted index read as possibly a jar: it is weighed now, so vendored mode warns instead of pinning a root VEX then never attests. Co-Authored-By: Claude Opus 5.5 --- .../src/formats/maven/mod.rs | 57 +++++++------------ .../src/patch/redirect/mod.rs | 16 ++++++ .../src/vendor/jvm/maven_reactor.rs | 31 +++++++++- .../src/vex/discover/maven.rs | 43 ++++++++++---- 4 files changed, 100 insertions(+), 47 deletions(-) diff --git a/crates/socket-patch-core/src/formats/maven/mod.rs b/crates/socket-patch-core/src/formats/maven/mod.rs index 9d732d4fd..73e88e95e 100644 --- a/crates/socket-patch-core/src/formats/maven/mod.rs +++ b/crates/socket-patch-core/src/formats/maven/mod.rs @@ -440,10 +440,8 @@ impl PomScope { /// artifactId (keeping an unresolved reference, /// [`interpolate_keeping`]) and version ([`interpolate_strict`]) read /// together ([`Self::resolve_all_in_build`]), every reading whose - /// coordinates differ from what it reads with no profile active - /// ([`ScopedDep::group`] / [`ScopedDep::artifact`]). A profile - /// declaration's own profile is always active with it, so what it reads - /// there is one too. + /// coordinates differ from what it reads with no profile active but + /// the one holding it ([`ScopedDep::group`] / [`ScopedDep::artifact`]). pub(crate) fn coordinate_alternatives(&self, dep: &ScopedDep) -> Vec { let (Some(group_text), Some(artifact_text), Some(group), Some(artifact)) = ( dep.group_text.as_deref(), @@ -463,22 +461,9 @@ impl PomScope { if let Some(version) = dep.version_text.as_deref() { texts.push((version, interpolate_strict)); } - let pos = dep.element.start; - let (own_base, readings) = self.resolve_all_in_build(&texts, pos); - let own: Vec = self - .own_profile(pos) - .into_iter() - .map(|i| self.profile_properties[i].id.clone()) - .collect(); - let own_reading = (!own.is_empty()).then(|| { - let mut values = own_base; - if let Some(v) = values.get_mut(2) { - *v = v.take().or_else(|| dep.version_text.clone()); - } - (values, MovedBy::Profiles(own)) - }); + let (_, readings) = self.resolve_all_in_build(&texts, dep.element.start); let mut out: Vec = Vec::new(); - for (values, by) in own_reading.into_iter().chain(readings) { + for (values, by) in readings { let mut values = values.into_iter(); let (Some(Some(g)), Some(Some(a))) = (values.next(), values.next()) else { continue; @@ -503,13 +488,16 @@ impl PomScope { out } - /// `text` (a coordinate) with every `${name}` the build defines with - /// no profile active interpolated, an undefined one kept as written - /// ([`interpolate_lenient`]). What any profile (a declaration's own - /// included) makes of it is [`ScopedDep::coordinate_alternatives`]' - /// job, which every coordinate check weighs. - fn interpolate_coordinate(&self, text: &str) -> String { - interpolate_lenient(text, &|name| self.lookup(name, &[], true)) + /// `text` (a coordinate of the declaration at `pos`) with every + /// `${name}` the build defines interpolated, reading the declaration's + /// own profile `` first when it sits in a profile (as its + /// version is: that profile is always active with it), an undefined + /// one kept as written ([`interpolate_lenient`]). What other profiles + /// make of it is [`Self::coordinate_alternatives`]' job, which every + /// coordinate check weighs. + fn interpolate_coordinate(&self, text: &str, pos: usize) -> String { + let own = self.own_profile(pos); + interpolate_lenient(text, &|name| self.lookup(name, &own, true)) } /// Inside a plugin / deploy section (never project resolution). @@ -556,12 +544,11 @@ fn inside(pos: usize, set: &[Element]) -> bool { pub(crate) struct ScopedDep { pub(crate) element: Element, /// `` / `` with every `${prop}` the build defines - /// with no profile active (user properties, the project model, the - /// root ``) interpolated - /// ([`PomScope::interpolate_coordinate`]); an undefined one stays as - /// written, so [`may_name`] can tell it apart. What a profile (a - /// `` declaration's own included) makes of them is - /// [`PomScope::coordinate_alternatives`]' job. + /// with no profile active but, for a `` declaration, its own + /// (user properties, the project model, the root ``) + /// interpolated ([`PomScope::interpolate_coordinate`]); an undefined + /// one stays as written, so [`may_name`] can tell it apart. What other + /// profiles make of them is [`PomScope::coordinate_alternatives`]' job. pub(crate) group: Option, pub(crate) artifact: Option, /// The trimmed `` / `` text as written. @@ -591,10 +578,10 @@ impl PomScope { element: dep, group: group_text .as_deref() - .map(|g| self.interpolate_coordinate(g)), + .map(|g| self.interpolate_coordinate(g, dep.start)), artifact: artifact_text .as_deref() - .map(|a| self.interpolate_coordinate(a)), + .map(|a| self.interpolate_coordinate(a, dep.start)), group_text, artifact_text, version_inner: version.map(|v| { @@ -1658,7 +1645,7 @@ mod tests { deps, [ (false, "org.a".to_string(), vec![pair("org.b", "x")]), - (true, "org.a".to_string(), vec![pair("org.b", "x")]), + (true, "org.b".to_string(), vec![]), ] ); } diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 23eab1721..03a2cf3c7 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -11085,6 +11085,22 @@ mod tests { let out = r.files.get("pom.xml").expect("pom rewritten"); assert!(out.contains(MAVEN_SUFFIXED), "{deps}: pinned: {out}"); } + // A profile declaration whose groupId only its own profile's + // `` define, as another group: it is read under its own + // profile, so it never names the GA, and the pin lands. + let own = format!( + "\n \n {pinned}\n \n \ + porg.other{}\ + \n\n", + decl("${g}", "slf4j-api", "2.0") + ); + let r = rewrite_registry_redirect( + &BTreeMap::from([("pom.xml".to_string(), own)]), + &[maven_override()], + ); + assert!(warning_codes(&r).is_empty(), "{:?}", r.warnings); + let out = r.files.get("pom.xml").expect("pom rewritten"); + assert!(out.contains(MAVEN_SUFFIXED), "pinned: {out}"); } /// A versioned `` declaration through an undefined coordinate diff --git a/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs b/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs index a7d14aaf7..d792bbfd4 100644 --- a/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs +++ b/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs @@ -2274,7 +2274,7 @@ impl Reactor { /// coordinate, which no pin reaches: an executable one is warned about /// as `classifier_declared`, as `rewrite_declarations` does (`sources` / /// `javadoc` never run; `test-jar` is the `tests` copy), and any other - /// `` but `jar` is not the g:a's jar. Text that can never + /// literal `` but `jar` is not the g:a's jar (a `${…}` one may be). Text that can never /// read the g:a, whatever its properties say ([`may_interpolate_to`]), /// is ruled out first, before any inheritor or profile is read. fn warn_property_coordinates( @@ -2312,9 +2312,10 @@ impl Reactor { .is_some_and(|c| !is_executable_classifier(c)) || !may_interpolate_to(&g, patch.group_id) || !may_interpolate_to(&a, patch.artifact_id) + // A `${…}` type may be `jar`, as discovery reads it. || type_text .as_deref() - .is_some_and(|t| t != "jar" && t != "test-jar") + .is_some_and(|t| t != "jar" && t != "test-jar" && !t.contains("${")) { continue; } @@ -6394,6 +6395,32 @@ mod tests { } } + // A `${…}` type may be `jar`, as discovery reads it: weighed too. + let typed = module( + "a", + &format!( + "{}{}", + dep("1.10.0"), + other("1.9${t}") + ), + ); + let files = fs(&[ + ( + "pom.xml", + &root_with(props, "org.apache.commons"), + ), + ("a/pom.xml", &typed), + ("b/pom.xml", &module("b", "")), + ]); + let plan = run(&files).unwrap(); + assert_eq!( + reasons(&plan), + ["coordinates_overridden"], + "{:?}", + plan.warnings + ); + assert!(!root_pinned(&plan, &files)); + // No version of its own: the pin applies, so the root stays pinned. let files = fs(&[ ( diff --git a/crates/socket-patch-core/src/vex/discover/maven.rs b/crates/socket-patch-core/src/vex/discover/maven.rs index 7f57e3165..5d365b350 100644 --- a/crates/socket-patch-core/src/vex/discover/maven.rs +++ b/crates/socket-patch-core/src/vex/discover/maven.rs @@ -2645,16 +2645,6 @@ mod tests { vec![to_slf4j.clone()], None, ), - // A profile declaration its own profile's properties retarget. - case( - h, - with(""), - vec![format!( - "{to_slf4j}\n{}\n", - other(Some("1.7.36")) - )], - None, - ), // Through a user property lower in the chain. case( "${corp}", @@ -2666,6 +2656,26 @@ mod tests { for c in &withheld { assert_coordinate_case(c, false).await; } + // A profile declaration its own profile's properties retarget: it + // is read under its own profile, so it is a profile declaration of + // the GA itself. + let own = property_project( + h, + &with(""), + &[&format!( + "{to_slf4j}\n{}\n", + other(Some("1.7.36")) + )], + ); + let out = run(&own).await; + assert_refs(&out, &[(FX_PURL, UUID_A, WiringMode::Hosted)]); + let kinds: Vec<_> = out.unattested.iter().map(|u| u.kind).collect(); + assert_eq!( + kinds, + [UnattestedKind::MavenProfileUnpatched], + "{:?}", + out.unattested + ); let attested = [ // No version of its own: the live pin applies. case(h, with(&other(None)), vec![to_slf4j.clone()], None), @@ -2771,6 +2781,19 @@ mod tests { vec![in_props("org.other")], none.clone(), ), + // A profile declaration whose groupId only its own profile's + // `` define, as another group: whenever it is + // read, it names that group. + ( + String::new(), + with(""), + vec![format!( + "{}\n{}\n", + in_props("org.other"), + dep("${g}", "slf4j-api", Some("2.0")) + )], + none.clone(), + ), ] { assert_coordinate_case(&case, true).await; } From 157b6d4aab04f6fc51a9183378ae8f75fc5e6e1c Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Mon, 9 Nov 2026 16:50:20 -0500 Subject: [PATCH 18/20] Simplify the folded Maven VEX stack Behavior-preserving cleanup of the folded #2005/#2020/#2026 diff: - Share the copies the three PRs each grew: `classpath_copy` (which copy a profile-moved declaration reads as) for discovery's and hosted's `retargeted` and the reactor's coordinate warning; `management_key` for both classifier-copy checks; `Definitions::fixed` for the model-reference / user-property prefix PomScope and the reactor built by hand; one `named` helper for "(which may be g:a)" details. - Drop dead code: resolve_in_build's always-strict `Interpolate` parameter (hosted reads `resolve_version` instead), the reactor's literal re-binding of already-literal coordinates, a redundant filter in the -D diagnostic, a re-checked `suffixed_version`. - Cheaper: literal coordinates skip the profile scan, `bypasses` borrows the pom's declarations unless a profile moves one, hosted computes build versions only for the profile / classifier declarations that are read, and its detail text is built only when returned. - Docs: no review-history narration; the UnattestedKind and note docs reflowed and pointed at one another; the discovery module doc defers to `bypasses`. - Tests: shared builders and assertion helpers (decl / mgmt / assert_withheld / vendored_project_with, rewrite_pom / assert_refused / assert_pinned, reactor / with_corp_profile, user_props) replace per-test copies; no case removed. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/vex_sources.rs | 16 +- .../src/formats/maven/mod.rs | 123 ++-- .../src/formats/maven/properties.rs | 22 +- .../src/patch/redirect/maven_index.rs | 69 ++- .../src/patch/redirect/mod.rs | 183 +++--- .../src/vendor/jvm/maven_reactor.rs | 176 ++---- .../src/vex/discover/maven.rs | 559 ++++++++---------- .../socket-patch-core/src/vex/discover/mod.rs | 30 +- 8 files changed, 514 insertions(+), 664 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/vex_sources.rs b/crates/socket-patch-cli/src/commands/vex_sources.rs index acd046db5..75b492899 100644 --- a/crates/socket-patch-cli/src/commands/vex_sources.rs +++ b/crates/socket-patch-cli/src/commands/vex_sources.rs @@ -228,18 +228,14 @@ const NOTE_NPM_SHRINKWRAP_ONLY: &str = "vex_npm_shrinkwrap_only"; /// Omission tag and note: a hosted Maven pin sits in a reactor root, where a /// module's own `` may override it (`vex::Unattested`). const NOTE_MAVEN_REACTOR_ROOT: &str = "vex_maven_reactor_root"; -/// Omission tag and note: a hosted (or pre-v5 vendored) Maven pin's GA also -/// has an executable classifier copy at another version, which the pin does not reach +/// Omission tag and note for [`UnattestedKind::MavenClassifierUnpatched`] /// (`vex::Unattested`). const NOTE_MAVEN_CLASSIFIER_UNPATCHED: &str = "vex_maven_classifier_unpatched"; -/// Omission tag and note: a hosted Maven pin's GA is also declared inside -/// `` at another version, or a profile's `` move -/// the pin's `${property}` version, which overrides the pin whenever that -/// profile is active (`vex::Unattested`). +/// Omission tag and note for [`UnattestedKind::MavenProfileUnpatched`] +/// (`vex::Unattested`). const NOTE_MAVEN_PROFILE_UNPATCHED: &str = "vex_maven_profile_unpatched"; -/// Omission tag and note: a hosted (or pre-v5 vendored) Maven pin is spelled -/// through a `${property}` that `.mvn/maven.config` redefines, so the build resolves -/// another version (`vex::Unattested`). +/// Omission tag and note for [`UnattestedKind::MavenConfigOverride`] +/// (`vex::Unattested`). const NOTE_MAVEN_CONFIG_OVERRIDE: &str = "vex_maven_config_override"; /// Omission tag and note: a pnpm lock pins the patch, but a /// `pnpm-lock..yaml` exists, which pnpm installs from instead when @@ -316,7 +312,7 @@ fn unattested_note(kind: UnattestedKind) -> (&'static str, &'static str) { itself is intact, so re-running `scan` does not change this (drop that profile \ or property or set it to the pin; `scan --mode vendored` rewrites a \ literal profile , but neither mode wires a pin a profile's \ - can move, and hosted `scan` now writes no pin a profile bypasses)", + can move, and hosted `scan` writes no pin a profile bypasses)", ), UnattestedKind::MavenConfigOverride => ( NOTE_MAVEN_CONFIG_OVERRIDE, diff --git a/crates/socket-patch-core/src/formats/maven/mod.rs b/crates/socket-patch-core/src/formats/maven/mod.rs index 73e88e95e..d2bd28653 100644 --- a/crates/socket-patch-core/src/formats/maven/mod.rs +++ b/crates/socket-patch-core/src/formats/maven/mod.rs @@ -8,9 +8,8 @@ mod properties; use std::collections::BTreeMap; pub(crate) use properties::{ - interpolate_keeping, interpolate_strict, is_model_reference, lookup_property, - profile_combinations, Definitions, Interpolate, PomLayer, MAX_COMBINED_PROFILES, - MAX_INTERPOLATION_DEPTH, + interpolate_keeping, interpolate_strict, lookup_property, profile_combinations, Definitions, + Interpolate, PomLayer, MAX_COMBINED_PROFILES, MAX_INTERPOLATION_DEPTH, }; use properties::{interpolate_lenient, read_properties}; @@ -298,15 +297,8 @@ impl PomScope { /// otherwise the root's, every profile's (each but `own`, which is /// always active, may be the one read) and a bare model reference. fn definitions(&self, name: &str, own: &[usize]) -> Definitions { - if let Some(v) = is_model_reference(name) - .then(|| self.model_ref(name)) - .flatten() - .or_else(|| self.user.get(name).cloned()) - { - return Definitions { - texts: vec![v], - profiles: Vec::new(), - }; + if let Some(fixed) = Definitions::fixed(name, &|n| self.model_ref(n), &self.user) { + return fixed; } let mut d = Definitions::default(); d.texts.extend(self.properties.get(name).cloned()); @@ -325,14 +317,18 @@ impl PomScope { /// The `` text of the declaration at `pos`, resolved as /// [`PomDep::version`], [`PomDep::build_version`] and /// [`PomDep::version_alternatives`] describe. - fn resolve_version(&self, text: &str, pos: usize) -> (String, String, Vec) { + pub(crate) fn resolve_version( + &self, + text: &str, + pos: usize, + ) -> (String, String, Vec) { if !text.contains("${") { return (text.to_string(), text.to_string(), Vec::new()); } let own = self.own_profile(pos); let spelled = interpolate_strict(text, &|name| self.lookup(name, &own, false)) .unwrap_or_else(|| text.to_string()); - match self.resolve_in_build(text, pos, interpolate_strict) { + match self.resolve_in_build(text, pos) { (Some(build), alternatives) => (spelled, build, alternatives), // Unresolved here (a parent pom this reader never sees may // define it): the build version stays as written, but a value @@ -348,22 +344,16 @@ impl PomScope { } } - /// What `text` (written in the declaration at `pos`) resolves to in the - /// build with no profile active but the one holding it, through - /// `interpolate` ([`interpolate_strict`] for a version), and each other - /// value a combination of active profiles gives it - /// ([`profile_combinations`], the vendored reactor's too), the - /// smallest combination first; a combination `interpolate` cannot - /// resolve gives the text as written. Fails closed: when the + /// What the version `text` (written in the declaration at `pos`) + /// resolves to in the build with no profile active but the one holding + /// it ([`interpolate_strict`]), and each other value a combination of + /// active profiles gives it ([`profile_combinations`], the vendored + /// reactor's too), the smallest combination first; a combination that + /// cannot resolve it gives the text as written. Fails closed: when the /// combinations cannot be enumerated or a profile's `` is /// unreadable, the text as written is an alternative. - pub(crate) fn resolve_in_build( - &self, - text: &str, - pos: usize, - interpolate: Interpolate, - ) -> (Option, Vec) { - let (mut base, readings) = self.resolve_all_in_build(&[(text, interpolate)], pos); + fn resolve_in_build(&self, text: &str, pos: usize) -> (Option, Vec) { + let (mut base, readings) = self.resolve_all_in_build(&[(text, interpolate_strict)], pos); let mut alternatives: Vec = Vec::new(); for (mut values, by) in readings { let value = values.remove(0).unwrap_or_else(|| text.to_string()); @@ -496,6 +486,9 @@ impl PomScope { /// make of it is [`Self::coordinate_alternatives`]' job, which every /// coordinate check weighs. fn interpolate_coordinate(&self, text: &str, pos: usize) -> String { + if !text.contains("${") { + return text.to_string(); + } let own = self.own_profile(pos); interpolate_lenient(text, &|name| self.lookup(name, &own, true)) } @@ -960,6 +953,36 @@ pub(crate) fn is_variant(type_text: Option<&str>, classifier: Option<&str>) -> b || type_text.is_some_and(|t| !t.is_empty() && t != "jar" && t != "pom") } +/// Which copy of its GA a declaration's `` / `` read as +/// when a profile moves its coordinates: `Some(None)` the main jar, +/// `Some(Some(c))` the classifier copy `c`, `None` no jar on the classpath. +/// `test-jar` is the `tests` copy, an explicit classifier names the copy +/// whatever the type, and a `${…}` type may be `jar`. +pub(crate) fn classpath_copy<'a>( + type_text: Option<&'a str>, + classifier: Option<&'a str>, +) -> Option> { + match (type_text, classifier) { + (None | Some("jar"), c) => Some(c), + (Some(t), c) if t.contains("${") => Some(c), + (Some("test-jar"), None) => Some(Some("tests")), + (Some(_), Some(c)) => Some(Some(c)), + _ => None, + } +} + +/// How Maven keys a `` entry besides its GA: by +/// classifier and type, an empty or `jar` type being the default. +pub(crate) fn management_key<'a>( + type_text: Option<&'a str>, + classifier: Option<&'a str>, +) -> (Option<&'a str>, Option<&'a str>) { + ( + classifier.filter(|c| !c.is_empty()), + type_text.filter(|t| !t.is_empty() && *t != "jar"), + ) +} + /// The label a variant ([`is_variant`]) is reported by: its classifier, /// else its type. pub(crate) fn variant_label<'a>( @@ -1037,6 +1060,14 @@ mod tests { use super::PomScope; + /// `.mvn/maven.config`-style user properties from `(name, value)` pairs. + fn user_props(pairs: &[(&str, &str)]) -> BTreeMap { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() + } + #[test] fn scope_skips_comments_and_plugin_sections_and_flags_profiles() { let pom = "\ @@ -1202,11 +1233,7 @@ mod tests { gc${u}\ "; let versions = |user: &[(&str, &str)]| { - let user = user - .iter() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect(); - super::parse_pom_with(pom, &user) + super::parse_pom_with(pom, &user_props(user)) .unwrap() .deps .into_iter() @@ -1239,12 +1266,7 @@ mod tests { ga${project.version}\ gb${pom.version}\ "; - let user = [ - ("project.version".to_string(), "9".to_string()), - ("pom.version".to_string(), "9".to_string()), - ] - .into_iter() - .collect(); + let user = user_props(&[("project.version", "9"), ("pom.version", "9")]); let deps = super::parse_pom_with(pom, &user).unwrap().deps; for d in &deps { assert_eq!(d.build_version, d.version, "{d:?}"); @@ -1268,11 +1290,10 @@ mod tests { ga\ {text}" ); - let user = user - .iter() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect(); - let dep = super::parse_pom_with(&pom, &user).unwrap().deps.remove(0); + let dep = super::parse_pom_with(&pom, &user_props(user)) + .unwrap() + .deps + .remove(0); assert!(dep.version_alternatives.is_empty(), "{props}"); (dep.version.unwrap(), dep.build_version.unwrap()) }; @@ -1342,11 +1363,7 @@ mod tests { "9{props}\ {deps}{profiles}" ); - let user = user - .iter() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect(); - super::parse_pom_with(&pom, &user) + super::parse_pom_with(&pom, &user_props(user)) .unwrap() .deps .into_iter() @@ -1448,7 +1465,7 @@ mod tests { 2", dep("${project.version}") ); - let user = [("revision".to_string(), "3".to_string())].into(); + let user = user_props(&[("revision", "3")]); for (user, built, alternatives) in [ (BTreeMap::new(), "1", vec![moved("2", &["ci"])]), (user, "3", vec![]), @@ -1518,11 +1535,7 @@ mod tests { "org.self{props}\ {deps}{profiles}" ); - let user = user - .iter() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect(); - super::parse_pom_with(&pom, &user) + super::parse_pom_with(&pom, &user_props(user)) .unwrap() .deps .into_iter() diff --git a/crates/socket-patch-core/src/formats/maven/properties.rs b/crates/socket-patch-core/src/formats/maven/properties.rs index a9658d56f..3f89d67eb 100644 --- a/crates/socket-patch-core/src/formats/maven/properties.rs +++ b/crates/socket-patch-core/src/formats/maven/properties.rs @@ -26,7 +26,7 @@ pub(crate) const MAX_COMBINED_PROFILES: usize = 8; /// A `project.*` / `pom.*` reference: Maven reads it from the model, ahead /// of any user or pom property of the same name. -pub(crate) fn is_model_reference(name: &str) -> bool { +fn is_model_reference(name: &str) -> bool { name.starts_with("project.") || name.starts_with("pom.") } @@ -191,6 +191,26 @@ pub(crate) struct Definitions { pub(crate) profiles: Vec, } +impl Definitions { + /// The one definition that fixes `${name}` whatever profiles are + /// active, read first as [`lookup_property`] reads it: a model + /// reference the model answers, else a user property. + pub(crate) fn fixed( + name: &str, + model: &dyn Fn(&str) -> Option, + user: &BTreeMap, + ) -> Option { + is_model_reference(name) + .then(|| model(name)) + .flatten() + .or_else(|| user.get(name).cloned()) + .map(|v| Definitions { + texts: vec![v], + profiles: Vec::new(), + }) + } +} + /// Every nonempty combination of the profiles that may change what any of /// `values` interpolates to — those defining a name reachable from one /// through any definition ([`Definitions`]) — smallest first, each sorted: diff --git a/crates/socket-patch-core/src/patch/redirect/maven_index.rs b/crates/socket-patch-core/src/patch/redirect/maven_index.rs index 7f23941f5..3fc553f20 100644 --- a/crates/socket-patch-core/src/patch/redirect/maven_index.rs +++ b/crates/socket-patch-core/src/patch/redirect/maven_index.rs @@ -7,8 +7,8 @@ //! not once per dependency. use crate::formats::maven::{ - interpolate_strict, is_executable_classifier, is_executable_variant, is_variant, may_name, - may_shadow, variant_label, CoordinateAlternative, PomScope, + classpath_copy, is_executable_classifier, is_executable_variant, is_variant, management_key, + may_name, may_shadow, variant_label, CoordinateAlternative, PomScope, }; use crate::formats::xml::{child_text, children, elements, Element}; @@ -33,10 +33,11 @@ pub(super) struct IndexedDep { /// Inside a ``. pub(super) managed: bool, /// Every version the build may resolve for it, as VEX discovery reads - /// one ([`PomScope::resolve_in_build`]): with no profile active but the + /// one ([`PomScope::resolve_version`]): with no profile active but the /// one holding it, then each combination of profiles whose /// `` move it (as written where one leaves it unresolved). - /// Empty when it has no ``. + /// Empty when it has no ``, and for a live declaration that is + /// no classifier copy, whose versions no check reads. pub(super) build_versions: Vec, } @@ -158,13 +159,18 @@ impl PomIndex { managed: managed .iter() .any(|m| d.element.start >= m.start && d.element.start < m.end), - build_versions: d.version_text.as_deref().map_or_else(Vec::new, |text| { - let (base, alternatives) = - scope.resolve_in_build(text, d.element.start, interpolate_strict); - std::iter::once(base.unwrap_or_else(|| text.to_string())) - .chain(alternatives.into_iter().map(|a| a.value)) - .collect() - }), + build_versions: d + .version_text + .as_deref() + .filter(|_| { + d.in_profile || is_variant(d.type_text.as_deref(), d.classifier.as_deref()) + }) + .map_or_else(Vec::new, |text| { + let (_, build, alternatives) = scope.resolve_version(text, d.element.start); + std::iter::once(build) + .chain(alternatives.into_iter().map(|a| a.value)) + .collect() + }), group: d.group, artifact: d.artifact, version_inner: d.version_inner, @@ -254,31 +260,29 @@ impl PomIndex { served: Option<&str>, ) -> Option { self.deps.iter().find_map(|d| { - let written = format!( - "{}:{}", - d.group_text.as_deref().unwrap_or_default(), - d.artifact_text.as_deref().unwrap_or_default() - ); + let written = || { + format!( + "{}:{}", + d.group_text.as_deref().unwrap_or_default(), + d.artifact_text.as_deref().unwrap_or_default() + ) + }; if d.group.as_deref() == Some(group) && d.artifact.as_deref() == Some(artifact) { let moved = d.coordinate_alternatives.first()?; return (!d.in_profile && d.classifier.is_none() && d.version_text.is_some()).then( || { format!( - "{written} names {group}:{artifact}, but the of {} \ - make it {}:{}", - moved.by, moved.group, moved.artifact + "{} names {group}:{artifact}, but the of {} make it \ + {}:{}", + written(), + moved.by, + moved.group, + moved.artifact ) }, ); } - let classifier = match (d.type_text.as_deref(), d.classifier.as_deref()) { - (None | Some("jar"), c) => c, - (Some(t), c) if t.contains("${") => c, - (Some("test-jar"), None) => Some("tests"), - // An explicit classifier names the copy whatever the type. - (Some(_), Some(c)) => Some(c), - _ => return None, - }; + let classifier = classpath_copy(d.type_text.as_deref(), d.classifier.as_deref())?; d.coordinate_alternatives.iter().find_map(|r| { let literal = r.group == group && r.artifact == artifact; let names = match classifier { @@ -297,8 +301,9 @@ impl PomIndex { }; (names && r.version.as_deref() != served).then(|| { format!( - "{written}{} declares {}:{} at {} under the \ + "{}{} declares {}:{} at {} under the \ of {}", + written(), classifier.map_or(String::new(), |c| format!(" (classifier {c})")), r.group, r.artifact, @@ -365,14 +370,8 @@ impl PomIndex { artifact: &str, served: &str, ) -> Option<(&str, Option<&str>)> { - // Keyed as Maven keys management: by classifier and type. fn key(d: &IndexedDep) -> (Option<&str>, Option<&str>) { - ( - d.classifier.as_deref().filter(|c| !c.is_empty()), - d.type_text - .as_deref() - .filter(|t| !t.is_empty() && *t != "jar"), - ) + management_key(d.type_text.as_deref(), d.classifier.as_deref()) } let copies = move |managed: bool| { self.deps.iter().filter_map(move |d| { diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 2dca346d4..34c863c77 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -8798,10 +8798,10 @@ fn rewrite_maven_pom( // (another release, a `${property}` a profile moves, or no version // in this pom) keeps unpatched code on the classpath, so no pin is // written that VEX could never attest. - let unserved = suffixed_version - .as_deref() - .and_then(|s| idx.unserved_classifier_copy(&group_id, &artifact_id, s)); - if let (Some((classifier, version)), Some(served)) = (unserved, &suffixed_version) { + if let Some((served, (classifier, version))) = suffixed_version.as_deref().and_then(|s| { + idx.unserved_classifier_copy(&group_id, &artifact_id, s) + .map(|copy| (s, copy)) + }) { result.warnings.push(RewriteWarning { code: "redirect_maven_classifier_unsupported".into(), detail: format!( @@ -10384,6 +10384,46 @@ mod tests { r.warnings.iter().map(|w| w.code.as_str()).collect() } + /// Hosted-redirects slf4j-api in a lone `pom.xml`. + fn rewrite_pom(pom: &str) -> RewriteResult { + let files = BTreeMap::from([("pom.xml".to_string(), pom.to_string())]); + rewrite_registry_redirect(&files, &[maven_override()]) + } + + /// A one-line `` with `tail` (a ``, ``, + /// ...) after its optional ``. + fn maven_decl(g: &str, a: &str, v: Option<&str>, tail: &str) -> String { + let v = v.map_or(String::new(), |v| format!("{v}")); + format!( + "{g}{a}{v}{tail}" + ) + } + + /// `pom` is refused whole: nothing written, `code` the only warning. + #[track_caller] + fn assert_refused(pom: &str, code: &str) { + let r = rewrite_pom(pom); + assert!( + r.files.is_empty() && r.edits.is_empty(), + "{pom}: nothing written: {:?}", + r.files + ); + assert_eq!(warning_codes(&r), vec![code], "{pom}"); + } + + /// `pom` gets the suffixed pin, with none of the `absent` warnings; + /// returns the rewritten pom.xml. + #[track_caller] + fn assert_pinned(pom: &str, absent: &[&str]) -> String { + let r = rewrite_pom(pom); + for code in absent { + assert!(!warning_codes(&r).contains(code), "{pom}: {:?}", r.warnings); + } + let out = r.files.get("pom.xml").expect("pom rewritten"); + assert!(out.contains(MAVEN_SUFFIXED), "{pom}: pinned: {out}"); + out.clone() + } + /// A profile's own `` (a property or any literal) beats the /// top-level pin while the profile is active: when the profile holds the /// GA's only versioned declaration nothing is pinned, and beside a @@ -10414,17 +10454,10 @@ mod tests { // Beside a top-level declaration the pin would be overridden // whenever the profile is active, and VEX never attests it: - // nothing is written either (#2026 review). + // nothing is written either. let both = pom_with_dep("\n 1.7.36", "") .replace("\n", &profile(version)); - let files = BTreeMap::from([("pom.xml".to_string(), both)]); - let r = rewrite_registry_redirect(&files, &[maven_override()]); - assert!(r.files.is_empty(), "{version}: {:?}", r.files); - assert_eq!( - warning_codes(&r), - vec!["redirect_maven_profile_dependency_unpatched"], - "{version}" - ); + assert_refused(&both, "redirect_maven_profile_dependency_unpatched"); } } @@ -10443,13 +10476,7 @@ mod tests { {profiles}\n\n" ) }; - let dep = |v: Option<&str>| { - let v = v.map_or(String::new(), |v| format!("{v}")); - format!( - "org.slf4jslf4j-api{v}\ - " - ) - }; + let dep = |v: Option<&str>| maven_decl("org.slf4j", "slf4j-api", v, ""); let profile = |id: &str, props: &str, body: &str| { format!("{id}{props}{body}") }; @@ -10476,14 +10503,7 @@ mod tests { ), ]; for pom in &refused { - let files = BTreeMap::from([("pom.xml".to_string(), pom.clone())]); - let r = rewrite_registry_redirect(&files, &[maven_override()]); - assert!(r.files.is_empty(), "{pom}: {:?}", r.files); - assert_eq!( - warning_codes(&r), - vec!["redirect_maven_profile_dependency_unpatched"], - "{pom}" - ); + assert_refused(pom, "redirect_maven_profile_dependency_unpatched"); } let pinned = [ // A profile's management under a direct pin. @@ -10501,15 +10521,7 @@ mod tests { ), ]; for pom in &pinned { - let files = BTreeMap::from([("pom.xml".to_string(), pom.clone())]); - let r = rewrite_registry_redirect(&files, &[maven_override()]); - assert!( - !warning_codes(&r).contains(&"redirect_maven_profile_dependency_unpatched"), - "{pom}: {:?}", - r.warnings - ); - let out = r.files.get("pom.xml").expect("pinned"); - assert!(out.contains(MAVEN_SUFFIXED), "{out}"); + assert_pinned(pom, &["redirect_maven_profile_dependency_unpatched"]); } } @@ -10556,7 +10568,7 @@ mod tests { /// suffixed pin — another release, a `${property}` a profile moves, no /// version in this pom (its classifier-keyed management consulted, a /// profile's too) — keeps unpatched code on the classpath, and VEX never - /// attests that pin: nothing is written (#2026 review). One the + /// attests that pin: nothing is written. One the /// management pins to the suffixed version, or a `sources` / `javadoc` /// one at any version, lets the main jar's pin land. #[test] @@ -10570,11 +10582,8 @@ mod tests { ) }; let copy = |classifier: &str, version: Option<&str>| { - let v = version.map_or(String::new(), |v| format!("{v}")); - format!( - "org.slf4jslf4j-api{v}\ - {classifier}" - ) + let tail = format!("{classifier}"); + maven_decl("org.slf4j", "slf4j-api", version, &tail) }; let managed = |version: &str| { format!( @@ -10603,14 +10612,7 @@ mod tests { ), ]; for pom in &refused { - let files = BTreeMap::from([("pom.xml".to_string(), pom.clone())]); - let r = rewrite_registry_redirect(&files, &[maven_override()]); - assert!(r.files.is_empty(), "{pom}: {:?}", r.files); - assert_eq!( - warning_codes(&r), - vec!["redirect_maven_classifier_unsupported"], - "{pom}" - ); + assert_refused(pom, "redirect_maven_classifier_unsupported"); } let pinned = [ pom(©("sources", Some("1.7.30")), ""), @@ -10623,9 +10625,7 @@ mod tests { ), ]; for pom in &pinned { - let files = BTreeMap::from([("pom.xml".to_string(), pom.clone())]); - let r = rewrite_registry_redirect(&files, &[maven_override()]); - let out = r.files.get("pom.xml").expect("main jar pinned"); + let out = assert_pinned(pom, &[]); assert!( out.contains(&format!( "slf4j-api{MAVEN_SUFFIXED}" @@ -10986,8 +10986,7 @@ mod tests { /// may move the declaration a pin would be written into off the GA, or /// make another declaration the GA at its own version, whenever that /// profile is active, and VEX never attests such a pin: hosted scan - /// writes none and says why (#2026 review, back-ported from VEX - /// discovery). A profile that leaves the coordinates alone, or makes an + /// writes none and says why, as VEX discovery weighs it. A profile that leaves the coordinates alone, or makes an /// unrelated declaration another unrelated package, does not block it. #[test] fn maven_pom_profile_moved_coordinates_refuse_the_dep() { @@ -10998,12 +10997,8 @@ mod tests { \n\n" ) }; - let decl = |g: &str, a: &str, v: &str| { - format!( - "{g}{a}\ - {v}" - ) - }; + let decl = |g: &str, a: &str, v: &str| maven_decl(g, a, Some(v), ""); + let h_decl = |v: Option<&str>, tail: &str| maven_decl("${h}", "slf4j-api", v, tail); let pinned = decl("org.slf4j", "slf4j-api", "1.7.36"); for (props, deps, profile) in [ // The declaration a pin goes into moves off the GA. @@ -11021,48 +11016,30 @@ mod tests { // ... or its executable classifier copy. ( "org.other", - format!( - "{pinned}${{h}}slf4j-api\ - 1.7.36tests\ - " - ), + pinned.clone() + &h_decl(Some("1.7.36"), "tests"), "org.slf4j", ), // ... a type with an explicit classifier included. ( "org.other", - format!( - "{pinned}${{h}}slf4j-api\ - 1.7.30test-jar\ - tests" - ), + pinned.clone() + + &h_decl( + Some("1.7.30"), + "test-jartests", + ), "org.slf4j", ), // ... or a classifier copy the profile leaves on a property this - // pom does not define, which may be the GA (as discovery weighs - // it, #2026 review). + // pom does not define, which may be the GA, as discovery weighs it. ( "org.other", - format!( - "{pinned}${{h}}slf4j-api\ - 1.7.30tests\ - " - ), + pinned.clone() + &h_decl(Some("1.7.30"), "tests"), "${parent.g}", ), ] { - let mut files = BTreeMap::new(); - files.insert("pom.xml".to_string(), pom(props, &deps, profile)); - let r = rewrite_registry_redirect(&files, &[maven_override()]); - assert!( - r.files.is_empty() && r.edits.is_empty(), - "{deps}: nothing written: {:?}", - r.files - ); - assert_eq!( - warning_codes(&r), - vec!["redirect_maven_profile_coordinates_unpatched"], - "{deps}" + assert_refused( + &pom(props, &deps, profile), + "redirect_maven_profile_coordinates_unpatched", ); } for (props, deps, profile) in [ @@ -11079,23 +11056,14 @@ mod tests { // Versionless: it takes the pin. ( "org.other", - format!( - "{pinned}${{h}}slf4j-api\ - " - ), + pinned.clone() + &h_decl(None, ""), "org.slf4j", ), ] { - let mut files = BTreeMap::new(); - files.insert("pom.xml".to_string(), pom(props, &deps, profile)); - let r = rewrite_registry_redirect(&files, &[maven_override()]); - assert!( - !warning_codes(&r).contains(&"redirect_maven_profile_coordinates_unpatched"), - "{deps}: {:?}", - r.warnings + assert_pinned( + &pom(props, &deps, profile), + &["redirect_maven_profile_coordinates_unpatched"], ); - let out = r.files.get("pom.xml").expect("pom rewritten"); - assert!(out.contains(MAVEN_SUFFIXED), "{deps}: pinned: {out}"); } // A profile declaration whose groupId only its own profile's // `` define, as another group: it is read under its own @@ -11106,10 +11074,7 @@ mod tests { \n\n", decl("${g}", "slf4j-api", "2.0") ); - let r = rewrite_registry_redirect( - &BTreeMap::from([("pom.xml".to_string(), own)]), - &[maven_override()], - ); + let r = rewrite_pom(&own); assert!(warning_codes(&r).is_empty(), "{:?}", r.warnings); let out = r.files.get("pom.xml").expect("pom rewritten"); assert!(out.contains(MAVEN_SUFFIXED), "pinned: {out}"); @@ -11118,7 +11083,7 @@ mod tests { /// A versioned `` declaration through an undefined coordinate /// property may be the GA while its profile is active: reported like a /// literal profile declaration (#683), and skipped beside a top-level - /// literal too, since that pin would never be attested (#2026 review). + /// literal too, since that pin would never be attested. #[test] fn maven_pom_undefined_coordinate_property_in_a_profile_is_reported() { let profile = "p\ diff --git a/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs b/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs index d792bbfd4..0df6f67de 100644 --- a/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs +++ b/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs @@ -12,7 +12,7 @@ use std::ops::Range; use serde_json::{json, Value}; use crate::formats::maven::{ - interpolate_keeping, interpolate_strict, is_executable_classifier, is_model_reference, + classpath_copy, interpolate_keeping, interpolate_strict, is_executable_classifier, lookup_property, maven_config_properties as cli_properties, profile_combinations, Definitions, Interpolate, PomLayer, MAX_COMBINED_PROFILES, MAX_INTERPOLATION_DEPTH, }; @@ -2012,15 +2012,10 @@ impl Reactor { }) .collect(); let definitions = |name: &str| { - if let Some(v) = is_model_reference(name) - .then(|| self.model_ref(reader, name)) - .flatten() - .or_else(|| self.cli_props.get(name).cloned()) + if let Some(fixed) = + Definitions::fixed(name, &|n| self.model_ref(reader, n), &self.cli_props) { - return Definitions { - texts: vec![v], - profiles: Vec::new(), - }; + return fixed; } let mut d = Definitions::default(); d.texts.extend( @@ -2296,15 +2291,12 @@ impl Reactor { continue; }; let type_text = doc.child_text(dep, "type").filter(|t| !t.is_empty()); - // `test-jar` is the `tests` classifier copy, as discovery and - // the hosted rewriter map it. - let classifier = match ( - type_text.as_deref(), - doc.child_text(dep, "classifier").filter(|c| !c.is_empty()), - ) { - (Some("test-jar"), None) => Some("tests".to_string()), - (_, classifier) => classifier, - }; + let classifier_text = doc.child_text(dep, "classifier").filter(|c| !c.is_empty()); + // Mapped as discovery and the hosted rewriter map it (the type + // check below drops any other literal type). + let classifier = classpath_copy(type_text.as_deref(), classifier_text.as_deref()) + .flatten() + .map(str::to_string); if !doc.has_property_coordinate(dep) || doc.pin_at(dep).is_some() || classifier @@ -2320,17 +2312,7 @@ impl Reactor { continue; } let (here_g, here_a) = (resolve(&g), resolve(&a)); - let here_g = if g.contains("${") { - here_g - } else { - Some(g.clone()) - }; - let here_a = if a.contains("${") { - here_a - } else { - Some(a.clone()) - }; - let why = match (&here_g, &here_a) { + let (reason, detail) = match (&here_g, &here_a) { (Some(hg), Some(ha)) if hg == patch.group_id && ha == patch.artifact_id => { continue; } @@ -2370,7 +2352,6 @@ impl Reactor { ) } }; - let (reason, detail) = why; let at = format!("{rel}:{}", doc.line_of(doc.nodes[dep].start)); if let Some(classifier) = classifier { warnings.push(degraded( @@ -6216,11 +6197,7 @@ mod tests { "raise", "1.11.0", ); - let files = fs(&[ - ("pom.xml", &root), - ("a/pom.xml", &a), - ("b/pom.xml", &module("b", "")), - ]); + let files = reactor(&root, &a); let plan = run(&files).unwrap(); assert_eq!(reasons(&plan), ["conflicting_literal_version"]); let detail = &plan.warnings[0].detail; @@ -6267,16 +6244,33 @@ mod tests { ); match profile { "" => root, - p => root.replace( - " ", - &format!( - " corp{p}\ - \n " - ), - ), + p => with_corp_profile(&root, p), } } + /// `root` with a profile `corp` whose `` are `props`, before + /// its ``: also how a profile is added to a vendored reactor + /// later. + fn with_corp_profile(root: &str, props: &str) -> String { + root.replace( + " ", + &format!( + " corp{props}\ + \n " + ), + ) + } + + /// The `a` / `b` reactor under `root`, module `a`'s pom being `a` and + /// `b` declaring nothing. + fn reactor(root: &str, a: &str) -> Fs { + fs(&[ + ("pom.xml", root), + ("a/pom.xml", a), + ("b/pom.xml", &module("b", "")), + ]) + } + /// A declaration whose `${...}` groupId a profile redefines names /// another artifact whenever that profile is active, which the literal /// SV must not reach: it is left as is and its root unpinned, as for @@ -6292,11 +6286,7 @@ mod tests { "org.other", "${nope}", ] { - let files = fs(&[ - ("pom.xml", &root_with(props, profile)), - ("a/pom.xml", &a), - ("b/pom.xml", &module("b", "")), - ]); + let files = reactor(&root_with(props, profile), &a); let plan = run(&files).unwrap(); assert_eq!(reasons(&plan), ["coordinates_overridden"], "{profile}"); assert!( @@ -6311,18 +6301,10 @@ mod tests { } // A vendored reactor a profile is added to later is not attestable. - let files = fs(&[ - ("pom.xml", &root_with(props, "")), - ("a/pom.xml", &a), - ("b/pom.xml", &module("b", "")), - ]); + let files = reactor(&root_with(props, ""), &a); let (_, mut after) = vendor(&files); assert_eq!(attestable(&after), (true, true)); - let root = text(&after, "pom.xml").replace( - " ", - " corporg.other\ - \n ", - ); + let root = with_corp_profile(&text(&after, "pom.xml"), "org.other"); after.insert("pom.xml".to_string(), root.into_bytes()); assert_eq!(attestable(&after), (true, false)); @@ -6372,11 +6354,7 @@ mod tests { ("org.third", false), ("org.apache.commons", false), ] { - let files = fs(&[ - ("pom.xml", &root_with(props, profile)), - ("a/pom.xml", &a), - ("b/pom.xml", &module("b", "")), - ]); + let files = reactor(&root_with(props, profile), &a); let plan = run(&files).unwrap(); if warned { assert_eq!(reasons(&plan), ["coordinates_overridden"], "{profile}"); @@ -6404,14 +6382,8 @@ mod tests { other("1.9${t}") ), ); - let files = fs(&[ - ( - "pom.xml", - &root_with(props, "org.apache.commons"), - ), - ("a/pom.xml", &typed), - ("b/pom.xml", &module("b", "")), - ]); + let to_commons = "org.apache.commons"; + let files = reactor(&root_with(props, to_commons), &typed); let plan = run(&files).unwrap(); assert_eq!( reasons(&plan), @@ -6422,36 +6394,20 @@ mod tests { assert!(!root_pinned(&plan, &files)); // No version of its own: the pin applies, so the root stays pinned. - let files = fs(&[ - ( - "pom.xml", - &root_with(props, "org.apache.commons"), - ), - ( - "a/pom.xml", - &module("a", &format!("{}{}", dep("1.10.0"), other(""))), - ), - ("b/pom.xml", &module("b", "")), - ]); + let files = reactor( + &root_with(props, to_commons), + &module("a", &format!("{}{}", dep("1.10.0"), other(""))), + ); let plan = run(&files).unwrap(); assert_eq!(reasons(&plan), ["coordinates_overridden"]); assert!(root_pinned(&plan, &files)); // Vendored first, the profile added later: not attestable; a user // property fixing the group keeps it attestable. - let files = fs(&[ - ("pom.xml", &root_with(props, "")), - ("a/pom.xml", &a), - ("b/pom.xml", &module("b", "")), - ]); + let files = reactor(&root_with(props, ""), &a); let (_, after) = vendor(&files); assert_eq!(attestable(&after), (true, true)); - let profiled = text(&after, "pom.xml").replace( - " ", - " corp\ - org.apache.commons\n \ - ", - ); + let profiled = with_corp_profile(&text(&after, "pom.xml"), to_commons); let mut later = after.clone(); later.insert("pom.xml".to_string(), profiled.into_bytes()); assert_eq!(attestable(&later), (true, false)); @@ -6464,8 +6420,7 @@ mod tests { /// Coordinates whose text can never read the patched g:a (`${g}:guava`) /// are not weighed, however many profiles redefine `g`: more than are - /// combined would otherwise fail closed and unpin the root (#2026 - /// review). The same text naming commons-text does fail closed. + /// combined would otherwise fail closed and unpin the root. The same text naming commons-text does fail closed. #[test] fn many_profiles_on_an_unrelated_coordinate_are_not_weighed() { let profiles: String = (0..=MAX_COMBINED_PROFILES) @@ -6490,20 +6445,12 @@ mod tests { ), ) }; - let files = fs(&[ - ("pom.xml", &root), - ("a/pom.xml", &named("guava")), - ("b/pom.xml", &module("b", "")), - ]); + let files = reactor(&root, &named("guava")); let plan = run(&files).unwrap(); assert!(plan.warnings.is_empty(), "{:?}", plan.warnings); assert!(root_pinned(&plan, &files)); - let files = fs(&[ - ("pom.xml", &root), - ("a/pom.xml", &named("commons-text")), - ("b/pom.xml", &module("b", "")), - ]); + let files = reactor(&root, &named("commons-text")); let plan = run(&files).unwrap(); assert_eq!(reasons(&plan), ["coordinates_overridden"]); assert!(!root_pinned(&plan, &files)); @@ -6512,7 +6459,7 @@ mod tests { /// A classifier copy a profile retargets onto the patched g:a is its /// own coordinate, which no pin reaches: warned about as /// `classifier_declared`, like a literal one, without unpinning the - /// root (#2026 review). + /// root. #[test] fn a_profile_retargeting_a_classifier_copy_warns() { let classified = "${lib.group}\ @@ -6523,20 +6470,13 @@ mod tests { commons-text1.10.0\ test-jar"; for copy in [classified, test_jar] { - let files = fs(&[ - ( - "pom.xml", - &root_with( - "org.other", - "org.apache.commons", - ), + let files = reactor( + &root_with( + "org.other", + "org.apache.commons", ), - ( - "a/pom.xml", - &module("a", &format!("{}{copy}", dep("1.10.0"))), - ), - ("b/pom.xml", &module("b", "")), - ]); + &module("a", &format!("{}{copy}", dep("1.10.0"))), + ); let plan = run(&files).unwrap(); assert_eq!(reasons(&plan), ["classifier_declared"], "{copy}"); assert!( diff --git a/crates/socket-patch-core/src/vex/discover/maven.rs b/crates/socket-patch-core/src/vex/discover/maven.rs index 5d365b350..d3abedf29 100644 --- a/crates/socket-patch-core/src/vex/discover/maven.rs +++ b/crates/socket-patch-core/src/vex/discover/maven.rs @@ -44,34 +44,17 @@ //! A hosted pin in a reactor root (`` / ``) stays //! a ref (rollback / remove find it) but marked unattested: a module's //! own literal `` overrides it, and only the root pom is read here -//! (the rewriter refuses such roots, #261). A `` copy of the -//! GA is its own coordinate, which the classifier-less pin does not -//! retarget and the grant does not serve: an executable one (anything but -//! `sources` / `javadoc`), live or in a profile, that resolves any version -//! other than the pin — its own literal, a classifier-keyed managed entry, -//! or none in this pom — keeps the ref but marks it unattested. So does a -//! `` declaration of the main jar at any version other than the -//! pin (a profile's `` only where management governs -//! the pin): the rewriter leaves it as written, and whenever that profile -//! is active it overrides the pin (vendored mode treats every profile as -//! live and rewrites it too). A `${property}` version is interpolated -//! through every property it names (the vendored reactor's interpolator: -//! at most 16 deep, a cycle never resolves) from the root `` — -//! the pin as the pom spells it — and again with `.mvn/maven.config`'s -//! `-D` user properties first at every level, as the build resolves it -//! (Maven's order; `${project.*}` stays the model's): a pin the build -//! config moves keeps the ref but marks it unattested, and a Socket -//! version only the build config supplies is diagnosed, never a ref -//! (hosted mode never writes one; rollback / remove never edit -//! `.mvn/maven.config`). A pin the build resolves but that any profile's -//! (or set of profiles') `` make resolve anything else keeps -//! the ref unattested too, as a profile declaration does. So do -//! `${property}` coordinates a profile's `` redefine (Maven -//! merges them in before it interpolates a groupId / artifactId, then -//! matches pins): the declaration carrying the pin then names another -//! package, or another declaration becomes the pinned GA at a version off -//! the pin. Coordinates keep an undefined reference as written, so a -//! profile value this pom cannot resolve may name anything. +//! (the rewriter refuses such roots, #261). A `${property}` version is +//! read twice: as the pom spells it (the root ``, what the +//! rewriter writes and rolls back) and as the build resolves it +//! (`.mvn/maven.config`'s `-D` user properties first, Maven's order). A +//! Socket version only the build config supplies is diagnosed, never a +//! ref (hosted mode never writes one; rollback / remove never edit +//! `.mvn/maven.config`). Every pin is then weighed by [`bypasses`]: an +//! executable classifier copy, a `` declaration, profile +//! `` that move its version or its `${property}` coordinates, +//! or a `-D` override that makes the build resolve anything else keeps +//! the ref but marks it unattested. //! //! Integrity: when the pom sha256 was known the rewriter also writes Maven //! Trusted Checksums — `.mvn/checksums/checksums.sha256` lines @@ -116,11 +99,10 @@ //! deploy targets) and dependencies inside `` / `` //! (plugin classpaths) are not project resolution and are skipped silently; //! `` are only read when activated, so a Socket repository or pin -//! there is diagnosed, never a ref, and a profile declaration of a pinned -//! GA at another version, or a profile's `` that move the -//! pin's `${property}`, withholds the pin's attestation. Submodule poms -//! and parent poms are not read (root-only). Gradle builds are discovered -//! by `gradle.rs` (the hosted index) and the vendored JVM backend's own +//! there is diagnosed, never a ref (a profile that bypasses a live pin +//! withholds its attestation, [`bypasses`]). Submodule poms and parent +//! poms are not read (root-only). Gradle builds are discovered by +//! `gradle.rs` (the hosted index) and the vendored JVM backend's own //! probes. use std::collections::{BTreeMap, BTreeSet}; @@ -131,9 +113,9 @@ use super::{ DIAG_REF_UNATTRIBUTABLE, }; use crate::formats::maven::{ - is_executable_variant, is_main_jar, is_maven_coordinate, is_maven_version_text, is_variant, - maven_config_properties, may_name, may_shadow, parse_pom_with, split_socket_version, - variant_label, MovedBy, Pom, PomDep, PomRepo, + classpath_copy, is_executable_variant, is_main_jar, is_maven_coordinate, is_maven_version_text, + is_variant, management_key, maven_config_properties, may_name, may_shadow, parse_pom_with, + split_socket_version, variant_label, MovedBy, Pom, PomDep, PomRepo, }; use crate::patch::redirect::{MVN_CHECKSUMS, MVN_CONFIG, MVN_WRAPPER_PROPERTIES}; use crate::vendor::jvm::layout; @@ -241,24 +223,19 @@ async fn extract_hosted( // edit `.mvn/maven.config`. let mut reported = BTreeSet::new(); for dep in pom.deps.iter().filter(|d| !d.in_profile) { - let Some(built) = dep - .build_version - .as_deref() - .filter(|v| split_socket_version(v).is_some() && dep.version.as_deref() != Some(v)) - else { - continue; - }; // A pom spelling that is itself a Socket pin is pom.xml wiring: // pin detection keeps its ref and reports the -D override // (`MavenConfigOverride`) instead. - if dep - .version - .as_deref() - .and_then(split_socket_version) - .is_some() - { + let Some(built) = dep.build_version.as_deref().filter(|v| { + split_socket_version(v).is_some() + && dep + .version + .as_deref() + .and_then(split_socket_version) + .is_none() + }) else { continue; - } + }; // Once per GA and version: its managed entry and classifier copies // spell the same property. if !reported.insert((dep.group.clone(), dep.artifact.clone(), built.to_string())) { @@ -865,7 +842,12 @@ fn bypasses( // Each declaration as written, then as each combination of profiles // that moves its coordinates reads it: what it then declares is weighed // by the same checks. - let read: Vec = deps.iter().cloned().chain(retargeted(deps)).collect(); + let moved: Vec = retargeted(deps).collect(); + let read: std::borrow::Cow<[PomDep]> = if moved.is_empty() { + std::borrow::Cow::Borrowed(deps) + } else { + std::borrow::Cow::Owned(deps.iter().cloned().chain(moved).collect()) + }; if let Some(detail) = unpatched_classifier_copy(&read, group, artifact, pinned, wiring) { found.push((UnattestedKind::MavenClassifierUnpatched, detail)); } @@ -882,21 +864,17 @@ fn bypasses( /// of its own: what Maven reads while those profiles are active, so read /// only then (`in_profile`), at the version it resolves there, and marked /// with the profiles that give it ([`PomDep::retargeted_by`]). Its `` -/// is read as Maven maps it, as [`may_shadow`] does: `test-jar` is the -/// `tests` classifier copy, and any other type but `jar` (or a property) -/// is not a jar on the classpath, so no reading of it is kept. +/// is read as Maven maps it ([`classpath_copy`]): one that is no jar on +/// the classpath gives no reading. fn retargeted(deps: &[PomDep]) -> impl Iterator + '_ { deps.iter().flat_map(|d| { d.coordinate_alternatives.iter().filter_map(move |r| { - let (classifier, type_text) = match d.type_text.as_deref() { - None | Some("jar") => (d.classifier.clone(), d.type_text.clone()), - Some(t) if t.contains("${") => (d.classifier.clone(), d.type_text.clone()), - Some("test-jar") if d.classifier.is_none() => (Some("tests".to_string()), None), - // An explicit classifier names the copy whatever the type - // (`test-jar` + `tests`): weighed as that classifier. - Some(_) if d.classifier.is_some() => (d.classifier.clone(), None), - Some(_) => return None, - }; + let classifier = classpath_copy(d.type_text.as_deref(), d.classifier.as_deref())? + .map(str::to_string); + let type_text = d + .type_text + .clone() + .filter(|t| t == "jar" || t.contains("${")); Some(PomDep { group: r.group.clone(), artifact: r.artifact.clone(), @@ -918,17 +896,27 @@ fn retargeted(deps: &[PomDep]) -> impl Iterator + '_ { /// written and the profiles that make it `group:artifact` (or a property /// it leaves unresolved that may be). fn retargeted_text(d: &PomDep, by: &MovedBy, group: &str, artifact: &str) -> String { - let maybe = if d.group == group && d.artifact == artifact { - String::new() - } else { - format!(" (which may be {group}:{artifact})") - }; format!( - "declared {}:{}, whose coordinates the of {by} make {}:{}{maybe}", - d.group_text, d.artifact_text, d.group, d.artifact + "declared {}:{}, whose coordinates the of {by} make {}", + d.group_text, + d.artifact_text, + named(d, group, artifact) ) } +/// `d`'s GA for a detail, with `group:artifact` when it only may be that +/// (through a property this pom leaves unresolved). +fn named(d: &PomDep, group: &str, artifact: &str) -> String { + if d.group == group && d.artifact == artifact { + format!("{group}:{artifact}") + } else { + format!( + "{}:{} (which may be {group}:{artifact})", + d.group, d.artifact + ) + } +} + /// Why the live main-jar declaration carrying `group:artifact`'s pin /// `pinned` (a managed one only where management governs it, /// `pin_managed`) stops naming it whenever some combination of profiles is @@ -998,18 +986,14 @@ fn unpatched_classifier_copy( // Each entry also carries whether it names `group:artifact` literally: // one reached only through an undefined property ([`may_name`]) may // manage another GA, so it never stands in for this copy's version alone. - let ty = |d: &PomDep| -> String { - d.type_text - .as_deref() - .filter(|t| !t.is_empty()) - .unwrap_or("jar") - .to_string() - }; + fn key(d: &PomDep) -> (Option<&str>, Option<&str>) { + management_key(d.type_text.as_deref(), d.classifier.as_deref()) + } let managed = |copy: &PomDep, in_profile: bool| -> Vec<(Option<&str>, bool, bool)> { classifier_declarations(deps, group, artifact, move |d| { d.managed && d.in_profile == in_profile }) - .filter(|(m, _)| m.classifier == copy.classifier && ty(m) == ty(copy)) + .filter(|(m, _)| key(m) == key(copy)) .flat_map(|(d, _)| { let literal = d.group == group && d.artifact == artifact; flagged(d, in_profile) @@ -1093,15 +1077,7 @@ fn profile_override( } else { "" }; - let literal = d.group == group && d.artifact == artifact; - let named = if literal { - format!("{group}:{artifact}") - } else { - format!( - "{}:{} (which may be {group}:{artifact})", - d.group, d.artifact - ) - }; + let named = named(d, group, artifact); if !d.in_profile { // Off the pin as built, it is no pin (or another bypass). // One this pom leaves unresolved (a parent pom's property) @@ -1118,7 +1094,7 @@ fn profile_override( .iter() .find(|a| a.value != pinned && !(unresolved && a.value.contains("${")))?; let declared = d.version_text.as_deref().unwrap_or_default(); - let value = if !matches!(moved.by, crate::formats::maven::MovedBy::Profiles(_)) { + let value = if !matches!(moved.by, MovedBy::Profiles(_)) { "resolve a version this reader cannot work out".to_string() } else if moved.value.contains("${") { format!("nothing it can resolve ({})", moved.value) @@ -1187,9 +1163,35 @@ mod tests { format!("https://patch.socket.dev/patch-registry/maven/{TOKEN}/{uuid}/maven2") } - fn dep(g: &str, a: &str, v: Option<&str>) -> String { + /// The hosted repository for `UUID_A` on the Socket registry. + fn hosted_repo_a() -> String { + hosted_repo(&format!("socket-patch-{UUID_A}"), ®istry_url(UUID_A)) + } + + /// A `` with `tail` (a ``, ``, ...) after + /// its optional ``. + fn decl(g: &str, a: &str, v: Option<&str>, tail: &str) -> String { let v = v.map_or(String::new(), |v| format!("{v}")); - format!("{g}{a}{v}\n") + format!("{g}{a}{v}{tail}\n") + } + + fn dep(g: &str, a: &str, v: Option<&str>) -> String { + decl(g, a, v, "") + } + + /// A slf4j-api `{c}` copy. + fn classified(c: &str, v: Option<&str>) -> String { + decl( + "org.slf4j", + "slf4j-api", + v, + &format!("{c}"), + ) + } + + /// A slf4j-api `{t}` declaration. + fn typed(t: &str, v: Option<&str>) -> String { + decl("org.slf4j", "slf4j-api", v, &format!("{t}")) } fn pom(body: &str) -> String { @@ -1500,13 +1502,6 @@ mod tests { /// but is not attested. `sources` / `javadoc` never run and do not. #[tokio::test] async fn executable_classifier_copy_off_the_pin_is_not_attested() { - fn classified(c: &str, v: Option<&str>) -> String { - let v = v.map_or(String::new(), |v| format!("{v}")); - format!( - "org.slf4jslf4j-api\ - {v}{c}\n" - ) - } fn project(deps: &str, managed: &str) -> Project { let p = Project::new(); p.write( @@ -1516,12 +1511,20 @@ mod tests { \n\n{}{deps}\n\ \n{}\n", dep("org.slf4j", "slf4j-api", Some(&suffixed(UUID_A))), - hosted_repo(&format!("socket-patch-{UUID_A}"), ®istry_url(UUID_A)), + hosted_repo_a(), )), ); p } let pin = suffixed(UUID_A); + let undefined_tests = |v: Option<&str>| { + decl( + "${undefined.group}", + "slf4j-api", + v, + "tests", + ) + }; let unattested = [ // At the base release: the public classifier jar. (classified("tests", Some("1.7.36")), String::new()), @@ -1536,45 +1539,22 @@ mod tests { // No version this pom gives it (a parent's): fail closed. (classified("tests", None), String::new()), // A coordinate property this pom does not define may be the GA. - ( - "${undefined.group}\ - slf4j-api1.7.36\ - tests\n" - .to_string(), - String::new(), - ), + (undefined_tests(Some("1.7.36")), String::new()), // Version-less copy whose only managed entry reaches the GA // through an undefined property: it may manage another GA, so // the copy's version may come from outside this pom. - ( - classified("tests", None), - format!( - "${{undefined.group}}\ - slf4j-api{pin}\ - tests\n" - ), - ), + (classified("tests", None), undefined_tests(Some(&pin))), // A type implying an executable classifier (the vendored // reactor's rule): `test-jar` is the `tests` jar. - ( - "org.slf4jslf4j-api\ - 1.7.36test-jar\n" - .to_string(), - String::new(), - ), + (typed("test-jar", Some("1.7.36")), String::new()), ]; for (deps, managed) in &unattested { let out = run(&project(deps, managed)).await; - assert_refs(&out, &[(FX_PURL, UUID_A, WiringMode::Hosted)]); - assert_eq!(out.unattested.len(), 1, "{deps}: {:?}", out.unattested); - let u = &out.unattested[0]; - assert_eq!(u.kind, UnattestedKind::MavenClassifierUnpatched, "{deps}"); - assert_eq!( - (u.purl.as_str(), u.uuid.as_str()), - (FX_PURL, UUID_A), - "{deps}" + assert_withheld( + &out, + UnattestedKind::MavenClassifierUnpatched, + &["org.slf4j:slf4j-api:"], ); - assert!(u.detail.contains("org.slf4j:slf4j-api:"), "{}", u.detail); } let attested = [ @@ -1992,7 +1972,7 @@ mod tests { ConfigOnly, NoRef, } - let repo = hosted_repo(&format!("socket-patch-{UUID_A}"), ®istry_url(UUID_A)); + let repo = hosted_repo_a(); let pin = suffixed(UUID_A); let by_prop = dep("org.slf4j", "slf4j-api", Some("${slf4j.version}")); let by_model = dep("org.slf4j", "slf4j-api", Some("${project.version}")); @@ -2056,14 +2036,15 @@ mod tests { ); match expect { Expect::Attested | Expect::Overridden => { - assert_refs(&out, &[(FX_PURL, UUID_A, WiringMode::Hosted)]); if expect == Expect::Attested { + assert_refs(&out, &[(FX_PURL, UUID_A, WiringMode::Hosted)]); assert!(out.unattested.is_empty(), "{what}"); } else { - assert_eq!(out.unattested.len(), 1, "{what}"); - let u = &out.unattested[0]; - assert_eq!(u.kind, UnattestedKind::MavenConfigOverride, "{what}"); - assert!(u.detail.contains(".mvn/maven.config"), "{}", u.detail); + assert_withheld( + &out, + UnattestedKind::MavenConfigOverride, + &[".mvn/maven.config"], + ); } assert!( !out.diagnostics @@ -2099,20 +2080,9 @@ mod tests { /// resolves, its own or its classifier-keyed managed one. #[tokio::test] async fn profile_declaration_off_the_pin_is_not_attested() { - let repo = hosted_repo(&format!("socket-patch-{UUID_A}"), ®istry_url(UUID_A)); + let repo = hosted_repo_a(); let pin = suffixed(UUID_A); let ga = |v: Option<&str>| dep("org.slf4j", "slf4j-api", v); - let extra = |tag: &str, c: &str, v: Option<&str>| { - let v = v.map_or(String::new(), |v| format!("{v}")); - format!( - "org.slf4jslf4j-api\ - {v}<{tag}>{c}\n" - ) - }; - let classified = |c: &str, v: Option<&str>| extra("classifier", c, v); - let typed = |t: &str, v: Option<&str>| extra("type", t, v); - let deps = |d: &str| format!("\n{d}\n"); - let mgmt = |d: &str| format!("{}\n", deps(d)); // The live pin: a direct literal, a managed one (governing a // version-less declaration), or a direct literal beside a // classifier-keyed managed entry at the pin. @@ -2188,20 +2158,7 @@ mod tests { ]; for (live, profile, kind) in &unattested { let out = run(&project(live, profile)).await; - assert_refs(&out, &[(FX_PURL, UUID_A, WiringMode::Hosted)]); - assert_eq!(out.unattested.len(), 1, "{profile}: {:?}", out.unattested); - let u = &out.unattested[0]; - assert_eq!(u.kind, *kind, "{profile}"); - assert_eq!( - (u.purl.as_str(), u.uuid.as_str()), - (FX_PURL, UUID_A), - "{profile}" - ); - assert!( - u.detail.contains("org.slf4j:slf4j-api") && u.detail.contains(""), - "{}", - u.detail - ); + assert_withheld(&out, *kind, &["org.slf4j:slf4j-api", ""]); } let attested = [ @@ -2250,12 +2207,27 @@ mod tests { }) } + /// `out` finds the hosted slf4j-api pin for `UUID_A` but withholds it: + /// exactly one unattested entry, of `kind`, for that pin, whose detail + /// names every one of `needles`. + #[track_caller] + fn assert_withheld(out: &Discovery, kind: UnattestedKind, needles: &[&str]) { + assert_refs(out, &[(FX_PURL, UUID_A, WiringMode::Hosted)]); + assert_eq!(out.unattested.len(), 1, "{:?}", out.unattested); + let u = &out.unattested[0]; + assert_eq!(u.kind, kind, "{}", u.detail); + assert_eq!((u.purl.as_str(), u.uuid.as_str()), (FX_PURL, UUID_A)); + for needle in needles { + assert!(u.detail.contains(needle), "{needle:?}: {}", u.detail); + } + } + /// A hosted project pinning slf4j-api through `decl` (a /// `` / `` block), with the root /// `` `props` and one `` per entry of `profiles` /// (each a profile body). fn property_project(props: &str, decl: &str, profiles: &[&str]) -> Project { - let repo = hosted_repo(&format!("socket-patch-{UUID_A}"), ®istry_url(UUID_A)); + let repo = hosted_repo_a(); let profiles: String = profiles .iter() .enumerate() @@ -2272,11 +2244,16 @@ mod tests { p } + fn deps(d: &str) -> String { + format!("\n{d}\n") + } + + fn mgmt(d: &str) -> String { + format!("{}\n", deps(d)) + } + fn direct_decl(version: &str) -> String { - format!( - "\n{}\n", - dep("org.slf4j", "slf4j-api", Some(version)) - ) + deps(&dep("org.slf4j", "slf4j-api", Some(version))) } /// A pin spelled through a property is what Maven resolves only while @@ -2292,62 +2269,47 @@ mod tests { let pin = suffixed(UUID_A); let v = |value: &str| format!("{value}"); let w = |value: &str| format!("{value}"); - let in_props = |s: String| format!("{s}"); let direct = direct_decl("${slf4j.version}"); let managed = format!( - "\n{}\n\ - \n{}\n", - dep("org.slf4j", "slf4j-api", Some("${slf4j.version}")), - dep("org.slf4j", "slf4j-api", None), + "{}{}", + mgmt(&dep("org.slf4j", "slf4j-api", Some("${slf4j.version}"))), + deps(&dep("org.slf4j", "slf4j-api", None)), ); let withheld = [ // The base release. - (v(&pin), &direct, vec![in_props(v("1.7.36"))]), - (v(&pin), &managed, vec![in_props(v("1.7.36"))]), + (v(&pin), &direct, vec![in_props(&v("1.7.36"))]), + (v(&pin), &managed, vec![in_props(&v("1.7.36"))]), // Through a property the pin's property names. - (v("${w}") + &w(&pin), &direct, vec![in_props(w("1.7.30"))]), + (v("${w}") + &w(&pin), &direct, vec![in_props(&w("1.7.30"))]), // A value this pom cannot resolve, or a cycle. - (v(&pin), &direct, vec![in_props(v("${undefined}"))]), - (v(&pin), &direct, vec![in_props(v("${slf4j.version}"))]), + (v(&pin), &direct, vec![in_props(&v("${undefined}"))]), + (v(&pin), &direct, vec![in_props(&v("${slf4j.version}"))]), // Two profiles that move the pin only together. ( v(&pin) + &w(&pin), &direct, - vec![in_props(v("${w}")), in_props(w("1.7.36"))], + vec![in_props(&v("${w}")), in_props(&w("1.7.36"))], ), ]; for (root, decl, profiles) in &withheld { let profiles: Vec<&str> = profiles.iter().map(String::as_str).collect(); let out = run(&property_project(root, decl, &profiles)).await; - assert_refs(&out, &[(FX_PURL, UUID_A, WiringMode::Hosted)]); - assert_eq!( - out.unattested.len(), - 1, - "{root} {profiles:?}: {:?}", - out.unattested - ); - let u = &out.unattested[0]; - assert_eq!( - u.kind, + assert_withheld( + &out, UnattestedKind::MavenProfileUnpatched, - "{profiles:?}" - ); - assert!( - u.detail.contains("org.slf4j:slf4j-api") && u.detail.contains(""), - "{}", - u.detail + &["org.slf4j:slf4j-api", ""], ); } for (root, profile, config) in [ // The pin itself, or an unrelated property. - (v(&pin), in_props(v(&pin)), None), - (v(&pin), in_props("1".to_string()), None), - (v("${w}") + &w(&pin), in_props(w(&pin)), None), + (v(&pin), in_props(&v(&pin)), None), + (v(&pin), in_props("1"), None), + (v("${w}") + &w(&pin), in_props(&w(&pin)), None), // A user property wins over every profile's. ( v(&pin), - in_props(v("1.7.36")), + in_props(&v("1.7.36")), Some(format!("-Dslf4j.version={pin}\n")), ), ] { @@ -2464,13 +2426,8 @@ mod tests { #[tokio::test] async fn profile_properties_moving_the_pinned_coordinates_are_not_attested() { let pin = suffixed(UUID_A); - let direct = |g: &str, a: &str| { - format!("\n{}\n", dep(g, a, Some(&pin))) - }; - let managed = format!( - "\n{}\n", - dep("${g}", "slf4j-api", Some(&pin)) - ); + let direct = |g: &str, a: &str| deps(&dep(g, a, Some(&pin))); + let managed = mgmt(&dep("${g}", "slf4j-api", Some(&pin))); let case = |props: &str, decl: &str, profiles: &[&str], config: Option<&str>| { ( props.to_string(), @@ -2554,11 +2511,13 @@ mod tests { let pinned = dep("org.slf4j", "slf4j-api", Some(&pin)); let with = |other: &str| format!("\n{pinned}{other}\n"); let other = |v: Option<&str>| dep("${h}", "slf4j-api", v); + let other_with = |v: &str, tail: &str| decl("${h}", "slf4j-api", Some(v), tail); let classified = |c: &str, v: Option<&str>| { - let v = v.map_or(String::new(), |v| format!("{v}")); - format!( - "${{h}}slf4j-api{v}\ - {c}\n" + decl( + "${h}", + "slf4j-api", + v, + &format!("{c}"), ) }; let h = "org.other"; @@ -2588,12 +2547,8 @@ mod tests { // Managed, where management governs the pin. case( h, - format!( - "\n{}\n\ - \n{pinned}{}\n", - dep("org.slf4j", "slf4j-api", None), - other(Some("1.7.36")) - ), + deps(&dep("org.slf4j", "slf4j-api", None)) + + &mgmt(&format!("{pinned}{}", other(Some("1.7.36")))), vec![to_slf4j.clone()], None, ), @@ -2627,21 +2582,17 @@ mod tests { // A `test-jar` is the `tests` classifier copy. case( h, - with( - "${h}slf4j-api\ - 1.7.36test-jar\n", - ), + with(&other_with("1.7.36", "test-jar")), vec![to_slf4j.clone()], None, ), // A type with an explicit classifier: that classifier's copy. case( h, - with( - "${h}slf4j-api\ - 1.7.30test-jartests\ - \n", - ), + with(&other_with( + "1.7.30", + "test-jartests", + )), vec![to_slf4j.clone()], None, ), @@ -2662,10 +2613,7 @@ mod tests { let own = property_project( h, &with(""), - &[&format!( - "{to_slf4j}\n{}\n", - other(Some("1.7.36")) - )], + &[&(to_slf4j.clone() + &deps(&other(Some("1.7.36"))))], ); let out = run(&own).await; assert_refs(&out, &[(FX_PURL, UUID_A, WiringMode::Hosted)]); @@ -2682,11 +2630,7 @@ mod tests { // Managed, while a direct version (the pin) governs. case( h, - format!( - "\n{pinned}\n\ - \n{}\n", - other(Some("1.7.36")) - ), + deps(&pinned) + &mgmt(&other(Some("1.7.36"))), vec![to_slf4j.clone()], None, ), @@ -2699,19 +2643,13 @@ mod tests { ), case( h, - with( - "${h}slf4j-api\ - 1.7.36pom\n", - ), + with(&other_with("1.7.36", "pom")), vec![to_slf4j.clone()], None, ), case( h, - with( - "${h}slf4j-api\ - 1.7.36war\n", - ), + with(&other_with("1.7.36", "war")), vec![to_slf4j.clone()], None, ), @@ -2774,10 +2712,7 @@ mod tests { ), ( "org.slf4j".to_string(), - format!( - "\n{}\n", - dep("${g}", "slf4j-api", Some(&pin)) - ), + deps(&dep("${g}", "slf4j-api", Some(&pin))), vec![in_props("org.other")], none.clone(), ), @@ -2787,11 +2722,7 @@ mod tests { ( String::new(), with(""), - vec![format!( - "{}\n{}\n", - in_props("org.other"), - dep("${g}", "slf4j-api", Some("2.0")) - )], + vec![in_props("org.other") + &deps(&dep("${g}", "slf4j-api", Some("2.0")))], none.clone(), ), ] { @@ -3060,6 +2991,21 @@ mod tests { p } + /// `vendored_project(UUID_A)` with its wired pom.xml passed through + /// `edit`. + fn vendored_project_with(edit: impl FnOnce(&str) -> String) -> Project { + let p = vendored_project(UUID_A); + let wired = std::fs::read_to_string(p.root().join("pom.xml")).expect("read pom"); + p.write("pom.xml", edit(&wired)); + p + } + + /// `pom` with `extra` spliced in just before its closing ``. + fn before_close(pom: &str, extra: &str) -> String { + let at = pom.rfind("").expect("project close"); + format!("{}{extra}{}", &pom[..at], &pom[at..]) + } + #[tokio::test] async fn vendor_backend_wiring_yields_the_committed_jar() { let p = vendored_project(UUID_A); @@ -3081,17 +3027,14 @@ mod tests { #[tokio::test] async fn vendored_ref_bypassed_by_a_profile_or_classifier_is_not_attested() { let classified = |c: &str, v: &str| { - format!( - "org.apache.commonscommons-text\ - {v}{c}\n" - ) + let tail = format!("{c}"); + decl("org.apache.commons", "commons-text", Some(v), &tail) }; let in_profile = |d: &str| { format!( "p\n{d}\n" ) }; - let live = |d: &str| format!("\n{d}\n"); for (extra, kind) in [ ( in_profile(&dep("org.apache.commons", "commons-text", Some("1.10.1"))), @@ -3102,22 +3045,16 @@ mod tests { None, ), ( - live(&classified("tests", "1.10.0")), + deps(&classified("tests", "1.10.0")), Some(UnattestedKind::MavenClassifierUnpatched), ), ( in_profile(&classified("tests", "1.10.0")), Some(UnattestedKind::MavenClassifierUnpatched), ), - (live(&classified("sources", "1.10.0")), None), + (deps(&classified("sources", "1.10.0")), None), ] { - let p = vendored_project(UUID_A); - let wired = std::fs::read_to_string(p.root().join("pom.xml")).expect("read pom"); - let at = wired.rfind("").expect("project close"); - p.write( - "pom.xml", - format!("{}{extra}{}", &wired[..at], &wired[at..]), - ); + let p = vendored_project_with(|wired| before_close(wired, &extra)); let out = run(&p).await; assert_refs(&out, &[(V_PURL, UUID_A, WiringMode::Vendored)]); let kinds: Vec<_> = out.unattested.iter().map(|u| u.kind).collect(); @@ -3138,20 +3075,15 @@ mod tests { ("", "1.10.1", false), ("", "1.10.0", true), ] { - let p = vendored_project(UUID_A); - let wired = std::fs::read_to_string(p.root().join("pom.xml")) - .expect("read pom") - .replace("1.10.0", "${ct}"); - let at = wired.rfind("").expect("project close"); - p.write( - "pom.xml", - format!( - "{}{root}\np\ - {version}\n{}", - &wired[..at], - &wired[at..] - ), - ); + let p = vendored_project_with(|wired| { + before_close( + &wired.replace("1.10.0", "${ct}"), + &format!( + "{root}\np\ + {version}\n" + ), + ) + }); let out = run(&p).await; assert_refs(&out, &[(V_PURL, UUID_A, WiringMode::Vendored)]); assert_eq!( @@ -3166,29 +3098,23 @@ mod tests { /// The pre-v5 vendored repository serves only the original GAV's main /// jar, so an executable classifier copy a profile's `` /// retarget onto the vendored GA comes from the next repository even at - /// the vendored version (#2026 review); a non-executable one does not + /// the vendored version; a non-executable one does not /// withhold. #[tokio::test] async fn vendored_ref_under_a_retargeted_classifier_copy_is_not_attested() { for (classifier, attested) in [("tests", false), ("sources", true)] { - let p = vendored_project(UUID_A); - let wired = std::fs::read_to_string(p.root().join("pom.xml")).expect("read pom"); let copy = format!( "${{h}}commons-text\ 1.10.0{classifier}\n \ " ); - let wired = wired.replacen("", ©, 1); - let at = wired.rfind("").expect("project close"); - p.write( - "pom.xml", - format!( - "{}org.other\np\ - org.apache.commons\n{}", - &wired[..at], - &wired[at..] - ), - ); + let p = vendored_project_with(|wired| { + before_close( + &wired.replacen("", ©, 1), + "org.other\np\ + org.apache.commons\n", + ) + }); let out = run(&p).await; assert_refs(&out, &[(V_PURL, UUID_A, WiringMode::Vendored)]); assert_eq!( @@ -3216,7 +3142,7 @@ mod tests { #[tokio::test] async fn model_references_move_with_the_properties_they_are_spelled_through() { let pin = suffixed(UUID_A); - let repo = hosted_repo(&format!("socket-patch-{UUID_A}"), ®istry_url(UUID_A)); + let repo = hosted_repo_a(); let project = |revision: &str, profile: &str| { let p = Project::new(); p.write( @@ -3240,12 +3166,7 @@ mod tests { "1.7.36", )) .await; - assert_refs(&out, &[(FX_PURL, UUID_A, WiringMode::Hosted)]); - assert_eq!(out.unattested.len(), 1, "{:?}", out.unattested); - assert_eq!( - out.unattested[0].kind, - UnattestedKind::MavenProfileUnpatched - ); + assert_withheld(&out, UnattestedKind::MavenProfileUnpatched, &[]); // One that keeps it does not. let out = run(&project( &pin, @@ -3267,7 +3188,7 @@ mod tests { ); } - /// Review on #2020 (round 2): a property defined twice is Maven's LAST + /// A property defined twice is Maven's LAST /// definition; a profile declaration's coordinates read its own /// profile's `` as its version does; a profile is named by /// its own ``, not one nested in it. @@ -3295,7 +3216,7 @@ mod tests { assert_refs(&out, &[(FX_PURL, UUID_A, WiringMode::Hosted)]); assert!(!attests(&out, FX_PURL), "{:?}", out.unattested); - let repo = hosted_repo(&format!("socket-patch-{UUID_A}"), ®istry_url(UUID_A)); + let repo = hosted_repo_a(); let p = Project::new(); p.write( "pom.xml", @@ -3368,12 +3289,7 @@ mod tests { &[broken], ); let out = run(&p).await; - assert_refs(&out, &[(FX_PURL, UUID_A, WiringMode::Hosted)]); - assert_eq!(out.unattested.len(), 1, "{:?}", out.unattested); - assert_eq!( - out.unattested[0].kind, - UnattestedKind::MavenProfileUnpatched - ); + assert_withheld(&out, UnattestedKind::MavenProfileUnpatched, &[]); let p = property_project("", &direct_decl(&pin), &[broken]); let out = run(&p).await; assert!( @@ -3427,19 +3343,19 @@ mod tests { ); } - /// Review on #2005 (round 2): a `-D` property `.mvn/maven.config` alone + /// A `-D` property `.mvn/maven.config` alone /// defines AT the vendored version is the pin, not an override. #[tokio::test] async fn vendored_version_from_maven_config_at_the_pin_is_attested() { - let p = vendored_project(UUID_A); - let wired = std::fs::read_to_string(p.root().join("pom.xml")).expect("read pom"); - let spelled = wired.replacen( - "1.10.0", - "${commons.version}", - 1, - ); - assert_ne!(spelled, wired); - p.write("pom.xml", &spelled); + let p = vendored_project_with(|wired| { + let spelled = wired.replacen( + "1.10.0", + "${commons.version}", + 1, + ); + assert_ne!(spelled, wired); + spelled + }); p.write(".mvn/maven.config", "-Dcommons.version=1.10.0\n"); let out = run(&p).await; assert_refs(&out, &[(V_PURL, UUID_A, WiringMode::Vendored)]); @@ -3455,17 +3371,18 @@ mod tests { ); } - /// Review on #2005 (round 2): a profile `` entry + /// A profile `` entry /// for a classifier copy that sets no `` (scope, exclusions) /// overrides no version: the live managed pin still governs. #[tokio::test] async fn a_versionless_profile_management_entry_overrides_nothing() { let pin = suffixed(UUID_A); let tests = |v: Option<&str>| { - let v = v.map_or(String::new(), |v| format!("{v}")); - format!( - "org.slf4jslf4j-api\ - {v}teststest\n" + decl( + "org.slf4j", + "slf4j-api", + v, + "teststest", ) }; let p = Project::new(); @@ -3481,7 +3398,7 @@ mod tests { dep("org.slf4j", "slf4j-api", Some(&pin)), tests(None), tests(None), - hosted_repo(&format!("socket-patch-{UUID_A}"), ®istry_url(UUID_A)), + hosted_repo_a(), )), ); let out = run(&p).await; diff --git a/crates/socket-patch-core/src/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index 85f3bef06..408cb9964 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -615,24 +615,24 @@ pub enum UnattestedKind { /// #261): a module's own literal `` overrides it, and only the /// root pom is read, so the reactor may build the upstream jar. MavenReactorRoot, - /// A Maven pin whose GA also has an executable classifier copy (live - /// or inside ``) (`tests`, a native build; not `sources` / `javadoc`) at another - /// 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. + /// A Maven pin (hosted, or the pre-v5 single-pom vendored repository) + /// whose GA also has an executable classifier copy (`tests`, a native + /// build; not `sources` / `javadoc`), live or inside ``, at + /// another 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 Maven pin (hosted, or the pre-v5 single-pom vendored repository) - /// whose GA a `` declaration of the root pom gives another - /// version (the base release, another release, an - /// unresolved `${property}`), or whose `${property}` version a - /// profile's `` redefine (directly or through a property - /// it names) to another or an unresolvable version: whenever that - /// profile is active it overrides the pin, so the build resolves the - /// unpatched jar. + /// that some profile overrides whenever it is active: a `` + /// declaration of the GA at another version, profile `` + /// that move the pin's `${property}` version, or ones that move a + /// `${property}` groupId / artifactId so the pin names another package + /// or another declaration names this one. MavenProfileUnpatched, - /// A hosted (or pre-v5 vendored) Maven pin the root pom spells through a - /// `${property}` that - /// `.mvn/maven.config` redefines (`-D`, which Maven reads before the - /// pom's ``): the build resolves that version, not the pin. + /// A Maven pin (hosted, or the pre-v5 single-pom vendored repository) + /// the root pom spells through a `${property}` that `.mvn/maven.config` + /// redefines (`-D`, which Maven reads before the pom's ``): + /// the build resolves that version, not the pin. MavenConfigOverride, /// A pnpm lock's pin while any `pnpm-lock..yaml` exists (#556), /// whatever pnpm's `gitBranchLockfile` setting says (discovery reads no From eda7b20f37d373cfcae14d4e5af1262737c2cee8 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Mon, 9 Nov 2026 16:58:09 -0500 Subject: [PATCH 19/20] Address /code-review on the simplify commit - Hosted build_versions are computed for every declaration again, so an empty list only ever means "no "; the index reads a new PomScope::build_versions (the build half of resolve_version) instead of resolving the pom-spelled version it discarded. - assert_withheld takes the case it checks, so a table-driven failure names the failing case again. - The discovery module doc lists the bypasses it withholds for (classifier copies, profile declarations and management, profile properties moving a version or coordinates, -D overrides) and the interpolation bounds. - The reactor's classifier comment says where its type rule is narrower. Co-Authored-By: Claude Opus 5.5 --- .../src/formats/maven/mod.rs | 16 +++-- .../src/patch/redirect/maven_index.rs | 23 +++---- .../src/vendor/jvm/maven_reactor.rs | 5 +- .../src/vex/discover/maven.rs | 66 +++++++++++++------ 4 files changed, 67 insertions(+), 43 deletions(-) diff --git a/crates/socket-patch-core/src/formats/maven/mod.rs b/crates/socket-patch-core/src/formats/maven/mod.rs index d2bd28653..829d90cbe 100644 --- a/crates/socket-patch-core/src/formats/maven/mod.rs +++ b/crates/socket-patch-core/src/formats/maven/mod.rs @@ -317,24 +317,26 @@ impl PomScope { /// The `` text of the declaration at `pos`, resolved as /// [`PomDep::version`], [`PomDep::build_version`] and /// [`PomDep::version_alternatives`] describe. - pub(crate) fn resolve_version( - &self, - text: &str, - pos: usize, - ) -> (String, String, Vec) { + fn resolve_version(&self, text: &str, pos: usize) -> (String, String, Vec) { if !text.contains("${") { return (text.to_string(), text.to_string(), Vec::new()); } let own = self.own_profile(pos); let spelled = interpolate_strict(text, &|name| self.lookup(name, &own, false)) .unwrap_or_else(|| text.to_string()); + let (build, alternatives) = self.build_versions(text, pos); + (spelled, build, alternatives) + } + + /// [`PomDep::build_version`] and [`PomDep::version_alternatives`] of + /// the `` text of the declaration at `pos`. + pub(crate) fn build_versions(&self, text: &str, pos: usize) -> (String, Vec) { match self.resolve_in_build(text, pos) { - (Some(build), alternatives) => (spelled, build, alternatives), + (Some(build), alternatives) => (build, alternatives), // Unresolved here (a parent pom this reader never sees may // define it): the build version stays as written, but a value // some profiles give it is still one the build may resolve. (None, alternatives) => ( - spelled, text.to_string(), alternatives .into_iter() diff --git a/crates/socket-patch-core/src/patch/redirect/maven_index.rs b/crates/socket-patch-core/src/patch/redirect/maven_index.rs index 3fc553f20..90934251b 100644 --- a/crates/socket-patch-core/src/patch/redirect/maven_index.rs +++ b/crates/socket-patch-core/src/patch/redirect/maven_index.rs @@ -33,11 +33,10 @@ pub(super) struct IndexedDep { /// Inside a ``. pub(super) managed: bool, /// Every version the build may resolve for it, as VEX discovery reads - /// one ([`PomScope::resolve_version`]): with no profile active but the + /// one ([`PomScope::build_versions`]): with no profile active but the /// one holding it, then each combination of profiles whose /// `` move it (as written where one leaves it unresolved). - /// Empty when it has no ``, and for a live declaration that is - /// no classifier copy, whose versions no check reads. + /// Empty when it has no ``. pub(super) build_versions: Vec, } @@ -159,18 +158,12 @@ impl PomIndex { managed: managed .iter() .any(|m| d.element.start >= m.start && d.element.start < m.end), - build_versions: d - .version_text - .as_deref() - .filter(|_| { - d.in_profile || is_variant(d.type_text.as_deref(), d.classifier.as_deref()) - }) - .map_or_else(Vec::new, |text| { - let (_, build, alternatives) = scope.resolve_version(text, d.element.start); - std::iter::once(build) - .chain(alternatives.into_iter().map(|a| a.value)) - .collect() - }), + build_versions: d.version_text.as_deref().map_or_else(Vec::new, |text| { + let (build, alternatives) = scope.build_versions(text, d.element.start); + std::iter::once(build) + .chain(alternatives.into_iter().map(|a| a.value)) + .collect() + }), group: d.group, artifact: d.artifact, version_inner: d.version_inner, diff --git a/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs b/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs index 0df6f67de..dc5c6e900 100644 --- a/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs +++ b/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs @@ -2292,8 +2292,9 @@ impl Reactor { }; let type_text = doc.child_text(dep, "type").filter(|t| !t.is_empty()); let classifier_text = doc.child_text(dep, "classifier").filter(|c| !c.is_empty()); - // Mapped as discovery and the hosted rewriter map it (the type - // check below drops any other literal type). + // `test-jar` is the `tests` copy ([`classpath_copy`]); the type + // check below drops any other literal type but `jar`, even with + // an explicit classifier. let classifier = classpath_copy(type_text.as_deref(), classifier_text.as_deref()) .flatten() .map(str::to_string); diff --git a/crates/socket-patch-core/src/vex/discover/maven.rs b/crates/socket-patch-core/src/vex/discover/maven.rs index d3abedf29..20ad2ac2b 100644 --- a/crates/socket-patch-core/src/vex/discover/maven.rs +++ b/crates/socket-patch-core/src/vex/discover/maven.rs @@ -45,16 +45,28 @@ //! a ref (rollback / remove find it) but marked unattested: a module's //! own literal `` overrides it, and only the root pom is read here //! (the rewriter refuses such roots, #261). A `${property}` version is -//! read twice: as the pom spells it (the root ``, what the -//! rewriter writes and rolls back) and as the build resolves it -//! (`.mvn/maven.config`'s `-D` user properties first, Maven's order). A -//! Socket version only the build config supplies is diagnosed, never a -//! ref (hosted mode never writes one; rollback / remove never edit -//! `.mvn/maven.config`). Every pin is then weighed by [`bypasses`]: an -//! executable classifier copy, a `` declaration, profile -//! `` that move its version or its `${property}` coordinates, -//! or a `-D` override that makes the build resolve anything else keeps -//! the ref but marks it unattested. +//! interpolated through every property it names (at most 16 deep; a cycle +//! or an undefined name never resolves to a pin) twice: as the pom spells +//! it (the root ``, what the rewriter writes and rolls back) +//! and as the build resolves it (`.mvn/maven.config`'s `-D` user +//! properties first, Maven's order). A Socket version only the build +//! config supplies is diagnosed, never a ref (hosted mode never writes +//! one; rollback / remove never edit `.mvn/maven.config`). +//! +//! Every pin is then weighed by [`bypasses`], and each way the build may +//! resolve something else keeps the ref but marks it unattested: +//! - an executable classifier copy (anything but `sources` / `javadoc`), +//! live or in a profile, at another version or none in this pom; +//! - a `` declaration of the main jar at another version (a +//! profile's `` only where management governs the +//! pin); every profile counts, as vendored mode treats every profile +//! as live; +//! - profile `` (any combination of profiles) that move the +//! pin's `${property}` version, or a `${property}` groupId / artifactId +//! so the pin names another package or another declaration names this +//! one (a coordinate property this pom cannot resolve may name +//! anything); +//! - a `-D` user property that moves the pin's `${property}` version. //! //! Integrity: when the pom sha256 was known the rewriter also writes Maven //! Trusted Checksums — `.mvn/checksums/checksums.sha256` lines @@ -1554,6 +1566,7 @@ mod tests { &out, UnattestedKind::MavenClassifierUnpatched, &["org.slf4j:slf4j-api:"], + &format!("{deps} {managed}"), ); } @@ -2044,6 +2057,7 @@ mod tests { &out, UnattestedKind::MavenConfigOverride, &[".mvn/maven.config"], + &what, ); } assert!( @@ -2158,7 +2172,12 @@ mod tests { ]; for (live, profile, kind) in &unattested { let out = run(&project(live, profile)).await; - assert_withheld(&out, *kind, &["org.slf4j:slf4j-api", ""]); + assert_withheld( + &out, + *kind, + &["org.slf4j:slf4j-api", ""], + &format!("{live} {profile}"), + ); } let attested = [ @@ -2209,16 +2228,24 @@ mod tests { /// `out` finds the hosted slf4j-api pin for `UUID_A` but withholds it: /// exactly one unattested entry, of `kind`, for that pin, whose detail - /// names every one of `needles`. + /// names every one of `needles` (`case` names the case in a failure). #[track_caller] - fn assert_withheld(out: &Discovery, kind: UnattestedKind, needles: &[&str]) { + fn assert_withheld(out: &Discovery, kind: UnattestedKind, needles: &[&str], case: &str) { assert_refs(out, &[(FX_PURL, UUID_A, WiringMode::Hosted)]); - assert_eq!(out.unattested.len(), 1, "{:?}", out.unattested); + assert_eq!(out.unattested.len(), 1, "{case}: {:?}", out.unattested); let u = &out.unattested[0]; - assert_eq!(u.kind, kind, "{}", u.detail); - assert_eq!((u.purl.as_str(), u.uuid.as_str()), (FX_PURL, UUID_A)); + assert_eq!(u.kind, kind, "{case}: {}", u.detail); + assert_eq!( + (u.purl.as_str(), u.uuid.as_str()), + (FX_PURL, UUID_A), + "{case}" + ); for needle in needles { - assert!(u.detail.contains(needle), "{needle:?}: {}", u.detail); + assert!( + u.detail.contains(needle), + "{case}: {needle:?}: {}", + u.detail + ); } } @@ -2298,6 +2325,7 @@ mod tests { &out, UnattestedKind::MavenProfileUnpatched, &["org.slf4j:slf4j-api", ""], + &format!("{root} {decl} {profiles:?}"), ); } @@ -3166,7 +3194,7 @@ mod tests { "1.7.36", )) .await; - assert_withheld(&out, UnattestedKind::MavenProfileUnpatched, &[]); + assert_withheld(&out, UnattestedKind::MavenProfileUnpatched, &[], ""); // One that keeps it does not. let out = run(&project( &pin, @@ -3289,7 +3317,7 @@ mod tests { &[broken], ); let out = run(&p).await; - assert_withheld(&out, UnattestedKind::MavenProfileUnpatched, &[]); + assert_withheld(&out, UnattestedKind::MavenProfileUnpatched, &[], ""); let p = property_project("", &direct_decl(&pin), &[broken]); let out = run(&p).await; assert!( From 823619ffa502341ca5906b2f4ba20a6b804dd02b Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Mon, 9 Nov 2026 16:59:37 -0500 Subject: [PATCH 20/20] Read an empty as jar when a profile moves coordinates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `${…}` groupId / artifactId declaration with an empty `` gave no reading once a profile's moved it: classpath_copy treated the empty type as "no jar on the classpath", while Maven (and is_main_jar / is_variant everywhere else) reads it as the default jar. So a declaration a profile turned into the pinned GA at another version went unweighed: hosted scan wrote the pin and VEX attested it (a false attestation, carried over from the folded #2026). An empty type now maps like `jar`. Tests: a withheld case in profile_properties_turning_a_declaration_into_the_pinned_ga_are_not_attested and a refused case in maven_pom_profile_moved_coordinates_refuse_the_dep, both failing before. Co-Authored-By: Claude Opus 5.5 --- crates/socket-patch-core/src/formats/maven/mod.rs | 7 ++++--- crates/socket-patch-core/src/patch/redirect/mod.rs | 6 ++++++ crates/socket-patch-core/src/vex/discover/maven.rs | 7 +++++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/crates/socket-patch-core/src/formats/maven/mod.rs b/crates/socket-patch-core/src/formats/maven/mod.rs index 829d90cbe..7d13c9ef7 100644 --- a/crates/socket-patch-core/src/formats/maven/mod.rs +++ b/crates/socket-patch-core/src/formats/maven/mod.rs @@ -958,14 +958,15 @@ pub(crate) fn is_variant(type_text: Option<&str>, classifier: Option<&str>) -> b /// Which copy of its GA a declaration's `` / `` read as /// when a profile moves its coordinates: `Some(None)` the main jar, /// `Some(Some(c))` the classifier copy `c`, `None` no jar on the classpath. -/// `test-jar` is the `tests` copy, an explicit classifier names the copy -/// whatever the type, and a `${…}` type may be `jar`. +/// An empty `` is Maven's default `jar`, `test-jar` is the `tests` +/// copy, an explicit classifier names the copy whatever the type, and a +/// `${…}` type may be `jar`. pub(crate) fn classpath_copy<'a>( type_text: Option<&'a str>, classifier: Option<&'a str>, ) -> Option> { match (type_text, classifier) { - (None | Some("jar"), c) => Some(c), + (None | Some("" | "jar"), c) => Some(c), (Some(t), c) if t.contains("${") => Some(c), (Some("test-jar"), None) => Some(Some("tests")), (Some(_), Some(c)) => Some(Some(c)), diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 34c863c77..1c14b4106 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -11013,6 +11013,12 @@ mod tests { format!("{pinned}{}", decl("${h}", "slf4j-api", "1.7.30")), "org.slf4j", ), + // ... with an empty ``, Maven's default `jar`. + ( + "org.other", + pinned.clone() + &h_decl(Some("1.7.30"), ""), + "org.slf4j", + ), // ... or its executable classifier copy. ( "org.other", diff --git a/crates/socket-patch-core/src/vex/discover/maven.rs b/crates/socket-patch-core/src/vex/discover/maven.rs index 20ad2ac2b..c25830940 100644 --- a/crates/socket-patch-core/src/vex/discover/maven.rs +++ b/crates/socket-patch-core/src/vex/discover/maven.rs @@ -2572,6 +2572,13 @@ mod tests { vec![to_slf4j.clone()], None, ), + // An empty `` is Maven's default, `jar`. + case( + h, + with(&other_with("1.7.30", "")), + vec![to_slf4j.clone()], + None, + ), // Managed, where management governs the pin. case( h,