diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 104cd0e9f..2905a06ec 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -327,7 +327,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) 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), 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) 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). **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`). diff --git a/crates/socket-patch-core/src/formats/maven/mod.rs b/crates/socket-patch-core/src/formats/maven/mod.rs index 7d6cb9d38..66a5db09d 100644 --- a/crates/socket-patch-core/src/formats/maven/mod.rs +++ b/crates/socket-patch-core/src/formats/maven/mod.rs @@ -47,6 +47,8 @@ pub(crate) struct PomDep { /// The non-empty ``: a sibling artifact of the GA (sources, /// tests, a native build), managed and resolved apart from the main jar. pub(crate) classifier: Option, + /// The trimmed ``, when written. + pub(crate) type_text: Option, /// Inside ``. pub(crate) managed: bool, pub(crate) in_profile: bool, @@ -65,12 +67,30 @@ pub(crate) struct PomScope { pub(crate) masked: String, ignored: Vec, profiles: Vec, + /// The root `` (see [`properties`]). + properties: BTreeMap, + /// [`Self::properties`] plus the `${project.*}` model references a + /// coordinate may name (see [`model_refs`]). + coordinate_refs: BTreeMap, } impl PomScope { /// Fails (fail-closed) on a pom that is not well-formed where it /// matters: no ``, or an unterminated element or CDATA. + #[cfg(test)] pub(crate) fn new(raw: &str) -> Result { + PomScope::with_user_properties(raw, &BTreeMap::new()) + } + + /// [`Self::new`], with the user properties the build passes Maven + /// (`.mvn/maven.config`'s `-D`, see [`maven_config_properties`]): they + /// override a same-named pom property when a coordinate is + /// interpolated, as Maven's model interpolator reads them after the + /// model's own. + pub(crate) fn with_user_properties( + raw: &str, + user: &BTreeMap, + ) -> Result { let masked = blank_non_markup(raw)?; if open_tags(&masked, "project")?.is_empty() || !masked.contains("") { return Err("no element".to_string()); @@ -83,10 +103,24 @@ impl PomScope { ] .concat(); let profiles = elements(&masked, "profiles")?; + let properties = properties(&masked, &ignored, &profiles)?; + let mut coordinate_refs = properties.clone(); + coordinate_refs.extend(user.iter().map(|(k, v)| (k.clone(), v.clone()))); + // A `project.*` / `pom.*` reference names the model, whatever a + // same-named property says; the deprecated bare form yields to one. + for (name, value) in model_refs(&masked, &coordinate_refs.clone())? { + if name.starts_with("project.") || name.starts_with("pom.") { + coordinate_refs.insert(name, value); + } else { + coordinate_refs.entry(name).or_insert(value); + } + } Ok(PomScope { masked, ignored, profiles, + properties, + coordinate_refs, }) } @@ -133,6 +167,10 @@ fn inside(pos: usize, set: &[Element]) -> bool { #[derive(Debug)] pub(crate) struct ScopedDep { pub(crate) element: Element, + /// `` / `` with every `${prop}` the root + /// `` or the project's own coordinates define + /// interpolated (see [`interpolate`]); an + /// undefined one stays as written, so [`may_name`] can tell it apart. pub(crate) group: Option, pub(crate) artifact: Option, /// Inner-text byte range of the literal ``, when present. @@ -155,8 +193,10 @@ impl PomScope { let version = elements(&body, "version")?.into_iter().next(); out.push(ScopedDep { element: dep, - group: child_text(&body, "groupId")?, - artifact: child_text(&body, "artifactId")?, + group: child_text(&body, "groupId")? + .map(|g| interpolate(&g, &self.coordinate_refs)), + artifact: child_text(&body, "artifactId")? + .map(|a| interpolate(&a, &self.coordinate_refs)), version_inner: version.map(|v| { ( dep.inner_start + v.inner_start, @@ -179,11 +219,15 @@ impl PomScope { /// unterminated element or CDATA section is an error (fail-closed: nothing /// is discovered from a pom that is not well-formed where it matters). pub(crate) fn parse_pom(raw: &str) -> Result { - let scope = PomScope::new(raw)?; + parse_pom_with(raw, &BTreeMap::new()) +} + +/// [`parse_pom`] with the build's user properties (see +/// [`PomScope::with_user_properties`]). +pub(crate) fn parse_pom_with(raw: &str, user: &BTreeMap) -> Result { + let scope = PomScope::with_user_properties(raw, user)?; let text = &scope.masked; let managed = elements(text, "dependencyManagement")?; - - let properties = properties(text, &scope.ignored, &scope.profiles)?; let mut pom = Pom::default(); for repo in scope.elements("repository")? { @@ -202,8 +246,11 @@ pub(crate) fn parse_pom(raw: &str) -> Result { pom.deps.push(PomDep { group, artifact, - version: dep.version_text.map(|v| resolve_property(&v, &properties)), + version: dep + .version_text + .map(|v| resolve_property(&v, &scope.properties)), classifier: dep.classifier, + type_text: dep.type_text, managed: inside(dep.element.start, &managed), in_profile: dep.in_profile, }); @@ -264,6 +311,246 @@ fn resolve_property(version: &str, properties: &BTreeMap) -> Str .unwrap_or_else(|| version.to_string()) } +/// Interpolation rounds before a still-unresolved reference is given up on +/// (a property defined through another, or a cycle). +const MAX_INTERPOLATION: usize = 8; + +/// Longest text interpolation builds. A real coordinate is far shorter; a +/// pom whose properties each name the previous one many times would +/// otherwise grow exponentially with every round ("billion laughs"). +const MAX_INTERPOLATED_LEN: usize = 1024; + +/// `text` with every `${name}` the root `` or project model +/// defines replaced by its value, as Maven interpolates a coordinate such as +/// `org.${vendor}` (a value naming another property is resolved too, up to +/// [`MAX_INTERPOLATION`] rounds); an undefined or malformed reference is +/// kept as written, and a round that would exceed [`MAX_INTERPOLATED_LEN`] +/// is not taken, leaving its references unresolved (which fails closed). +fn interpolate(text: &str, properties: &BTreeMap) -> String { + let mut text = text.to_string(); + for _ in 0..MAX_INTERPOLATION { + let Some(next) = interpolate_once(&text, properties) else { + break; + }; + if next == text { + break; + } + text = next; + } + text +} + +/// One round of [`interpolate`], or `None` once the result would exceed +/// [`MAX_INTERPOLATED_LEN`]. +fn interpolate_once(text: &str, properties: &BTreeMap) -> Option { + let mut out = String::with_capacity(text.len()); + let mut rest = text; + while let Some(open) = rest.find("${") { + if out.len() > MAX_INTERPOLATED_LEN { + return None; + } + out.push_str(&rest[..open]); + let after = &rest[open + 2..]; + match after + .find('}') + .map(|close| (&after[..close], close)) + .filter(|(name, _)| is_property_name(name)) + .and_then(|(name, close)| properties.get(name).map(|v| (v, close))) + { + Some((value, close)) => { + out.push_str(value); + rest = &after[close + 1..]; + } + None => { + out.push_str("${"); + rest = after; + } + } + } + out.push_str(rest); + (out.len() <= MAX_INTERPOLATED_LEN).then_some(out) +} + +/// The model references a sibling declaration commonly uses +/// (`${project.groupId}`, `${pom.version}`, the deprecated bare +/// `${groupId}`, `${project.parent.groupId}`), read from the project's own +/// `` / `` / `` and its ``'s; groupId +/// and version fall back to the parent's, which the project inherits when +/// it omits them. A coordinate written through a property is resolved from +/// `properties` (never inherited instead: Maven interpolates a present +/// element), and one that stays unresolved is not bound. +fn model_refs( + masked: &str, + properties: &BTreeMap, +) -> Result, String> { + let Some(project) = elements(masked, "project")?.into_iter().next() else { + return Ok(BTreeMap::new()); + }; + let body = project.inner(masked); + let parent = elements(body, "parent")?.into_iter().next(); + let parent_text = |tag: &str| match &parent { + Some(p) => child_text(p.inner(body), tag), + None => Ok(None), + }; + // The project's own coordinates: what is left once every section that + // nests a groupId / artifactId / version of something else is blanked. + let mut own = body.to_string(); + for section in [ + "build", + "reporting", + "profiles", + "distributionManagement", + "dependencyManagement", + "parent", + "dependencies", + "properties", + ] { + own = blank_elements(&own, section)?; + } + let usable = |v: Option| { + v.map(|v| interpolate(&v, properties)) + .filter(|v| !v.is_empty() && !v.contains("${")) + }; + let mut refs = BTreeMap::new(); + for (field, inherited) in [("groupId", true), ("artifactId", false), ("version", true)] { + let parent_value = usable(parent_text(field)?); + let value = match child_text(&own, field)? { + Some(v) => usable(Some(v)), + None if inherited => parent_value.clone(), + None => None, + }; + if let Some(value) = value { + for prefix in ["project.", "pom.", ""] { + refs.insert(format!("{prefix}{field}"), value.clone()); + } + } + if let Some(value) = parent_value { + for prefix in ["project.parent.", "pom.parent.", "parent."] { + refs.insert(format!("{prefix}{field}"), value.clone()); + } + } + } + Ok(refs) +} + +/// Whether a dependency's (interpolated) `coordinate` may be `want`: it +/// is, or it still holds a `${…}` this pom does not define (a parent's or +/// the build's property) and Maven might resolve it to `want` — each such +/// reference may stand for any text, while the literal text around it must +/// still match. A reader that cannot rule a declaration out must not pin or +/// attest past it. +pub(crate) fn may_name(coordinate: &str, want: &str) -> bool { + if !coordinate.contains("${") { + return coordinate == want; + } + // The literal pieces between the references (an unterminated `${` + // reads as a reference running to the end). + let mut pieces = Vec::new(); + let mut rest = coordinate; + while let Some(open) = rest.find("${") { + pieces.push(&rest[..open]); + rest = match rest[open..].find('}') { + Some(close) => &rest[open + close + 1..], + None => "", + }; + } + pieces.push(rest); + let (first, last) = (pieces[0], pieces[pieces.len() - 1]); + if want.len() < first.len() + last.len() || !want.starts_with(first) || !want.ends_with(last) { + return false; + } + let mut middle = &want[first.len()..want.len() - last.len()]; + for piece in &pieces[1..pieces.len() - 1] { + match middle.find(piece) { + Some(at) => middle = &middle[at + piece.len()..], + None => return false, + } + } + true +} + +/// The user properties a `maven.config` defines, as Maven 3.9+ reads it: +/// one argument per line, `#` lines are comments (older Maven rejects +/// them), and a wholly quoted argument is unquoted. A property is +/// `-Dk=v`, `-D k=v`, `--define=k=v` or `--define k=v`, or a bare `-D` / +/// `--define` whose `k=v` is the next line. The value is the rest of the +/// argument, so `-Dv=1.0 # note` defines `1.0 # note`, as in Maven. +/// `-Dk` sets `k=true`; a later definition wins. Maven before 3.9 splits +/// the file on whitespace instead, so a `-Dk=v` token that only that reading +/// finds (`-B -Dk=v` on one line) is kept too, unless a line defines `k`. +pub(crate) fn maven_config_properties(config: &str) -> BTreeMap { + // Java's `String.trim`: every char up to U+0020, nothing beyond. + fn trim(s: &str) -> &str { + s.trim_matches(|c: char| c <= ' ') + } + fn unquote(s: &str) -> &str { + s.strip_prefix('"') + .and_then(|s| s.strip_suffix('"')) + .map_or(s, trim) + } + let mut args = config + .lines() + .map(|line| unquote(trim(line))) + .filter(|arg| !arg.is_empty() && !arg.starts_with('#')) + .peekable(); + let mut props = BTreeMap::new(); + while let Some(arg) = args.next() { + let def = match arg.strip_prefix("--define") { + Some(rest) => rest + .strip_prefix('=') + .or_else(|| (rest.is_empty() || rest.starts_with(' ')).then_some(rest)), + None => arg.strip_prefix("-D"), + }; + let Some(def) = def.map(trim) else { + continue; + }; + let def = match def { + "" => match args.next_if(|next| !next.starts_with('-')) { + Some(next) => next, + None => continue, + }, + def => def, + }; + let (k, v) = def.split_once('=').unwrap_or((def, "true")); + if !k.is_empty() { + props.insert(k.to_string(), v.to_string()); + } + } + let tokens = config + .lines() + .filter(|line| !trim(line).starts_with('#')) + .flat_map(str::split_whitespace) + .filter_map(|t| t.strip_prefix("-D")?.split_once('=')); + let mut legacy = BTreeMap::new(); + for (k, v) in tokens { + if !k.is_empty() && !props.contains_key(k) { + legacy.insert(k.to_string(), v.to_string()); + } + } + props.extend(legacy); + props +} + +/// Whether a declaration Maven always reads (outside `` when +/// `in_profile` is false) whose coordinates are not `group:artifact` as +/// written may still be that GA's main jar through an undefined coordinate +/// property (#683): a jar-typed (or untyped, or typed through a property +/// that may be `jar`), classifier-less declaration +/// whose coordinates [`may_name`] it. The hosted rewriter and VEX share this +/// rule, so what one pins the other attests. +pub(crate) fn may_shadow( + (dep_group, dep_artifact): (&str, &str), + type_text: Option<&str>, + classifier: Option<&str>, + (group, artifact): (&str, &str), +) -> bool { + (dep_group != group || dep_artifact != artifact) + && classifier.is_none() + && type_text.is_none_or(|t| t == "jar" || t.contains("${")) + && may_name(dep_group, group) + && may_name(dep_artifact, artifact) +} + fn is_property_name(name: &str) -> bool { !name.is_empty() && name @@ -341,6 +628,111 @@ mod tests { assert!(PomScope::new("").is_err()); } + #[test] + fn exponential_property_expansion_is_capped() { + // Each property names the previous one 40 times: uncapped, `${p6}` + // would expand to 40^6 bytes. + let mut props = String::from("x"); + for i in 1..=6 { + props.push_str(&format!( + "{}", + format!("${{p{}}}", i - 1).repeat(40) + )); + } + let pom = format!( + "${{p6}}{props}\ + ${{p6}}a1\ + " + ); + let deps = PomScope::new(&pom).unwrap().dependencies().unwrap(); + let group = deps[0].group.as_deref().unwrap(); + assert!( + group.len() <= super::MAX_INTERPOLATED_LEN, + "{}", + group.len() + ); + assert!(group.contains("${"), "left unresolved, so it fails closed"); + } + + /// #683: a coordinate property the pom defines, or a project model + /// reference, is resolved; an undefined one may name only what its + /// literal text around it allows. + #[test] + fn coordinate_references() { + let pom = "org.parent9\ + apporg.slf4j\ + \ + ${v}${project.artifactId}-core\ + ${project.groupId}x-${undefined}\ + "; + let deps = PomScope::new(pom).unwrap().dependencies().unwrap(); + assert_eq!(deps[0].group.as_deref(), Some("org.slf4j")); + assert_eq!(deps[0].artifact.as_deref(), Some("app-core")); + assert_eq!(deps[1].group.as_deref(), Some("org.parent")); + assert_eq!(deps[1].artifact.as_deref(), Some("x-${undefined}")); + + assert!(super::may_name("slf4j-api", "slf4j-api")); + assert!(!super::may_name("slf4j-apix", "slf4j-api")); + assert!(super::may_name("${a}", "slf4j-api")); + assert!(super::may_name("slf4j-${a}", "slf4j-api")); + assert!(super::may_name("${a}-${b}", "slf4j-api")); + assert!(super::may_name("${a", "slf4j-api")); + assert!(!super::may_name("${a}-core", "slf4j-api")); + assert!(!super::may_name("x-${a}", "slf4j-api")); + assert!(!super::may_name("sl${a}api-${b}x", "slf4j-api")); + + // A property defined through another, the parent's coordinates, + // and the model winning over a same-named property. + let pom = "org.parentcom.acme\ + org.slf4j${base}org.slf4j\ + \ + ${g}a\ + ${project.parent.groupId}b\ + ${project.groupId}c\ + "; + let deps = PomScope::new(pom).unwrap().dependencies().unwrap(); + let groups: Vec<_> = deps.iter().map(|d| d.group.as_deref().unwrap()).collect(); + assert_eq!(groups, ["org.slf4j", "org.parent", "com.acme"]); + + let ga = ("org.slf4j", "slf4j-api"); + assert!(super::may_shadow(("${g}", "slf4j-api"), None, None, ga)); + assert!(super::may_shadow( + ("${g}", "slf4j-api"), + Some("jar"), + None, + ga + )); + assert!(!super::may_shadow(("${g}", "${b}"), Some("pom"), None, ga)); + assert!(!super::may_shadow( + ("${g}", "slf4j-api"), + None, + Some("tests"), + ga + )); + assert!(super::may_shadow( + ("${g}", "slf4j-api"), + Some("${t}"), + None, + ga + )); + assert!(!super::may_shadow(ga, None, None, ga)); + + // Bugbot: a project coordinate written through a property is + // interpolated, not inherited from the parent, and a same-named + // element inside is not the project's own. + let pom = "org.parent9\ + org.wrongcom.acme\ + ${g}${undefined}\ + \ + ${project.groupId}a\ + ${project.version}\ + "; + let scope = PomScope::new(pom).unwrap(); + let deps = scope.dependencies().unwrap(); + assert_eq!(deps[0].group.as_deref(), Some("com.acme")); + assert!(!scope.coordinate_refs.contains_key("project.version")); + } + #[test] fn suffix_grammar_is_exact() { assert_eq!( 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 9a2acbdf2..3d2a024ff 100644 --- a/crates/socket-patch-core/src/patch/redirect/maven_index.rs +++ b/crates/socket-patch-core/src/patch/redirect/maven_index.rs @@ -119,9 +119,14 @@ fn authored_element(text: &str, at: usize, name: &str) -> Element { } impl PomIndex { - /// Fails (fail-closed) when the pom is not readable as one. - pub(super) fn build(pom: &str) -> Result { - let scope = PomScope::new(pom)?; + /// Fails (fail-closed) when the pom is not readable as one. `user` is + /// the build's `.mvn/maven.config` user properties (see + /// [`PomScope::with_user_properties`]). + pub(super) fn build( + pom: &str, + user: &std::collections::BTreeMap, + ) -> Result { + let scope = PomScope::with_user_properties(pom, user)?; let masked = &scope.masked; let deps = scope .dependencies()? @@ -171,6 +176,31 @@ impl PomIndex { .collect() } + /// A versioned declaration (inside `` exactly when + /// `in_profile`) that is not `group:artifact` as written but may be + /// its main jar through a coordinate property this pom does not define + /// (see [`may_shadow`](crate::formats::maven::may_shadow)). + pub(super) fn placeholder_shadow( + &self, + group: &str, + artifact: &str, + in_profile: bool, + ) -> Option<&IndexedDep> { + self.deps.iter().find(|d| { + let (Some(g), Some(a)) = (d.group.as_deref(), d.artifact.as_deref()) else { + return false; + }; + d.in_profile == in_profile + && d.version_text.is_some() + && crate::formats::maven::may_shadow( + (g, a), + d.type_text.as_deref(), + d.classifier.as_deref(), + (group, artifact), + ) + }) + } + pub(super) fn dep(&self, i: usize) -> &IndexedDep { &self.deps[i] } @@ -419,7 +449,7 @@ mod tests { /// After any sequence of the rewriter's edits, the kept index is the /// index a fresh build of the edited pom reads. fn assert_in_step(index: &PomIndex, pom: &str) { - let fresh = PomIndex::build(pom).expect("the edited pom reads"); + let fresh = PomIndex::build(pom, &Default::default()).expect("the edited pom reads"); let key = |i: &PomIndex| { let mut deps: Vec<_> = i .deps @@ -462,7 +492,7 @@ mod tests { "\n \n \n \n ga1\n \n\n", ] { let mut pom = pom.to_string(); - let mut index = PomIndex::build(&pom).unwrap(); + let mut index = PomIndex::build(&pom, &Default::default()).unwrap(); let [i] = index.matches("g", "a")[..] else { panic!("one match"); }; @@ -492,7 +522,7 @@ mod tests { let mut pom = format!( "\n \n ga\n \n {tail}\n" ); - let mut index = PomIndex::build(&pom).unwrap(); + let mut index = PomIndex::build(&pom, &Default::default()).unwrap(); if dm_first { index.insert_dependency_management(&mut pom, "g", "a", "1-socket.1"); assert_in_step(&index, &pom); diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 8ccf37006..9aa2f00a0 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -8590,8 +8590,17 @@ fn rewrite_maven_pom( // unchanged (a no-op rescan then never re-scans the pom per dep). // The hosted rewriter's view of the pom, read once (through `PomScope`) // and kept in step with every edit below. - let mut index: Option> = - pom.as_deref().map(maven_index::PomIndex::build); + // `.mvn/maven.config`'s `-D` user properties win over the pom's own + // when Maven interpolates a coordinate. + let user_properties = crate::formats::maven::maven_config_properties( + files + .get(MVN_CONFIG) + .map(String::as_str) + .unwrap_or_default(), + ); + let mut index: Option> = pom + .as_deref() + .map(|pom| maven_index::PomIndex::build(pom, &user_properties)); let original_repos: Vec<(String, Option)> = match (&index, pom.as_deref()) { (Some(Ok(index)), Some(pom)) => index.repository_ids_and_urls(pom), _ => Vec::new(), @@ -8740,6 +8749,53 @@ fn rewrite_maven_pom( continue; } } + // A declaration whose groupId / artifactId is a property this pom + // does not define (a parent's) may be this GA, and then its own + // `` beats any pin written here (#683): nothing is pinned. + // One inside `` beats it only while that profile is + // active, and is reported like a literal profile declaration. + let shadow_text = |d: &maven_index::IndexedDep| { + format!( + "{}:{} {}", + d.group.as_deref().unwrap_or_default(), + d.artifact.as_deref().unwrap_or_default(), + d.version_text.as_deref().unwrap_or_default(), + ) + }; + if let Some(shadow) = idx.placeholder_shadow(&group_id, &artifact_id, false) { + result.warnings.push(RewriteWarning { + code: "redirect_maven_coordinate_unresolved".into(), + detail: format!( + "{} names a property pom.xml does not define, so it may be \ + {group_id}:{artifact_id} and its own version would win over a pin; pom.xml \ + was left as it is and the dep is not counted as redirected", + shadow_text(shadow), + ), + }); + continue; + } + if profile_version.is_none() { + if let Some(shadow) = idx.placeholder_shadow(&group_id, &artifact_id, true) { + result.warnings.push(RewriteWarning { + code: "redirect_maven_profile_dependency_unpatched".into(), + detail: format!( + "{} inside names a property pom.xml does not define, so it \ + may be {group_id}:{artifact_id}; it is not edited, and while that \ + profile is active it may resolve that version, not the patched \ + artifact{}", + shadow_text(shadow), + if matches.is_empty() { + " (no declaration outside , so nothing is pinned)" + } else { + "" + } + ), + }); + if matches.is_empty() { + continue; + } + } + } let has_live_repo = idx.has_repository(&repo_id); // LEGACY same-GAV fallback: no suffixed version means the patched jar is @@ -10099,6 +10155,199 @@ mod tests { assert!(!warning_codes(&r).contains(&"redirect_maven_multimodule_unsupported")); } + /// A direct declaration whose groupId / artifactId is a property of + /// the pom's own `` is the GA (#683): its literal is + /// rewritten, not shadowed by an added `` pin + /// Maven would never apply. `` before the coordinates do + /// not hide it either. + #[test] + fn maven_pom_property_coordinates_are_the_direct_declaration() { + for (case, props, coords) in [ + ( + "property groupId", + "org.slf4j", + "${g}slf4j-api", + ), + ( + "property artifactId", + "slf4j-api", + "org.slf4j${a}", + ), + ( + "interpolated groupId", + "slf4j", + "org.${vendor}slf4j-api", + ), + ( + "exclusions first", + "", + "org.examplenoneorg.slf4jslf4j-api", + ), + ] { + let mut files = BTreeMap::new(); + files.insert( + "pom.xml".to_string(), + format!( + "\n {props}\n \n {coords}1.7.36\n \n\n" + ), + ); + let r = rewrite_registry_redirect(&files, &[maven_override()]); + let out = r.files.get("pom.xml").expect("pom rewritten"); + assert!( + out.contains(&format!("{coords}{MAVEN_SUFFIXED}")), + "{case}: the direct literal is pinned: {out}" + ); + assert!( + !out.contains(""), + "{case}: no shadowed managed pin: {out}" + ); + assert!( + !warning_codes(&r).contains(&"redirect_maven_dep_management_added"), + "{case}: {:?}", + r.warnings + ); + } + } + + /// `.mvn/maven.config`'s `-D` user properties override the pom's own + /// when Maven interpolates a coordinate: one naming another group is + /// not this GA (its literal is left alone), and one defining a property + /// the pom lacks makes the declaration this GA. + #[test] + fn maven_pom_coordinate_properties_read_maven_config_overrides() { + let pom = |props: &str| { + format!( + "\n {props}\n \n ${{g}}slf4j-api1.7.36\n \n\n" + ) + }; + let mut files = BTreeMap::new(); + files.insert("pom.xml".to_string(), pom("org.slf4j")); + files.insert(MVN_CONFIG.to_string(), "-Dg=com.acme.fork\n".to_string()); + let r = rewrite_registry_redirect(&files, &[maven_override()]); + let out = r.files.get("pom.xml").expect("pom rewritten"); + assert!( + out.contains("${g}slf4j-api1.7.36"), + "the com.acme.fork declaration keeps its version: {out}" + ); + + let mut files = BTreeMap::new(); + files.insert("pom.xml".to_string(), pom("")); + files.insert(MVN_CONFIG.to_string(), "-Dg=org.slf4j\n".to_string()); + let r = rewrite_registry_redirect(&files, &[maven_override()]); + let out = r.files.get("pom.xml").expect("pom rewritten"); + assert!( + out.contains(&format!( + "${{g}}slf4j-api{MAVEN_SUFFIXED}" + )) && !out.contains(""), + "the maven.config-defined GA is pinned directly: {out}" + ); + assert!( + !warning_codes(&r).contains(&"redirect_maven_coordinate_unresolved"), + "{:?}", + r.warnings + ); + } + + /// A versioned declaration whose groupId / artifactId is a property + /// the pom does not define (a parent's) may be the GA, and its own + /// version would then beat any pin (#683): the dep is refused with + /// nothing written, not pinned through ``. + #[test] + fn maven_pom_undefined_coordinate_property_refuses_the_dep() { + for coords in [ + "${parent.g}slf4j-api", + "org.slf4j${parent.a}", + "${g}${a}", + ] { + let mut files = BTreeMap::new(); + files.insert( + "pom.xml".to_string(), + format!( + "\n \n {coords}1.7.36\n \n\n" + ), + ); + let r = rewrite_registry_redirect(&files, &[maven_override()]); + assert!( + r.files.is_empty() && r.edits.is_empty(), + "{coords}: nothing written: {:?}", + r.files + ); + assert_eq!( + warning_codes(&r), + vec!["redirect_maven_coordinate_unresolved"], + "{coords}" + ); + } + // A placeholder that cannot be the GA (the other coordinate, or the + // literal text around the reference, differs), or a versionless + // one (it takes the managed pin), does not block the pin. + for other in [ + "${parent.g}logback-classic1.4.14", + "${parent.g}slf4j-api", + "${project.groupId}${project.artifactId}-core${project.version}", + "${parent.g}${bom}3pomimport", + ] { + let mut files = BTreeMap::new(); + files.insert( + "pom.xml".to_string(), + format!( + "\n \n {other}\n \n\n" + ), + ); + let r = rewrite_registry_redirect(&files, &[maven_override()]); + assert!( + !warning_codes(&r).contains(&"redirect_maven_coordinate_unresolved"), + "{other}: {:?}", + r.warnings + ); + assert!( + r.files + .get("pom.xml") + .is_some_and(|p| p.contains(MAVEN_SUFFIXED)), + "{other}: pinned: {:?}", + r.warnings + ); + } + } + + /// 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. + #[test] + fn maven_pom_undefined_coordinate_property_in_a_profile_is_reported() { + let profile = "p\ + ${parent.g}slf4j-api\ + 1.7.36"; + for (top, pinned) in [ + ( + "org.slf4jslf4j-api1.7.36", + true, + ), + ("", false), + ] { + let mut files = BTreeMap::new(); + files.insert( + "pom.xml".to_string(), + format!("\n {top}\n {profile}\n\n"), + ); + let r = rewrite_registry_redirect(&files, &[maven_override()]); + assert!( + warning_codes(&r).contains(&"redirect_maven_profile_dependency_unpatched"), + "{pinned}: {:?}", + r.warnings + ); + assert_eq!( + r.files + .get("pom.xml") + .is_some_and(|p| p.contains(MAVEN_SUFFIXED)), + pinned, + "{:?}", + r.files + ); + } + } + /// Fail-closed transitive-only (no matching dependency): a /// `` pin for the suffixed version is authored (with /// the informational note, NOT the legacy dep_not_found warning). diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/maven.rs b/crates/socket-patch-core/src/patch/redirect/upstream/maven.rs index f0298ecb7..453046e91 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/maven.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/maven.rs @@ -21,6 +21,8 @@ //! Checksum lines keep the file's remaining order (the rewriter re-sorted //! and dropped malformed lines; that is not recoverable). +use std::collections::BTreeMap; + use regex::Regex; use super::{by_uuid, read_or_refuse, refuse_all_in, Ctx, FormatResult, HostedPin, View}; @@ -55,7 +57,30 @@ struct DepMatch { version_text: Option, } -fn dep_matches(pom: &str, group: &str, artifact: &str) -> Vec { +/// Read from the pom's [`PomScope`](crate::formats::maven::PomScope) +/// dependencies when it is readable as one, so a `${property}` groupId / +/// artifactId the rewriter resolved to the GA (#683) is found again; else +/// by the raw block scan. +fn dep_matches( + pom: &str, + scoped: Option<&[crate::formats::maven::ScopedDep]>, + group: &str, + artifact: &str, +) -> Vec { + if let Some(deps) = scoped { + return deps + .iter() + .filter(|d| { + d.group.as_deref() == Some(group) && d.artifact.as_deref() == Some(artifact) + }) + .map(|d| DepMatch { + start: d.element.start, + end: d.element.end, + version: d.version_inner, + version_text: d.version_text.clone(), + }) + .collect(); + } MAVEN_DEPENDENCY_BLOCK_RE .find_iter(pom) .filter(|m| { @@ -136,6 +161,7 @@ fn remove_repository(pom: &str, uuid: &str, ctx: &Ctx<'_>) -> Result, group: &str, artifact: &str, base: &str, @@ -145,7 +171,9 @@ fn restore_pin( let suffixed = format!("{base}-socket.{}", &uuid[..8]); let mut text = remove_repository(pom, uuid, ctx)?; - let matches = dep_matches(&text, group, artifact); + let scoped = crate::formats::maven::PomScope::with_user_properties(&text, user_properties) + .and_then(|s| s.dependencies()); + let matches = dep_matches(&text, scoped.as_deref().ok(), group, artifact); if !matches .iter() .any(|m| m.version_text.as_deref() == Some(suffixed.as_str())) @@ -154,17 +182,19 @@ fn restore_pin( "pom.xml declares no {group}:{artifact} {suffixed}" )); } - // The GA's literal versions where Maven reads them for the main jar: + // The GA's suffixed literals where Maven reads them for the main jar: // not commented out, outside `` / `` / ``, // no classifier — the same scope the rewriter decides "transitive" by. + // A lone one in the authored shape is the rewriter's own entry, even + // beside a declaration at the base version: an earlier release added + // that entry when it could not read a `${property}` groupId (#683). let sections = dm_sections(&text); let managed = |pos: usize| sections.iter().any(|(s, e)| pos >= *s && pos < *e); - let scoped = crate::formats::maven::PomScope::new(&text).and_then(|s| s.dependencies()); // In the same scope: a versionless direct declaration with nothing but // this pom's own `` to manage it means that entry // is the user's (a commented, plugin, profile or classifier sibling is // not such a declaration). - let (versioned, versionless_direct) = match &scoped { + let (pinned, versionless_direct) = match &scoped { Ok(deps) => { let main = || { deps.iter().filter(|d| { @@ -175,12 +205,17 @@ fn restore_pin( }) }; ( - main().filter(|d| d.version_inner.is_some()).count(), + main() + .filter(|d| d.version_text.as_deref() == Some(suffixed.as_str())) + .count(), main().any(|d| d.version_inner.is_none() && !managed(d.element.start)), ) } Err(_) => ( - matches.iter().filter(|m| m.version.is_some()).count(), + matches + .iter() + .filter(|m| m.version_text.as_deref() == Some(suffixed.as_str())) + .count(), matches .iter() .any(|m| m.version.is_none() && !managed(m.start)), @@ -195,7 +230,7 @@ fn restore_pin( .iter() .filter(|m| m.version_text.as_deref() == Some(suffixed.as_str())) { - let authored = versioned == 1 + let authored = pinned == 1 && !entry_is_users && text[m.start..m.end] == authored_dm_block(group, artifact, &suffixed) && after_dm_open(&masked, m.start); @@ -270,6 +305,12 @@ pub(crate) async fn restore( }) .collect(); let mut text = original.clone(); + // `.mvn/maven.config`'s `-D` user properties, which Maven reads over + // the pom's own when it interpolates a coordinate. + let user_properties = match view.read(&format!("{dir}{MVN_CONFIG}")).await { + Ok(Some(config)) => crate::formats::maven::maven_config_properties(&config), + _ => BTreeMap::new(), + }; // Checksum-file path prefixes of the restored pins. let mut checksum_dirs: Vec = Vec::new(); let mut restored: Vec<&str> = Vec::new(); @@ -308,7 +349,15 @@ pub(crate) async fn restore( ); continue; } - match restore_pin(&text, group, artifact, &base, &pin.uuid, ctx) { + match restore_pin( + &text, + &user_properties, + group, + artifact, + &base, + &pin.uuid, + ctx, + ) { Ok(next) => { text = next; checksum_dirs.push(format!( @@ -496,6 +545,93 @@ mod tests { } } + /// A pin the rewriter wrote on a `${property}` groupId / artifactId + /// declaration (#683) is found and restored: the round trip gives the + /// input pom back. + #[tokio::test] + async fn property_coordinate_pin_round_trips() { + let overrides: Vec = + serde_json::from_str(&fixture("basic", ".", "overrides.json")).unwrap(); + for (prop, from, to) in [ + ( + "org.slf4j", + "org.slf4j", + "${g}", + ), + ( + "slf4j-api", + "slf4j-api", + "${a}", + ), + ] { + let input = fixture("basic", "input", "pom.xml") + .replacen(from, to, 1) + .replacen( + " ", + &format!(" {prop}\n "), + 1, + ); + let files = BTreeMap::from([("pom.xml".to_string(), input.clone())]); + let rewritten = crate::patch::redirect::rewrite_registry_redirect(&files, &overrides); + let pom = &rewritten.files["pom.xml"]; + assert!( + pom.contains(&format!("{SUFFIXED}")) + && !pom.contains(""), + "{to}: {pom}" + ); + let tree: Vec<(&str, String)> = rewritten + .files + .iter() + .map(|(k, v)| (k.as_str(), v.clone())) + .collect(); + let (outcome, after) = run(&tree).await; + assert!( + matches!(outcome.pins[0].status, PinStatus::Restored), + "{to}: {:?}", + outcome.pins[0].status + ); + assert_eq!(after["pom.xml"], input, "{to}"); + } + } + + /// A pin an earlier release wrote for a `${property}` groupId (#683): + /// it could not read the declaration, so it added a + /// `` entry beside the base-version literal. The + /// entry is still the rewriter's own and is removed, giving the input + /// pom back. + #[tokio::test] + async fn pre_683_managed_pin_beside_a_property_declaration_is_removed() { + let direct = "\n ch.qos.logback\n \ + logback-classic\n 1.4.14\n \ + "; + let shadowed = "\n ${g}\n \ + slf4j-api\n 1.7.36\n \ + "; + let with = |pom: String| { + assert!(pom.contains(direct)); + pom.replacen(direct, shadowed, 1).replacen( + " ", + " org.slf4j\n ", + 1, + ) + }; + let input = with(fixture("transitive-depmgmt", "input", "pom.xml")); + let mut tree = vec![( + "pom.xml", + with(fixture("transitive-depmgmt", "expected", "pom.xml")), + )]; + for rel in [".mvn/maven.config", ".mvn/checksums/checksums.sha256"] { + tree.push((rel, fixture("transitive-depmgmt", "expected", rel))); + } + let (outcome, after) = run(&tree).await; + assert!( + matches!(outcome.pins[0].status, PinStatus::Restored), + "{:?}", + outcome.pins[0].status + ); + assert_eq!(after["pom.xml"], input); + } + /// A versionless sibling Maven does not read for the main jar (in a /// comment, a plugin, a profile, or with a classifier) does not make the /// rewriter's own `` pin look user-written: the 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 c8c54f681..0ba127c02 100644 --- a/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs +++ b/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs @@ -11,6 +11,8 @@ use std::ops::Range; use serde_json::{json, Value}; +use crate::formats::maven::maven_config_properties as cli_properties; + use super::super::state::{WiringAction, WiringRecord}; use super::layout::{self, safe_coordinates}; use super::{ @@ -1956,68 +1958,6 @@ fn has_fallback_repository(doc: &Doc) -> bool { }) } -/// The user properties a `maven.config` defines, as Maven 3.9+ reads it: -/// one argument per line, `#` lines are comments (older Maven rejects -/// them), and a wholly quoted argument is unquoted. A property is -/// `-Dk=v`, `-D k=v`, `--define=k=v` or `--define k=v`, or a bare `-D` / -/// `--define` whose `k=v` is the next line. The value is the rest of the -/// argument, so `-Dv=1.0 # note` defines `1.0 # note`, as in Maven. -/// `-Dk` sets `k=true`; a later definition wins. Maven before 3.9 splits -/// the file on whitespace instead, so a `-Dk=v` token that only that reading -/// finds (`-B -Dk=v` on one line) is kept too, unless a line defines `k`. -fn cli_properties(config: &str) -> BTreeMap { - // Java's `String.trim`: every char up to U+0020, nothing beyond. - fn trim(s: &str) -> &str { - s.trim_matches(|c: char| c <= ' ') - } - fn unquote(s: &str) -> &str { - s.strip_prefix('"') - .and_then(|s| s.strip_suffix('"')) - .map_or(s, trim) - } - let mut args = config - .lines() - .map(|line| unquote(trim(line))) - .filter(|arg| !arg.is_empty() && !arg.starts_with('#')) - .peekable(); - let mut props = BTreeMap::new(); - while let Some(arg) = args.next() { - let def = match arg.strip_prefix("--define") { - Some(rest) => rest - .strip_prefix('=') - .or_else(|| (rest.is_empty() || rest.starts_with(' ')).then_some(rest)), - None => arg.strip_prefix("-D"), - }; - let Some(def) = def.map(trim) else { - continue; - }; - let def = match def { - "" => match args.next_if(|next| !next.starts_with('-')) { - Some(next) => next, - None => continue, - }, - def => def, - }; - let (k, v) = def.split_once('=').unwrap_or((def, "true")); - if !k.is_empty() { - props.insert(k.to_string(), v.to_string()); - } - } - let tokens = config - .lines() - .filter(|line| !trim(line).starts_with('#')) - .flat_map(str::split_whitespace) - .filter_map(|t| t.strip_prefix("-D")?.split_once('=')); - let mut legacy = BTreeMap::new(); - for (k, v) in tokens { - if !k.is_empty() && !props.contains_key(k) { - legacy.insert(k.to_string(), v.to_string()); - } - } - props.extend(legacy); - props -} - // ── pom edits ─────────────────────────────────────────────────── /// Replace `text[start..end]` with `text` (an insertion when `start == end`). diff --git a/crates/socket-patch-core/src/vex/discover/maven.rs b/crates/socket-patch-core/src/vex/discover/maven.rs index c826edf51..1b9fa30be 100644 --- a/crates/socket-patch-core/src/vex/discover/maven.rs +++ b/crates/socket-patch-core/src/vex/discover/maven.rs @@ -104,8 +104,8 @@ use super::{ DIAG_REF_UNATTRIBUTABLE, }; use crate::formats::maven::{ - is_maven_coordinate, is_maven_version_text, parse_pom, split_socket_version, Pom, PomDep, - PomRepo, + is_maven_coordinate, is_maven_version_text, maven_config_properties, may_shadow, + parse_pom_with, split_socket_version, Pom, PomDep, PomRepo, }; use crate::patch::redirect::{MVN_CHECKSUMS, MVN_CONFIG, TRUSTED_CHECKSUMS_ON}; use crate::utils::digest::sha256_hex; @@ -128,7 +128,13 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { let Some(raw) = ctx.read_text(POM, out).await else { return; }; - let pom = match parse_pom(&raw) { + // `.mvn/maven.config`'s `-D` user properties win over the pom's own + // when Maven interpolates a coordinate. + let user_properties = match ctx.read_text(MVN_CONFIG, out).await { + Some(config) => maven_config_properties(&config), + None => BTreeMap::new(), + }; + let pom = match parse_pom_with(&raw, &user_properties) { Ok(pom) => pom, Err(e) => { out.diag( @@ -158,7 +164,7 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { let reactor = crate::vendor::jvm::maven_reactor::declares_modules(&raw); extract_hosted(ctx, &pom, &gas, &hosted, reactor, out).await; if !vendored.is_empty() { - extract_vendored(ctx, &gas, &vendored, out).await; + extract_vendored(ctx, &pom.deps, &gas, &vendored, out).await; } } @@ -197,7 +203,8 @@ async fn extract_hosted( } } - for ((group, artifact), versions) in gas { + for (group, artifact) in gas.keys() { + let versions = &ga_versions(&pom.deps, gas, group, artifact); let socket_pins: BTreeSet<&str> = versions .direct .iter() @@ -417,6 +424,7 @@ async fn trusted_checksums(ctx: &DiscoverCtx<'_>, out: &mut Discovery) -> BTreeM async fn extract_vendored( ctx: &DiscoverCtx<'_>, + deps: &[PomDep], gas: &BTreeMap<(String, String), GaVersions>, vendored: &BTreeSet, out: &mut Discovery, @@ -495,7 +503,8 @@ async fn extract_vendored( } // The vendored repository serves the ORIGINAL GAV; a pom that now // pins this GA to another version no longer consumes the artifact. - if let Some(versions) = gas.get(&(group.to_string(), artifact.to_string())) { + { + let versions = ga_versions(deps, gas, group, artifact); let effective = if versions.direct.is_empty() { &versions.managed } else { @@ -631,12 +640,48 @@ fn classify_repo(ctx: &DiscoverCtx<'_>, repo: &PomRepo, out: &mut Discovery) -> /// Distinct literal versions of one `groupId:artifactId`, split by where /// they are declared (profile-scoped declarations excluded). -#[derive(Debug, Default)] +#[derive(Debug, Default, Clone)] struct GaVersions { direct: BTreeSet, managed: BTreeSet, } +/// What Maven may resolve for `group:artifact`'s main jar: its own +/// declarations ([`group_by_ga`]) plus the version of every declaration +/// Maven always reads whose groupId or artifactId is a property this pom +/// does not define and may be this GA (#683, +/// [`may_shadow`](crate::formats::maven::may_shadow)) — its version then +/// takes part in Maven's precedence, unseen under the literal key. +fn ga_versions( + deps: &[PomDep], + gas: &BTreeMap<(String, String), GaVersions>, + group: &str, + artifact: &str, +) -> GaVersions { + let mut versions = gas + .get(&(group.to_string(), artifact.to_string())) + .cloned() + .unwrap_or_default(); + for dep in deps.iter().filter(|d| !d.in_profile) { + let Some(version) = &dep.version else { + continue; + }; + if may_shadow( + (&dep.group, &dep.artifact), + dep.type_text.as_deref(), + dep.classifier.as_deref(), + (group, artifact), + ) { + if dep.managed { + versions.managed.insert(version.clone()); + } else { + versions.direct.insert(version.clone()); + } + } + } + versions +} + fn group_by_ga(deps: &[PomDep]) -> BTreeMap<(String, String), GaVersions> { let mut map: BTreeMap<(String, String), GaVersions> = BTreeMap::new(); // A classifier variant (`sources`, `tests`, a native build) is its own @@ -1288,6 +1333,132 @@ mod tests { assert_refs(&out, &[(FX_PURL, UUID_A, WiringMode::Hosted)]); } + /// A `${property}` groupId / artifactId is read through the pom's own + /// `` (#683): a property-named direct declaration keeping + /// the plain version shadows the managed pin (Maven resolves Central's + /// jar), and one carrying the suffixed version is the pin. + #[tokio::test] + async fn property_coordinates_are_the_declared_ga() { + let repo = hosted_repo(&format!("socket-patch-{UUID_A}"), ®istry_url(UUID_A)); + for (props, g, a) in [ + ("org.slf4j", "${g}", "slf4j-api"), + ("slf4j-api", "org.slf4j", "${a}"), + ] { + let shadowed = Project::new(); + shadowed.write( + "pom.xml", + pom(&format!( + "{props}\n\ + \n{}\n\ + \n{}\n\n{repo}\n", + dep("org.slf4j", "slf4j-api", Some(&suffixed(UUID_A))), + dep(g, a, Some("1.7.36")), + )), + ); + let out = run(&shadowed).await; + assert_refs(&out, &[]); + assert!( + diag_codes(&out).contains(&DIAG_REF_INVALID), + "{g}:{a}: {:?}", + out.diagnostics + ); + + let pinned = Project::new(); + pinned.write( + "pom.xml", + pom(&format!( + "{props}\n\n{}\n\ + \n{repo}\n", + dep(g, a, Some(&suffixed(UUID_A))), + )), + ); + let out = run(&pinned).await; + assert_refs(&out, &[(FX_PURL, UUID_A, WiringMode::Hosted)]); + } + } + + /// `.mvn/maven.config`'s `-D` user properties override the pom's own + /// when Maven interpolates a coordinate: a suffixed pin on a + /// declaration that Maven reads as another group is not this GA's. + #[tokio::test] + async fn coordinate_properties_read_maven_config_overrides() { + let repo = hosted_repo(&format!("socket-patch-{UUID_A}"), ®istry_url(UUID_A)); + for (props, config, attested) in [ + ("org.slf4j", "-Dg=com.acme.fork\n", false), + ("", "-Dg=org.slf4j\n", true), + ] { + let p = Project::new(); + p.write( + "pom.xml", + pom(&format!( + "{props}\n\n{}\n\ + \n{repo}\n", + dep("${g}", "slf4j-api", Some(&suffixed(UUID_A))), + )), + ); + p.write(".mvn/maven.config", config); + let out = run(&p).await; + let slf4j = out.refs.iter().any(|r| r.purl == FX_PURL); + assert_eq!( + slf4j, attested, + "{config}: {:?} {:?}", + out.refs, out.diagnostics + ); + } + } + + /// A versioned declaration whose coordinate is a property this pom + /// does not define may be the pinned GA, so the pin is not attested + /// past it (#683). + #[tokio::test] + async fn undefined_coordinate_property_is_not_attested_past() { + let repo = hosted_repo(&format!("socket-patch-{UUID_A}"), ®istry_url(UUID_A)); + let p = Project::new(); + p.write( + "pom.xml", + pom(&format!( + "\n{}\n\ + \n{}\n\n{repo}\n", + dep("org.slf4j", "slf4j-api", Some(&suffixed(UUID_A))), + dep("${parent.g}", "slf4j-api", Some("1.7.36")), + )), + ); + let out = run(&p).await; + assert_refs(&out, &[]); + assert!( + diag_codes(&out).contains(&DIAG_REF_INVALID), + "{:?}", + out.diagnostics + ); + assert!( + out.diagnostics + .iter() + .any(|d| d.detail.contains("org.slf4j:slf4j-api") && d.detail.contains("1.7.36")), + "{:?}", + out.diagnostics + ); + + // A placeholder that cannot be the GA (a non-jar type, or literal + // text around the reference that differs) does not. + for other in [ + "${parent.g}${bom}\ + 3pomimport", + "${parent.g}${a}-core\ + 3", + ] { + let p = Project::new(); + p.write( + "pom.xml", + pom(&format!( + "\n{}{other}\n\n\n{repo}\n", + dep("org.slf4j", "slf4j-api", Some(&suffixed(UUID_A))), + )), + ); + let out = run(&p).await; + assert_refs(&out, &[(FX_PURL, UUID_A, WiringMode::Hosted)]); + } + } + #[tokio::test] async fn unsafe_coordinates_on_a_socket_pin_are_invalid() { let p = Project::new(); @@ -1543,6 +1714,32 @@ mod tests { ); let out = run(&p).await; assert_refs(&out, &[(V_PURL, UUID_A, WiringMode::Vendored)]); + + // #683: a declaration whose groupId is a property this pom does not + // define may be the GA. At the vendored version it consumes the + // vendored jar; at another version it may not. + for (version, attested) in [("1.10.0", true), ("1.11.0", false)] { + let p = vendored_project(UUID_A); + let text = std::fs::read_to_string(p.root().join("pom.xml")).expect("pom"); + p.write( + "pom.xml", + text.replace( + "org.apache.commons", + "${parent.g}", + ) + .replace( + "1.10.0", + &format!("{version}"), + ), + ); + let out = run(&p).await; + if attested { + assert_refs(&out, &[(V_PURL, UUID_A, WiringMode::Vendored)]); + } else { + assert_refs(&out, &[]); + assert_eq!(diag_codes(&out), vec![DIAG_REF_INVALID], "{version}"); + } + } } /// REGRESSION: the vendored repository is `checksumPolicy=fail`, so the diff --git a/docs/ecosystems.md b/docs/ecosystems.md index a52b5c5a8..135ea7dd5 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -830,6 +830,11 @@ Honest limits of the Maven and NuGet flows — documented behavior, not bugs: `` (a literal or a `${property}`) is left as-is and reported (`redirect_maven_profile_dependency_unpatched`), because an active profile's version beats the pin, and when a profile holds the GA's only declaration nothing is pinned. + A `${property}` groupId or artifactId is resolved from the pom's own `` + before matching, so `${ct.group}` is rewritten like a literal. One + the pom does not define (a parent's) may be the patched GA, and then its own version + wins: the dep is refused with nothing written (`redirect_maven_coordinate_unresolved`), + and `vex` does not attest a pin past such a declaration. The grant serves the main jar only: a `` variant keeps its version (`redirect_maven_classifier_unsupported`), and the literal-or-pin decision is made from the classifier-less declarations. A `sources` / `javadoc` variant at the patched release