diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index a3ed909d6..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`. 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`. 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,7 +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_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 25e49273e..322b05f49 100644 --- a/crates/socket-patch-cli/src/commands/vex_sources.rs +++ b/crates/socket-patch-cli/src/commands/vex_sources.rs @@ -232,6 +232,14 @@ 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"; +/// 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. @@ -268,6 +276,18 @@ 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`)", + ), + 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 66a5db09d..6625d3e1e 100644 --- a/crates/socket-patch-core/src/formats/maven/mod.rs +++ b/crates/socket-patch-core/src/formats/maven/mod.rs @@ -41,9 +41,15 @@ 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 + /// 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, @@ -69,6 +75,11 @@ 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`]). coordinate_refs: BTreeMap, @@ -84,8 +95,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, @@ -104,12 +115,18 @@ 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()))); // 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); @@ -120,6 +137,7 @@ impl PomScope { ignored, profiles, properties, + build_properties, coordinate_refs, }) } @@ -248,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), @@ -551,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 @@ -733,6 +775,72 @@ 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(), d.build_version.unwrap())) + .collect::>() + }; + 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-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 cc4c69fe8..acb5bd0ca 100644 --- a/crates/socket-patch-core/src/vex/discover/maven.rs +++ b/crates/socket-patch-core/src/vex/discover/maven.rs @@ -46,11 +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. A `${property}` -//! version is resolved one level from the root ``. +//! 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 @@ -92,9 +103,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}; @@ -104,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; @@ -129,7 +142,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), @@ -161,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; } } @@ -178,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. @@ -208,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() @@ -229,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(), _ => { @@ -301,25 +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, - ); + out.unattested(&purl, uuid, POM, detail, kind); } ties.entry(*uuid).or_default().insert(( purl, @@ -378,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 @@ -397,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 @@ -431,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, ) { @@ -510,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("${")); @@ -531,6 +547,9 @@ async fn extract_vendored( continue; } } + 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)); } } @@ -644,106 +663,273 @@ 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)) + 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 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 + } + }; + let (version, from_profile) = resolved + .into_iter() + .find(|(version, _)| version.is_none() || *version != served)?; + Some(format!( + "{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)) }) - .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)) + .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" + )) }) } @@ -1467,6 +1653,255 @@ mod tests { } } + /// Maven gives `.mvn/maven.config`'s `-D` user properties precedence + /// 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); + 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(), + &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(), + &by_prop, + "-Dother=1\n".to_string(), + Expect::Attested, + ), + ] { + let p = Project::new(); + p.write( + "pom.xml", + pom(&format!( + "{value}\n\ + \n{declared}\n\n{repo}\n", + )), + ); + p.write(".mvn/maven.config", &config); + let out = run(&p).await; + let what = format!( + "{config}: {:?} {:?} {:?}", + 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 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 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!( + "{live}\n{repo}\n\ + p\n{profile}\n", + )), + ); + p + }; + 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, *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 + ); + } + + let attested = [ + // No version: the live pin applies. + (&direct_pin, deps(&ga(None))), + // The pin itself. + (&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. + ( + &direct_pin, + deps(&dep("org.slf4j", "slf4j-simple", Some("1.7.36"))), + ), + // A non-executable classifier copy, or one on the 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. + ( + &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); + } + } + /// 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 +2119,58 @@ mod tests { assert!(!r.lockfile_basis_ok()); } + /// 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_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!("{}{extra}{}", &wired[..at], &wired[at..]), + ); + 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(); + assert_eq!(kinds, kind.into_iter().collect::>(), "{extra}"); + } + } + #[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..ca636edcb 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -601,11 +601,21 @@ 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, + /// 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 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,