diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 47abe95a3..adf3c9c64 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), a `${property}` groupId / artifactId resolved from the pom's own `` before matching (one the pom does not define, which may be the patched GA and whose own version would then win, refuses the dep with nothing written: `redirect_maven_coordinate_unresolved`), and a pom not readable as one left untouched (`redirect_maven_pom_unreadable`). Matches and anchors skip comments, CDATA, ``, ``, ``, `` and `` (the scope `vex` reads), and an existing self-closed `` / `` is expanded in place, never duplicated; an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). **gradle** (v5.0) is automated wiring, no longer a pasted snippet: the owned settings script `.socket/gradle/socket-patch.hosted.settings.gradle` with its index `.socket/gradle/hosted-index.tsv`, one apply line per build's settings file, every lock entry of the GA moved to the suffixed version, and the suffixed component in an existing `gradle/verification-metadata.xml`. A refused dep writes nothing and keeps `redirect_gradle_manual_snippet` as its fallback; same-GAV grants are refused (`redirect_gradle_same_gav_unsupported`). Rules, refusals and codes: [Gradle builds](#gradle-builds-v50). +The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate, `bun.lock` / `bun.lockb`, and `vlt-lock.json` with `vlt.json` and `node_modules/.vlt-lock.json` read only), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; the hosted redirect is a `sparse+` registry, which cargo loads only from **1.68**, so `rust-toolchain.toml` / `rust-toolchain` are read too (never edited) and a project whose declared floor — the lowest `rust-version` of the root and the member manifests read here (a `rust-version.workspace = true` inheritor reads `[workspace.package]`), else the channel rustup picks from the root (the legacy `rust-toolchain` over `rust-toolchain.toml`; a dated `nightly-`/`beta-YYYY-MM-DD` maps to its release train) — is below 1.68 is refused `redirect_cargo_old_cargo` per crate (nothing written, an earlier run's pins left in place with the `rollback` that drops them named; `--mode vendored` builds on cargo 1.41+), while an undeclared or rolling-channel floor still redirects (toolchain files in ancestor directories are not read); **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. With NO `Cargo.lock` there is no resolved graph to ask, so the dependents question is answered from the manifests instead: a crate declared beside any other dependency — anything but a path dependency on a manifest this run also pins, or a `workspace = true` inheritor of a table it scans — or beside a workspace member this run did not read (a `members` glob, or a member outside the project or behind a symbolic link, which member discovery drops) is refused `redirect_cargo_lockless_dependents`, whose detail names the remedies (commit a lockfile, or `--mode vendored`); a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json` (plus, v5.0 #353/#514, every lock a project under the root restores into: a member project's `packages.lock.json`, a per-project `packages..lock.json`, a literal `NuGetLockFilePath` — each pinned with the root config; a `NuGetLockFilePath` it cannot evaluate warns `redirect_nuget_lock_path_unresolved` and a project tree it cannot list warns `redirect_nuget_lock_unreadable`, both skipping the nuget redirect with nothing written), `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and, for a Gradle build, every settings, build, `buildSrc`, included-build, applied and plugin-source script, version catalog and lock file the script graph reaches, plus `gradle/verification-metadata.xml`, `gradle/wrapper/gradle-wrapper.properties` and the owned `.socket/gradle/` files), and the sbt build files (`socket-patch.sbt`, `socket-patch-vendor.sbt`, `build.sbt`, `project/build.properties`, `.sbtopts`, `.jvmopts`; `build.sbt.lock` and the Mill / scala-cli build files `build.mill`, `build.mill.yaml`, `build.sc`, `.mill-version`, `project.scala` for their presence only) — read, never edited; `socket-patch.sbt` is the only sbt file hosted mode writes (see **Hosted sbt** below). **npm-family flavor coverage**: package-lock / npm-shrinkwrap (a package the project patches itself with npm ≥ 12.1's native `npm patch` — a root `patchedDependencies` key, or the lock entry's `patched` record — is left on its registry entry in every npm lock, `redirect_npm_patched_dependency_skipped`), pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic (a yarn 2+ install migrates a v1 `yarn.lock` and drops its pins, so a run whose v1 lock carries a hosted pin warns `redirect_yarn_classic_berry_migration_risk` — the hosted twin of the vendored `yarn_classic_berry_migration_risk` — unless the root `package.json`, read as advisory input, declares `"packageManager": "yarn@1…"`), **yarn berry** (the pin yarn writes for a root `resolutions` entry: the root `package.json` — edited only beside a berry `yarn.lock` — gains one `"@npm:": ""` selector per locked range (`redirect_yarn_berry_resolution` edits), and only that `yarn.lock` entry is re-keyed `"@"` with the same `resolution:` + `yarnBerry10c0` checksum (`redirect_yarn_berry_entry`), moved to yarn's key order; never an `npm:` locator, whose fetcher sends npm registry auth to the patch host, nor a tarball locator under an `npm:` key, which hardened mode rejects (YN0078). An older release's `npm:::__archiveUrl=` pin is still recognized and is re-pinned on the next run; rollback rebuilds the key from the selectors and drops them. Refused, nothing written: a user-authored `resolutions` entry for the package `redirect_yarn_berry_resolutions_conflict`, no root manifest `redirect_yarn_berry_manifest_missing`, a builtin `patch:` entry wrapping the same descriptor `redirect_yarn_berry_shared_descriptor`, an artifact URL yarn cannot fetch as a tarball `redirect_yarn_berry_artifact_url_unsupported`; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found` (or `redirect_bun_non_registry_entry_skipped` when the only same-version entry is a user URL / `file:` tarball, #497), a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`), and **vlt** (`vlt-lock.json` without `lockfileVersion`, `0` or `1`; see the vlt hosted-mode contract below). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when the `repo-state.json` beside a rewritten lock exists (`common/config/rush/repo-state.json` for the common lock, `common/config/subspaces//repo-state.json` for a subspace lock; the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives), and `redirect_pnpm_trust_lockfile` carries the Rush pnpm >=11 install remedy (see the trust paragraph above). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`; when `.mvn/wrapper/maven-wrapper.properties` pins a Maven older than 3.9.4, which ignores those files, the additive warning `redirect_maven_trusted_checksums_unenforced`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a reactor root (a `` / `` declaration, a profile's included, or a Maven 4 root that lists none but whose subprojects Maven discovers: model version not `4.0.0`, `pom` packaging, a direct subdirectory holding a `pom.xml`, and `maven.project.discoverSubprojects` not turned off in `.mvn/maven.config`; each such subdirectory's `pom.xml` is read for this, never edited) is refused whole with `redirect_maven_multimodule_unsupported`: `pom.xml` and `.mvn/` are left untouched and the dep is not counted as redirected (a Gradle build beside it is still planned by the Gradle rewriter, but a dep in a root with a `pom.xml` is confirmed only when the pom pins it too), since a module's own literal `` would shadow a root pin (use `--mode vendored`; `vex` omits a hosted pin in a reactor root as `vex_maven_reactor_root`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), a `` variant (sources, tests, a native build) left at its version (`redirect_maven_classifier_unsupported`; the grant serves the main jar only, and the dep is skipped when a variant other than `sources` / `javadoc` names the patched release), a versioned declaration inside `` (a literal or a `${property}`) left as-is (`redirect_maven_profile_dependency_unpatched`; the dep is skipped when a profile holds its only declaration), a `${property}` groupId / artifactId resolved from the pom's own `` before matching (one the pom does not define, which may be the patched GA and whose own version would then win, refuses the dep with nothing written: `redirect_maven_coordinate_unresolved`), and a 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`). @@ -545,7 +545,7 @@ Recognition rules that hold for every ecosystem: * **Patch hosts.** A hosted reference counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin, with no userinfo. The uuid is the URL's LAST canonical-uuid path segment, because grant tokens may themselves be uuid-shaped. The Go module prefix is fixed. `socket-patch-` registry / repository / source names count only through a pin. For a URL on any other host, see **Patch hosts** above. * **Pins, not definitions.** A registry, index or source *definition* alone (cargo `[registries]`, nuget ``, pom ``, uv index tables, `.npmrc`) never makes a reference, because it survives a reverted pin. Sections the package manager ignores are not read: npm's v2 `dependencies` mirror, a `.cargo/config.toml` shadowed by `.cargo/config`. A Socket pin inside a maven `` is diagnosed, never a reference. * **Contested locks.** When one lock wires a package to a patch and another lock resolves the same `name@version` from a non-Socket source, the build's bytes depend on which package manager runs. The reference is then dropped with a `patched_ref_unattributable` diagnostic naming both files. This applies across npm / pnpm / yarn / bun and across uv / pylock / poetry / pdm / Pipfile.lock / requirements. PEP 723 script locks neither contest nor are contested. The root `requirements.txt` and its in-root `-r` includes count as one lock here: pip reads them as one requirement set, where a direct reference wins over a compatible `==` pin of the same version in another file of the set (#1086). A PyPI version matches under PEP 440 equality, so a requirements line pinned in any spelling that can only install the wired release (`==1.2.3.0`, `==v1.2.3`, `===1.2.3`) contests it like `==1.2.3` does; a range or wildcard (`~=1.2.3`, `==1.2.*`) does not (#1602). A **bundled** npm copy (`inBundle: true`, or v1 `bundled: true`, beneath a dependency that bundles it; the root project's own bundles install from the lock, #1618) of the same `name@version` contests the reference too, in the same lock, in the other npm lock of a shrinkwrap/package-lock pair, or in any other lock. npm unpacks it from the parent package's tarball, so no rewire reaches it and it stays unpatched. Bun and vlt unpack bundled copies the same way (#469, #471). pnpm unpacks bundled copies too, but its lock cannot tie one to a reference (see **Unattested references** below). For Bun, that is a `bun.lock` entry whose meta is `{ "bundled": true }`, or a `bun.lockb` record that a dependency edge with the `bundled` behavior bit reaches, including one Bun shares with a regular install. Such an entry is never a reference, and it contests the reference the same way. For vlt, the lock records no node for a bundled copy, so the copy is found in the installed store: a real package directory inside a store package's own `node_modules`. yarn classic's `yarn.lock` records nothing about bundled copies either (#758), so for a lock with a Socket wiring they are found in the installed `node_modules`: each package a parent's `bundleDependencies` / `bundledDependencies` names inside the parent's own `node_modules`, the dependencies of those beside them, and everything nested under them. Such a copy contests the reference in `yarn.lock` (the diagnostic names the parent's lock entry and the copy's directory); a lock-only `vex` with no `node_modules` can't see it. Hosted and vendored scans skip these copies with `redirect_bun_bundled_instance_skipped` / `redirect_vlt_bundled_instance_skipped` / `vendor_bundled_instance_skipped`, and a yarn classic hosted scan names one with `redirect_copy_unpatched`. When a bundled copy is the only instance, vendoring refuses with `vendor_lock_entry_not_rewritable`. A reference withheld only because such an unreachable copy installs beside it (a bundled npm / Bun / vlt copy, an npm copy beneath a `hasShrinkwrap` package (#753) or one npm installs from a git / url / `file:` spec, or a yarn classic git or `file:` directory block of the same `name@version`) is still the rewriter's own wiring of exactly one package version: it is never attested, but `rollback`, `remove`, `list` and the hosted → vendored takeover treat it as an ordinary hosted pin and restore its upstream registry entry (#828), rather than refusing it with `hosted_wiring_contested`. Another entry of the **same** npm, Bun or yarn lock that resolves the wired `name@version` from a non-Socket source (for example a workspace member added after the rewire, then `npm install` / `bun install` / `yarn install`; for yarn berry, a registry locator such as `left-pad@npm:1.3.0` beside the hosted `…::__archiveUrl=` one) contests the reference too (#588). The package manager installs both entries, and that copy stays unpatched. Re-running `scan` / `vendor` rewires every copy. -* **Unattested references.** Some evidence shows a build may run a copy no wiring reaches, but cannot be tied to the reference's exact `name@version` or cannot say the build runs it. The reference then stays a reference: `list`, `rollback` and `remove` find it, and the ledgers' liveness gates (`vendor --check`, `scan`) keep treating the wiring as live, because re-running `scan` / `vendor` could never clear the evidence. Only `vex` omits it, as a run warning and as the `failed[].reason`. Four cases besides Gradle's (`vex_gradle_lock_above_base`): **pnpm bundled copies** (`vex_pnpm_bundled_copy`): a `packages:` entry's `bundledDependencies:` names the copies pnpm unpacks from that package's own tarball, but pnpm never locks them, so the bundled version is not in the lock. A pnpm reference whose package name a `bundledDependencies` list in the same lock names is omitted whatever its version (a missed attestation when the bundled copy is another version, never a false one), and `bundledDependencies: true` (or a value that cannot be read) omits every reference of that lock. It reaches no other lock. **deno.lock** (`vex_deno_lock_copy`): `deno install` installs a `package.json` project's npm dependencies from `deno.lock` and never reads `package-lock.json` / `pnpm-lock.yaml` / `yarn.lock`, so an npm-family reference whose `name@version` the `deno.lock` npm section also locks is omitted (#406). Whether Deno or another package manager populates `node_modules` (`nodeModulesDir: "manual"` allows either) is not in the files, so this holds whatever `nodeModulesDir` says. **Maven reactor roots** (`vex_maven_reactor_root`): a hosted pin in a root `pom.xml` that declares `` / `` is omitted, because a module's own literal `` overrides a root pin and module poms are not read (#261); hosted scan refuses such roots (`redirect_maven_multimodule_unsupported`), so this only covers pins an older release wrote. **Maven classifier copies** (`vex_maven_classifier_unpatched`): a `` dependency of a hosted-pinned GA is its own coordinate; the classifier-less pin does not retarget it and the grant serves only the main jar, so Maven fetches it from the next repository. When a live, non-profile classifier copy other than `sources` / `javadoc` (`tests`, a native or JDK-specific build) resolves any version other than the suffixed pin — its own literal ``, a `` entry with the same classifier, or no version in the root pom — the pin is omitted. `sources` / `javadoc` copies never run on a classpath and do not withhold the statement; hosted scan only warns about them (`redirect_maven_classifier_unsupported`) and skips the dep when an executable copy names the patched release. +* **Unattested references.** Some evidence shows a build may run a copy no wiring reaches, but cannot be tied to the reference's exact `name@version` or cannot say the build runs it. The reference then stays a reference: `list`, `rollback` and `remove` find it, and the ledgers' liveness gates (`vendor --check`, `scan`) keep treating the wiring as live, because re-running `scan` / `vendor` could never clear the evidence. Only `vex` omits it, as a run warning and as the `failed[].reason`. Four cases besides Gradle's (`vex_gradle_lock_above_base`): **pnpm bundled copies** (`vex_pnpm_bundled_copy`): a `packages:` entry's `bundledDependencies:` names the copies pnpm unpacks from that package's own tarball, but pnpm never locks them, so the bundled version is not in the lock. A pnpm reference whose package name a `bundledDependencies` list in the same lock names is omitted whatever its version (a missed attestation when the bundled copy is another version, never a false one), and `bundledDependencies: true` (or a value that cannot be read) omits every reference of that lock. It reaches no other lock. **deno.lock** (`vex_deno_lock_copy`): `deno install` installs a `package.json` project's npm dependencies from `deno.lock` and never reads `package-lock.json` / `pnpm-lock.yaml` / `yarn.lock`, so an npm-family reference whose `name@version` the `deno.lock` npm section also locks is omitted (#406). Whether Deno or another package manager populates `node_modules` (`nodeModulesDir: "manual"` allows either) is not in the files, so this holds whatever `nodeModulesDir` says. **Maven reactor roots** (`vex_maven_reactor_root`): a hosted pin in a root `pom.xml` that declares `` / ``, or whose subprojects Maven 4 discovers (see the hosted Maven rewriter), is omitted, because a module's own literal `` overrides a root pin and module poms are not read (#261); hosted scan refuses such roots (`redirect_maven_multimodule_unsupported`), so this only covers pins an older release wrote. **Maven classifier copies** (`vex_maven_classifier_unpatched`): a `` dependency of a hosted-pinned GA is its own coordinate; the classifier-less pin does not retarget it and the grant serves only the main jar, so Maven fetches it from the next repository. When a live, non-profile classifier copy other than `sources` / `javadoc` (`tests`, a native or JDK-specific build) resolves any version other than the suffixed pin — its own literal ``, a `` entry with the same classifier, or no version in the root pom — the pin is omitted. `sources` / `javadoc` copies never run on a classpath and do not withhold the statement; hosted scan only warns about them (`redirect_maven_classifier_unsupported`) and skips the dep when an executable copy names the patched release. * **Shrinkwrap without a twin (#899).** npm >= 12 never reads `npm-shrinkwrap.json`: on a project whose only npm lock is the shrinkwrap it resolves the tree from the registry and writes a fresh `package-lock.json`, so a Socket reference there reaches npm <= 11 only. The reference is still discovered, so `list`, `rollback` and `remove` manage it, but `vex` omits the patch (`vex_npm_shrinkwrap_only`, as a run warning and as the `failed[].reason`). A `package-lock.json` beside it (wired the same way) makes it attestable again. Hosted and vendored runs still rewire the shrinkwrap and warn `redirect_npm_shrinkwrap_only` / `vendor_npm_shrinkwrap_only`. * **Lockless pins.** With no lock to name a version, a `Cargo.toml` pin (every declaration on `socket-patch-`, that registry defined on the patch host for the same uuid) or an exclusive nuget exact-id mapping is never a reference on its own, so v5.0 does not attest it (nor does `list` show it, or `rollback` / `remove` restore it — restore those files from version control). Only a pre-v5 redirect-ledger record naming a version the pin admits keeps it live. The same holds for a gem wired only in the `Gemfile` (the pre-bundler-2.6 mixed state, lock not converged). diff --git a/crates/socket-patch-core/src/crawlers/maven_scope.rs b/crates/socket-patch-core/src/crawlers/maven_scope.rs index 1b39e43d2..d6aea2c5a 100644 --- a/crates/socket-patch-core/src/crawlers/maven_scope.rs +++ b/crates/socket-patch-core/src/crawlers/maven_scope.rs @@ -9,7 +9,8 @@ //! (Maven caches the pom of every artifact, parent and BOM it resolves): //! //! - **Seeds**: every `` of the reactor (the root `pom.xml`, -//! its `` / `` recursively, every profile, and the +//! its `` / `` recursively (and the subprojects +//! Maven 4 discovers under a root listing none), every profile, and the //! `` each inherits from its parents), any scope. //! - **Edges**: an artifact's own non-optional `compile` / `runtime` //! dependencies (and those its parents declare), which is what Maven @@ -30,7 +31,9 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::path::{Path, PathBuf}; use std::sync::Arc; -use crate::vendor::jvm::maven_reactor::{pom_model, PomDecl, PomModel}; +use crate::vendor::jvm::maven_reactor::{ + implicit_subprojects, may_discover_subprojects, pom_model, PomDecl, PomModel, MAVEN_CONFIG, +}; /// `(groupId, artifactId, version)`. type Gav = (String, String, String); @@ -225,12 +228,17 @@ impl Walk<'_> { local: true, }]; let mut seen: HashSet = HashSet::from([root]); + let config = crate::utils::fs::read_regular_to_bytes_sync(&self.cwd.join(MAVEN_CONFIG)) + .ok() + .map(|b| String::from_utf8_lossy(&b).into_owned()); while let Some(node) = pending.pop() { if seen.len() > MAX_REACTOR { return None; } let dir = node.path.parent().unwrap_or(self.cwd).to_path_buf(); - for module in node.model.modules.clone() { + let mut modules = node.model.modules.clone(); + modules.extend(discovered_subprojects(&node.path, &dir, config.as_deref())); + for module in modules { if module.is_empty() || module.contains("${") { continue; } @@ -461,6 +469,40 @@ impl Walk<'_> { } } +/// The subprojects Maven 4 discovers under the pom at `path` (#1809): when +/// it lists none ([`may_discover_subprojects`]), the direct subdirectories +/// of `dir` holding a `pom.xml` ([`implicit_subprojects`]), as `` +/// names. +fn discovered_subprojects(path: &Path, dir: &Path, config: Option<&str>) -> Vec { + let Ok(bytes) = crate::utils::fs::read_regular_to_bytes_sync(path) else { + return Vec::new(); + }; + if !may_discover_subprojects(&String::from_utf8_lossy(&bytes), config) { + return Vec::new(); + } + // The planners' listing: bare names, directories (followed through a + // link) ending in `/`. + let list = |rel: &str| -> Vec { + let Ok(entries) = std::fs::read_dir(dir.join(rel)) else { + return Vec::new(); + }; + entries + .flatten() + .filter_map(|e| { + let name = e.file_name().to_str()?.to_string(); + Some(if e.path().is_dir() { + format!("{name}/") + } else { + name + }) + }) + .collect() + }; + let mut out = implicit_subprojects(&list, ""); + out.sort(); + out +} + /// The pom at `path` parsed, when it is readable. fn read_model(path: &Path, include_profiles: bool) -> Option> { crate::utils::fs::read_regular_to_bytes_sync(path) @@ -754,6 +796,43 @@ mod tests { assert!(!scope.admits("new.group", "lib", "3")); } + /// #1809: Maven 4 builds every direct subdirectory holding a `pom.xml` + /// of a 4.1.0 `pom`-packaged root that lists no subprojects, so their + /// dependencies seed the scope; with discovery turned off in + /// `.mvn/maven.config` they do not. + #[test] + fn maven_4_discovered_subprojects_seed_the_scope() { + let dir = tempfile::tempdir().unwrap(); + let (cwd, repo) = (dir.path().join("p"), dir.path().join("m2")); + write( + &cwd, + "pom.xml", + "4.1.0\ + com.exampleroot1\ + pom", + ); + write( + &cwd, + "a/pom.xml", + &format!( + "4.1.0\ + a{}", + deps(&[dep("org.example", "lib", Some("1.0"), "")]) + ), + ); + cache(&repo, "org.example", "lib", "1.0", ""); + let scope = project_scope(&cwd, &repo).unwrap(); + assert!(scope.admits("org.example", "lib", "1.0")); + + write( + &cwd, + ".mvn/maven.config", + "-Dmaven.project.discoverSubprojects=false\n", + ); + let scope = project_scope(&cwd, &repo).unwrap(); + assert!(!scope.admits("org.example", "lib", "1.0")); + } + #[test] fn modules_parents_boms_and_management_are_followed() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index f214e686d..aedbc63b8 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -739,6 +739,27 @@ pub async fn read_candidate_files( read_go_workspace(view, unreadable, &mut out).await; } + // A Maven 4 root that discovers its subprojects (#1809): each direct + // subdirectory holding a `pom.xml` is a module, which the pom rewriter + // must see to refuse the root as a reactor (it pins only the root pom). + // Advisory: keyed `/pom.xml`, never rewritten, and one that cannot + // be read still counts as present. + if candidates.iter().any(|c| c.dep.ecosystem == "maven") { + if let Some(pom) = out.files.get("pom.xml") { + let config = out.files.get(crate::patch::redirect::MVN_CONFIG); + if crate::vendor::jvm::maven_reactor::may_discover_subprojects( + pom, + config.map(String::as_str), + ) { + for dir in crate::vendor::jvm::maven_reactor::implicit_subprojects_in(view).await { + let rel = format!("{dir}/pom.xml"); + let text = read_advisory(view, unreadable, &rel).await; + out.files.insert(rel, text.unwrap_or_default()); + } + } + } + } + // NuGet merges the user config and every parent directory's config // under the project's own: a catch-all mapping the rewriter creates // must name their sources too (#354). Disk only (the in-memory engine @@ -3251,10 +3272,23 @@ fn confirm( fn is_npm_manifest(name: &str) -> bool { name == "package.json" || name.ends_with("/package.json") } + // A Maven reactor root the pom rewriter refused pins nothing, so a + // suffixed version an earlier release left in it, or in `.mvn/`, + // proves nothing (#1809); nor do the module poms read beside a Maven 4 + // root, advisory input only. + let maven_reactor_refused = rewrite + .warnings + .iter() + .any(|w| w.code == "redirect_maven_multimodule_unsupported"); + let is_unpinned_maven = |name: &str| { + name.ends_with("/pom.xml") + || maven_reactor_refused && (name == "pom.xml" || name.starts_with(".mvn/")) + }; let final_texts: Vec<(&str, &String)> = files .iter() .filter(|(name, _)| !(pdm_inactive && name.as_str() == "pdm.lock")) .filter(|(name, _)| !is_npm_manifest(name)) + .filter(|(name, _)| !is_unpinned_maven(name)) .filter(|(name, _)| !crate::patch::redirect::sbt::is_synthetic_key(name)) .map(|(name, content)| (name.as_str(), rewrite.files.get(name).unwrap_or(content))) .chain( @@ -5290,6 +5324,69 @@ snapshots: assert!(done.confirmed.is_empty(), "{:?}", done.confirmed); } + /// #1809: a Maven 4 root whose subprojects Maven discovers (model + /// 4.1.0, `pom` packaging, no list) is a reactor once a direct + /// subdirectory holds a `pom.xml`. The engine reads that pom beside + /// the root, on disk and in memory, so the pom rewriter refuses the + /// root instead of pinning only it while the module keeps its version. + #[tokio::test] + async fn a_maven_4_implicit_reactor_root_is_refused() { + let root = "\n 4.1.0\n x\n y\n 1\n pom\n \n \n com.socketfixture\n victim\n 1.10.0\n \n \n\n"; + let child = "\n 4.1.0\n \n a\n\n"; + let refused = |read: &CandidateFiles, done: &Rewritten| { + assert!( + read.files.contains_key("a/pom.xml"), + "{:?}", + read.files.keys() + ); + assert!(!done.rewrite.files.contains_key("pom.xml")); + assert!(done.confirmed.is_empty(), "{:?}", done.confirmed); + assert!(done + .rewrite + .warnings + .iter() + .any(|w| w.code == "redirect_maven_multimodule_unsupported")); + }; + + let mut p = MemoryProject::new(); + p.insert_text("pom.xml", root); + p.insert_text("a/pom.xml", child); + let (read, done) = gradle_rewrite(&p).await; + refused(&read, &done); + + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("pom.xml"), root).unwrap(); + std::fs::create_dir(tmp.path().join("a")).unwrap(); + std::fs::write(tmp.path().join("a/pom.xml"), child).unwrap(); + // A directory without a pom is no subproject. + std::fs::create_dir(tmp.path().join("src")).unwrap(); + let (read, done) = gradle_rewrite_in(&ProjectView::Disk(tmp.path())).await; + refused(&read, &done); + assert!(!read.files.contains_key("src/pom.xml")); + + // A suffixed version an earlier release pinned into the root, or one + // a module names, confirms nothing: the refused root pins nothing. + let stale = root.replace("1.10.0", "1.10.0-socket.4d5e6f70"); + let named = child.replace( + "", + "\n 1.10.0-socket.4d5e6f70", + ); + for (r, c) in [(stale.as_str(), child), (root, named.as_str())] { + let mut p = MemoryProject::new(); + p.insert_text("pom.xml", r); + p.insert_text("a/pom.xml", c); + let (read, done) = gradle_rewrite(&p).await; + refused(&read, &done); + } + + // Without a subproject the root is a single pom, pinned as before. + std::fs::remove_dir_all(tmp.path().join("a")).unwrap(); + let (read, done) = gradle_rewrite_in(&ProjectView::Disk(tmp.path())).await; + assert!(!read.files.keys().any(|k| k.ends_with("/pom.xml"))); + assert!(done.rewrite.files["pom.xml"].contains("1.10.0-socket.4d5e6f70")); + assert_eq!(done.confirmed.len(), 1); + } + /// An sbt build beside a Gradle build: the Gradle planner pinning a /// uuid the sbt rewriter refused (here: no resolution evidence) does /// not confirm it — the sbt build still loads the upstream artifact. diff --git a/crates/socket-patch-core/src/patch/redirect/gradle.rs b/crates/socket-patch-core/src/patch/redirect/gradle.rs index 9f35108e0..24b38c6b8 100644 --- a/crates/socket-patch-core/src/patch/redirect/gradle.rs +++ b/crates/socket-patch-core/src/patch/redirect/gradle.rs @@ -602,7 +602,7 @@ pub fn wiring_files(files: &BTreeMap) -> Vec { } /// A [`crate::gradle::ListFn`] over the keys of `files`. -fn key_list(files: &BTreeMap, dir: &str) -> Vec { +pub(crate) fn key_list(files: &BTreeMap, dir: &str) -> Vec { let prefix = if dir.is_empty() { String::new() } else { diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index ccb8bb226..435cfe4ab 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -8554,10 +8554,13 @@ fn rewrite_maven_pom( // would keep the unpatched upstream jar while the dep counts as // redirected and VEX attests it (#261). Refuse the whole root, untouched; // vendored mode's reactor planner rewrites each module's declaration. - if pom - .as_deref() - .is_some_and(crate::vendor::jvm::maven_reactor::declares_modules) - { + // Maven 4 subprojects found by discovery count too (#1809): the engine + // reads each direct subdirectory's `pom.xml` beside such a root. + if pom.is_some() && { + let read = |rel: &str| files.get(rel).map(|t| t.as_bytes().to_vec()); + let list = |dir: &str| gradle::key_list(files, dir); + crate::vendor::jvm::maven_reactor::is_reactor_root(&read, &list) + } { let gas: Vec = maven .iter() .map(|dep| match &dep.namespace { @@ -8568,7 +8571,7 @@ fn rewrite_maven_pom( result.warnings.push(RewriteWarning { code: "redirect_maven_multimodule_unsupported".into(), detail: format!( - "pom.xml declares /; hosted mode reads only the root pom, so a module's own of {} would stay unpatched. pom.xml and .mvn/ were left as they are and the dep is not counted as redirected; use `scan --mode vendored`, which pins each module's declaration", + "pom.xml heads a multi-module reactor (/, or Maven 4 subproject discovery); hosted mode reads only the root pom, so a module's own of {} would stay unpatched. pom.xml and .mvn/ were left as they are and the dep is not counted as redirected; use `scan --mode vendored`, which pins each module's declaration", gas.join(", ") ), }); @@ -10350,6 +10353,64 @@ mod tests { assert!(!warning_codes(&r).contains(&"redirect_maven_multimodule_unsupported")); } + /// #1809: a Maven 4 (model 4.1.0) `pom`-packaged root that lists no + /// subprojects builds every direct subdirectory holding a `pom.xml`, so + /// it is a reactor like a declared one and refused untouched. Without + /// such a subdirectory, under a 4.0.0 model, or with discovery turned + /// off in `.mvn/maven.config`, it is a single pom and still rewritten. + #[test] + fn maven_4_implicit_subprojects_root_is_refused() { + let dep = "\n \n org.slf4j\n slf4j-api\n 1.7.36\n \n \n"; + let root = |model: &str| { + format!( + "\n {model}\n com.example\n root\n 1.0.0\n pom\n {dep}\n" + ) + }; + let child = "\n 4.1.0\n \n child\n \n \n org.slf4j\n slf4j-api\n 1.7.36\n \n \n\n"; + let tree = |model: &str, extra: &[(&str, &str)]| { + let mut files = BTreeMap::from([("pom.xml".to_string(), root(model))]); + files.extend(extra.iter().map(|(k, v)| (k.to_string(), v.to_string()))); + rewrite_registry_redirect(&files, &[maven_override()]) + }; + + let r = tree("4.1.0", &[("child/pom.xml", child)]); + assert!( + r.files.is_empty() && r.edits.is_empty(), + "an implicit reactor root must not be edited: files={:?} edits={:?}", + r.files.keys(), + r.edits + ); + assert_eq!( + warning_codes(&r), + vec!["redirect_maven_multimodule_unsupported"] + ); + assert!(r.warnings[0] + .detail + .contains("Maven 4 subproject discovery")); + + let off = "-Dmaven.project.discoverSubprojects=false\n"; + for (case, r) in [ + ("no subproject", tree("4.1.0", &[])), + ("model 4.0.0", tree("4.0.0", &[("child/pom.xml", child)])), + ( + "discovery off", + tree( + "4.1.0", + &[("child/pom.xml", child), (".mvn/maven.config", off)], + ), + ), + ] { + assert!( + r.files["pom.xml"].contains(MAVEN_SUFFIXED), + "{case}: a single pom is still rewritten" + ); + assert!( + !warning_codes(&r).contains(&"redirect_maven_multimodule_unsupported"), + "{case}" + ); + } + } + /// 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 diff --git a/crates/socket-patch-core/src/vendor/jvm/gradle.rs b/crates/socket-patch-core/src/vendor/jvm/gradle.rs index 7340b2034..d95d23d89 100644 --- a/crates/socket-patch-core/src/vendor/jvm/gradle.rs +++ b/crates/socket-patch-core/src/vendor/jvm/gradle.rs @@ -3418,7 +3418,7 @@ mod tests { fn detect_routes_gradle_only_roots_here() { let files = fs(&[("build.gradle", "")]); let read = |rel: &str| files.get(rel).cloned(); - assert_eq!(detect(&read), Shape::Gradle); + assert_eq!(detect(&read, &super::super::no_list), Shape::Gradle); } #[test] 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 fe5bfd2f2..42a853fff 100644 --- a/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs +++ b/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs @@ -71,6 +71,64 @@ pub fn declares_modules(pom: &str) -> bool { } } +/// Whether the project's root pom heads a multi-module reactor: it +/// declares ``/`` ([`declares_modules`]), or Maven 4 +/// discovers a subproject under it ([`may_discover_subprojects`] and a +/// direct subdirectory holding a `pom.xml`). The rule the reactor planner +/// builds by, for every caller that only asks "reactor or single pom?". +pub fn is_reactor_root(read: ReadFn<'_>, list: ListFn<'_>) -> bool { + let Some(pom) = read(layout::POM_FILE) else { + return false; + }; + let pom = String::from_utf8_lossy(&pom); + if declares_modules(&pom) { + return true; + } + let config = read(MAVEN_CONFIG).map(|b| String::from_utf8_lossy(&b).into_owned()); + may_discover_subprojects(&pom, config.as_deref()) && !implicit_subprojects(list, "").is_empty() +} + +/// Whether Maven 4 discovers the subprojects of the root `pom` on its own +/// ([`discovers_subprojects`]) under the root's `.mvn/maven.config` +/// (`maven_config`), which may turn discovery off. The subprojects are +/// then the direct subdirectories holding a `pom.xml` +/// ([`implicit_subprojects`]). +pub fn may_discover_subprojects(pom: &str, maven_config: Option<&str>) -> bool { + discovery_on(&maven_config.map(cli_properties).unwrap_or_default()) + && Doc::parse(pom.to_string()).is_ok_and(|doc| discovers_subprojects(&doc)) +} + +/// [`implicit_subprojects`] of the root over a [`ProjectView`]: the direct +/// subdirectories (a link to one included) holding a regular `pom.xml`, +/// `.git` never, for the hosted engine and VEX discovery. +pub async fn implicit_subprojects_in( + view: &crate::vendor::lock_inventory::ProjectView<'_>, +) -> Vec { + let mut out = Vec::new(); + for entry in view.list_dir("").await.unwrap_or_default() { + if entry.name == GIT_DIR || !(entry.is_dir || view.is_symlink(&entry.name)) { + continue; + } + if view.is_file(&format!("{}/{}", entry.name, layout::POM_FILE)) { + out.push(entry.name); + } + } + out +} + +/// A checkout's git directory: never a subproject, whatever it holds (the +/// planner's reader refuses every path through it). +const GIT_DIR: &str = ".git"; + +/// Maven 4's opt-out among the `.mvn/maven.config` user properties, read +/// with Java's `Boolean.parseBoolean`: once set, anything but `true` turns +/// discovery off. +fn discovery_on(cli_props: &BTreeMap) -> bool { + cli_props + .get(DISCOVER_SUBPROJECTS) + .is_none_or(|v| v.eq_ignore_ascii_case("true")) +} + /// The tree directory of `c`'s suffixed version. pub fn tree_dir(c: &Coords<'_>) -> String { c.tree_dir(TREE_ROOT, &c.suffixed_version()) @@ -1566,11 +1624,7 @@ impl Reactor { let cli_props = read(MAVEN_CONFIG) .map(|bytes| cli_properties(&String::from_utf8_lossy(&bytes))) .unwrap_or_default(); - // Maven 4's opt-out, read with Java's `Boolean.parseBoolean`: once - // set, anything but `true` turns discovery off. - let discovery = cli_props - .get(DISCOVER_SUBPROJECTS) - .is_none_or(|v| v.eq_ignore_ascii_case("true")); + let discovery = discovery_on(&cli_props); let mut poms = BTreeMap::new(); poms.insert("pom.xml".to_string(), Pom::parse("pom.xml", root)?); let mut reactor = vec!["pom.xml".to_string()]; @@ -2107,10 +2161,11 @@ const POM_NAMESPACE: &str = "maven.apache.org/POM/"; /// `` names relative to `dir`. Probed through the listing, not /// a read: a directory the reader refuses (`.git/`) lists as empty, where /// reading its `pom.xml` would record a read error. -fn implicit_subprojects(list: ListFn<'_>, dir: &str) -> Vec { +pub fn implicit_subprojects(list: ListFn<'_>, dir: &str) -> Vec { list(dir) .iter() .filter_map(|entry| entry.strip_suffix('/')) + .filter(|name| *name != GIT_DIR) .filter(|name| { let sub = match dir { "" => name.to_string(), @@ -5986,7 +6041,7 @@ mod tests { "; assert!(!declares_modules(ear)); let read = |p: &str| (p == "pom.xml").then(|| ear.as_bytes().to_vec()); - let builds = super::super::detect_builds(&read); + let builds = super::super::detect_builds(&read, &super::super::no_list); assert_eq!(builds.maven, Some(super::super::MavenShape::Single)); // A single pom is a reactor of one (#973). assert_eq!(builds.shape(), Shape::MavenReactor); diff --git a/crates/socket-patch-core/src/vendor/jvm/mod.rs b/crates/socket-patch-core/src/vendor/jvm/mod.rs index 996440192..82e3dffe8 100644 --- a/crates/socket-patch-core/src/vendor/jvm/mod.rs +++ b/crates/socket-patch-core/src/vendor/jvm/mod.rs @@ -304,9 +304,10 @@ pub struct JvmUnplan { /// The Maven half of a project root. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MavenShape { - /// A root `pom.xml` that declares ``. + /// A root `pom.xml` heading a reactor: it declares ``, or + /// Maven 4 discovers its subprojects ([`maven_reactor::is_reactor_root`]). Reactor, - /// A root `pom.xml` without ``. + /// Any other root `pom.xml`. Single, } @@ -345,9 +346,9 @@ impl Detected { } /// Every build the project root holds. -pub fn detect_builds(read: ReadFn<'_>) -> Detected { - let maven = read(layout::POM_FILE).map(|pom| { - if maven_reactor::declares_modules(&String::from_utf8_lossy(&pom)) { +pub fn detect_builds(read: ReadFn<'_>, list: ListFn<'_>) -> Detected { + let maven = read(layout::POM_FILE).map(|_| { + if maven_reactor::is_reactor_root(read, list) { MavenShape::Reactor } else { MavenShape::Single @@ -361,8 +362,8 @@ pub fn detect_builds(read: ReadFn<'_>) -> Detected { } /// Classify the project root (see [`Detected::shape`]). -pub fn detect(read: ReadFn<'_>) -> Shape { - detect_builds(read).shape() +pub fn detect(read: ReadFn<'_>, list: ListFn<'_>) -> Shape { + detect_builds(read, list).shape() } /// Plan the vendoring of `patch` for a project of `shape`. @@ -409,7 +410,7 @@ pub fn plan_with_external( let gradle = gradle::plan(read, list, patch)?; Ok(compose(maven, gradle)) } - Shape::Sbt => sbt::plan(read, patch), + Shape::Sbt => sbt::plan(read, list, patch), Shape::ScalaCli => scala_cli::plan(read, patch), Shape::Other => Err(no_build_file_refusal()), } @@ -874,13 +875,13 @@ mod tests { (p == "pom.xml") .then(|| b"a".to_vec()) }; - assert_eq!(detect(&reactor), Shape::MavenReactor); + assert_eq!(detect(&reactor, &no_list), Shape::MavenReactor); let single = |p: &str| (p == "pom.xml").then(|| b"".to_vec()); - assert_eq!(detect(&single), Shape::MavenReactor); + assert_eq!(detect(&single, &no_list), Shape::MavenReactor); let gradle = |p: &str| (p == "settings.gradle.kts").then(Vec::new); - assert_eq!(detect(&gradle), Shape::Gradle); + assert_eq!(detect(&gradle, &no_list), Shape::Gradle); let empty = |_: &str| None; - assert_eq!(detect(&empty), Shape::Other); + assert_eq!(detect(&empty, &no_list), Shape::Other); } /// #395: a `pom.xml` next to a Gradle build is both, whichever kind of @@ -921,13 +922,34 @@ mod tests { (&single[..], None, MavenShape::Single, Shape::MavenReactor), ] { let read = files(pom, gradle); - let d = detect_builds(&read); + let d = detect_builds(&read, &no_list); assert_eq!(d.maven, Some(maven), "{gradle:?}"); assert_eq!(d.gradle, gradle.is_some(), "{gradle:?}"); assert_eq!(d.shape(), shape, "{gradle:?}"); } } + /// #1809: a Maven 4 root whose subprojects Maven discovers (model + /// 4.1.0, `pom` packaging, no list, a child directory holding a pom) + /// is a reactor, not a single pom. + #[test] + fn a_maven_4_implicit_subprojects_root_is_a_reactor() { + let root = "\ + 4.1.0pom"; + use std::collections::BTreeMap; + let mut files = BTreeMap::from([("pom.xml".to_string(), root.as_bytes().to_vec())]); + let read = |files: &BTreeMap>| { + let files = files.clone(); + move |rel: &str| files.get(rel).cloned() + }; + let maven = |files: &BTreeMap>| { + detect_builds(&read(files), &|d: &str| testing::list_of(files, d)).maven + }; + assert_eq!(maven(&files), Some(MavenShape::Single)); + files.insert("a/pom.xml".into(), b"".to_vec()); + assert_eq!(maven(&files), Some(MavenShape::Reactor)); + } + const MIXED_UUID: &str = "1d3c1fd2-5e6f-4a7b-8c9d-0e1f2a3b4c5d"; fn mixed_patch() -> JvmPatch<'static> { @@ -966,7 +988,7 @@ mod tests { ); let (pristine, pristine_dirs) = (testing::snapshot(root), testing::dirs(root)); let read = |rel: &str| apply::read_project_file(root, rel); - assert_eq!(detect(&read), Shape::Mixed); + assert_eq!(detect(&read, &no_list), Shape::Mixed); let mut ledger = std::collections::BTreeMap::new(); let p = mixed_patch(); let plan = testing::vendor(root, Shape::Mixed, &p, &mut ledger) diff --git a/crates/socket-patch-core/src/vendor/jvm/sbt.rs b/crates/socket-patch-core/src/vendor/jvm/sbt.rs index e17ea4fb3..8dcb2f5ec 100644 --- a/crates/socket-patch-core/src/vendor/jvm/sbt.rs +++ b/crates/socket-patch-core/src/vendor/jvm/sbt.rs @@ -28,7 +28,7 @@ use serde_json::json; use super::layout::{self, safe_coordinates}; use super::{ adopt, coursier_tree, finish_writes, fragment, maven_reactor, owned_file, CommittedTree, - Coords, FileWrite, JvmPatch, JvmPlan, JvmRefusal, JvmUnplan, JvmWarning, ReadFn, Shape, + Coords, FileWrite, JvmPatch, JvmPlan, JvmRefusal, JvmUnplan, JvmWarning, ListFn, ReadFn, Shape, WiringAction, WiringRecord, OWNED_FILE_KIND, SBT_FRAGMENT_KIND, TREE_GITATTRIBUTES, }; use crate::formats::sbt::build::{ @@ -233,8 +233,12 @@ fn owned_file_refusal(e: OwnedFileError) -> JvmRefusal { /// Plan vendoring `patch` into the sbt build `read` serves (`docs/design/ /// sbt-support.md` §5.1). Refusals come before any write is planned. -pub fn plan(read: ReadFn<'_>, patch: &JvmPatch<'_>) -> Result { - plan_with_digest(read, patch, None) +pub fn plan( + read: ReadFn<'_>, + list: ListFn<'_>, + patch: &JvmPatch<'_>, +) -> Result { + plan_with_digest(read, list, patch, None) } /// [`plan`], the pin recording `deps` as its dependency digest: the @@ -244,6 +248,7 @@ pub fn plan(read: ReadFn<'_>, patch: &JvmPatch<'_>) -> Result, + list: ListFn<'_>, patch: &JvmPatch<'_>, deps: Option<&str>, ) -> Result { @@ -257,12 +262,13 @@ pub fn plan_with_digest( let read_text = text(read); let mut warnings = Vec::new(); - if let Some(pom) = read_text("pom.xml") { - if maven_reactor::declares_modules(&pom) { + if read_text("pom.xml").is_some() { + if maven_reactor::is_reactor_root(read, list) { return Err(refusal( "vendor_jvm_build_ambiguous", - "a root pom.xml declaring sits beside the sbt build, so which build \ - runs is unknowable; vendor from a checkout holding only one of them", + "a root pom.xml heading a Maven reactor (, or Maven 4 subprojects) \ + sits beside the sbt build, so which build runs is unknowable; vendor from a \ + checkout holding only one of them", )); } warnings.push(warning( @@ -656,7 +662,11 @@ mod tests { files: &BTreeMap>, p: &JvmPatch<'_>, ) -> Result { - plan(&|rel: &str| files.get(rel).cloned(), p) + plan( + &|rel: &str| files.get(rel).cloned(), + &|dir: &str| super::super::testing::list_of(files, dir), + p, + ) } fn code(r: Result) -> &'static str { @@ -675,7 +685,7 @@ mod tests { let files = build("1.9.9", &[]); assert_eq!(detect(&|p: &str| files.get(p).cloned()), Some(Shape::Sbt)); assert_eq!( - super::super::detect(&|p: &str| files.get(p).cloned()), + super::super::detect(&|p: &str| files.get(p).cloned(), &super::super::no_list), Shape::Sbt ); // A subproject directory (no project/) is still sbt-shaped. @@ -692,7 +702,10 @@ mod tests { ); let read = |p: &str| reactor.get(p).cloned(); assert_eq!(detect(&read), None); - assert_eq!(super::super::detect(&read), Shape::MavenReactor); + assert_eq!( + super::super::detect(&read, &super::super::no_list), + Shape::MavenReactor + ); // An unwired reactor beside sbt routes to sbt, which refuses it. let ambiguous = build( "1.9.9", @@ -709,6 +722,28 @@ mod tests { code(plan_over(&ambiguous, &text_patch(UUID_A))), "vendor_jvm_build_ambiguous" ); + // #1809: so does a Maven 4 root whose subprojects Maven discovers + // (model 4.1.0, `pom` packaging, no list, a child holding a pom). + let implicit_root = "\ + 4.1.0pom"; + let implicit = build( + "1.9.9", + &[ + ("pom.xml", implicit_root), + ("a/pom.xml", "a"), + ], + ); + assert_eq!( + code(plan_over(&implicit, &text_patch(UUID_A))), + "vendor_jvm_build_ambiguous" + ); + // Without a subproject it is a single pom sbt never reads. + let lone = build("1.9.9", &[("pom.xml", implicit_root)]); + let planned = plan_over(&lone, &text_patch(UUID_A)).unwrap(); + assert!(planned + .warnings + .iter() + .any(|w| w.code == "vendor_sbt_pom_ignored")); // A Gradle build already wired keeps Gradle. let gradle = build( "1.9.9", @@ -719,7 +754,10 @@ mod tests { ); let read = |p: &str| gradle.get(p).cloned(); assert_eq!(detect(&read), None); - assert_eq!(super::super::detect(&read), Shape::Gradle); + assert_eq!( + super::super::detect(&read, &super::super::no_list), + Shape::Gradle + ); // A scala-cli build already wired keeps scala-cli when it gains // sbt files. let scala = build( @@ -734,7 +772,10 @@ mod tests { ); let read = |p: &str| scala.get(p).cloned(); assert_eq!(detect(&read), None); - assert_eq!(super::super::detect(&read), Shape::ScalaCli); + assert_eq!( + super::super::detect(&read, &super::super::no_list), + Shape::ScalaCli + ); // A single-module pom the pre-v5 path already wired stays off sbt // (a Maven root, refused until reverted); an unwired one beside // sbt routes to sbt. @@ -749,7 +790,10 @@ mod tests { ); let read = |p: &str| legacy.get(p).cloned(); assert_eq!(detect(&read), None); - assert_eq!(super::super::detect(&read), Shape::MavenReactor); + assert_eq!( + super::super::detect(&read, &super::super::no_list), + Shape::MavenReactor + ); // A Gradle build with an unrelated `project/build.properties` is // not sbt-shaped. let stray: BTreeMap> = [ @@ -759,10 +803,13 @@ mod tests { .into(); let read = |p: &str| stray.get(p).cloned(); assert_eq!(detect(&read), None); - assert_eq!(super::super::detect(&read), Shape::Gradle); + assert_eq!( + super::super::detect(&read, &super::super::no_list), + Shape::Gradle + ); let plain = build("1.9.9", &[("pom.xml", "")]); assert_eq!( - super::super::detect(&|p: &str| plain.get(p).cloned()), + super::super::detect(&|p: &str| plain.get(p).cloned(), &super::super::no_list), Shape::Sbt ); } @@ -1074,7 +1121,8 @@ mod tests { let files = files.clone(); move |rel: &str| files.get(rel).cloned() }; - let first = plan_with_digest(&read(&files), &p, Some("feedf00d")).unwrap(); + let first = + plan_with_digest(&read(&files), &super::super::no_list, &p, Some("feedf00d")).unwrap(); for w in &first.writes { files.insert(w.rel.clone(), w.bytes.clone()); } @@ -1088,9 +1136,11 @@ mod tests { .clone() }; assert_eq!(pin(&files).deps_digest, "feedf00d"); - let same = plan_with_digest(&read(&files), &p, Some("feedf00d")).unwrap(); + let same = + plan_with_digest(&read(&files), &super::super::no_list, &p, Some("feedf00d")).unwrap(); assert!(same.writes.is_empty() && same.warnings.is_empty()); - let refreshed = plan_with_digest(&read(&files), &p, Some("0badcafe")).unwrap(); + let refreshed = + plan_with_digest(&read(&files), &super::super::no_list, &p, Some("0badcafe")).unwrap(); assert!(refreshed.warnings.is_empty(), "{:?}", refreshed.warnings); for w in &refreshed.writes { files.insert(w.rel.clone(), w.bytes.clone()); @@ -1372,7 +1422,7 @@ mod tests { ); let reader = |root: &Path| { let r = apply::ProjectReader::new(root); - super::super::detect(&|rel: &str| r.read(rel)) + super::super::detect(&|rel: &str| r.read(rel), &|d: &str| r.list(d)) }; assert_eq!(reader(root), Shape::MavenReactor); let mut ledger = BTreeMap::new(); @@ -1412,7 +1462,10 @@ mod tests { reader.read(rel) } }; - assert_eq!(super::super::detect(&read), Shape::Sbt); + assert_eq!( + super::super::detect(&read, &super::super::no_list), + Shape::Sbt + ); let replanned = super::super::plan(Shape::Sbt, &read, &super::super::no_list, &p).unwrap(); let tree: Vec<_> = replanned.writes.iter().filter(|w| w.tree).collect(); let recorded: Vec<_> = e.wiring.iter().filter(|w| w.kind == TREE_KIND).collect(); @@ -1435,6 +1488,9 @@ mod tests { upstream_module: module.as_deref(), ..p }; - assert!(plan(&read, &again).unwrap().writes.is_empty()); + assert!(plan(&read, &|d: &str| reader.list(d), &again) + .unwrap() + .writes + .is_empty()); } } diff --git a/crates/socket-patch-core/src/vendor/maven_repo.rs b/crates/socket-patch-core/src/vendor/maven_repo.rs index fd49914ec..e3c3d3c71 100644 --- a/crates/socket-patch-core/src/vendor/maven_repo.rs +++ b/crates/socket-patch-core/src/vendor/maven_repo.rs @@ -291,7 +291,7 @@ fn legacy_root_detail() -> String { /// The planner shape of the project root (see [`super::jvm::detect`]). fn detect_shape(project_root: &Path) -> super::jvm::Shape { let reader = super::jvm::apply::ProjectReader::new(project_root); - super::jvm::detect(&|rel: &str| reader.read(rel)) + super::jvm::detect(&|rel: &str| reader.read(rel), &|dir: &str| reader.list(dir)) } /// The root's ledger still holds a pre-v5 single-pom entry (a @@ -960,9 +960,12 @@ async fn vendor_maven_jvm( } let planned = match shape { // The pin records the gate's digest (every build source). - Shape::Sbt => { - super::jvm::sbt::plan_with_digest(&read, &patch, gate_pass.deps_digest.as_deref()) - } + Shape::Sbt => super::jvm::sbt::plan_with_digest( + &read, + &list, + &patch, + gate_pass.deps_digest.as_deref(), + ), Shape::MavenReactor | Shape::Mixed => { let external = external_maven_poms(&read, &list, &patch, &local, service).await; super::jvm::plan_with_external( diff --git a/crates/socket-patch-core/src/vendor/redownload.rs b/crates/socket-patch-core/src/vendor/redownload.rs index cb58d9833..55ae09617 100644 --- a/crates/socket-patch-core/src/vendor/redownload.rs +++ b/crates/socket-patch-core/src/vendor/redownload.rs @@ -566,7 +566,7 @@ async fn restore_maven_metadata( }; let list = |dir: &str| reader.list(dir); // Re-planned through every build the root holds (#395). - let shape = super::jvm::detect(&read); + let shape = super::jvm::detect(&read, &list); let enabled = !entry .wiring .iter() diff --git a/crates/socket-patch-core/src/vex/discover/maven.rs b/crates/socket-patch-core/src/vex/discover/maven.rs index 1b9fa30be..cc4c69fe8 100644 --- a/crates/socket-patch-core/src/vex/discover/maven.rs +++ b/crates/socket-patch-core/src/vex/discover/maven.rs @@ -130,8 +130,9 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { }; // `.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), + let config = ctx.read_text(MVN_CONFIG, out).await; + let user_properties = match &config { + Some(config) => maven_config_properties(config), None => BTreeMap::new(), }; let pom = match parse_pom_with(&raw, &user_properties) { @@ -161,7 +162,11 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { } let gas = group_by_ga(&pom.deps); - let reactor = crate::vendor::jvm::maven_reactor::declares_modules(&raw); + let reactor = crate::vendor::jvm::maven_reactor::declares_modules(&raw) + || (crate::vendor::jvm::maven_reactor::may_discover_subprojects(&raw, config.as_deref()) + && !crate::vendor::jvm::maven_reactor::implicit_subprojects_in(&ctx.view) + .await + .is_empty()); extract_hosted(ctx, &pom, &gas, &hosted, reactor, out).await; if !vendored.is_empty() { extract_vendored(ctx, &pom.deps, &gas, &vendored, out).await; @@ -288,7 +293,8 @@ async fn extract_hosted( uuid, POM, format!( - "{POM} declares /, and a module's own \ + "{POM} heads a multi-module reactor (/, or \ + Maven 4 subproject discovery), and a module's own \ of {ga} overrides this root pin; hosted mode reads only \ the root pom (re-patch the reactor with `scan --mode vendored`)" ), @@ -953,6 +959,60 @@ mod tests { assert!(out.unattested.is_empty(), "{:?}", out.unattested); } + /// #1809: a Maven 4 (model 4.1.0) `pom`-packaged root listing no + /// subprojects builds every direct subdirectory holding a `pom.xml`, + /// so its hosted pin is a reactor root's: kept as a ref, never + /// attested. A 4.0.0 model, no such subdirectory, or discovery turned + /// off in `.mvn/maven.config` leaves it a single pom, attested. + #[tokio::test] + async fn hosted_pin_in_a_maven_4_implicit_reactor_root_is_not_attested() { + let body = format!( + "pom\n\n\n{}\n\n\n{}\n", + dep("org.slf4j", "slf4j-api", Some(&suffixed(UUID_A))), + hosted_repo(&format!("socket-patch-{UUID_A}"), ®istry_url(UUID_A)), + ); + let model_4_1 = pom(&body) + .replace("POM/4.0.0", "POM/4.1.0") + .replace("4.0.0", "4.1.0"); + let child = "\n4.1.0\n\nchild\n\n"; + let off = "-Dmaven.project.discoverSubprojects=false\n"; + for (case, root, files, attested) in [ + ( + "implicit reactor", + &model_4_1, + &[("child/pom.xml", child)][..], + false, + ), + ("no subproject", &model_4_1, &[][..], true), + ( + "model 4.0.0", + &pom(&body), + &[("child/pom.xml", child)][..], + true, + ), + ( + "discovery off", + &model_4_1, + &[("child/pom.xml", child), (".mvn/maven.config", off)][..], + true, + ), + ] { + let p = Project::new(); + p.write("pom.xml", root.clone()); + for (rel, text) in files { + p.write(rel, text.to_string()); + } + let out = run(&p).await; + assert_refs(&out, &[(FX_PURL, UUID_A, WiringMode::Hosted)]); + if attested { + assert!(out.unattested.is_empty(), "{case}: {:?}", out.unattested); + } else { + assert_eq!(out.unattested.len(), 1, "{case}: {:?}", out.unattested); + assert_eq!(out.unattested[0].kind, UnattestedKind::MavenReactorRoot); + } + } + } + /// A classifier copy is its own coordinate: the classifier-less pin /// does not retarget it and the grant serves only the main jar. An /// executable one (tests, a native build) left at another version keeps