diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 314658806..8f8e5d3b0 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, 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 (`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, 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). @@ -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 (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), 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 (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. | diff --git a/crates/socket-patch-cli/src/commands/vex_sources.rs b/crates/socket-patch-cli/src/commands/vex_sources.rs index 448b60a39..e0d382f9c 100644 --- a/crates/socket-patch-cli/src/commands/vex_sources.rs +++ b/crates/socket-patch-cli/src/commands/vex_sources.rs @@ -280,9 +280,12 @@ 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; `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/formats/maven/mod.rs b/crates/socket-patch-core/src/formats/maven/mod.rs index ff8ccd27c..a30400b86 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, Definitions, Interpolate, PomLayer, MAX_COMBINED_PROFILES, + MAX_INTERPOLATION_DEPTH, }; +use properties::{interpolate_lenient, read_properties}; use super::xml::{blank_non_markup, child_text, elements, open_tags, Element}; @@ -45,10 +46,23 @@ 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. pub(crate) group: String, pub(crate) artifact: String, + /// The trimmed `` / `` text as written. + pub(crate) group_text: String, + pub(crate) artifact_text: String, + /// 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.*` / /// `pom.*`), the `` of the profile holding the declaration @@ -78,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 @@ -91,6 +109,23 @@ pub(crate) struct Alternative { pub(crate) by: MovedBy, } +/// 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 { @@ -313,51 +348,153 @@ 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) } - /// `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, pos: usize) -> String { - let own = self.own_profile(pos); - interpolate_lenient(text, &|name| self.lookup(name, &own, true)) + /// [`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(); + }; + 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() + .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; + }; + 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 + } + + /// `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)) } /// Inside a plugin / deploy section (never project resolution). @@ -410,6 +547,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). @@ -428,12 +568,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, dep.start)), - artifact: child_text(&body, "artifactId")? - .map(|a| self.interpolate_coordinate(&a, dep.start)), + 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, @@ -478,7 +624,10 @@ 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 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 (version, build_version, version_alternatives) = match &dep.version_text { @@ -491,6 +640,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, @@ -499,6 +651,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) @@ -1277,6 +1430,161 @@ 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")] + ); + // 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"), + (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 1f9cc3343..2cf282cf6 100644 --- a/crates/socket-patch-core/src/formats/maven/properties.rs +++ b/crates/socket-patch-core/src/formats/maven/properties.rs @@ -109,6 +109,14 @@ pub(crate) fn interpolate_lenient(value: &str, lookup: &dyn Fn(&str) -> Option Option, +) -> Option { + Some(interpolate_lenient(value, lookup)) +} + fn expand( value: &str, lookup: &dyn Fn(&str) -> Option, @@ -183,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()) { @@ -350,15 +364,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..d877d0c51 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::{ + interpolate_strict, is_executable_classifier, may_shadow, CoordinateAlternative, PomScope, +}; use crate::formats::xml::{child_text, children, elements, Element}; /// One `` outside ignored sections. @@ -20,6 +22,21 @@ 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, + /// 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 ``. @@ -126,12 +143,27 @@ 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 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, @@ -139,6 +171,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 +235,165 @@ 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"), + // An explicit classifier names the copy whatever the type. + (Some(_), Some(c)) => Some(c), + _ => 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 + ) + }) + }) + }) + } + + /// 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())) + }) + } + + /// 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] } @@ -344,6 +537,11 @@ 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(), + 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 bb0e0f24e..259031c6c 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 = crate::formats::maven::is_executable_classifier(classifier); + 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(), @@ -8831,7 +8854,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 +8938,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(), @@ -8897,6 +8960,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 @@ -10301,20 +10379,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}"); } } @@ -10347,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). @@ -10694,10 +10939,115 @@ 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", + ), + // ... 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)); + 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 - /// 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\ @@ -10706,7 +11056,7 @@ mod tests { for (top, pinned) in [ ( "org.slf4jslf4j-api1.7.36", - true, + false, ), ("", false), ] { 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..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,9 +12,9 @@ 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_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}; @@ -1712,13 +1712,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 @@ -1778,30 +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, &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 a version `moved` accepts, with the - /// version and where; fails closed (an undetermined version) 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, - 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| { @@ -1836,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" @@ -1850,9 +1895,7 @@ impl Reactor { .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()); + let v = self.read_under(reader, texts, &active); moved(&v).then(|| { let under = combination .iter() @@ -1864,6 +1907,32 @@ 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 ([`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, + dep: usize, + hit: &dyn Fn(&str, &str) -> bool, + ) -> Option { + let doc = &self.poms[rel].doc; + 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). fn inheritors<'s>(&'s self, rel: &'s str) -> impl Iterator + 's { self.scope @@ -1890,7 +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_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") @@ -1910,15 +1979,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( @@ -2017,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<'_>, @@ -2031,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"), @@ -2038,29 +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 (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) { + (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(", ") + ), + ) + } + }; + 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; + } let versioned = doc .child_text(dep, "version") .is_some_and(|v| !v.is_empty()); @@ -2071,14 +2203,8 @@ impl Reactor { String::new() }; 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(", ") - ), + reason, + format!("{at}: {detail}; left as is, probably unpatched{unpin}"), )); if versioned { unpinned.insert(root); @@ -5912,6 +6038,265 @@ 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)); + } + + /// 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 c94398d20..1f089e50a 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 @@ -123,7 +129,7 @@ use super::{ }; use crate::formats::maven::{ is_executable_classifier, is_maven_coordinate, is_maven_version_text, maven_config_properties, - may_name, may_shadow, parse_pom_with, split_socket_version, Pom, PomDep, PomRepo, + may_name, 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; @@ -856,15 +862,109 @@ 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)); } - 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(&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), + // 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 { + 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 @@ -935,10 +1035,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 { @@ -1027,6 +1127,14 @@ fn profile_override( )); } let version = resolutions(d).find_map(|v| v.filter(|v| *v != pinned))?; + if let Some(by) = &d.retargeted_by { + return Some(format!( + "{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) + )); + } Some(format!( "{POM}: a {section}declaration of {named} carries \ {version}, not the patched {pinned}; whenever that profile \ @@ -2199,6 +2307,381 @@ 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]; + // 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") + && 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 `test-jar` is the `tests` classifier copy. + case( + h, + with( + "${h}slf4j-api\ + 1.7.36test-jar\n", + ), + 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, + 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, + ), + 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, + 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 @@ -2555,6 +3038,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