diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index a3ed909d6..34107916c 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -329,7 +329,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Attribution gate (v5.0).** A hosted run never leaves wiring that lockfile discovery calls contested: before the rewrite writes any lockfile, the same discovery `vex`, `list`, `rollback`, `remove` and `vendor` read runs over the project as the rewrite would leave it. A candidate whose pin discovery reads but cannot attribute to one package version (a requirements `-r` include resolving the same version from the registry beside a rewired `Pipfile.lock`, #567; a Maven pin in a ``, #260) is left out of the rewrite and reported as a `skipped` event with `errorCode` `redirect_unattributable` (nothing written for it; exit code unchanged). Unchanged: a pin in a file discovery does not read (a pre-2.6 bundler `Gemfile`, locked by the next `bundle install`) keeps the rewriter's verdict, and so does a deliberate partial redirect the run already reports (a bundled or `bun patch`-ed copy left on the registry, a yarn `npm:` alias entry left on the registry with `redirect_yarn_classic_alias_skipped` / `redirect_yarn_berry_alias_skipped` while the package's direct entry is pinned, a dep withheld from the vlt rewrite while a sibling lock takes it), and so does a vendored→hosted takeover: its vendored wiring is reverted in the run's staged overlay before the rewrite plans, and it is redirected when the rewriters pin it (otherwise it is retracted and stays vendored), in a `--dry-run` preview the same. A pin whose own pnpm or yarn lock also installs an unpatched copy of the same `name@version` (a pnpm `file:` directory or tarball dependency; a yarn `file:` / URL / berry `portal:` / `link:` dependency declared under another name) is written too, with a `redirect_copy_unpatched` warning naming that entry, and is not attested. A lockless NuGet / Cargo pin (an exclusive Socket source mapping without `packages.lock.json`, a Cargo registry pin without `Cargo.lock`) is still written, with a `redirect_pin_lockless` warning: no lockfile records its version, so `vex` cannot attest it and `rollback` / `remove` / `vendor` refuse it until the lockfile exists (whether such pins should be written at all is an open decision). The rollout's recorded view uses the same discovery: a uuid a file merely mentions (a stale `package.json` field, an inactive `pdm.lock`, a comment) is not a pin and does not count as already patched. -The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate, `bun.lock` / `bun.lockb`, and `vlt-lock.json` with `vlt.json` and `node_modules/.vlt-lock.json` read only), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; the hosted redirect is a `sparse+` registry, which cargo loads only from **1.68**, so `rust-toolchain.toml` / `rust-toolchain` are read too (never edited) and a project whose declared floor — the lowest `rust-version` of the root and the member manifests read here (a `rust-version.workspace = true` inheritor reads `[workspace.package]`), else the channel rustup picks from the root (the legacy `rust-toolchain` over `rust-toolchain.toml`; a dated `nightly-`/`beta-YYYY-MM-DD` maps to its release train) — is below 1.68 is refused `redirect_cargo_old_cargo` per crate (nothing written, an earlier run's pins left in place with the `rollback` that drops them named; `--mode vendored` builds on cargo 1.41+), while an undeclared or rolling-channel floor still redirects (toolchain files in ancestor directories are not read); **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. With NO `Cargo.lock` there is no resolved graph to ask, so the dependents question is answered from the manifests instead: a crate declared beside any other dependency — anything but a path dependency on a manifest this run also pins, or a `workspace = true` inheritor of a table it scans — or beside a workspace member this run did not read (a `members` glob, or a member outside the project or behind a symbolic link, which member discovery drops) is refused `redirect_cargo_lockless_dependents`, whose detail names the remedies (commit a lockfile, or `--mode vendored`); a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json` (plus, v5.0 #353/#514, every lock a project under the root restores into: a member project's `packages.lock.json`, a per-project `packages..lock.json`, a literal `NuGetLockFilePath` — each pinned with the root config; a `NuGetLockFilePath` it cannot evaluate warns `redirect_nuget_lock_path_unresolved` and a project tree it cannot list warns `redirect_nuget_lock_unreadable`, both skipping the nuget redirect with nothing written), `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and, for a Gradle build, every settings, build, `buildSrc`, included-build, applied and plugin-source script, version catalog and lock file the script graph reaches, plus `gradle/verification-metadata.xml`, `gradle/wrapper/gradle-wrapper.properties` and the owned `.socket/gradle/` files), and the sbt build files (`socket-patch.sbt`, `socket-patch-vendor.sbt`, `build.sbt`, `project/build.properties`, `.sbtopts`, `.jvmopts`; `build.sbt.lock` and the Mill / scala-cli build files `build.mill`, `build.mill.yaml`, `build.sc`, `.mill-version`, `project.scala` for their presence only) — read, never edited; `socket-patch.sbt` is the only sbt file hosted mode writes (see **Hosted sbt** below). **npm-family flavor coverage**: package-lock / npm-shrinkwrap (a package the project patches itself with npm ≥ 12.1's native `npm patch` — a root `patchedDependencies` key, or the lock entry's `patched` record — is left on its registry entry in every npm lock, `redirect_npm_patched_dependency_skipped`), pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic (a yarn 2+ install migrates a v1 `yarn.lock` and drops its pins, so a run whose v1 lock carries a hosted pin warns `redirect_yarn_classic_berry_migration_risk` — the hosted twin of the vendored `yarn_classic_berry_migration_risk` — unless the root `package.json`, read as advisory input, declares `"packageManager": "yarn@1…"`), **yarn berry** (the pin yarn writes for a root `resolutions` entry: the root `package.json` — edited only beside a berry `yarn.lock` — gains one `"@npm:": ""` selector per locked range (`redirect_yarn_berry_resolution` edits), and only that `yarn.lock` entry is re-keyed `"@"` with the same `resolution:` + `yarnBerry10c0` checksum (`redirect_yarn_berry_entry`), moved to yarn's key order; never an `npm:` locator, whose fetcher sends npm registry auth to the patch host, nor a tarball locator under an `npm:` key, which hardened mode rejects (YN0078). An older release's `npm:::__archiveUrl=` pin is still recognized and is re-pinned on the next run; rollback rebuilds the key from the selectors and drops them. Refused, nothing written: a user-authored `resolutions` entry for the package `redirect_yarn_berry_resolutions_conflict`, no root manifest `redirect_yarn_berry_manifest_missing`, a builtin `patch:` entry wrapping the same descriptor `redirect_yarn_berry_shared_descriptor`, an artifact URL yarn cannot fetch as a tarball `redirect_yarn_berry_artifact_url_unsupported`; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found` (or `redirect_bun_non_registry_entry_skipped` when the only same-version entry is a user URL / `file:` tarball, #497), a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`), and **vlt** (`vlt-lock.json` without `lockfileVersion`, `0` or `1`; see the vlt hosted-mode contract below). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when the `repo-state.json` beside a rewritten lock exists (`common/config/rush/repo-state.json` for the common lock, `common/config/subspaces//repo-state.json` for a subspace lock; the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives), and `redirect_pnpm_trust_lockfile` carries the Rush pnpm >=11 install remedy (see the trust paragraph above). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`; when `.mvn/wrapper/maven-wrapper.properties` pins a Maven older than 3.9.4, which ignores those files, the additive warning `redirect_maven_trusted_checksums_unenforced`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a reactor root (a `` / `` declaration, a profile's included, or a Maven 4 root that lists none but whose subprojects Maven discovers: model version not `4.0.0`, `pom` packaging, a direct subdirectory holding a `pom.xml`, and `maven.project.discoverSubprojects` not turned off in `.mvn/maven.config`; each such subdirectory's `pom.xml` is read for this, never edited) is refused whole with `redirect_maven_multimodule_unsupported`: `pom.xml` and `.mvn/` are left untouched and the dep is not counted as redirected (a Gradle build beside it is still planned by the Gradle rewriter, but a dep in a root with a `pom.xml` is confirmed only when the pom pins it too), since a module's own literal `` would shadow a root pin (use `--mode vendored`; `vex` omits a hosted pin in a reactor root as `vex_maven_reactor_root`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), a `` variant (sources, tests, a native build) left at its version (`redirect_maven_classifier_unsupported`; the grant serves the main jar only, and the dep is skipped when a variant other than `sources` / `javadoc` names the patched release), a versioned declaration inside `` (a literal or a `${property}`) left as-is (`redirect_maven_profile_dependency_unpatched`; the dep is skipped when a profile holds its only declaration), a `${property}` groupId / artifactId resolved from the pom's own `` before matching (one the pom does not define, which may be the patched GA and whose own version would then win, refuses the dep with nothing written: `redirect_maven_coordinate_unresolved`), and a pom not readable as one left untouched (`redirect_maven_pom_unreadable`). Matches and anchors skip comments, CDATA, ``, ``, ``, `` and `` (the scope `vex` reads), and an existing self-closed `` / `` is expanded in place, never duplicated; an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). **gradle** (v5.0) is automated wiring, no longer a pasted snippet: the owned settings script `.socket/gradle/socket-patch.hosted.settings.gradle` with its index `.socket/gradle/hosted-index.tsv`, one apply line per build's settings file, every lock entry of the GA moved to the suffixed version, and the suffixed component in an existing `gradle/verification-metadata.xml`. A refused dep writes nothing and keeps `redirect_gradle_manual_snippet` as its fallback; same-GAV grants are refused (`redirect_gradle_same_gav_unsupported`). Rules, refusals and codes: [Gradle builds](#gradle-builds-v50). +The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate, `bun.lock` / `bun.lockb`, and `vlt-lock.json` with `vlt.json` and `node_modules/.vlt-lock.json` read only), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; the hosted redirect is a `sparse+` registry, which cargo loads only from **1.68**, so `rust-toolchain.toml` / `rust-toolchain` are read too (never edited) and a project whose declared floor — the lowest `rust-version` of the root and the member manifests read here (a `rust-version.workspace = true` inheritor reads `[workspace.package]`), else the channel rustup picks from the root (the legacy `rust-toolchain` over `rust-toolchain.toml`; a dated `nightly-`/`beta-YYYY-MM-DD` maps to its release train) — is below 1.68 is refused `redirect_cargo_old_cargo` per crate (nothing written, an earlier run's pins left in place with the `rollback` that drops them named; `--mode vendored` builds on cargo 1.41+), while an undeclared or rolling-channel floor still redirects (toolchain files in ancestor directories are not read); **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. With NO `Cargo.lock` there is no resolved graph to ask, so the dependents question is answered from the manifests instead: a crate declared beside any other dependency — anything but a path dependency on a manifest this run also pins, or a `workspace = true` inheritor of a table it scans — or beside a workspace member this run did not read (a `members` glob, or a member outside the project or behind a symbolic link, which member discovery drops) is refused `redirect_cargo_lockless_dependents`, whose detail names the remedies (commit a lockfile, or `--mode vendored`); a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json` (plus, v5.0 #353/#514, every lock a project under the root restores into: a member project's `packages.lock.json`, a per-project `packages..lock.json`, a literal `NuGetLockFilePath` — each pinned with the root config; a `NuGetLockFilePath` it cannot evaluate warns `redirect_nuget_lock_path_unresolved` and a project tree it cannot list warns `redirect_nuget_lock_unreadable`, both skipping the nuget redirect with nothing written), `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and, for a Gradle build, every settings, build, `buildSrc`, included-build, applied and plugin-source script, version catalog and lock file the script graph reaches, plus `gradle/verification-metadata.xml`, `gradle/wrapper/gradle-wrapper.properties` and the owned `.socket/gradle/` files), and the sbt build files (`socket-patch.sbt`, `socket-patch-vendor.sbt`, `build.sbt`, `project/build.properties`, `.sbtopts`, `.jvmopts`; `build.sbt.lock` and the Mill / scala-cli build files `build.mill`, `build.mill.yaml`, `build.sc`, `.mill-version`, `project.scala` for their presence only) — read, never edited; `socket-patch.sbt` is the only sbt file hosted mode writes (see **Hosted sbt** below). **npm-family flavor coverage**: package-lock / npm-shrinkwrap (a package the project patches itself with npm ≥ 12.1's native `npm patch` — a root `patchedDependencies` key, or the lock entry's `patched` record — is left on its registry entry in every npm lock, `redirect_npm_patched_dependency_skipped`), pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic (a yarn 2+ install migrates a v1 `yarn.lock` and drops its pins, so a run whose v1 lock carries a hosted pin warns `redirect_yarn_classic_berry_migration_risk` — the hosted twin of the vendored `yarn_classic_berry_migration_risk` — unless the root `package.json`, read as advisory input, declares `"packageManager": "yarn@1…"`), **yarn berry** (the pin yarn writes for a root `resolutions` entry: the root `package.json` — edited only beside a berry `yarn.lock` — gains one `"@npm:": ""` selector per locked range (`redirect_yarn_berry_resolution` edits), and only that `yarn.lock` entry is re-keyed `"@"` with the same `resolution:` + `yarnBerry10c0` checksum (`redirect_yarn_berry_entry`), moved to yarn's key order; never an `npm:` locator, whose fetcher sends npm registry auth to the patch host, nor a tarball locator under an `npm:` key, which hardened mode rejects (YN0078). An older release's `npm:::__archiveUrl=` pin is still recognized and is re-pinned on the next run; rollback rebuilds the key from the selectors and drops them. Refused, nothing written: a user-authored `resolutions` entry for the package `redirect_yarn_berry_resolutions_conflict`, no root manifest `redirect_yarn_berry_manifest_missing`, a builtin `patch:` entry wrapping the same descriptor `redirect_yarn_berry_shared_descriptor`, an artifact URL yarn cannot fetch as a tarball `redirect_yarn_berry_artifact_url_unsupported`; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found` (or `redirect_bun_non_registry_entry_skipped` when the only same-version entry is a user URL / `file:` tarball, #497), a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`), and **vlt** (`vlt-lock.json` without `lockfileVersion`, `0` or `1`; see the vlt hosted-mode contract below). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when the `repo-state.json` beside a rewritten lock exists (`common/config/rush/repo-state.json` for the common lock, `common/config/subspaces//repo-state.json` for a subspace lock; the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives), and `redirect_pnpm_trust_lockfile` carries the Rush pnpm >=11 install remedy (see the trust paragraph above). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`; when `.mvn/wrapper/maven-wrapper.properties` pins a Maven older than 3.9.4, which ignores those files, the additive warning `redirect_maven_trusted_checksums_unenforced`; a grant sha256 that is not 64 hex digits writes no checksum line, `redirect_maven_trusted_checksums_invalid_hash`); 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`). diff --git a/crates/socket-patch-core/src/formats/maven/mod.rs b/crates/socket-patch-core/src/formats/maven/mod.rs index 66a5db09d..d22942801 100644 --- a/crates/socket-patch-core/src/formats/maven/mod.rs +++ b/crates/socket-patch-core/src/formats/maven/mod.rs @@ -1,11 +1,14 @@ //! The root `pom.xml` reader (repositories and dependencies, see //! [`parse_pom`]), the Maven coordinate grammars and the hosted -//! `-socket.` version split. Pure: text in, structure out. +//! `-socket.` version split, and the Trusted Checksums files +//! ([`trusted_checksums`]). Pure: text in, structure out. use std::collections::BTreeMap; use super::xml::{blank_non_markup, child_text, elements, open_tags, Element}; +pub(crate) mod trusted_checksums; + // ── pure reader ── /// The hosted version suffix marker (`-socket.`). @@ -479,14 +482,35 @@ pub(crate) fn may_name(coordinate: &str, want: &str) -> bool { /// 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 <= ' ') + let mut props = maven_config_line_properties(config); + let tokens = config + .lines() + .filter(|line| !java_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 +} + +/// Java's `String.trim`: every char up to U+0020, nothing beyond. +fn java_trim(s: &str) -> &str { + s.trim_matches(|c: char| c <= ' ') +} + +/// [`maven_config_properties`] as Maven 3.9+ alone reads it: only the +/// argument a whole line holds, never a token of the older whitespace split. +pub(crate) fn maven_config_line_properties(config: &str) -> BTreeMap { + let trim = java_trim; fn unquote(s: &str) -> &str { s.strip_prefix('"') .and_then(|s| s.strip_suffix('"')) - .map_or(s, trim) + .map_or(s, java_trim) } let mut args = config .lines() @@ -516,18 +540,6 @@ pub(crate) fn maven_config_properties(config: &str) -> BTreeMap 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 } diff --git a/crates/socket-patch-core/src/formats/maven/trusted_checksums.rs b/crates/socket-patch-core/src/formats/maven/trusted_checksums.rs new file mode 100644 index 000000000..8216ce34a --- /dev/null +++ b/crates/socket-patch-core/src/formats/maven/trusted_checksums.rs @@ -0,0 +1,573 @@ +//! Maven Trusted Checksums: the `.mvn/checksums/checksums.sha256` summary +//! file, the `.mvn/maven.config` settings that make Maven read it, and the +//! Maven release that enforces it. Pure: text in, text out. Discovery, the +//! hosted rewriter and its unwind all judge both files through here, so +//! what one writes, the others read and judge alike. +//! +//! The summary file is written in Maven's own grammar, ` ` +//! (lowercase hex, two spaces). maven-resolver's +//! `SummaryFileTrustedChecksumsSource` splits on exactly two spaces, skips +//! `#` and empty lines, lets a later line for a path win, and compares the +//! hash to its own lowercase hex. It is READ tolerantly, as +//! `sha256sum`-style tools write it: any run of spaces/tabs, an optional +//! `*` binary marker, CRLF. [`edit`] keeps a line it does not own +//! byte-for-byte, never drops it. + +use std::collections::{BTreeMap, BTreeSet}; + +use super::maven_config_line_properties; +use crate::utils::digest::{is_hex64_lower, sha256_hex}; + +// One literal per settings prefix, so the args written and the keys read +// cannot drift apart. +macro_rules! post_processor { + () => { + "aether.artifactResolver.postProcessor.trustedChecksums" + }; +} +macro_rules! summary_file { + () => { + "aether.trustedChecksumsSource.summaryFile" + }; +} +macro_rules! basedir { + () => { + "${session.rootDirectory}/.mvn/checksums" + }; +} + +/// The resolver property that switches the Trusted Checksums +/// post-processor on. +pub(crate) const SWITCH_KEY: &str = post_processor!(); +const ALGORITHMS_KEY: &str = concat!(post_processor!(), ".checksumAlgorithms"); +const SUMMARY_FILE_KEY: &str = summary_file!(); +const BASEDIR_KEY: &str = concat!(summary_file!(), ".basedir"); +const ORIGIN_AWARE_KEY: &str = concat!(summary_file!(), ".originAware"); + +/// The `.mvn/maven.config` lines hosted mode writes, one argument each: +/// the post-processor on, checking SHA-256, `failIfMissing=false` (a +/// dependency without a committed checksum still resolves; only a MISMATCH +/// fails), and the summary-file source on, at `.mvn/checksums`, origin +/// unaware (one checksum matches the artifact from any repository). +pub(crate) const CONFIG_ARGS: &[&str] = &[ + concat!("-D", post_processor!(), "=true"), + concat!("-D", post_processor!(), ".checksumAlgorithms=SHA-256"), + concat!("-D", post_processor!(), ".failIfMissing=false"), + concat!("-D", summary_file!(), "=true"), + concat!("-D", summary_file!(), ".basedir=", basedir!()), + concat!("-D", summary_file!(), ".originAware=false"), +]; + +/// Whether `.mvn/maven.config` makes Maven check artifacts against +/// `.mvn/checksums/checksums.sha256` — the one rule every mode shares. +/// +/// `maven.config` is read as Maven 3.9+ reads it (the only line that +/// enforces Trusted Checksums at all): one argument per LINE, `#` +/// comments, `-Dk=v` / `--define`, `-Dk` alone meaning `true`, a later +/// definition winning ([`maven_config_line_properties`]); a `-Dk=v` token +/// inside a longer line is not an argument there. Each setting is then +/// judged as the resolver judges it, with its default when unset: +/// - the post-processor on (`ConfigUtils.getBoolean`, i.e. Java +/// `Boolean.parseBoolean`: `true` in any case; default off); +/// - its comma-separated `checksumAlgorithms` naming `SHA-256` (default +/// `SHA-1`, which never reads a `.sha256` file); +/// - the summary-file source on (default off); +/// - its `basedir` exactly the `.mvn/checksums` hosted mode writes (the +/// default is `.checksums` under the local repository; any other value is +/// not this file); +/// - `originAware` off (default on, which reads a per-repository +/// `checksums-.sha256` instead). +/// +/// `failIfMissing` does not decide whether a committed line is checked. +pub(crate) fn config_enforces(config: &str) -> bool { + let props = maven_config_line_properties(config); + let get = |key: &str| props.get(key).map(String::as_str); + let is_true = |key: &str| get(key).is_some_and(|v| v.eq_ignore_ascii_case("true")); + is_true(SWITCH_KEY) + && get(ALGORITHMS_KEY).is_some_and(|v| v.split(',').any(|a| a.trim() == "SHA-256")) + && is_true(SUMMARY_FILE_KEY) + && get(BASEDIR_KEY) == Some(basedir!()) + && get(ORIGIN_AWARE_KEY).is_some_and(|v| !v.eq_ignore_ascii_case("true")) +} + +/// Whether any setting hosted mode writes is present in `config`, whatever +/// its value, in any spelling some Maven reads (a whole line, or a token of +/// the pre-3.9 whitespace split): the rollback's "leftover settings" +/// warning keys on presence, not on enforcement. +pub(crate) fn config_has_written_settings(config: &str) -> bool { + let props = crate::formats::maven::maven_config_properties(config); + CONFIG_ARGS + .iter() + .filter_map(|arg| arg.strip_prefix("-D")?.split_once('=')) + .any(|(key, _)| props.contains_key(key)) +} + +/// The first Maven release that enforces the `.mvn/*` Trusted Checksums +/// files (#258). 3.9.0 / 3.9.1 do not interpolate `${session.rootDirectory}` +/// in `maven.config`, so the summary file is never found; 3.9.2 / 3.9.3 +/// ignore `checksumAlgorithms=SHA-256` and check SHA-1 only. Older lines have +/// no Trusted Checksums post-processor at all. +const MIN_MAVEN: [u32; 3] = [3, 9, 4]; + +/// The Apache Maven release a `maven-wrapper.properties` pins, taken from its +/// `distributionUrl` (`…/apache-maven--bin.zip` or `.tar.gz`). None +/// for a missing key or any other distribution (e.g. mvnd), which is then +/// taken to enforce. +pub(crate) fn wrapper_maven_version(props: &str) -> Option { + props.lines().find_map(|line| { + let line = line.trim(); + if line.starts_with('#') || line.starts_with('!') { + return None; + } + let rest = line.strip_prefix("distributionUrl")?; + if !rest.starts_with(|c: char| c == '=' || c == ':' || c.is_whitespace()) { + return None; + } + let value = rest + .trim_start() + .strip_prefix(['=', ':']) + .unwrap_or(rest) + .trim(); + let file = value.rsplit('/').next()?; + let version = file + .strip_prefix("apache-maven-")? + .strip_suffix(".zip") + .or_else(|| file.strip_prefix("apache-maven-")?.strip_suffix(".tar.gz"))? + .strip_suffix("-bin")?; + (!version.is_empty()).then(|| version.to_string()) + }) +} + +/// Whether Maven `version` enforces the Trusted Checksums files (≥ 3.9.4; +/// pre-release suffixes such as `-rc-6` compare as their release). +pub(crate) fn maven_enforces(version: &str) -> bool { + let mut parts = [0u32; 3]; + for (slot, part) in parts.iter_mut().zip(version.split(['.', '-'])) { + *slot = part.parse().unwrap_or(0); + } + parts >= MIN_MAVEN +} + +/// Whether the summary file is an enforced pin: [`config_enforces`], and a +/// Maven Wrapper (`wrapper`, its properties) that names no Apache Maven +/// release older than 3.9.4. No wrapper, or one naming another +/// distribution, is taken to enforce — the hosted rewriter's rule, which +/// warns only on a release it can name. +pub(crate) fn enforced(config: &str, wrapper: Option<&str>) -> bool { + config_enforces(config) + && wrapper + .and_then(wrapper_maven_version) + .is_none_or(|v| maven_enforces(&v)) +} + +/// One non-comment line of the summary file, read as two fields. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Entry<'a> { + /// The first field as written (a hash, or whatever stands there). + pub(crate) hash: &'a str, + /// The local-repository-relative path, without a `*` binary marker. + pub(crate) path: &'a str, + /// The line is exactly `<64 lowercase hex> `, the only shape + /// Maven checks an artifact against: any other spelling Maven skips, + /// reads as another path, or fails the build on (an uppercase hash never + /// equals its lowercase digest). + pub(crate) maven_reads: bool, +} + +/// Whether `line` (its `\n` excluded) is a comment, which Maven skips. +fn is_comment(line: &str) -> bool { + line.trim_start_matches([' ', '\t']).starts_with('#') +} + +/// `line` (its `\n` excluded; a trailing `\r` is tolerated) as an entry: +/// a first field, a run of spaces/tabs, an optional `*`, a non-empty path; +/// surrounding blanks ignored. The first field need not be a valid hash, so +/// a line for a path hosted mode owns is recognised (and replaced or +/// dropped) whatever its hash. `None` for a comment, a blank line, or a +/// single field. +pub(crate) fn parse_line(line: &str) -> Option> { + let line = line.strip_suffix('\r').unwrap_or(line); + if is_comment(line) { + return None; + } + let blank = |c: char| c == ' ' || c == '\t'; + let trimmed = line.trim_matches(blank); + let (hash, rest) = trimmed.split_at(trimmed.find(blank)?); + let rest = rest.trim_start_matches(blank); + let path = rest.strip_prefix('*').unwrap_or(rest); + if path.is_empty() { + return None; + } + let maven_reads = is_hex64_lower(hash) + && line.strip_prefix(hash).and_then(|r| r.strip_prefix(" ")) == Some(path); + Some(Entry { + hash, + path, + maven_reads, + }) +} + +/// Every entry of `text`, in file order (tolerant: see [`parse_line`]). +pub(crate) fn entries(text: &str) -> impl Iterator> { + text.split('\n').filter_map(parse_line) +} + +/// `path -> sha256` of the entries Maven checks: only lines it reads +/// ([`Entry::maven_reads`]), a later line for a path winning, as in the +/// resolver. A tolerated spelling is recognised (and replaced, or dropped, +/// by [`edit`]) but pins nothing, so nothing attests it. +pub(crate) fn pinned(text: &str) -> BTreeMap { + entries(text) + .filter(|e| e.maven_reads) + .map(|e| (e.path.to_string(), e.hash.to_string())) + .collect() +} + +/// The line ending most of `text`'s lines use (`\n` on a tie or none), so +/// an inserted line never adds an ending the file does not mostly use. +fn dominant_eol(text: &str) -> &'static str { + let crlf = text.matches("\r\n").count(); + if crlf * 2 > text.matches('\n').count() { + "\r\n" + } else { + "\n" + } +} + +/// `text` with the entries for the paths in `set` (`(path, sha256)`, the +/// hash 64 lowercase hex) made ` `, and every other entry +/// whose path `drop` names removed. +/// +/// A `set` path the file already names is rewritten where it first stands +/// (that line's ending kept) and any later line for it removed. One it +/// lacks is inserted before the first later checksum line whose path sorts +/// after it — ahead of the comment block directly above that line, which +/// stays with it — so a sorted file stays sorted; else appended. An +/// inserted line takes the file's dominant line ending. Every other line (a +/// comment, a blank line, an unparseable line, another path's entry in any +/// spelling) is kept byte-for-byte. +pub(crate) fn edit(text: &str, drop: impl Fn(&str) -> bool, set: &[(String, String)]) -> String { + debug_assert!(set.iter().all(|(_, h)| is_hex64_lower(h)), "{set:?}"); + let set: BTreeMap<&str, &str> = set.iter().map(|(p, h)| (p.as_str(), h.as_str())).collect(); + // Which owned paths the file already names decides where each goes, so + // it is known before the first line is written. + let present: BTreeSet<&str> = entries(text) + .map(|e| e.path) + .filter(|p| set.contains_key(p)) + .collect(); + let mut pending: BTreeMap<&str, &str> = set + .iter() + .filter(|(p, _)| !present.contains(*p)) + .map(|(p, h)| (*p, *h)) + .collect(); + let eol = dominant_eol(text); + let push_entry = |out: &mut String, hash: &str, path: &str, ending: &str| { + out.push_str(hash); + out.push_str(" "); + out.push_str(path); + out.push_str(ending); + }; + let mut out = String::with_capacity(text.len() + 160 * pending.len()); + let mut written: BTreeSet<&str> = BTreeSet::new(); + // The comment lines directly above the line not yet reached. + let mut held = String::new(); + for line in text.split_inclusive('\n') { + let body = line.strip_suffix('\n').unwrap_or(line); + if is_comment(body) { + held.push_str(line); + continue; + } + let entry = parse_line(body); + if let Some(e) = entry.as_ref().filter(|e| sha256_hex(e.hash).is_some()) { + while let Some((p, h)) = pending.first_key_value().filter(|(p, _)| **p < e.path) { + push_entry(&mut out, h, p, eol); + pending.pop_first(); + } + } + out.push_str(&held); + held.clear(); + match entry { + Some(e) if set.contains_key(e.path) => { + if written.insert(e.path) { + let ending = &line[body.trim_end_matches('\r').len()..]; + push_entry(&mut out, set[e.path], e.path, ending); + } + } + Some(e) if drop(e.path) => {} + _ => out.push_str(line), + } + } + out.push_str(&held); + for (p, h) in pending { + if !out.is_empty() && !out.ends_with('\n') { + out.push_str(eol); + } + push_entry(&mut out, h, p, eol); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + const A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const OURS: &str = "org/x/1.0-socket.aaaaaaaa/x-1.0-socket.aaaaaaaa.jar"; + + fn set(entries: &[(&str, &str)]) -> Vec<(String, String)> { + entries + .iter() + .map(|(p, h)| (p.to_string(), h.to_string())) + .collect() + } + + #[test] + fn parse_line_accepts_sha256sum_spellings() { + let upper = A.to_uppercase(); + for (line, path, maven_reads) in [ + (format!("{A} a/b.jar"), "a/b.jar", true), + (format!("{A} a/b.jar\r"), "a/b.jar", true), + (format!("{A} a/b.jar"), "a/b.jar", false), + (format!("{A} *a/b.jar"), "a/b.jar", false), + (format!("{A} *a/b.jar"), "a/b.jar", false), + (format!("{A}\ta/b.jar"), "a/b.jar", false), + (format!("{A} a/b.jar"), "a/b.jar", false), + (format!(" {A} a/b.jar "), "a/b.jar", false), + // maven-resolver compares against its lowercase digest: an + // uppercase line fails the build, it pins nothing. + (format!("{upper} a/b.jar"), "a/b.jar", false), + // A truncated or SRI hash is still a line for its path. + (format!("{} a/b.jar", &A[1..]), "a/b.jar", false), + ("sha256-q83vEjRWeJA= a/b.jar".to_string(), "a/b.jar", false), + ] { + let entry = parse_line(&line).unwrap_or_else(|| panic!("{line:?}")); + assert_eq!(entry.path, path, "{line:?}"); + assert_eq!(entry.maven_reads, maven_reads, "{line:?}"); + } + for line in [ + String::new(), + "# comment".into(), + format!("#{A} a/b.jar"), + format!(" # {A} a/b.jar"), + A.to_string(), + format!("{A} "), + format!("{A} *"), + ] { + assert_eq!(parse_line(&line), None, "{line:?}"); + } + } + + #[test] + fn pinned_keeps_only_what_maven_reads_last_line_winning() { + let upper = B.to_uppercase(); + let text = + format!("# header\r\n{A} x.jar\r\n{A} y.jar\n{A} *z.jar\n{B} x.jar\n{upper} w.jar"); + assert_eq!( + pinned(&text), + BTreeMap::from([("x.jar".to_string(), B.to_string())]) + ); + assert_eq!( + entries(&text).map(|e| e.path).collect::>(), + ["x.jar", "y.jar", "z.jar", "x.jar", "w.jar"] + ); + } + + #[test] + fn edit_round_trips_untouched_text_byte_for_byte() { + for text in [ + String::new(), + "\n\n".into(), + format!("# keep\r\n{A} *one.jar\r\n\r\nnot an entry\r\n{B}\ttwo.jar"), + format!("{B} z.jar\n{A} a.jar\n"), + ] { + assert_eq!(edit(&text, |_| false, &[]), text); + assert_eq!(edit(&text, |p| p == "absent.jar", &[]), text); + } + } + + #[test] + fn edit_keeps_every_line_it_does_not_own() { + // A user's single-space, `*` and tab lines, a comment and a stray + // line, around one of ours being replaced and one being added. + let text = format!( + "# pins\n{A} c/user.jar\n{A} *d/user.jar\n{A}\te/user.jar\ngarbage\n{A} m/ours.jar\n" + ); + let owned = set(&[("m/ours.jar", B), ("b/new.jar", B)]); + let out = edit(&text, |p| p == "never.jar", &owned); + assert_eq!( + out, + format!( + "{B} b/new.jar\n# pins\n{A} c/user.jar\n{A} *d/user.jar\n{A}\te/user.jar\n\ + garbage\n{B} m/ours.jar\n" + ) + ); + // Idempotent: a second pass changes nothing. + assert_eq!(edit(&out, |_| false, &owned), out); + } + + #[test] + fn edit_replaces_a_tolerated_spelling_of_an_owned_path_in_place() { + let text = format!("{A} *m/ours.jar\r\nkeep\r\n"); + assert_eq!( + edit(&text, |_| false, &set(&[("m/ours.jar", B)])), + format!("{B} m/ours.jar\r\nkeep\r\n") + ); + } + + /// An owned line goes whatever its first field holds: a truncated or + /// SRI hash a past writer left must not outlive the pin. + #[test] + fn edit_owns_a_path_whatever_its_hash() { + for stale in [ + format!("sha256-q83vEjRWeJA= {OURS}\n"), + format!("{} {OURS}\n", &A[1..]), + format!("{} {OURS}\n", A.to_uppercase()), + ] { + let text = format!("# keep\n{stale}"); + assert_eq!(edit(&text, |p| p == OURS, &[]), "# keep\n", "{stale:?}"); + assert_eq!( + edit(&text, |_| false, &set(&[(OURS, B)])), + format!("# keep\n{B} {OURS}\n"), + "{stale:?}" + ); + } + } + + #[test] + fn edit_collapses_an_owned_path_to_one_line() { + let text = format!("{A} {OURS}\nkeep\n{A} *{OURS}\r\n{B} {OURS}"); + assert_eq!( + edit(&text, |_| false, &set(&[(OURS, B)])), + format!("{B} {OURS}\nkeep\n") + ); + } + + #[test] + fn edit_drops_only_named_entries_and_keeps_line_endings() { + let text = format!("{A} gone.jar\r\n# note\r\n{A} kept.jar\r\n{B} *gone.jar\r\n"); + assert_eq!( + edit(&text, |p| p == "gone.jar", &[]), + format!("# note\r\n{A} kept.jar\r\n") + ); + } + + /// A comment block directly above a line stays with it when an entry + /// is inserted before that line; a blank line ends a block. + #[test] + fn edit_inserts_ahead_of_the_comment_block_above_a_line() { + let text = format!("# about z\n# more\n{A} z.jar\n"); + assert_eq!( + edit(&text, |_| false, &set(&[("m.jar", B)])), + format!("{B} m.jar\n# about z\n# more\n{A} z.jar\n") + ); + let text = format!("# header\n\n{A} z.jar\n# trailing\n"); + assert_eq!( + edit(&text, |_| false, &set(&[("m.jar", B), ("zz.jar", B)])), + format!("# header\n\n{B} m.jar\n{A} z.jar\n# trailing\n{B} zz.jar\n") + ); + } + + #[test] + fn edit_inserts_with_the_dominant_line_ending() { + assert_eq!( + edit( + &format!("{A} a.jar\r\nlast"), + |_| false, + &set(&[("z.jar", B)]) + ), + format!("{A} a.jar\r\nlast\r\n{B} z.jar\r\n") + ); + // One CRLF line among LF lines does not make the file CRLF. + let text = format!("{A} a.jar\r\n{A} b.jar\n{A} c.jar\n"); + assert_eq!( + edit(&text, |_| false, &set(&[("z.jar", B)])), + format!("{text}{B} z.jar\n") + ); + let text = format!("x\r\n{A} b.jar\r\n{A} c.jar\n"); + assert_eq!( + edit(&text, |_| false, &set(&[("a.jar", B)])), + format!("x\r\n{B} a.jar\r\n{A} b.jar\r\n{A} c.jar\n") + ); + assert_eq!( + edit("", |_| false, &set(&[("z.jar", B), ("a.jar", A)])), + format!("{A} a.jar\n{B} z.jar\n") + ); + } + + /// The rule decides on whole-line arguments only, and only when every + /// setting Maven reads this file by is set as hosted mode writes it (or + /// to an equivalent value). + #[test] + fn config_enforces_needs_every_setting_maven_reads_the_file_by() { + let all = CONFIG_ARGS.join("\n"); + assert!(config_enforces(&all)); + assert!(config_enforces(&format!( + "{}\r\n", + CONFIG_ARGS.join("\r\n") + ))); + let switch_line = format!("-D{SWITCH_KEY}=true"); + let with = |from: &str, to: &str| { + assert!(all.contains(from), "{from}"); + all.replacen(from, to, 1) + }; + for (config, want) in [ + (with(&switch_line, &format!("-D{SWITCH_KEY}")), true), + (with(&switch_line, &format!("-D{SWITCH_KEY}=TRUE")), true), + ( + with( + "checksumAlgorithms=SHA-256", + "checksumAlgorithms=SHA-1, SHA-256", + ), + true, + ), + (with("failIfMissing=false", "failIfMissing=true"), true), + (with("originAware=false", "originAware=no"), true), + (format!("{all}\n-D{SWITCH_KEY}=false"), false), + (with(&switch_line, &format!("-D{SWITCH_KEY}=yes")), false), + ( + with("checksumAlgorithms=SHA-256", "checksumAlgorithms=SHA-1"), + false, + ), + (with("summaryFile=true", "summaryFile=false"), false), + (with("/.mvn/checksums", "/.mvn/sums"), false), + (with("originAware=false", "originAware=true"), false), + // Maven 3.9+ reads an argument per line: a token inside a + // longer line, or a commented line, is no setting. + (with(&switch_line, &format!("-B {switch_line}")), false), + (with(&switch_line, &format!("# {switch_line}")), false), + (switch_line.clone(), false), + (String::new(), false), + ] { + assert_eq!(config_enforces(&config), want, "{config}"); + } + // Each setting but failIfMissing defaults to "not this file". + for skip in CONFIG_ARGS.iter().filter(|a| !a.contains("failIfMissing")) { + let config: Vec<&str> = CONFIG_ARGS.iter().copied().filter(|a| a != skip).collect(); + assert!(!config_enforces(&config.join("\n")), "without {skip}"); + } + assert!(config_has_written_settings(&format!( + "-D{SWITCH_KEY}=false" + ))); + // Presence, not enforcement: a token joined onto a longer line is + // still a leftover the rollback warns about. + assert!(config_has_written_settings(&format!("-B {switch_line}"))); + assert!(!config_has_written_settings(&format!("# {switch_line}"))); + } + + #[test] + fn enforced_follows_the_wrapper_maven() { + let all = CONFIG_ARGS.join("\n"); + let wrapper = |v: &str| { + format!("distributionUrl=https://repo/apache-maven/{v}/apache-maven-{v}-bin.zip\n") + }; + assert!(enforced(&all, None)); + assert!(enforced(&all, Some(&wrapper("3.9.4")))); + assert!(enforced( + &all, + Some("distributionUrl=https://x/maven-mvnd-1.0.2.zip\n") + )); + assert!(!enforced(&all, Some(&wrapper("3.9.3")))); + assert!(!enforced("", None)); + } +} diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 0c778ac74..04668a346 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -69,6 +69,7 @@ use crate::formats::gem::hosted::{ refresh_merged_gem_lock_remote, }; use crate::formats::gem::{lock_lists_direct_dependency, locked_specs as gem_locked_specs}; +use crate::formats::maven::trusted_checksums::{self, CONFIG_ARGS as MVN_CONFIG_ARGS}; #[cfg(test)] use crate::formats::pnpm::hosted::pnpm_unrewritten_instances; use crate::formats::pnpm::plan_hosted; @@ -8476,25 +8477,6 @@ pub(crate) fn no_maven_or_gradle(files: &BTreeMap) -> bool { !files.contains_key("pom.xml") && !gradle::gradle_build_present(files) } -/// The six `-Daether.*` args that enable Maven's Trusted Checksums resolver -/// post-processor (twin of the TS `MVN_CONFIG_ARGS`), one per `.mvn/maven.config` -/// line. `failIfMissing=false` so a dependency without a committed checksum -/// still resolves (only a MISMATCH fails); origin-unaware so one checksum -/// matches the artifact from any repository. -const MVN_CONFIG_ARGS: &[&str] = &[ - TRUSTED_CHECKSUMS_ON, - "-Daether.artifactResolver.postProcessor.trustedChecksums.checksumAlgorithms=SHA-256", - "-Daether.artifactResolver.postProcessor.trustedChecksums.failIfMissing=false", - "-Daether.trustedChecksumsSource.summaryFile=true", - "-Daether.trustedChecksumsSource.summaryFile.basedir=${session.rootDirectory}/.mvn/checksums", - "-Daether.trustedChecksumsSource.summaryFile.originAware=false", -]; - -/// The resolver switch (the first [`MVN_CONFIG_ARGS`] line) that makes the -/// checksums file an enforced pin; without it the file is inert. -pub(crate) const TRUSTED_CHECKSUMS_ON: &str = - "-Daether.artifactResolver.postProcessor.trustedChecksums=true"; - pub(crate) const MVN_CONFIG: &str = ".mvn/maven.config"; pub(crate) const MVN_CHECKSUMS: &str = ".mvn/checksums/checksums.sha256"; /// The Maven Wrapper's settings file. Read only, never edited: its @@ -8502,52 +8484,6 @@ pub(crate) const MVN_CHECKSUMS: &str = ".mvn/checksums/checksums.sha256"; /// rewriter can tell when the Trusted Checksums files it writes are inert. pub(crate) const MVN_WRAPPER_PROPERTIES: &str = ".mvn/wrapper/maven-wrapper.properties"; -/// The first Maven release that enforces the `.mvn/*` Trusted Checksums -/// files (#258). 3.9.0 / 3.9.1 do not interpolate `${session.rootDirectory}` -/// in `maven.config`, so the summary file is never found; 3.9.2 / 3.9.3 -/// ignore `checksumAlgorithms=SHA-256` and check SHA-1 only. Older lines have -/// no Trusted Checksums post-processor at all. -const TRUSTED_CHECKSUMS_MIN_MAVEN: [u32; 3] = [3, 9, 4]; - -/// The Apache Maven release a `maven-wrapper.properties` pins, taken from its -/// `distributionUrl` (`…/apache-maven--bin.zip` or `.tar.gz`). None -/// for a missing key or any other distribution (e.g. mvnd), which then goes -/// unwarned. -fn maven_wrapper_version(props: &str) -> Option { - props.lines().find_map(|line| { - let line = line.trim(); - if line.starts_with('#') || line.starts_with('!') { - return None; - } - let rest = line.strip_prefix("distributionUrl")?; - if !rest.starts_with(|c: char| c == '=' || c == ':' || c.is_whitespace()) { - return None; - } - let value = rest - .trim_start() - .strip_prefix(['=', ':']) - .unwrap_or(rest) - .trim(); - let file = value.rsplit('/').next()?; - let version = file - .strip_prefix("apache-maven-")? - .strip_suffix(".zip") - .or_else(|| file.strip_prefix("apache-maven-")?.strip_suffix(".tar.gz"))? - .strip_suffix("-bin")?; - (!version.is_empty()).then(|| version.to_string()) - }) -} - -/// Whether Maven `version` enforces the Trusted Checksums files (≥ 3.9.4; -/// pre-release suffixes such as `-rc-6` compare as their release). -fn maven_enforces_trusted_checksums(version: &str) -> bool { - let mut parts = [0u32; 3]; - for (slot, part) in parts.iter_mut().zip(version.split(['.', '-'])) { - *slot = part.parse().unwrap_or(0); - } - parts >= TRUSTED_CHECKSUMS_MIN_MAVEN -} - /// Strip any `sha256-`/`sha256:` SRI-style prefix off a stored hash, leaving the /// bare lowercase hex Maven's trusted-checksums summary file expects (twin of /// the TS `bareSha256Hex`). @@ -8744,8 +8680,35 @@ fn rewrite_maven_pom( .clone() .unwrap_or_else(|| dep.name.clone()); let suffixed_version = ov.identifiers.maven_suffixed_version.clone(); - let pom_sha256 = ov.identifiers.maven_pom_sha256.clone(); - let jar_sha256 = dep.integrity.sha256.clone(); + // The jar and served-pom sha256 a Trusted Checksums pin needs, as + // the bare lowercase hex Maven compares; a grant hash of any other + // shape (an SRI base64 digest, a truncated hex) writes no pin. + let checksum_hashes = dep + .integrity + .sha256 + .as_deref() + .zip(ov.identifiers.maven_pom_sha256.as_deref()) + .map(|(jar, pom)| (bare_sha256_hex(jar), bare_sha256_hex(pom))); + let bad_checksum_hashes = checksum_hashes + .as_ref() + .is_some_and(|(jar, pom)| !is_hex64_lower(jar) || !is_hex64_lower(pom)); + let checksum_hashes = checksum_hashes.filter(|_| !bad_checksum_hashes); + if let Some(suffixed) = suffixed_version.as_deref().filter(|_| bad_checksum_hashes) { + // No pin is written for the suffixed version, so none may stay + // either: an earlier release wrote such a grant's hash mangled + // (lowercased base64), and Maven fails the build on a line + // whose hash does not match. Dropped whether or not the version + // pin lands this run (a re-run with the pin present too). + for ext in ["jar", "pom"] { + checksum_drops.push(crate::vendor::jvm::layout::artifact_path( + &group_id, + &artifact_id, + suffixed, + None, + ext, + )); + } + } // The pom for the rest of this iteration; edits land in place. let Some(pom_text) = pom.as_mut() else { @@ -9099,7 +9062,7 @@ fn rewrite_maven_pom( }); } - if jar_sha256.is_some() && pom_sha256.is_some() { + if checksum_hashes.is_some() { pinned_jar_paths.push(crate::vendor::jvm::layout::artifact_path( &group_id, &artifact_id, @@ -9193,7 +9156,17 @@ fn rewrite_maven_pom( // Trusted Checksums: only when BOTH the jar sha256 and the served pom // sha256 are known. Two entries per dep — the jar and the pom — under // the SUFFIXED version's local-repo path. - if let (Some(jar), Some(pom_hash)) = (&jar_sha256, &pom_sha256) { + if bad_checksum_hashes { + result.warnings.push(RewriteWarning { + code: "redirect_maven_trusted_checksums_invalid_hash".into(), + detail: format!( + "{group_id}:{artifact_id}: the grant's sha256 is not 64 hex digits; \ + no Trusted Checksums pin written to {MVN_CHECKSUMS} (the -socket \ + version suffix still pins the patched artifact)" + ), + }); + } + if let Some((jar, pom_hash)) = &checksum_hashes { let (merged, conflicts) = merge_mvn_config(&mvn_config, &format!("{group_id}:{artifact_id}")); for conflict in conflicts { @@ -9227,7 +9200,7 @@ fn rewrite_maven_pom( None, "jar", ), - bare_sha256_hex(jar), + jar.clone(), )); checksum_entries.push(( crate::vendor::jvm::layout::artifact_path( @@ -9237,7 +9210,7 @@ fn rewrite_maven_pom( None, "pom", ), - bare_sha256_hex(pom_hash), + pom_hash.clone(), )); } } @@ -9250,32 +9223,21 @@ fn rewrite_maven_pom( if mvn_config_changed { result.files.insert(MVN_CONFIG.into(), mvn_config); } - // LF-normalized, so the drop check and the filter below split lines the - // same way (a CRLF file's paths would otherwise keep a trailing `\r`). - let existing_checksums = files - .get(MVN_CHECKSUMS) - .map(|text| text.replace("\r\n", "\n")) - .unwrap_or_default(); - let names_dropped_path = |line: &str| { - line.split_once(" ") - .is_some_and(|(_, p)| checksum_drops.iter().any(|d| d == p)) - }; - let drops_checksums = existing_checksums.split('\n').any(names_dropped_path); - if !checksum_entries.is_empty() || drops_checksums { - let action = if files.contains_key(MVN_CHECKSUMS) { + // Every line this run does not own (another path's entry in any + // spelling, a comment, an unparseable line) keeps its bytes and place. + let existing_checksums = files.get(MVN_CHECKSUMS).map(String::as_str); + let merged_checksums = trusted_checksums::edit( + existing_checksums.unwrap_or_default(), + |path| checksum_drops.iter().any(|d| d == path), + &checksum_entries, + ); + if merged_checksums != existing_checksums.unwrap_or_default() { + let action = if existing_checksums.is_some() { "rewritten" } else { "added" }; - let kept: String = existing_checksums - .split('\n') - .filter(|l| !names_dropped_path(l)) - .collect::>() - .join("\n"); - result.files.insert( - MVN_CHECKSUMS.into(), - merge_checksums(&kept, &checksum_entries), - ); + result.files.insert(MVN_CHECKSUMS.into(), merged_checksums); result.edits.push(FileEdit { path: MVN_CHECKSUMS.into(), kind: "redirect_maven_trusted_checksums".into(), @@ -9293,17 +9255,15 @@ fn rewrite_maven_pom( // re-scan of an already-pinned project warns as well. let final_text = |rel: &str| result.files.get(rel).or_else(|| files.get(rel)); let pin_in_place = final_text(MVN_CONFIG) - .is_some_and(|c| c.lines().any(|l| l.trim() == TRUSTED_CHECKSUMS_ON)) + .is_some_and(|c| trusted_checksums::config_enforces(c)) && final_text(MVN_CHECKSUMS).is_some_and(|c| { - pinned_jar_paths.iter().any(|p| { - c.lines() - .any(|l| l.split_whitespace().nth(1) == Some(p.as_str())) - }) + let pinned = trusted_checksums::pinned(c); + pinned_jar_paths.iter().any(|p| pinned.contains_key(p)) }); if let Some(version) = files .get(MVN_WRAPPER_PROPERTIES) - .and_then(|props| maven_wrapper_version(props)) - .filter(|v| pin_in_place && !maven_enforces_trusted_checksums(v)) + .and_then(|props| trusted_checksums::wrapper_maven_version(props)) + .filter(|v| pin_in_place && !trusted_checksums::maven_enforces(v)) { result.warnings.push(RewriteWarning { code: "redirect_maven_trusted_checksums_unenforced".into(), @@ -9362,37 +9322,28 @@ pub(crate) fn maven_repository_removal(pom: &str, id: &str) -> Option<(usize, us } /// Merge trusted-checksums resolver args into `.mvn/maven.config` (one arg per -/// line). Dedupe by the `-Dkey=` prefix: an arg whose key is already present is -/// left untouched (existing value wins). Returns the merged text + any conflict -/// messages (a pre-existing SAME key with a DIFFERENT value). Twin of the TS -/// `mergeMvnConfig`. +/// line). A setting is present when Maven 3.9+ reads it from the file +/// ([`crate::formats::maven::maven_config_line_properties`], the reader +/// discovery's [`trusted_checksums::config_enforces`] uses: `-Dk=v`, +/// `--define k=v`, indented or quoted lines, the last definition winning). +/// A present setting is left untouched (existing value wins); a different +/// value is reported as a conflict. Returns the merged text + those +/// conflict messages. Twin of the TS `mergeMvnConfig`. fn merge_mvn_config(existing: &str, coordinate: &str) -> (String, Vec) { - let lines: Vec<&str> = if existing.is_empty() { - vec![] - } else { - existing.split('\n').collect() - }; + let present = crate::formats::maven::maven_config_line_properties(existing); let mut conflicts = vec![]; - let key_of = - |line: &str| -> Option { line.find('=').map(|eq| line[..=eq].to_string()) }; - let mut present: std::collections::HashMap = std::collections::HashMap::new(); - for line in &lines { - if let Some(key) = key_of(line) { - present.insert(key, (*line).to_string()); - } - } let mut appended: Vec<&str> = vec![]; for arg in MVN_CONFIG_ARGS { - let key = key_of(arg).expect("every MVN_CONFIG_ARGS entry contains '='"); - match present.get(&key) { - None => { - appended.push(arg); - present.insert(key, (*arg).to_string()); - } - Some(existing_line) if existing_line.trim() != *arg => { + let (key, value) = arg + .strip_prefix("-D") + .and_then(|def| def.split_once('=')) + .expect("every MVN_CONFIG_ARGS entry is -Dkey=value"); + match present.get(key) { + None => appended.push(arg), + Some(existing_value) if existing_value != value => { conflicts.push(format!( - "{coordinate}: {MVN_CONFIG} already sets {key} to a different value ({}); leaving it as-is", - existing_line.trim() + "{coordinate}: {MVN_CONFIG} already sets -D{key}= to a different value \ + (-D{key}={existing_value}); leaving it as-is" )); } Some(_) => {} @@ -9411,35 +9362,6 @@ fn merge_mvn_config(existing: &str, coordinate: &str) -> (String, Vec) { (format!("{base}{}\n", appended.join("\n")), conflicts) } -/// Merge trusted-checksum entries into `.mvn/checksums/checksums.sha256` (GNU -/// coreutils format: ``). -/// Parse existing entries, replace/add by path, re-sort by path, trailing -/// newline. A malformed line (no double-space separator) is dropped. Twin of -/// the TS `mergeChecksums`. -fn merge_checksums(existing: &str, entries: &[(String, String)]) -> String { - let mut by_path: BTreeMap = BTreeMap::new(); - if !existing.is_empty() { - for line in existing.split('\n') { - if line.trim().is_empty() { - continue; - } - if let Some(sep) = line.find(" ") { - by_path.insert(line[sep + 2..].to_string(), line[..sep].to_string()); - } - } - } - for (path, sha256) in entries { - by_path.insert(path.clone(), sha256.clone()); - } - // BTreeMap iterates keys in sorted (byte) order — matching JS's default - // sort on the ASCII paths. - let body: Vec = by_path - .iter() - .map(|(path, sha)| format!("{sha} {path}")) - .collect(); - format!("{}\n", body.join("\n")) -} - // ── golang (go.mod fork-replace + go.sum pin) ──────────────────────────────── /// go.mod and go.sum are whitespace-delimited line formats, and the golang /// rewriter interpolates server-controlled strings into both — any embedded @@ -10959,33 +10881,38 @@ mod tests { #[test] fn maven_wrapper_version_parses_distribution_url() { assert_eq!( - maven_wrapper_version(&maven_wrapper("3.9.3")).as_deref(), + trusted_checksums::wrapper_maven_version(&maven_wrapper("3.9.3")).as_deref(), Some("3.9.3") ); assert_eq!( - maven_wrapper_version( + trusted_checksums::wrapper_maven_version( "distributionUrl : https://archive.apache.org/dist/maven/maven-3/3.9.2/binaries/apache-maven-3.9.2-bin.tar.gz\r\n" ) .as_deref(), Some("3.9.2") ); assert_eq!( - maven_wrapper_version( + trusted_checksums::wrapper_maven_version( "! comment\r\ndistributionUrl=https\\://repo/apache-maven-4.0.0-rc-6-bin.zip\r\n" ) .as_deref(), Some("4.0.0-rc-6") ); - assert_eq!(maven_wrapper_version("wrapperVersion=3.3.2\n"), None); assert_eq!( - maven_wrapper_version("distributionUrlOld=https://x/apache-maven-3.9.3-bin.zip\n"), + trusted_checksums::wrapper_maven_version("wrapperVersion=3.3.2\n"), None ); - assert!(!maven_enforces_trusted_checksums("3.9.3")); - assert!(maven_enforces_trusted_checksums("3.9.4")); - assert!(maven_enforces_trusted_checksums("3.10.0-rc-1")); - assert!(maven_enforces_trusted_checksums("4.0.0")); - assert!(!maven_enforces_trusted_checksums("3.8.9")); + assert_eq!( + trusted_checksums::wrapper_maven_version( + "distributionUrlOld=https://x/apache-maven-3.9.3-bin.zip\n" + ), + None + ); + assert!(!trusted_checksums::maven_enforces("3.9.3")); + assert!(trusted_checksums::maven_enforces("3.9.4")); + assert!(trusted_checksums::maven_enforces("3.10.0-rc-1")); + assert!(trusted_checksums::maven_enforces("4.0.0")); + assert!(!trusted_checksums::maven_enforces("3.8.9")); } #[test] @@ -11009,6 +10936,38 @@ mod tests { assert!(warning_codes(&r).contains(&"redirect_maven_trusted_checksums_conflict")); } + /// The writer sees a user setting in every spelling Maven 3.9+ reads + /// (the reader discovery judges enforcement with): an indented line, + /// `--define k=v`, `-D k=v`, a quoted argument. Appending ours after + /// one would silently override the user's value (the later line wins). + #[test] + fn maven_config_conflict_in_any_spelling_keeps_the_user_value() { + for user in [ + " -Daether.trustedChecksumsSource.summaryFile.originAware=true", + "--define aether.trustedChecksumsSource.summaryFile.originAware=true", + "--define=aether.trustedChecksumsSource.summaryFile.originAware=true", + "\"-Daether.trustedChecksumsSource.summaryFile.originAware=true\"", + ] { + let mut files = BTreeMap::new(); + files.insert( + "pom.xml".to_string(), + pom_with_dep("\n 1.7.36", ""), + ); + files.insert(".mvn/maven.config".to_string(), format!("{user}\n")); + let r = rewrite_registry_redirect(&files, &[maven_override()]); + let config = r.files.get(".mvn/maven.config").expect("config"); + assert!(!config.contains("originAware=false"), "{user}: {config}"); + assert!( + warning_codes(&r).contains(&"redirect_maven_trusted_checksums_conflict"), + "{user}" + ); + assert!( + !trusted_checksums::config_enforces(config), + "{user}: the user's originAware=true still governs" + ); + } + } + /// Legacy same-GAV fallback (no suffixed version): only the repository is /// added, no `.mvn` files, and the same_gav_fallback warning is emitted. #[test] @@ -30328,10 +30287,126 @@ mod owned_pin_generation_matrix { let (repinned, _) = plan(&wired, &maven(B, tok)); let checksums = &repinned[MVN_CHECKSUMS]; assert!(!checksums.contains("aaaaaaaa"), "{checksums}"); + // The file stays CRLF: nothing is normalized to LF. + assert!( + checksums + .split_inclusive('\n') + .all(|line| line.ends_with("\r\n")), + "{checksums:?}" + ); + let (_, again) = plan(&repinned, &maven(B, tok)); + assert!(again.files.is_empty(), "{:?}", again.files.keys()); + } + + /// A user's checksums lines survive the pin and the re-pin byte for + /// byte, in any spelling `sha256sum`-style tools write (one space, a + /// `*` binary marker, a tab) and as comments or stray text. The hosted + /// merge used to re-parse the file on two spaces and silently drop + /// every line it could not split that way. + #[test] + fn maven_checksums_merge_keeps_every_user_line() { + let tok = "22222222-3333-4444-8555-666666666666"; + let h = "0123456789abcdef".repeat(4); + let user = [ + "# team pins".to_string(), + format!("{h} com/acme/single/1.0/single-1.0.jar"), + format!("{h} *com/acme/binary/1.0/binary-1.0.jar"), + format!("{h}\tcom/acme/tab/1.0/tab-1.0.jar"), + format!("{h} com/acme/canonical/1.0/canonical-1.0.jar"), + "not a checksum line".to_string(), + ]; + let mut files = pom_project(); + files.insert(MVN_CHECKSUMS.into(), format!("{}\n", user.join("\n"))); + let (wired, result) = plan(&files, &maven(A, tok)); + assert!(result.warnings.is_empty(), "{:?}", result.warnings); + let (repinned, _) = plan(&wired, &maven(B, tok)); + for (stage, tree) in [("pin", &wired), ("re-pin", &repinned)] { + let checksums = &tree[MVN_CHECKSUMS]; + let lines: Vec<&str> = checksums.lines().collect(); + for line in &user { + assert!( + lines.contains(&line.as_str()), + "{stage} lost {line:?}:\n{checksums}" + ); + } + assert_eq!(lines.len(), user.len() + 2, "{stage}:\n{checksums}"); + } + assert!(repinned[MVN_CHECKSUMS].contains("3.12.0-socket.bbbbbbbb")); + assert!(!repinned[MVN_CHECKSUMS].contains("socket.aaaaaaaa")); let (_, again) = plan(&repinned, &maven(B, tok)); assert!(again.files.is_empty(), "{:?}", again.files.keys()); } + /// A superseded generation's checksum line goes on re-pin whatever its + /// hash holds (a past writer's SRI or truncated digest included), and a + /// grant whose hash is not 64 hex digits writes no checksum line at all. + #[test] + fn maven_checksums_owned_lines_go_whatever_their_hash() { + let tok = "22222222-3333-4444-8555-666666666666"; + let (clean, _) = plan(&pom_project(), &maven(A, tok)); + for stale in ["sha256-q83vEjRWeJA=", "a".repeat(63).as_str()] { + let mut wired = clean.clone(); + let mangled = wired[MVN_CHECKSUMS].replace(&"a".repeat(64), stale); + wired.insert(MVN_CHECKSUMS.to_string(), mangled); + let (repinned, _) = plan(&wired, &maven(B, tok)); + let checksums = &repinned[MVN_CHECKSUMS]; + assert!( + !checksums.contains("socket.aaaaaaaa"), + "{stale}: {checksums}" + ); + assert_eq!(checksums.lines().count(), 2, "{stale}: {checksums}"); + } + + let mut bad = maven(A, tok); + bad.integrity.sha256 = Some("sha256-q83vEjRWeJA=".into()); + let (wired, result) = plan(&pom_project(), &bad); + assert!( + wired["pom.xml"].contains("3.12.0-socket.aaaaaaaa"), + "the version pin still lands" + ); + assert!( + !wired.contains_key(MVN_CHECKSUMS), + "{:?}", + wired.get(MVN_CHECKSUMS) + ); + assert!( + result + .warnings + .iter() + .any(|w| w.code == "redirect_maven_trusted_checksums_invalid_hash"), + "{:?}", + result.warnings + ); + + // An earlier release wrote such a grant's hash mangled for the same + // suffixed version: a re-run (the version pin already present) and + // a fresh pin both drop it, since Maven fails on a mismatched line. + let mut wired = clean.clone(); + let mangled = wired[MVN_CHECKSUMS].replace(&"a".repeat(64), "q83vejrweja="); + wired.insert(MVN_CHECKSUMS.to_string(), mangled); + let (rerun, _) = plan(&wired, &bad); + assert!( + !rerun + .get(MVN_CHECKSUMS) + .is_some_and(|c| c.contains("socket.aaaaaaaa")), + "{:?}", + rerun.get(MVN_CHECKSUMS) + ); + let mut fresh = pom_project(); + fresh.insert( + MVN_CHECKSUMS.to_string(), + clean[MVN_CHECKSUMS].replace(&"a".repeat(64), "q83vejrweja="), + ); + let (fresh, _) = plan(&fresh, &bad); + assert!( + !fresh + .get(MVN_CHECKSUMS) + .is_some_and(|c| c.contains("socket.aaaaaaaa")), + "{:?}", + fresh.get(MVN_CHECKSUMS) + ); + } + /// #266: a rotated grant token (same uuid, new token path segment) /// refreshes the repository URL in place. #[test] 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 453046e91..ff2e815d9 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/maven.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/maven.rs @@ -18,14 +18,15 @@ //! was the user's). The `.mvn` files are removed only when they hold //! nothing but what hosted mode writes; otherwise the resolver lines stay //! and a warning says so (whether they pre-existed is not recorded). -//! Checksum lines keep the file's remaining order (the rewriter re-sorted -//! and dropped malformed lines; that is not recoverable). +//! Every other checksums line keeps its bytes and place (the rewriter +//! leaves them so too; see `trusted_checksums::edit`). use std::collections::BTreeMap; use regex::Regex; use super::{by_uuid, read_or_refuse, refuse_all_in, Ctx, FormatResult, HostedPin, View}; +use crate::formats::maven::trusted_checksums; use crate::patch::redirect::{ generation, maven_repositories_with_id, maven_tag_inner_range, maven_tag_text_in, remove_maven_repository, MAVEN_DEPENDENCY_BLOCK_RE, MVN_CHECKSUMS, MVN_CONFIG, MVN_CONFIG_ARGS, @@ -409,27 +410,22 @@ async fn restore_mvn( let Ok(Some(sums)) = view.read(&sums_rel).await else { return; }; - let path_of = |line: &str| { - line.trim_end_matches('\r') - .split_once(" ") - .map(|(_, p)| p.to_string()) - }; - let kept: Vec<&str> = sums - .split('\n') - .filter(|line| { - !path_of(line).is_some_and(|p| checksum_dirs.iter().any(|d| p.starts_with(d.as_str()))) - }) - .collect(); - let rest = kept.join("\n"); + let rest = trusted_checksums::edit( + &sums, + |p| checksum_dirs.iter().any(|d| p.starts_with(d.as_str())), + &[], + ); if rest == sums { return; } let config = view.read(&config_rel).await.ok().flatten(); let written_config = format!("{}\n", MVN_CONFIG_ARGS.join("\n")); - let has_resolver_lines = config.as_deref().is_some_and(|c| { - c.lines() - .any(|l| MVN_CONFIG_ARGS.contains(&l.trim_end_matches('\r'))) - }); + // Whether settings hosted mode may have written are left over, read as + // `trusted_checksums::config_enforces` reads them but by presence: a + // partial or switched-off set is still lines the user may want gone. + let has_resolver_lines = config + .as_deref() + .is_some_and(trusted_checksums::config_has_written_settings); if rest.trim().is_empty() { view.remove(&sums_rel); if config.as_deref() == Some(written_config.as_str()) { @@ -439,11 +435,10 @@ async fn restore_mvn( } else { view.write(&sums_rel, rest.clone()); } - let other_hosted = rest.lines().any(|l| { - path_of(l).is_some_and(|p| { - p.split('/') - .any(|seg| crate::formats::maven::split_socket_version(seg).is_some()) - }) + let other_hosted = trusted_checksums::entries(&rest).any(|e| { + e.path + .split('/') + .any(|seg| crate::formats::maven::split_socket_version(seg).is_some()) }); if has_resolver_lines && !other_hosted { result.warnings.push(( @@ -863,4 +858,63 @@ mod tests { assert_eq!(after[".mvn/checksums/checksums.sha256"], other); assert!(after.contains_key(".mvn/maven.config")); } + + /// The pin's lines go whatever hash a past writer left on them (an SRI + /// or truncated digest): the unwind must not stop early and leave them. + #[tokio::test] + async fn checksums_unwind_drops_owned_lines_whatever_their_hash() { + let sums = fixture("basic", "expected", ".mvn/checksums/checksums.sha256") + .replace(&"c".repeat(64), "sha256-q83vEjRWeJA=") + .replace(&"d".repeat(64), &"d".repeat(40)); + let files = vec![ + ("pom.xml", fixture("basic", "expected", "pom.xml")), + ( + ".mvn/maven.config", + fixture("basic", "expected", ".mvn/maven.config"), + ), + (".mvn/checksums/checksums.sha256", sums), + ]; + let (outcome, after) = run(&files).await; + assert_eq!(outcome.pins[0].status, PinStatus::Restored); + assert!( + !after.contains_key(".mvn/checksums/checksums.sha256"), + "{:?}", + after.get(".mvn/checksums/checksums.sha256") + ); + assert!(!after.contains_key(".mvn/maven.config")); + } + + /// The unwind reads the checksums file as the rewriter does: the pin's + /// lines go in any spelling a tool rewrote them to, every other line + /// (one space, a `*` marker, a comment) keeps its bytes and CRLF, and + /// a resolver switch in another spelling still counts as left behind. + #[tokio::test] + async fn checksums_unwind_reads_every_spelling_and_keeps_crlf() { + let other = format!( + "# team pins\r\n{} zz/other/1.0/other-1.0.jar\r\n{} *aa/first/1.0/first-1.0.jar\r\n", + "e".repeat(64), + "f".repeat(64) + ); + let ours = fixture("basic", "expected", ".mvn/checksums/checksums.sha256") + .replace(" ", " *") + .replace('\n', "\r\n"); + let config = "-Daether.artifactResolver.postProcessor.trustedChecksums\r\n"; + let files = vec![ + ("pom.xml", fixture("basic", "expected", "pom.xml")), + (".mvn/maven.config", config.to_string()), + (".mvn/checksums/checksums.sha256", format!("{other}{ours}")), + ]; + let (outcome, after) = run(&files).await; + assert_eq!(outcome.pins[0].status, PinStatus::Restored); + assert_eq!(after[".mvn/checksums/checksums.sha256"], other); + assert_eq!(after[".mvn/maven.config"], config); + assert!( + outcome + .warnings + .iter() + .any(|(code, _)| *code == "maven_trusted_checksums_left"), + "{:?}", + outcome.warnings + ); + } } diff --git a/crates/socket-patch-core/src/vex/discover/maven.rs b/crates/socket-patch-core/src/vex/discover/maven.rs index cc4c69fe8..e69e5b2f5 100644 --- a/crates/socket-patch-core/src/vex/discover/maven.rs +++ b/crates/socket-patch-core/src/vex/discover/maven.rs @@ -57,7 +57,10 @@ //! ` ///-.jar` under the SUFFIXED version, made //! effective by `-Daether.artifactResolver.postProcessor.trustedChecksums=true` //! in `.mvn/maven.config`. That jar line becomes -//! [`LockIntegrity::Sha256Hex`] (only while the resolver switch is on). +//! [`LockIntegrity::Sha256Hex`] only while Maven enforces the file +//! ([`crate::formats::maven::trusted_checksums::enforced`]: every setting +//! hosted writes, read as Maven 3.9+ reads `maven.config`, and no wrapper +//! pinning a Maven older than 3.9.4). //! `integrity_required` is FALSE: the rewriter writes checksums only when //! both hashes are known, and the suffixed version itself is the //! fail-closed pin (no other repository serves it; the Socket repository is @@ -107,8 +110,7 @@ use crate::formats::maven::{ is_maven_coordinate, is_maven_version_text, maven_config_properties, may_shadow, parse_pom_with, split_socket_version, Pom, PomDep, PomRepo, }; -use crate::patch::redirect::{MVN_CHECKSUMS, MVN_CONFIG, TRUSTED_CHECKSUMS_ON}; -use crate::utils::digest::sha256_hex; +use crate::patch::redirect::{MVN_CHECKSUMS, MVN_CONFIG, MVN_WRAPPER_PROPERTIES}; use crate::vendor::jvm::layout; use crate::vendor::lock_inventory::LockIntegrity; use crate::vendor::maven_repo::{sha1_sidecar_matches, VENDOR_REPO_URL_PREFIX}; @@ -397,33 +399,23 @@ async fn extract_hosted( } /// `path -> sha256 hex` from `.mvn/checksums/checksums.sha256`, or empty -/// when `.mvn/maven.config` does not switch the trusted-checksums resolver -/// on (the file is then inert and pins nothing). Malformed lines are -/// skipped, like the rewriter's own merge. +/// when Maven does not enforce it: `.mvn/maven.config` lacks a setting it +/// reads the file by, or the Maven Wrapper pins a Maven older than 3.9.4. +/// Judged by the hosted rewriter's own rule, and only a line Maven checks +/// attests (`formats::maven::trusted_checksums`). async fn trusted_checksums(ctx: &DiscoverCtx<'_>, out: &mut Discovery) -> BTreeMap { - let mut map = BTreeMap::new(); + use crate::formats::maven::trusted_checksums::{enforced, pinned}; let Some(config) = ctx.read_text(MVN_CONFIG, out).await else { - return map; + return BTreeMap::new(); }; - if !config - .split_whitespace() - .any(|arg| arg == TRUSTED_CHECKSUMS_ON) - { - return map; + let wrapper = ctx.read_text(MVN_WRAPPER_PROPERTIES, out).await; + if !enforced(&config, wrapper.as_deref()) { + return BTreeMap::new(); } - let Some(text) = ctx.read_text(MVN_CHECKSUMS, out).await else { - return map; - }; - for line in text.lines() { - let Some((hash, path)) = line.trim().split_once(char::is_whitespace) else { - continue; - }; - let path = path.trim_start().trim_start_matches('*').trim_end(); - if let Some(hash) = sha256_hex(hash).filter(|_| !path.is_empty()) { - map.insert(path.to_string(), hash); - } - } - map + ctx.read_text(MVN_CHECKSUMS, out) + .await + .map(|text| pinned(&text)) + .unwrap_or_default() } // ── vendored ───────────────────────────────────────────────────────────── @@ -840,11 +832,12 @@ mod tests { assert!(out.diagnostics.is_empty(), "{case}: {:?}", out.diagnostics); let r = &out.refs[0]; assert_eq!(r.source_file, std::path::PathBuf::from("pom.xml"), "{case}"); - assert_eq!( - r.locked_integrity, - Some(LockIntegrity::Sha256Hex(FX_JAR_SHA.into())), - "{case}" - ); + // mvn-config-merge keeps the user's `originAware=true`, under + // which Maven reads a per-repository summary file, not this one: + // its checksums pin nothing. + let integrity = + (case != "mvn-config-merge").then(|| LockIntegrity::Sha256Hex(FX_JAR_SHA.into())); + assert_eq!(r.locked_integrity, integrity, "{case}"); assert!(!r.integrity_required, "{case}"); assert!(r.lockfile_basis_ok(), "{case}"); assert!( @@ -904,6 +897,78 @@ mod tests { ); } + /// Integrity attests only what Maven enforces, judged by the hosted + /// rewriter's own rule: every `maven.config` setting Maven reads the + /// summary file by, each a whole-line argument (CRLF and a bare `-Dk` + /// switch count); no wrapper Maven older than 3.9.4; and a jar line in + /// the exact lowercase ` ` shape. A tolerated spelling + /// (one space, a `*` marker, uppercase hex) is a line Maven skips or + /// fails on, so it pins nothing. + #[tokio::test] + async fn checksums_attest_only_what_maven_enforces() { + use crate::formats::maven::trusted_checksums::{CONFIG_ARGS, SWITCH_KEY}; + let jar = "org/slf4j/slf4j-api/1.7.36-socket.77777777/slf4j-api-1.7.36-socket.77777777.jar"; + let all = CONFIG_ARGS.join("\n"); + let switch = format!("-D{SWITCH_KEY}=true"); + let wrapper = |v: &str| { + format!("distributionUrl=https://repo/apache-maven/{v}/apache-maven-{v}-bin.zip\n") + }; + let line = format!("{FX_JAR_SHA} {jar}\n"); + let upper = FX_JAR_SHA.to_uppercase(); + for (config, wrapper, sums, attests) in [ + ( + CONFIG_ARGS.join("\r\n"), + None, + format!("{FX_JAR_SHA} {jar}\r\n"), + true, + ), + ( + all.replace(&switch, &format!("-D{SWITCH_KEY}")), + None, + format!("# pins\n{FX_JAR_SHA} {jar}"), + true, + ), + (all.clone(), Some(wrapper("3.9.4")), line.clone(), true), + (all.clone(), Some(wrapper("3.9.3")), line.clone(), false), + (switch.clone(), None, line.clone(), false), + ( + all.replace(&switch, &format!("-B {switch}")), + None, + line.clone(), + false, + ), + ( + all.replace("originAware=false", "originAware=true"), + None, + line.clone(), + false, + ), + ( + format!("{all}\n-D{SWITCH_KEY}=false"), + None, + line.clone(), + false, + ), + (all.clone(), None, format!("{FX_JAR_SHA} {jar}\n"), false), + (all.clone(), None, format!("{FX_JAR_SHA} *{jar}\n"), false), + (all.clone(), None, format!("{upper} {jar}\n"), false), + ] { + let p = fixture("basic"); + p.write(".mvn/maven.config", config.clone()); + if let Some(props) = &wrapper { + p.write(".mvn/wrapper/maven-wrapper.properties", props.clone()); + } + p.write(".mvn/checksums/checksums.sha256", sums.clone()); + let out = run(&p).await; + assert_refs(&out, &[(FX_PURL, FX_UUID, WiringMode::Hosted)]); + assert_eq!( + out.refs[0].locked_integrity, + attests.then(|| LockIntegrity::Sha256Hex(FX_JAR_SHA.into())), + "{config:?} / {wrapper:?} / {sums:?}" + ); + } + } + #[tokio::test] async fn hosted_pin_resolved_through_a_property_and_crlf() { let p = Project::new(); diff --git a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-maven.json b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-maven.json index 5d05aa7e7..9d433d65e 100644 --- a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-maven.json +++ b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-maven.json @@ -367,7 +367,7 @@ "mode": "hosted", "source_file": "pom.xml", "artifact_rel": null, - "locked_integrity": "Sha256Hex(\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\")", + "locked_integrity": null, "integrity_required": false, "url": "https://patch.socket.dev/patch-registry/maven/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/maven2", "lockfile_basis_ok": true diff --git a/docs/ecosystems.md b/docs/ecosystems.md index b03dc6665..762915850 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -881,7 +881,12 @@ Honest limits of the Maven and NuGet flows — documented behavior, not bugs: ([MNG-8182](https://issues.apache.org/jira/browse/MNG-8182)). The args are `originAware=false` and `failIfMissing=false`, so one checksum matches the artifact from any repository and a dependency with no committed checksum still resolves — only a - *mismatch* fails. + *mismatch* fails. A grant whose sha256 is not 64 hex digits writes no checksum line + (and drops any line an earlier run wrote for that suffixed version) and warns + `redirect_maven_trusted_checksums_invalid_hash`. `vex` attests a pin's checksum only + when `maven.config` enforces the file as Maven 3.9+ reads it (all five settings above + with the values hosted writes; `failIfMissing` aside) and the wrapper, if any, pins + Maven 3.9.4 or later. * **A Maven project's scan is scoped to its dependency graph.** The Maven local repository is shared by every project on the machine, so in project mode (no `--global` / `--global-prefix`) a Maven build's crawl (a `pom.xml`, no Gradle or