From f7aeaafaa13404d4212038e8a9ae6fdfdb5c7105 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 5 Nov 2026 13:41:21 -0600 Subject: [PATCH 1/4] Stop attesting sbt pins a build reassignment or uncommitted wiring bypasses Hosted sbt: a pin the resolution evidence does not verify keeps its ref and loses only the lockfile basis, so `vex` still attested it from the suffixed copy sbt downloaded into `.socket/sbt-hosted`. That copy proves the hosted file loaded once, not that the build resolves it. Discovery now also marks the ref unattested (new `UnattestedKind::SbtPinBypassed`, VEX note `vex_sbt_pin_bypassed`) when the build provably does not apply the pin: - a build source reassigns `dependencyOverrides` (the generated override is replaced, so the build resolves its own version) or `resolvers`, or sets `sbt.override.build.repos=true` (the generated resolver is dropped). These are read from the build sources whether or not any evidence exists; - evidence newer than every build source and the generated file records another version of the GA, or an artifact of the pinned version whose bytes are not pinned. Stale or partial evidence, project definitions that cannot be read statically, and a `build.sbt.lock` still only withhold the lockfile basis: none of them stops the pin from applying. Vendored sbt / scala-cli liveness: neither re-plans, so no pending write masked missing wiring, but `entry_wired_checked` (the VEX liveness test) judged only the wiring rows and owned files. It now also requires that the committed files route the build to the vendored copy, failing with a reason (`vendor_jvm_shape_unsupported`, "cannot establish JVM wiring"); `vendor --revert` and `scan --prune` keep the plain wiring test, and `vendor --check` is unchanged: - `sbt_pin_conflict`: the committed hosted `socket-patch.sbt` pins the same GA under another patch (the load-time verifier holds the GA to the hosted row), or no longer parses strictly; - `scala_cli_tree_uncommitted`: the index has no row for the patch's jar or pom, or the committed file is missing or does not hash to its row (without the tree's pom, Coursier reads the module from Central); - `scala_cli_repository_shadowed`: an input declares a repository or a direct `url=` for the patched group, which `vendor` refuses but nothing re-checked once vendored. Co-Authored-By: Claude Opus 5.5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 19 +- .../src/commands/vex_sources.rs | 11 + .../socket-patch-cli/tests/e2e_sbt_hosted.rs | 46 +++ .../socket-patch-core/src/vendor/jvm/apply.rs | 26 +- .../src/vendor/jvm/coursier_gate.rs | 19 + .../socket-patch-core/src/vendor/jvm/sbt.rs | 106 ++++++ .../src/vendor/jvm/scala_cli.rs | 138 +++++++ .../socket-patch-core/src/vex/discover/mod.rs | 7 + .../socket-patch-core/src/vex/discover/sbt.rs | 342 +++++++++++++++++- docs/ecosystems.md | 6 +- 10 files changed, 692 insertions(+), 28 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index a3ed909d6..bf8979280 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -331,7 +331,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate, `bun.lock` / `bun.lockb`, and `vlt-lock.json` with `vlt.json` and `node_modules/.vlt-lock.json` read only), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; the hosted redirect is a `sparse+` registry, which cargo loads only from **1.68**, so `rust-toolchain.toml` / `rust-toolchain` are read too (never edited) and a project whose declared floor — the lowest `rust-version` of the root and the member manifests read here (a `rust-version.workspace = true` inheritor reads `[workspace.package]`), else the channel rustup picks from the root (the legacy `rust-toolchain` over `rust-toolchain.toml`; a dated `nightly-`/`beta-YYYY-MM-DD` maps to its release train) — is below 1.68 is refused `redirect_cargo_old_cargo` per crate (nothing written, an earlier run's pins left in place with the `rollback` that drops them named; `--mode vendored` builds on cargo 1.41+), while an undeclared or rolling-channel floor still redirects (toolchain files in ancestor directories are not read); **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. With NO `Cargo.lock` there is no resolved graph to ask, so the dependents question is answered from the manifests instead: a crate declared beside any other dependency — anything but a path dependency on a manifest this run also pins, or a `workspace = true` inheritor of a table it scans — or beside a workspace member this run did not read (a `members` glob, or a member outside the project or behind a symbolic link, which member discovery drops) is refused `redirect_cargo_lockless_dependents`, whose detail names the remedies (commit a lockfile, or `--mode vendored`); a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json` (plus, v5.0 #353/#514, every lock a project under the root restores into: a member project's `packages.lock.json`, a per-project `packages..lock.json`, a literal `NuGetLockFilePath` — each pinned with the root config; a `NuGetLockFilePath` it cannot evaluate warns `redirect_nuget_lock_path_unresolved` and a project tree it cannot list warns `redirect_nuget_lock_unreadable`, both skipping the nuget redirect with nothing written), `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and, for a Gradle build, every settings, build, `buildSrc`, included-build, applied and plugin-source script, version catalog and lock file the script graph reaches, plus `gradle/verification-metadata.xml`, `gradle/wrapper/gradle-wrapper.properties` and the owned `.socket/gradle/` files), and the sbt build files (`socket-patch.sbt`, `socket-patch-vendor.sbt`, `build.sbt`, `project/build.properties`, `.sbtopts`, `.jvmopts`; `build.sbt.lock` and the Mill / scala-cli build files `build.mill`, `build.mill.yaml`, `build.sc`, `.mill-version`, `project.scala` for their presence only) — read, never edited; `socket-patch.sbt` is the only sbt file hosted mode writes (see **Hosted sbt** below). **npm-family flavor coverage**: package-lock / npm-shrinkwrap (a package the project patches itself with npm ≥ 12.1's native `npm patch` — a root `patchedDependencies` key, or the lock entry's `patched` record — is left on its registry entry in every npm lock, `redirect_npm_patched_dependency_skipped`), pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic (a yarn 2+ install migrates a v1 `yarn.lock` and drops its pins, so a run whose v1 lock carries a hosted pin warns `redirect_yarn_classic_berry_migration_risk` — the hosted twin of the vendored `yarn_classic_berry_migration_risk` — unless the root `package.json`, read as advisory input, declares `"packageManager": "yarn@1…"`), **yarn berry** (the pin yarn writes for a root `resolutions` entry: the root `package.json` — edited only beside a berry `yarn.lock` — gains one `"@npm:": ""` selector per locked range (`redirect_yarn_berry_resolution` edits), and only that `yarn.lock` entry is re-keyed `"@"` with the same `resolution:` + `yarnBerry10c0` checksum (`redirect_yarn_berry_entry`), moved to yarn's key order; never an `npm:` locator, whose fetcher sends npm registry auth to the patch host, nor a tarball locator under an `npm:` key, which hardened mode rejects (YN0078). An older release's `npm:::__archiveUrl=` pin is still recognized and is re-pinned on the next run; rollback rebuilds the key from the selectors and drops them. Refused, nothing written: a user-authored `resolutions` entry for the package `redirect_yarn_berry_resolutions_conflict`, no root manifest `redirect_yarn_berry_manifest_missing`, a builtin `patch:` entry wrapping the same descriptor `redirect_yarn_berry_shared_descriptor`, an artifact URL yarn cannot fetch as a tarball `redirect_yarn_berry_artifact_url_unsupported`; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found` (or `redirect_bun_non_registry_entry_skipped` when the only same-version entry is a user URL / `file:` tarball, #497), a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`), and **vlt** (`vlt-lock.json` without `lockfileVersion`, `0` or `1`; see the vlt hosted-mode contract below). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when the `repo-state.json` beside a rewritten lock exists (`common/config/rush/repo-state.json` for the common lock, `common/config/subspaces//repo-state.json` for a subspace lock; the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives), and `redirect_pnpm_trust_lockfile` carries the Rush pnpm >=11 install remedy (see the trust paragraph above). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`; when `.mvn/wrapper/maven-wrapper.properties` pins a Maven older than 3.9.4, which ignores those files, the additive warning `redirect_maven_trusted_checksums_unenforced`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a reactor root (a `` / `` declaration, a profile's included, or a Maven 4 root that lists none but whose subprojects Maven discovers: model version not `4.0.0`, `pom` packaging, a direct subdirectory holding a `pom.xml`, and `maven.project.discoverSubprojects` not turned off in `.mvn/maven.config`; each such subdirectory's `pom.xml` is read for this, never edited) is refused whole with `redirect_maven_multimodule_unsupported`: `pom.xml` and `.mvn/` are left untouched and the dep is not counted as redirected (a Gradle build beside it is still planned by the Gradle rewriter, but a dep in a root with a `pom.xml` is confirmed only when the pom pins it too), since a module's own literal `` would shadow a root pin (use `--mode vendored`; `vex` omits a hosted pin in a reactor root as `vex_maven_reactor_root`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), a `` variant (sources, tests, a native build) left at its version (`redirect_maven_classifier_unsupported`; the grant serves the main jar only, and the dep is skipped when a variant other than `sources` / `javadoc` names the patched release), a versioned declaration inside `` (a literal or a `${property}`) left as-is (`redirect_maven_profile_dependency_unpatched`; the dep is skipped when a profile holds its only declaration), a `${property}` groupId / artifactId resolved from the pom's own `` before matching (one the pom does not define, which may be the patched GA and whose own version would then win, refuses the dep with nothing written: `redirect_maven_coordinate_unresolved`), and a pom not readable as one left untouched (`redirect_maven_pom_unreadable`). Matches and anchors skip comments, CDATA, ``, ``, ``, `` and `` (the scope `vex` reads), and an existing self-closed `` / `` is expanded in place, never duplicated; an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). **gradle** (v5.0) is automated wiring, no longer a pasted snippet: the owned settings script `.socket/gradle/socket-patch.hosted.settings.gradle` with its index `.socket/gradle/hosted-index.tsv`, one apply line per build's settings file, every lock entry of the GA moved to the suffixed version, and the suffixed component in an existing `gradle/verification-metadata.xml`. A refused dep writes nothing and keeps `redirect_gradle_manual_snippet` as its fallback; same-GAV grants are refused (`redirect_gradle_same_gav_unsupported`). Rules, refusals and codes: [Gradle builds](#gradle-builds-v50). -**Hosted sbt (v5.0, additive)**: an sbt build root (`project/build.properties` naming an `sbt.version`, 0.13.18 or later) is wired through ONE generated root file, `socket-patch.sbt` — no user file is edited. It pins every granted Maven patch build-wide (a `ThisBuild` `dependencyOverrides +=` of the Socket-only `-socket.` version plus a `file:` resolver over `.socket/sbt-hosted/maven2/`, moved ahead of the default repositories on sbt 0.13 / 1.x so an unreachable one never blocks it offline), downloads the pinned pom and jar there on the first sbt load (sha256-checked, gitignored by the file itself), and installs a load-time verifier that fails `update` when any project resolves another version or a pinned artifact whose bytes are not pinned. Edits: `redirect_sbt_pin` (added), `redirect_sbt_pin_updated` (an existing row replaced: same GA and base under a new uuid, or the same uuid with new served values; `original` names the previous uuid and version), `redirect_sbt_pin_rechecked` (an existing row re-verified after the build's dependencies changed: its dependency digest is recorded anew, `original`/`new` are `{deps}`). The load-time verifier also fails `update` when a project declares a pinned GA at a version newer than the pin's base (the build-wide override would otherwise force it back down). A new pin is gated on sbt's own resolution records under `target/` (never the machine-wide cache): run-level stops wire nothing, warn once and exit 0 — `redirect_sbt_no_resolution_evidence` (none; run `sbt update` first; always the in-memory engine's answer), `redirect_sbt_resolution_incomplete` (a declared project left no evidence, or the project definitions cannot be read statically), `redirect_sbt_resolution_stale` (a build source is newer than some project's evidence: each project is dated by its own newest record, so a partial `sbt /update` does not vouch for the others). Per-patch refusals (never confirmed): `redirect_sbt_missing_override` (no `maven2` override or no suffixed version), `redirect_sbt_integrity_missing` (jar or pom sha256 missing), `redirect_sbt_unsafe_value` (a value unsafe in a Scala literal, or an index URL not naming the uuid), `redirect_sbt_version_conflict` (some project resolves another version, or a build source declares the GA newer than the patch's base), `redirect_sbt_override_conflict` (two patches for one GA in a run, or another base already pinned), `redirect_sbt_vendored_conflict` (the GA is pinned by `socket-patch-vendor.sbt`, or that file cannot be parsed — then every Maven patch), `redirect_sbt_owned_file_modified` / `redirect_sbt_owned_file_foreign` (`socket-patch.sbt` edited, or not socket-patch's — every Maven patch), `redirect_sbt_owned_file_unreadable` (a whole-run refusal: `socket-patch.sbt` is on disk but cannot be read as UTF-8 text, so writing it would replace it; nothing is written), `redirect_sbt_unsupported_version`, `redirect_sbt_build_root_unknown` (sbt files but no versioned build root — every Maven patch), `redirect_sbt_overrides_assignment` / `redirect_sbt_resolvers_assignment` (a build source reassigns `dependencyOverrides` / `resolvers` with `:=`, `~=` or `--=`), `redirect_sbt_dependency_lock_present` (a `build.sbt.lock`), `redirect_sbt_scala_runtime_unsupported` (`org.scala-lang`), `redirect_sbt_classifier_unsupported`; a GA no library configuration resolves is skipped silently (`redirect_sbt_meta_build_only` when only the meta-build resolves it). Advisories: `redirect_sbt_version_untested` (sbt 2.1+, still wired), `redirect_sbt_override_build_repos` (`sbt.override.build.repos=true`), `redirect_maven_pom_ignored_sbt_build` (a `pom.xml` beside the sbt build, which sbt never reads; the Maven rewriter still edits it for the Maven build). A re-run keeps an existing row and re-checks it. When the build's dependency digest changed since the pin, evidence resolved after the change (fresh, newer than the generated file) re-verifies it and the row's digest is refreshed (`redirect_sbt_pin_rechecked`); the uuid is NOT confirmed on `redirect_sbt_pin_declared_newer` (a build source now declares the GA newer than the pin's base; the row stays, sbt's load-time verifier fails the build, and the remedy is `socket-patch rollback` or declaring the base again), `redirect_sbt_pin_unverifiable` (the digest changed and the evidence predates the change, or the digest cannot be computed: run `sbt update`, then re-run socket-patch), `redirect_sbt_override_shadowed` (the evidence still resolves the base version) or `redirect_sbt_resolved_elsewhere` (the pinned version resolves from outside the pin repository from a file whose sha256 is not the pinned jar's; a copy holding the pinned bytes, such as the Ivy cache a second checkout reads, is fine — at most 64 pinned artifact files of up to 256 MiB are hashed, anything else counts as elsewhere), and also when a build source now reassigns `dependencyOverrides` / `resolvers` or a `build.sbt.lock` appeared (the same `redirect_sbt_overrides_assignment` / `redirect_sbt_resolvers_assignment` / `redirect_sbt_dependency_lock_present` codes; the row stays and sbt's load-time verifier fails the build). For a pure sbt root (no `pom.xml` / Gradle script beside it), maven confirmation is decided only by the sbt rewriter's report; on a mixed root a uuid the sbt rewriter refused is still confirmed by the Maven rewriter's own `pom.xml` pin (the generated sbt files never prove a pin by substring). **Mill and scala-cli** are guidance only: per Maven patch `redirect_mill_manual_snippet` / `redirect_scala_cli_manual_snippet` carry a paste-able snippet (repository + forced suffixed version), nothing is written or confirmed, and a pure Mill / scala-cli root gets no `redirect_maven_no_pom`; there, a Maven patch the server sent without a `maven2` registry override gets `redirect_maven_missing_override` instead of a snippet (with a `pom.xml` beside the Mill / scala-cli files the pom rewriter reports it). `rollback` / `remove` restore `socket-patch.sbt` offline (the rows removed, the file deleted with its last pin; the gitignored downloads are left). Manifest-less VEX reads every strictly parsed pin as a hosted reference but grants it the lockfile basis only when the local evidence shows every recorded version of the GA is the pinned one and every recorded artifact hashes to a pinned sha256 (else `sbt_resolution_unverified`). +**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`, and the suffixed copy sbt downloaded into `.socket/sbt-hosted` is its only basis). Where the build provably does not apply the pin, that copy proves nothing and the patch is omitted (`vex_sbt_pin_bypassed`): a build source reassigns `dependencyOverrides` or `resolvers` (`:=`, `~=`, `--=`) or sets `sbt.override.build.repos=true`, whether or not any evidence exists, or evidence newer than every build source and the generated file records another version of the GA, or an artifact of the pinned version whose bytes are not pinned. Stale or partial evidence, project definitions that cannot be read statically and a `build.sbt.lock` only withhold the lockfile basis. **Non-UTF-8 candidate files (#721)**: the rewriters edit UTF-8 text only. A candidate file that exists but is not UTF-8 (for example a UTF-16 `requirements.txt`, which is what Windows PowerShell 5.1's `pip freeze >` writes and which pip installs from) is never read as absent. When a candidate of its ecosystem could rewrite it, the run is refused with `candidate_file_unreadable` (exit 1, `--dry-run` included), the message names the file, and nothing is written; the remedy is to re-save the file as UTF-8. Two exceptions keep their own refusals. A Gradle build file the Gradle planner reaches gets that planner's per-build refusal (`redirect_gradle_build_file_unreadable`, exit 0) and the rest of the run goes ahead, and with no readable Gradle build a stray Gradle file (a lock, a nested script) is never rewritten, so it does not refuse the run, while a non-UTF-8 root `settings.gradle(.kts)` or `build.gradle(.kts)` still refuses it (it may be the build itself). An unreadable `socket-patch.sbt` is refused with `redirect_sbt_owned_file_unreadable`. A vendored→hosted takeover checks this before it reverts anything, so a refused run leaves the vendored wiring, ledger entry and artifact byte-identical. Vendored mode likewise refuses a non-UTF-8 `requirements.txt` or `-r` include by name (`pypi_no_requirements`) instead of wiring around it. Lock-only discovery reads `requirements.txt` and its in-root `-r` includes the way pip decodes them (a UTF-16 or UTF-32 byte-order mark selects that encoding), so such a project's pins are still found instead of reporting "No packages found". @@ -547,7 +547,7 @@ Recognition rules that hold for every ecosystem: * **Patch hosts.** A hosted reference counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin, with no userinfo. The uuid is the URL's LAST canonical-uuid path segment, because grant tokens may themselves be uuid-shaped. The Go module prefix is fixed. `socket-patch-` registry / repository / source names count only through a pin. For a URL on any other host, see **Patch hosts** above. * **Pins, not definitions.** A registry, index or source *definition* alone (cargo `[registries]`, nuget ``, pom ``, uv index tables, `.npmrc`) never makes a reference, because it survives a reverted pin. Sections the package manager ignores are not read: npm's v2 `dependencies` mirror, a `.cargo/config.toml` shadowed by `.cargo/config`. A Socket pin inside a maven `` is diagnosed, never a reference. * **Contested locks.** When one lock wires a package to a patch and another lock resolves the same `name@version` from a non-Socket source, the build's bytes depend on which package manager runs. The reference is then dropped with a `patched_ref_unattributable` diagnostic naming both files. This applies across npm / pnpm / yarn / bun and across uv / pylock / poetry / pdm / Pipfile.lock / requirements. PEP 723 script locks neither contest nor are contested. The root `requirements.txt` and its in-root `-r` includes count as one lock here: pip reads them as one requirement set, where a direct reference wins over a compatible `==` pin of the same version in another file of the set (#1086). A PyPI version matches under PEP 440 equality, so a requirements line pinned in any spelling that can only install the wired release (`==1.2.3.0`, `==v1.2.3`, `===1.2.3`) contests it like `==1.2.3` does; a range or wildcard (`~=1.2.3`, `==1.2.*`) does not (#1602). A **bundled** npm copy (`inBundle: true`, or v1 `bundled: true`, beneath a dependency that bundles it; the root project's own bundles install from the lock, #1618) of the same `name@version` contests the reference too, in the same lock, in the other npm lock of a shrinkwrap/package-lock pair, or in any other lock. npm unpacks it from the parent package's tarball, so no rewire reaches it and it stays unpatched. Bun and vlt unpack bundled copies the same way (#469, #471). pnpm unpacks bundled copies too, but its lock cannot tie one to a reference (see **Unattested references** below). For Bun, that is a `bun.lock` entry whose meta is `{ "bundled": true }`, or a `bun.lockb` record that a dependency edge with the `bundled` behavior bit reaches, including one Bun shares with a regular install. Such an entry is never a reference, and it contests the reference the same way. For vlt, the lock records no node for a bundled copy, so the copy is found in the installed store: a real package directory inside a store package's own `node_modules`. yarn classic's `yarn.lock` records nothing about bundled copies either (#758), so for a lock with a Socket wiring they are found in the installed `node_modules`: each package a parent's `bundleDependencies` / `bundledDependencies` names inside the parent's own `node_modules`, the dependencies of those beside them, and everything nested under them. Such a copy contests the reference in `yarn.lock` (the diagnostic names the parent's lock entry and the copy's directory); a lock-only `vex` with no `node_modules` can't see it. Hosted and vendored scans skip these copies with `redirect_bun_bundled_instance_skipped` / `redirect_vlt_bundled_instance_skipped` / `vendor_bundled_instance_skipped`, and a yarn classic hosted scan names one with `redirect_copy_unpatched`. When a bundled copy is the only instance, vendoring refuses with `vendor_lock_entry_not_rewritable`. A reference withheld only because such an unreachable copy installs beside it (a bundled npm / Bun / vlt copy, an npm copy beneath a `hasShrinkwrap` package (#753) or one npm installs from a git / url / `file:` spec, or a yarn classic git or `file:` directory block of the same `name@version`) is still the rewriter's own wiring of exactly one package version: it is never attested, but `rollback`, `remove`, `list` and the hosted → vendored takeover treat it as an ordinary hosted pin and restore its upstream registry entry (#828), rather than refusing it with `hosted_wiring_contested`. Another entry of the **same** npm, Bun or yarn lock that resolves the wired `name@version` from a non-Socket source (for example a workspace member added after the rewire, then `npm install` / `bun install` / `yarn install`; for yarn berry, a registry locator such as `left-pad@npm:1.3.0` beside the hosted `…::__archiveUrl=` one) contests the reference too (#588). The package manager installs both entries, and that copy stays unpatched. Re-running `scan` / `vendor` rewires every copy. -* **Unattested references.** Some evidence shows a build may run a copy no wiring reaches, but cannot be tied to the reference's exact `name@version` or cannot say the build runs it. The reference then stays a reference: `list`, `rollback` and `remove` find it, and the ledgers' liveness gates (`vendor --check`, `scan`) keep treating the wiring as live, because re-running `scan` / `vendor` could never clear the evidence. Only `vex` omits it, as a run warning and as the `failed[].reason`. Four cases besides Gradle's (`vex_gradle_lock_above_base`): **pnpm bundled copies** (`vex_pnpm_bundled_copy`): a `packages:` entry's `bundledDependencies:` names the copies pnpm unpacks from that package's own tarball, but pnpm never locks them, so the bundled version is not in the lock. A pnpm reference whose package name a `bundledDependencies` list in the same lock names is omitted whatever its version (a missed attestation when the bundled copy is another version, never a false one), and `bundledDependencies: true` (or a value that cannot be read) omits every reference of that lock. It reaches no other lock. **deno.lock** (`vex_deno_lock_copy`): `deno install` installs a `package.json` project's npm dependencies from `deno.lock` and never reads `package-lock.json` / `pnpm-lock.yaml` / `yarn.lock`, so an npm-family reference whose `name@version` the `deno.lock` npm section also locks is omitted (#406). Whether Deno or another package manager populates `node_modules` (`nodeModulesDir: "manual"` allows either) is not in the files, so this holds whatever `nodeModulesDir` says. **Maven reactor roots** (`vex_maven_reactor_root`): a hosted pin in a root `pom.xml` that declares `` / ``, or whose subprojects Maven 4 discovers (see the hosted Maven rewriter), is omitted, because a module's own literal `` overrides a root pin and module poms are not read (#261); hosted scan refuses such roots (`redirect_maven_multimodule_unsupported`), so this only covers pins an older release wrote. **Maven classifier copies** (`vex_maven_classifier_unpatched`): a `` dependency of a hosted-pinned GA is its own coordinate; the classifier-less pin does not retarget it and the grant serves only the main jar, so Maven fetches it from the next repository. When a live, non-profile classifier copy other than `sources` / `javadoc` (`tests`, a native or JDK-specific build) resolves any version other than the suffixed pin — its own literal ``, a `` entry with the same classifier, or no version in the root pom — the pin is omitted. `sources` / `javadoc` copies never run on a classpath and do not withhold the statement; hosted scan only warns about them (`redirect_maven_classifier_unsupported`) and skips the dep when an executable copy names the patched release. +* **Unattested references.** Some evidence shows a build may run a copy no wiring reaches, but cannot be tied to the reference's exact `name@version` or cannot say the build runs it. The reference then stays a reference: `list`, `rollback` and `remove` find it, and the ledgers' liveness gates (`vendor --check`, `scan`) keep treating the wiring as live, because re-running `scan` / `vendor` could never clear the evidence. Only `vex` omits it, as a run warning and as the `failed[].reason`. Five cases besides Gradle's (`vex_gradle_lock_above_base`); the fifth, **hosted sbt pins a build bypasses** (`vex_sbt_pin_bypassed`), is under **Hosted sbt** above: **pnpm bundled copies** (`vex_pnpm_bundled_copy`): a `packages:` entry's `bundledDependencies:` names the copies pnpm unpacks from that package's own tarball, but pnpm never locks them, so the bundled version is not in the lock. A pnpm reference whose package name a `bundledDependencies` list in the same lock names is omitted whatever its version (a missed attestation when the bundled copy is another version, never a false one), and `bundledDependencies: true` (or a value that cannot be read) omits every reference of that lock. It reaches no other lock. **deno.lock** (`vex_deno_lock_copy`): `deno install` installs a `package.json` project's npm dependencies from `deno.lock` and never reads `package-lock.json` / `pnpm-lock.yaml` / `yarn.lock`, so an npm-family reference whose `name@version` the `deno.lock` npm section also locks is omitted (#406). Whether Deno or another package manager populates `node_modules` (`nodeModulesDir: "manual"` allows either) is not in the files, so this holds whatever `nodeModulesDir` says. **Maven reactor roots** (`vex_maven_reactor_root`): a hosted pin in a root `pom.xml` that declares `` / ``, or whose subprojects Maven 4 discovers (see the hosted Maven rewriter), is omitted, because a module's own literal `` overrides a root pin and module poms are not read (#261); hosted scan refuses such roots (`redirect_maven_multimodule_unsupported`), so this only covers pins an older release wrote. **Maven classifier copies** (`vex_maven_classifier_unpatched`): a `` dependency of a hosted-pinned GA is its own coordinate; the classifier-less pin does not retarget it and the grant serves only the main jar, so Maven fetches it from the next repository. When a live, non-profile classifier copy other than `sources` / `javadoc` (`tests`, a native or JDK-specific build) resolves any version other than the suffixed pin — its own literal ``, a `` entry with the same classifier, or no version in the root pom — the pin is omitted. `sources` / `javadoc` copies never run on a classpath and do not withhold the statement; hosted scan only warns about them (`redirect_maven_classifier_unsupported`) and skips the dep when an executable copy names the patched release. * **Shrinkwrap without a twin (#899).** npm >= 12 never reads `npm-shrinkwrap.json`: on a project whose only npm lock is the shrinkwrap it resolves the tree from the registry and writes a fresh `package-lock.json`, so a Socket reference there reaches npm <= 11 only. The reference is still discovered, so `list`, `rollback` and `remove` manage it, but `vex` omits the patch (`vex_npm_shrinkwrap_only`, as a run warning and as the `failed[].reason`). A `package-lock.json` beside it (wired the same way) makes it attestable again. Hosted and vendored runs still rewire the shrinkwrap and warn `redirect_npm_shrinkwrap_only` / `vendor_npm_shrinkwrap_only`. * **Lockless pins.** With no lock to name a version, a `Cargo.toml` pin (every declaration on `socket-patch-`, that registry defined on the patch host for the same uuid) or an exclusive nuget exact-id mapping is never a reference on its own, so v5.0 does not attest it (nor does `list` show it, or `rollback` / `remove` restore it — restore those files from version control). Only a pre-v5 redirect-ledger record naming a version the pin admits keeps it live. The same holds for a gem wired only in the `Gemfile` (the pre-bundler-2.6 mixed state, lock not converged). @@ -579,7 +579,7 @@ A reverted lockfile plus a leftover ledger or artifact therefore stops attesting | `patched_ref_invalid` | A Socket-shaped reference failed validation or is not live wiring: unsafe coordinates, a non-canonical uuid, a leaf naming another package, a path escaping the root, an inert or orphaned entry. | | `patched_ref_unattributable` | A Socket patch uuid cannot be tied to exactly one artifact / version. Examples: a maven repository no pin names, a nuget source without a mapping, a lock contested by another lock. | | `sbt_owned_file_modified` | `socket-patch.sbt` / `socket-patch-vendor.sbt` is not byte-for-byte what socket-patch generates; none of its pins is discovered. | -| `sbt_resolution_unverified` | An sbt pin the build's local resolution evidence (`target/`, after `sbt update`) does not verify: the reference stays, without the lockfile basis. | +| `sbt_resolution_unverified` | An sbt pin the build's local resolution evidence (`target/`, after `sbt update`) does not verify: the reference stays, without the lockfile basis (a hosted pin the build provably bypasses is also omitted, `vex_sbt_pin_bypassed`). | | `vendored_tree_missing` | A `socket-patch-vendor.sbt` pin whose `.socket/vendor/maven2/` file is missing or modified. | Human mode also prints `Note:` lines: superseded records, fetch failures, `--offline` withholding fetches, conflict details, and why a claim is dead. While patch records are fetched, a terminal shows a transient `Fetching patch records... (n/N)` status line on stderr (never under `--json` / `--silent`). @@ -2678,9 +2678,20 @@ match it, withholds the statement: | `vex_deno_lock_copy` | run warning and `failed[].reason` | An npm pin is wired, but `deno.lock` locks the same `name@version`, which `deno install` installs without the wiring; no statement while it does. `vendor --check` and `scan` still treat the wiring as live. | | `vex_maven_reactor_root` | run warning and `failed[].reason` | A hosted Maven pin sits in a root `pom.xml` that declares `` / `` (a profile's included), where a module's own literal `` overrides it; only the root pom is read, so no statement (#261). `list`, `rollback` and `remove` still find the pin. | | `vex_maven_classifier_unpatched` | run warning and `failed[].reason` | A hosted Maven pin's GA also has a live executable `` copy (not `sources` / `javadoc`) at a version other than the pin, which the classifier-less pin does not retarget and the grant does not serve; no statement while it does. `list`, `rollback` and `remove` still find the pin. | +| `vex_sbt_pin_bypassed` | run warning and `failed[].reason` | A hosted sbt pin (`socket-patch.sbt`) the build provably does not apply: a build source reassigns `dependencyOverrides` (replacing the generated override, so the build resolves the version it declares) or `resolvers` / sets `sbt.override.build.repos=true` (dropping the generated resolver), or current resolution evidence (newer than every build source and the generated file) records another version of the GA or other bytes at the pinned version. The copy downloaded into `.socket/sbt-hosted` then proves nothing, so no statement. `list`, `rollback` and `remove` still find the pin. | | `vex_gradle_derived_cache_unchecked` | run warning | The derived-cache walk was cut short (a very large transforms cache); the statement is still emitted, the copies the walk did reach were checked. | A vendored Gradle entry attests only while its wiring is live (apply line, index rows, intact script, a re-plan over the committed tree refused and degraded nowhere, classifier jars free of unpatched patched members per the tree marker's `patched` -list); otherwise `vendor_unwired`. +list); otherwise `vendor_unwired`. A vendored sbt or scala-cli entry attests only while +its committed files route the build to the vendored copy; otherwise the entry is omitted +as `vendor_jvm_shape_unsupported` ("cannot establish JVM wiring") with the reason (VEX +liveness only: `vendor --revert` and `scan --prune` still see the wiring): +`sbt_pin_conflict` (the committed hosted `socket-patch.sbt` pins the same GA under +another patch, so the build may resolve that pin instead, or no longer parses strictly), +`scala_cli_tree_uncommitted` (the index has no row for the patch's jar or pom, or the +committed tree file is missing or does not hash to its row: without the tree's pom, +Coursier reads the module from the next repository, unpatched jar included) and +`scala_cli_repository_shadowed` (an input declares a repository or a direct `url=` for +the patched group, as `vendor` refuses with `vendor_scala_cli_repository_shadowed`). diff --git a/crates/socket-patch-cli/src/commands/vex_sources.rs b/crates/socket-patch-cli/src/commands/vex_sources.rs index 25e49273e..cab841624 100644 --- a/crates/socket-patch-cli/src/commands/vex_sources.rs +++ b/crates/socket-patch-cli/src/commands/vex_sources.rs @@ -232,6 +232,11 @@ const NOTE_MAVEN_REACTOR_ROOT: &str = "vex_maven_reactor_root"; /// classifier copy at another version, which the pin does not reach /// (`vex::Unattested`). const NOTE_MAVEN_CLASSIFIER_UNPATCHED: &str = "vex_maven_classifier_unpatched"; +/// Omission tag and note: a hosted sbt pin the build provably does not +/// apply (a `dependencyOverrides` / `resolvers` reassignment, or current +/// evidence resolving another version or other bytes), so the downloaded +/// copy proves nothing (`vex::Unattested`). +const NOTE_SBT_PIN_BYPASSED: &str = "vex_sbt_pin_bypassed"; /// The omission tag of an [`Unattested`](socket_patch_core::vex::Unattested) /// ref, and the remedy its note ends with. @@ -268,6 +273,12 @@ fn unattested_note(kind: UnattestedKind) -> (&'static str, &'static str) { "not attested while that classifier copy resolves an unpatched version; the \ wiring itself is intact, so re-running `scan` does not change this", ), + UnattestedKind::SbtPinBypassed => ( + NOTE_SBT_PIN_BYPASSED, + "not attested while the build bypasses the pin, even from the copy under \ + `.socket/sbt-hosted`; the wiring itself is intact, so re-running `scan` does not \ + change this (fix the build, then run `sbt update`)", + ), } } diff --git a/crates/socket-patch-cli/tests/e2e_sbt_hosted.rs b/crates/socket-patch-cli/tests/e2e_sbt_hosted.rs index 46b47491d..dd6444303 100644 --- a/crates/socket-patch-cli/tests/e2e_sbt_hosted.rs +++ b/crates/socket-patch-cli/tests/e2e_sbt_hosted.rs @@ -430,6 +430,52 @@ fn hosted_vex_without_evidence_is_unverified() { ); } +/// An unverified pin (no evidence) keeps the installed-copy basis: the +/// suffixed copy sbt downloaded into `.socket/sbt-hosted` at load attests +/// it. A build source reassigning `dependencyOverrides` or `resolvers` +/// drops the pin where it applies, so the same copy proves nothing about +/// what the build runs: the patch is omitted with `vex_sbt_pin_bypassed`. +#[test] +fn hosted_vex_installed_copy_is_withdrawn_by_a_pin_dropping_edit() { + for edit in [ + None, + Some("dependencyOverrides := Seq()\n"), + Some("resolvers := Seq(Resolver.mavenLocal)\n"), + ] { + let tmp = tempfile::tempdir().unwrap(); + let home = SbtHome::new(tmp.path()); + let project = committed_project(tmp.path()); + if let Some(edit) = edit { + let mut build = std::fs::read_to_string(project.join("build.sbt")).unwrap(); + build.push_str(edit); + std::fs::write(project.join("build.sbt"), build).unwrap(); + } + let copy = project + .join(HOSTED_REPO_REL) + .join("com/google/code/gson/gson") + .join(SV); + std::fs::create_dir_all(©).unwrap(); + std::fs::write(copy.join(format!("gson-{SV}.jar")), JAR).unwrap(); + std::fs::write(copy.join(format!("gson-{SV}.pom")), POM).unwrap(); + let api = Api::start(); + api.grant(UUID, GROUP, ARTIFACT, BASE); + let (_, json) = vex(&home, &project, &api.server.uri()); + let codes = all_codes(&json); + assert!( + codes.contains(&"sbt_resolution_unverified".to_string()), + "{edit:?}: {json}" + ); + let doc = std::fs::read_to_string(project.join("vex.json")).unwrap_or_default(); + let attested = doc.contains("\"not_affected\""); + let bypassed = codes.contains(&"vex_sbt_pin_bypassed".to_string()); + assert_eq!( + (attested, bypassed), + (edit.is_none(), edit.is_some()), + "{edit:?}: {json}\n{doc}" + ); + } +} + /// What `sbt update` records after wiring: `b` resolves the pinned version, /// its jar at `jar` (absolute), dated after the generated file. fn post_update_evidence(project: &Path, jar: &Path) { diff --git a/crates/socket-patch-core/src/vendor/jvm/apply.rs b/crates/socket-patch-core/src/vendor/jvm/apply.rs index 0fffc3a24..f19f1dc47 100644 --- a/crates/socket-patch-core/src/vendor/jvm/apply.rs +++ b/crates/socket-patch-core/src/vendor/jvm/apply.rs @@ -1038,8 +1038,18 @@ pub fn entry_references(root: &Path, entry: &VendorEntry) -> bool { /// Gradle half's script is intact and re-plans with no refusal and no /// degraded warning (a conflicting rule or unchecked build logic added /// since vendoring withholds attestation), and no Maven declaration is -/// left at a version that may still read the base. +/// left at a version that may still read the base. An sbt or scala-cli +/// entry's committed files must also route the build to the vendored copy +/// (`sbt::attestable_checked`, `scala_cli::attestable_checked`): when they +/// do not, the reason is the `Err`, which withholds attestation without +/// declaring the wiring gone. pub fn entry_wired_checked(root: &Path, entry: &VendorEntry) -> Result { + entry_wiring_checked(root, entry, true) +} + +/// [`entry_wired_checked`]; `attest: false` skips the sbt / scala-cli +/// attestability checks (`check_entry`'s reference test). +fn entry_wiring_checked(root: &Path, entry: &VendorEntry, attest: bool) -> Result { let (g, a, v) = entry_gav(entry)?; let c = Coords { group_id: &g, @@ -1051,9 +1061,17 @@ pub fn entry_wired_checked(root: &Path, entry: &VendorEntry) -> Result sbt::attestable_checked(&read, &c).map(|()| true), + other => other, + } + .map_err(|e| e.detail) } else if scala_cli::owns(&entry.wiring) { - scala_cli::wired_checked(&read, &c).map_err(|e| e.detail) + match scala_cli::wired_checked(&read, &c) { + Ok(true) if attest => scala_cli::attestable_checked(root, &read, &c).map(|()| true), + other => other, + } + .map_err(|e| e.detail) } else { let (maven, gradle) = sides(&entry.wiring); let mut wired = Ok(true); @@ -1202,7 +1220,7 @@ pub fn check_entry( return Err(format!("vendored wiring or metadata drifted: {}", w.rel)); } let referenced = if sbt_entry || scala_cli_entry { - entry_wired_checked(root, entry)? + entry_wiring_checked(root, entry, false)? } else { (!maven || maven_reactor::wired_checked(&read, &list, &c).map_err(|e| e.detail)?) && (!gradle || gradle::references(&read, &c)) diff --git a/crates/socket-patch-core/src/vendor/jvm/coursier_gate.rs b/crates/socket-patch-core/src/vendor/jvm/coursier_gate.rs index 7e0e8b277..8bc797801 100644 --- a/crates/socket-patch-core/src/vendor/jvm/coursier_gate.rs +++ b/crates/socket-patch-core/src/vendor/jvm/coursier_gate.rs @@ -181,6 +181,25 @@ fn directive_refusal( Some(refuse("vendor_scala_cli_repository_shadowed", detail)) } +/// The gate's `vendor_scala_cli_repository_shadowed` directive check over +/// the build at `project_root` as it stands, for `vex` liveness +/// (`scala_cli::attestable_checked`): the detail naming the input that +/// declares a repository or pins `g` to a direct `url=`, if any. The +/// inputs are the directory's own plus, when evidence lists them, the +/// out-of-root ones it builds. +pub(crate) fn shadowing_directive(project_root: &Path, g: &str, a: &str) -> Option { + let evidence = scala_evidence::discover(project_root); + let sources = evidence + .as_ref() + .map(|e| e.sources.as_slice()) + .unwrap_or_default(); + match directive_refusal(project_root, sources, g, a)? { + GateStop::Refuse(r) => Some(r.detail), + GateStop::Skip(w) => Some(w.detail), + GateStop::Silent => None, + } +} + /// Whether the committed tree's index already lists `g:a:base` (under any /// patch uuid: an update re-plans it too). FIFO-safe; an unreadable index /// lists nothing (the planner refuses it). diff --git a/crates/socket-patch-core/src/vendor/jvm/sbt.rs b/crates/socket-patch-core/src/vendor/jvm/sbt.rs index 8dcb2f5ec..2c9e982d0 100644 --- a/crates/socket-patch-core/src/vendor/jvm/sbt.rs +++ b/crates/socket-patch-core/src/vendor/jvm/sbt.rs @@ -592,6 +592,50 @@ pub fn wired_checked(read: ReadFn<'_>, c: &Coords<'_>) -> Result, c: &Coords<'_>) -> Result<(), JvmRefusal> { + let conflict = |detail: String| JvmRefusal { + code: "vendor_jvm_shape_unsupported", + detail: format!("reason: sbt_pin_conflict: {detail}"), + }; + let (g, a) = (c.group_id, c.artifact_id); + let hosted = match read_owned(read, SbtFileMode::Hosted) { + Ok(file) => file, + Err(OwnedFileError::Foreign) => None, + Err(OwnedFileError::Modified(why)) => { + return Err(conflict(format!( + "{HOSTED_FILE} is not what socket-patch generated ({why}), so it may pin \ + {g}:{a} too; restore or remove it" + ))) + } + }; + let others: Vec = hosted + .iter() + .flat_map(|f| f.pins.values()) + .filter(|p| p.uuid != c.uuid && p.group == g && p.artifact == a) + .map(|p| format!("{} (patch {})", p.sv, p.uuid)) + .collect(); + if !others.is_empty() { + return Err(conflict(format!( + "{HOSTED_FILE} also pins {g}:{a} at {}, and the build resolves one version of it; \ + roll that patch back", + others.join(", ") + ))); + } + Ok(()) +} + /// The committed tree bytes of the patch at `c` (the reactor's tree). pub fn committed(read: ReadFn<'_>, c: &Coords<'_>) -> Option { maven_reactor::committed(read, c).map(|(jar, pom)| (jar, pom, None)) @@ -1493,4 +1537,66 @@ mod tests { .writes .is_empty()); } + + /// VEX liveness reads the committed hosted file too: its pin of the + /// same GA under another patch (as a merge of two branches leaves) may + /// be the one the build resolves, and a hosted file that no longer + /// parses strictly may pin anything. The revert / prune reference test + /// still sees the pin. (Two rows of one GA in the vendored file never + /// parse strictly, so `wired_checked` already fails there.) + #[tokio::test] + async fn liveness_refuses_another_committed_pin_of_the_ga() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write(root, "1.9.9", &[]); + let mut ledger = BTreeMap::new(); + vendor(root, Shape::Sbt, &text_patch(UUID_A), &mut ledger) + .await + .unwrap(); + let e = ledger.values().next().unwrap().clone(); + assert_eq!(apply::entry_wired_checked(root, &e), Ok(true)); + let reader = super::super::apply::ProjectReader::new(root); + let read = |rel: &str| reader.read(rel); + let committed = read_owned(&read, SbtFileMode::Vendored).unwrap().unwrap(); + let mine = committed.pins[UUID_A].clone(); + let other = |uuid: &str, mode: SbtFileMode| SbtPin { + uuid: uuid.into(), + sv: format!("1.10.0-socket.{}", &uuid[..8]), + index_url: (mode == SbtFileMode::Hosted) + .then(|| format!("https://patch.socket.dev/patch-registry/maven/t/{uuid}/maven2")), + ..mine.clone() + }; + let conflict = |what: &str| { + let err = apply::entry_wired_checked(root, &e).unwrap_err(); + assert!(err.contains("reason: sbt_pin_conflict:"), "{what}: {err}"); + assert!(apply::entry_references(root, &e), "{what}"); + // `vendor --check` re-plans, and the planner refuses it. + assert!(apply::check_entry(root, &e, None).is_err(), "{what}"); + }; + + // A hosted row of the GA under another patch. + let hosted = SbtOwnedFile { + mode: SbtFileMode::Hosted, + line: committed.line, + crlf: false, + pins: [(UUID_A2.to_string(), other(UUID_A2, SbtFileMode::Hosted))].into(), + }; + let hosted_text = render(&hosted).unwrap(); + std::fs::write(root.join(HOSTED_FILE), &hosted_text).unwrap(); + conflict("hosted row"); + // The same patch hosted is the same version and bytes. + let same = SbtOwnedFile { + pins: [(UUID_A.to_string(), other(UUID_A, SbtFileMode::Hosted))].into(), + ..hosted.clone() + }; + std::fs::write(root.join(HOSTED_FILE), render(&same).unwrap()).unwrap(); + assert_eq!(apply::entry_wired_checked(root, &e), Ok(true)); + // An edited hosted file, but not a user's own file under the name. + std::fs::write(root.join(HOSTED_FILE), format!("{hosted_text}// edit\n")).unwrap(); + conflict("modified hosted file"); + std::fs::write(root.join(HOSTED_FILE), "lazy val x = 1\n").unwrap(); + assert_eq!(apply::entry_wired_checked(root, &e), Ok(true)); + std::fs::remove_file(root.join(HOSTED_FILE)).unwrap(); + assert_eq!(apply::entry_wired_checked(root, &e), Ok(true)); + } } diff --git a/crates/socket-patch-core/src/vendor/jvm/scala_cli.rs b/crates/socket-patch-core/src/vendor/jvm/scala_cli.rs index 4d67ba604..a62aca92f 100644 --- a/crates/socket-patch-core/src/vendor/jvm/scala_cli.rs +++ b/crates/socket-patch-core/src/vendor/jvm/scala_cli.rs @@ -233,6 +233,74 @@ pub fn wired_checked(read: ReadFn<'_>, c: &Coords<'_>) -> Result, + c: &Coords<'_>, +) -> Result<(), JvmRefusal> { + let not_attestable = |reason: &str, detail: String| JvmRefusal { + code: "vendor_jvm_shape_unsupported", + detail: format!("reason: {reason}: {detail}"), + }; + let rows = match read(INDEX_REL) { + Some(bytes) => coursier_tree::parse_index(&bytes).map_err(|why| { + not_attestable( + "coursier_index_unreadable", + format!("{INDEX_REL}: {why}; restore it from git"), + ) + })?, + None => Vec::new(), + }; + let gav = format!("{}:{}:{}", c.group_id, c.artifact_id, c.version); + let version_dir = layout::version_dir(c.group_id, c.artifact_id, c.version); + for ext in ["jar", "pom"] { + let rel = format!("{version_dir}/{}-{}.{ext}", c.artifact_id, c.version); + let path = format!("{}/{rel}", layout::COURSIER_TREE); + let row = rows + .iter() + .find(|r| r.gav == gav && r.uuid == c.uuid && r.rel == rel); + let why = match (row, read(&path)) { + (None, _) => format!("{INDEX_REL} lists no {ext} of patch {}", c.uuid), + (Some(_), None) => format!("{path} is missing"), + (Some(row), Some(bytes)) if super::sha256_hex(&bytes) != row.sha256 => { + format!("{path} does not hash to its {INDEX_REL} row") + } + (Some(_), Some(_)) => continue, + }; + return Err(not_attestable( + "scala_cli_tree_uncommitted", + format!( + "{why}, so scala-cli may resolve {gav} past the vendored tree; restore \ + .socket/vendor/coursier from git or re-run socket-patch vendor" + ), + )); + } + if let Some(why) = super::coursier_gate::shadowing_directive(root, c.group_id, c.artifact_id) { + return Err(not_attestable("scala_cli_repository_shadowed", why)); + } + Ok(()) +} + /// The committed tree bytes of the patch at `c`. pub fn committed(read: ReadFn<'_>, c: &Coords<'_>) -> Option { coursier_tree::committed(read, c) @@ -543,4 +611,74 @@ mod tests { let bad = fs(&[(INDEX_REL, "#nope\n")]); assert!(wired_checked(&bad, &p.coords()).is_err()); } + + /// VEX liveness judges the committed files a build reads, not the + /// wiring test alone: a tree pom gone (Coursier then reads the module + /// from the next repository, Central's unpatched jar with it), a tree + /// file the index no longer vouches for, or a repository directive + /// added since vendoring withholds attestation with its reason, while + /// `vendor --check`'s reference test and the revert / prune reference + /// test still see the wiring. + #[tokio::test] + async fn liveness_needs_the_committed_tree_and_no_shadowing_repository() { + use super::super::apply::{check_entry, entry_references, entry_wired_checked}; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + populate( + root, + &[ + (PROJECT_FILE, "//> using scala 3.3.6\n"), + ("main.scala", "x\n"), + ], + ); + let mut ledger = BTreeMap::new(); + let p = patch("config", UUID, b"PATCHED"); + vendor(root, Shape::ScalaCli, &p, &mut ledger) + .await + .unwrap(); + let entry = ledger.values().next().unwrap().clone(); + assert_eq!(entry_wired_checked(root, &entry), Ok(true)); + check_entry(root, &entry, None).unwrap(); + + let tree = root.join(coursier_tree::tree_dir(&p.coords())); + let reason = |want: &str| { + let err = entry_wired_checked(root, &entry).unwrap_err(); + assert!(err.contains(&format!("reason: {want}:")), "{err}"); + // Still wired for every other caller. + let read = |rel: &str| super::super::apply::read_project_file(root, rel); + assert_eq!(wired_checked(&read, &p.coords()), Ok(true)); + assert!(entry_references(root, &entry)); + }; + let pom = tree.join("config-1.4.3.pom"); + let pom_bytes = std::fs::read(&pom).unwrap(); + std::fs::remove_file(&pom).unwrap(); + reason("scala_cli_tree_uncommitted"); + std::fs::write(&pom, b"\n").unwrap(); + reason("scala_cli_tree_uncommitted"); + std::fs::write(&pom, &pom_bytes).unwrap(); + // An index edited to drop the pom row no longer vouches for it. + let index = root.join(INDEX_REL); + let index_bytes = std::fs::read(&index).unwrap(); + let kept: String = String::from_utf8(index_bytes.clone()) + .unwrap() + .lines() + .filter(|l| !l.contains(".pom\t")) + .map(|l| format!("{l}\n")) + .collect(); + std::fs::write(&index, kept).unwrap(); + reason("scala_cli_tree_uncommitted"); + std::fs::write(&index, &index_bytes).unwrap(); + assert_eq!(entry_wired_checked(root, &entry), Ok(true)); + + // A repository another input declares is consulted before the tree. + std::fs::write( + root.join("main.scala"), + "//> using repository https://repo.example.com/maven2\nx\n", + ) + .unwrap(); + reason("scala_cli_repository_shadowed"); + check_entry(root, &entry, None).unwrap(); + std::fs::write(root.join("main.scala"), "x\n").unwrap(); + assert_eq!(entry_wired_checked(root, &entry), Ok(true)); + } } diff --git a/crates/socket-patch-core/src/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index 815d4644d..5ede1c71f 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -606,6 +606,13 @@ pub enum UnattestedKind { /// version: the classifier-less pin does not retarget it and the grant /// serves only the main jar, so the build runs the public classifier jar. MavenClassifierUnpatched, + /// A hosted sbt pin (`socket-patch.sbt`) the build provably does not + /// apply: a build source reassigns `dependencyOverrides` or `resolvers` + /// (or sets `sbt.override.build.repos`), dropping the generated + /// override or resolver, or current resolution evidence records another + /// version or other bytes of the GA. The downloaded copy under + /// `.socket/sbt-hosted` then proves nothing about what the build runs. + SbtPinBypassed, } /// A ref discovery emits (so rollback, remove and list find the wiring, diff --git a/crates/socket-patch-core/src/vex/discover/sbt.rs b/crates/socket-patch-core/src/vex/discover/sbt.rs index f77b5f81e..f9bd19546 100644 --- a/crates/socket-patch-core/src/vex/discover/sbt.rs +++ b/crates/socket-patch-core/src/vex/discover/sbt.rs @@ -11,8 +11,16 @@ //! verifies it: every version sbt recorded for the GA is the pinned //! `-socket.`, and every artifact file recorded for it hashes to //! one of the pin's sha256s, the jar's among them. Otherwise the diagnostic -//! `sbt_resolution_unverified`, and the ref stays unattested (it must still -//! be verified against an installed copy). +//! `sbt_resolution_unverified`, and the ref keeps only the installed-copy +//! basis (the suffixed copy under `.socket/sbt-hosted`, which only the +//! hosted file downloads). +//! +//! That copy proves sbt loaded the hosted file once, not that the build +//! resolves it. Where the build provably does not apply the pin +//! ([`bypass`]: a build edit dropping the generated override or resolver, +//! or current evidence resolving another version or other bytes), the ref +//! is also marked [`Unattested`] (`SbtPinBypassed`), so no basis attests +//! it. //! //! The check is on content, not location, like the generated file's own //! load-time verifier: Ivy legitimately serves a second checkout from the @@ -26,11 +34,13 @@ //! A generated file socket-patch cannot parse strictly is //! `sbt_owned_file_modified` (a foreign file under the name is not ours and //! is ignored). +//! +//! [`Unattested`]: super::Unattested use std::collections::BTreeMap; use std::path::{Path, PathBuf}; -use super::{DiscoverCtx, Discovery, PatchedRef, DIAG_REF_INVALID}; +use super::{DiscoverCtx, Discovery, PatchedRef, UnattestedKind, DIAG_REF_INVALID}; use crate::formats::sbt::owned_file::{ parse, OwnedFileError, SbtFileMode, SbtPin, HOSTED_FILE, VENDORED_FILE, VENDORED_REPO_REL, }; @@ -51,46 +61,159 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { if hosted.is_none() && vendored.is_none() { return; } - // The resolution evidence is sbt's own output tree: only a disk has one. - let (res, blocker) = match ctx.disk_root() { - Some(root) => evidence(root).await, - None => (None, None), + // The resolution evidence is sbt's own output tree, and the build + // sources are a walk: only a disk has either. + let build = match ctx.disk_root() { + Some(root) => build_state(root).await, + None => BuildState::default(), }; - let blocker = blocker.as_deref(); let mut hashes = Hashes::default(); if let Some(text) = hosted { if let Some(pins) = strict(SbtFileMode::Hosted, &text, out) { for pin in pins { - hosted_ref(ctx, &pin, res.as_ref(), blocker, &mut hashes, out).await; + hosted_ref(ctx, &pin, &build, &mut hashes, out).await; } } } if let Some(text) = vendored { if let Some(pins) = strict(SbtFileMode::Vendored, &text, out) { + let (res, blocker) = (build.res.as_ref(), build.blocker.as_deref()); for pin in pins { - vendored_check(ctx, &pin, res.as_ref(), blocker, &mut hashes, out).await; + vendored_check(ctx, &pin, res, blocker, &mut hashes, out).await; } } } } -/// The build's resolution evidence, read off disk (`None` when there is -/// none), and why it cannot vouch for any pin even where it shows one -/// ([`evidence_blocker`]). -async fn evidence(root: &Path) -> (Option, Option) { +/// What the build's local state says about every pin. +#[derive(Debug, Default)] +struct BuildState { + /// The build's resolution evidence (`None` when there is none). + res: Option, + /// Why the evidence cannot vouch for any pin even where it shows one + /// ([`evidence_blocker`]). + blocker: Option, + /// A build edit that keeps the generated pin from applying + /// ([`edit_bypass`]), read off the build sources whether or not any + /// evidence exists. + edit: Option, + /// The evidence postdates every build source and the generated files, + /// so what it records is what the build resolves now. + current: bool, +} + +/// The build's state under `root`: its sources scanned, its evidence read +/// (`sbt_evidence::discover`'s rule: no evidence file, no evidence). +async fn build_state(root: &Path) -> BuildState { let root = root.to_path_buf(); tokio::task::spawn_blocking(move || { - let Some(e) = crate::crawlers::sbt_evidence::discover(&root) else { - return (None, None); + let Ok(e) = crate::crawlers::sbt_evidence::scan(&root) else { + return BuildState::default(); }; + let edit = edit_bypass(&crate::formats::sbt::build::scan_build_sources( + &e.build_sources, + )); + if e.files.is_empty() { + return BuildState { + edit, + ..BuildState::default() + }; + } let res = crate::crawlers::sbt_evidence::resolution(&e); let blocker = res.as_ref().and_then(|res| evidence_blocker(&e, res)); - (res, blocker) + BuildState { + res, + blocker, + edit, + current: !e.stale && !e.wiring_newer, + } }) .await .unwrap_or_default() } +/// The build edit under which the generated file pins nothing where the +/// edit applies, so no copy (installed, cached or resolved) shows that the +/// build uses the pinned bytes: a `dependencyOverrides` reassignment +/// replaces the generated override (the build resolves the version it +/// declares), and a `resolvers` reassignment or `sbt.override.build.repos` +/// drops the generated resolver (the pinned version resolves from another +/// repository, or not at all). The other build-wide blockers +/// ([`evidence_blocker`]) only make the evidence too old or too partial to +/// verify a pin, and a `build.sbt.lock` checks the resolution without +/// changing it: under none of them does the pin stop applying. +fn edit_bypass(findings: &crate::formats::sbt::build::BuildFindings) -> Option { + if !findings.overrides_assignment.is_empty() { + return Some(format!( + "{} reassign(s) `dependencyOverrides`, which replaces socket-patch's override where \ + it applies, so the build resolves the version it declares there (append with `+=` \ + instead)", + findings.overrides_assignment.join(", ") + )); + } + if !findings.resolvers_assignment.is_empty() { + return Some(format!( + "{} reassign(s) `resolvers`, which drops socket-patch's resolver where it applies, \ + so the pinned version does not resolve from it there (append with `+=` instead)", + findings.resolvers_assignment.join(", ") + )); + } + if !findings.override_build_repos.is_empty() { + return Some(format!( + "{} set(s) `sbt.override.build.repos=true`, so the launcher's repositories replace \ + the build's, socket-patch's resolver among them", + findings.override_build_repos.join(", ") + )); + } + None +} + +/// Why the build provably does not use `pin`'s bytes, whatever copy is +/// installed: a build edit ([`edit_bypass`]), or current evidence +/// ([`BuildState::current`]) recording another version of the GA, or a +/// readable artifact of the pinned version whose bytes are not pinned. +/// Evidence older than a build source or the generated files proves +/// nothing either way (it may predate the pin), and neither does an +/// artifact file that is gone (an evicted cache). +async fn bypass(pin: &SbtPin, build: &BuildState, hashes: &mut Hashes) -> Option { + if let Some(edit) = &build.edit { + return Some(edit.clone()); + } + let res = build.res.as_ref().filter(|_| build.current)?; + let ga = format!("{}:{}", pin.group, pin.artifact); + if let Some(other) = res + .versions(&pin.group, &pin.artifact) + .into_iter() + .find(|v| *v != pin.sv) + { + return Some(format!( + "the build's current resolution evidence resolves {ga}:{other}, not the pinned {} \ + (something in the build forces that version, or a project the pin does not reach \ + resolves it)", + pin.sv + )); + } + let paths = res + .artifacts + .get(&(pin.group.clone(), pin.artifact.clone(), pin.sv.clone())) + .into_iter() + .flatten(); + for path in paths { + if let Some(got) = hashes.of(path).await { + if got != pin.jar_sha256 && got != pin.pom_sha256 { + return Some(format!( + "the build's current resolution evidence resolves {ga}:{} to {} with \ + sha256 {got}, which is not pinned (a copy another repository serves, \ + such as `mavenLocal` or an Ivy-local publish)", + pin.sv, + path.display() + )); + } + } + } + None +} + /// The build-wide conditions under which evidence showing the pin is not /// enough (the same ones the hosted rewriter's `check_new` gate refuses a /// new pin on): a build source newer than some project's evidence, a @@ -266,8 +389,7 @@ async fn verify( async fn hosted_ref( ctx: &DiscoverCtx<'_>, pin: &SbtPin, - res: Option<&JvmResolution>, - blocker: Option<&str>, + build: &BuildState, hashes: &mut Hashes, out: &mut Discovery, ) { @@ -297,6 +419,7 @@ async fn hosted_ref( ); return; } + let (res, blocker) = (build.res.as_ref(), build.blocker.as_deref()); let locked = match verify_with(pin, res, blocker, hashes).await { Ok(()) => Some(LockIntegrity::Sha256Hex(pin.jar_sha256.clone())), Err(why) => { @@ -308,6 +431,17 @@ async fn hosted_ref( None } }; + // Unverified, the ref still attests from the installed copy; where the + // build provably does not apply the pin, from nothing. + if let Some(why) = bypass(pin, build, hashes).await { + out.unattested( + &purl, + &pin.uuid, + HOSTED_FILE, + why, + UnattestedKind::SbtPinBypassed, + ); + } out.push(PatchedRef::hosted( purl, pin.uuid.clone(), @@ -504,4 +638,174 @@ mod tests { Ok(()) ); } + + /// Only the edits that drop the generated override or resolver mean the + /// pin is not applied; staleness, partial evidence and a lock do not. + #[test] + fn edit_bypass_is_the_pin_dropping_edits_only() { + use crate::formats::sbt::build::BuildFindings; + assert_eq!(edit_bypass(&BuildFindings::default()), None); + let lock = BuildFindings { + dependency_lock: vec!["build.sbt.lock".into()], + ..Default::default() + }; + assert_eq!(edit_bypass(&lock), None); + for (findings, want) in [ + ( + BuildFindings { + overrides_assignment: vec!["a/build.sbt:1".into()], + ..Default::default() + }, + "`dependencyOverrides`", + ), + ( + BuildFindings { + resolvers_assignment: vec!["build.sbt:2".into()], + ..Default::default() + }, + "`resolvers`", + ), + ( + BuildFindings { + override_build_repos: vec![".sbtopts:1".into()], + ..Default::default() + }, + "sbt.override.build.repos", + ), + ] { + let why = edit_bypass(&findings).unwrap(); + assert!(why.contains(want), "{why}"); + } + } + + /// The installed-copy fallback is withdrawn by an edit whatever the + /// evidence, and by evidence only when it postdates the sources and the + /// generated file and shows another version or other bytes. + #[tokio::test] + async fn bypass_needs_an_edit_or_current_evidence() { + let tmp = tempfile::tempdir().unwrap(); + let jar = tmp.path().join("x.jar"); + std::fs::write(&jar, b"patched").unwrap(); + let sha = hex::encode(Sha256::digest(b"patched")); + let evil = tmp.path().join("evil.jar"); + std::fs::write(&evil, b"evil").unwrap(); + let gone = tmp.path().join("gone.jar"); + let state = |res: Option, current: bool| BuildState { + res, + current, + ..Default::default() + }; + let mut h = Hashes::default(); + let p = pin(&sha); + + assert_eq!(bypass(&p, &BuildState::default(), &mut h).await, None); + let edited = BuildState { + edit: Some("build.sbt:1 reassign(s) `dependencyOverrides`".into()), + ..Default::default() + }; + let why = bypass(&p, &edited, &mut h).await.unwrap(); + assert!(why.contains("build.sbt:1"), "{why}"); + // The pinned version and bytes: nothing bypasses. + let ok = res(&[SV], Some(&jar)); + assert_eq!(bypass(&p, &state(Some(ok), true), &mut h).await, None); + // Another version: only current evidence counts. + let shadowed = res(&[SV, "3.11"], Some(&jar)); + assert_eq!( + bypass(&p, &state(Some(shadowed.clone()), false), &mut h).await, + None + ); + let why = bypass(&p, &state(Some(shadowed), true), &mut h) + .await + .unwrap(); + assert!(why.contains("3.11, not the pinned"), "{why}"); + let base_only = res(&["3.11"], None); + assert!(bypass(&p, &state(Some(base_only), true), &mut h) + .await + .is_some()); + // Other bytes at the pinned version: the same. + let elsewhere = res(&[SV], Some(&evil)); + assert_eq!( + bypass(&p, &state(Some(elsewhere.clone()), false), &mut h).await, + None + ); + let why = bypass(&p, &state(Some(elsewhere), true), &mut h) + .await + .unwrap(); + assert!(why.contains("which is not pinned"), "{why}"); + // A recorded file that is gone, or no resolution of the GA at all, + // proves nothing. + let evicted = res(&[SV], Some(&gone)); + assert_eq!(bypass(&p, &state(Some(evicted), true), &mut h).await, None); + assert_eq!( + bypass(&p, &state(Some(res(&[], None)), true), &mut h).await, + None + ); + } + + /// A hosted file at the root of an sbt build with no evidence (a fresh + /// clone), over `build_sbt`. + fn hosted_build(build_sbt: &str) -> tempfile::TempDir { + use crate::formats::sbt::owned_file::{render, SbtLine, SbtOwnedFile}; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::create_dir_all(root.join("project")).unwrap(); + std::fs::write(root.join("project/build.properties"), "sbt.version=1.9.9\n").unwrap(); + std::fs::write(root.join("build.sbt"), build_sbt).unwrap(); + let file = render(&SbtOwnedFile { + mode: SbtFileMode::Hosted, + line: SbtLine::Sbt1, + crlf: false, + pins: [(UUID.to_string(), pin(&"b".repeat(64)))].into(), + }) + .unwrap(); + std::fs::write(root.join(HOSTED_FILE), file).unwrap(); + tmp + } + + /// A build source reassigning `dependencyOverrides` (or `resolvers`) + /// drops the pin where it applies: the hosted ref stays (rollback, + /// remove and list find it) but is marked unattested, so the copy sbt + /// downloaded into `.socket/sbt-hosted` cannot attest it. Without the + /// edit, the unverified ref keeps its installed-copy basis. + #[tokio::test] + async fn a_pin_dropping_edit_unattests_the_hosted_ref() { + const PURL: &str = "pkg:maven/org.apache.commons/commons-lang3@3.11"; + let plain = hosted_build( + "libraryDependencies += \"org.apache.commons\" % \"commons-lang3\" % \"3.11\"\n", + ); + let out = super::super::discover_patched_refs(plain.path()).await; + assert!( + out.refs.iter().any(|r| r.uuid == UUID && r.purl == PURL), + "{:?}", + out.refs + ); + assert!(out.unattested.is_empty(), "{:?}", out.unattested); + + for (edit, want) in [ + ( + "dependencyOverrides := Seq(\"org.apache.commons\" % \"commons-lang3\" % \"3.11\")\n", + "`dependencyOverrides`", + ), + ("resolvers := Nil\n", "`resolvers`"), + ] { + let edited = hosted_build(&format!( + "libraryDependencies += \"org.apache.commons\" % \"commons-lang3\" % \"3.11\"\n{edit}" + )); + let out = super::super::discover_patched_refs(edited.path()).await; + assert!( + out.refs.iter().any(|r| r.uuid == UUID && r.purl == PURL), + "the ref stays: {:?}", + out.refs + ); + let [u] = out.unattested.as_slice() else { + panic!("{edit}: {:?}", out.unattested); + }; + assert_eq!( + (u.kind, u.uuid.as_str(), u.purl.as_str()), + (UnattestedKind::SbtPinBypassed, UUID, PURL) + ); + assert_eq!(u.file, Path::new(HOSTED_FILE)); + assert!(u.detail.contains("build.sbt:2") && u.detail.contains(want), "{}", u.detail); + } + } } diff --git a/docs/ecosystems.md b/docs/ecosystems.md index b03dc6665..bb6998933 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -1197,7 +1197,11 @@ then a re-run re-verifies the pin and records the build's new dependency digest; a build that now declares the GA newer than the patched base is refused (`redirect_sbt_pin_declared_newer`: roll the patch back, or declare the base again). `rollback` / `remove` restore the file -offline. The full code list is in +offline. `vex` attests a pin that post-wiring evidence verifies, or else from +the copy sbt downloaded into `.socket/sbt-hosted/`, but never one the build +provably bypasses: a `dependencyOverrides` / `resolvers` reassignment or +`sbt.override.build.repos=true`, or current evidence resolving another version +or other bytes (`vex_sbt_pin_bypassed`). The full code list is in [`CLI_CONTRACT.md`](../crates/socket-patch-cli/CLI_CONTRACT.md) (**Hosted sbt**). From 67b885d23b592e5fd0eb0296bd9cc5f1e15886c0 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 5 Nov 2026 23:19:41 -0600 Subject: [PATCH 2/4] Withdraw sbt pins only on proof; judge records, not builds Review fixes for the hosted sbt installed-copy fallback and the vendored sbt / scala-cli liveness checks. Hosted sbt (`vex/discover/sbt.rs`): - Date evidence per record: the crawler now records each library evidence file's date (`SbtEvidence::record_dates`, plus `generated` and `newest_source`). Only records newer than the generated files and every build source count, so a project or cross build not re-resolved since the wiring no longer withdraws a valid pin. - Never withdraw a pin that those records verify (`verify` over the current view), and never run the bypass check when the lockfile basis stands. - A `resolvers` reassignment or `sbt.override.build.repos` alone is no bypass: the generated override still forces the Socket-only version and the load-time verifier checks its bytes. Only a `dependencyOverrides` reassignment (the override replaced) or current records do. - Current records are judged by the hosted rewriter's own `gate::check_existing` (base version shadowed, or other bytes from outside the pin repository), with a hash predicate where an unreadable file proves nothing. - Build sources are scanned once and the reassignment reason is shared with `evidence_blocker`. - A file under a generated name without socket-patch's header is a root definition sbt loads: the evidence walk lists it as a build source and `scan_build_sources` reads it (`owned_file::is_foreign`). Vendored liveness: - sbt: a user's own `socket-patch.sbt` is `sbt_pin_conflict`, not absent. - scala-cli: liveness also applies the gate's post-vendoring `resolved_elsewhere` check over non-stale Bloop evidence (`coursier_gate::vendored_bypass`). The directive and elsewhere checks return their detail, and only the gate wraps them in a refusal. The tree check reuses `coursier_tree::{tree_dir, committed}` and a shared `patch_rows` index parse. - `entry_wired` takes the reference path (`attest: false`), since it maps every `Err` to live anyway. Co-Authored-By: Claude Opus 5.5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 13 +- .../src/commands/vex_sources.rs | 8 +- .../socket-patch-cli/tests/e2e_sbt_hosted.rs | 97 ++- .../src/crawlers/sbt_evidence.rs | 53 +- .../src/crawlers/sbt_evidence/tests.rs | 16 +- .../src/formats/sbt/build.rs | 16 +- .../src/formats/sbt/owned_file.rs | 13 +- .../socket-patch-core/src/vendor/jvm/apply.rs | 6 +- .../src/vendor/jvm/coursier_gate.rs | 81 +-- .../src/vendor/jvm/coursier_tree.rs | 13 +- .../socket-patch-core/src/vendor/jvm/sbt.rs | 31 +- .../src/vendor/jvm/scala_cli.rs | 120 +++- .../socket-patch-core/src/vex/discover/mod.rs | 11 +- .../socket-patch-core/src/vex/discover/sbt.rs | 604 +++++++++++------- docs/ecosystems.md | 8 +- 15 files changed, 725 insertions(+), 365 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index bf8979280..62478a464 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -331,7 +331,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate, `bun.lock` / `bun.lockb`, and `vlt-lock.json` with `vlt.json` and `node_modules/.vlt-lock.json` read only), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; the hosted redirect is a `sparse+` registry, which cargo loads only from **1.68**, so `rust-toolchain.toml` / `rust-toolchain` are read too (never edited) and a project whose declared floor — the lowest `rust-version` of the root and the member manifests read here (a `rust-version.workspace = true` inheritor reads `[workspace.package]`), else the channel rustup picks from the root (the legacy `rust-toolchain` over `rust-toolchain.toml`; a dated `nightly-`/`beta-YYYY-MM-DD` maps to its release train) — is below 1.68 is refused `redirect_cargo_old_cargo` per crate (nothing written, an earlier run's pins left in place with the `rollback` that drops them named; `--mode vendored` builds on cargo 1.41+), while an undeclared or rolling-channel floor still redirects (toolchain files in ancestor directories are not read); **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. With NO `Cargo.lock` there is no resolved graph to ask, so the dependents question is answered from the manifests instead: a crate declared beside any other dependency — anything but a path dependency on a manifest this run also pins, or a `workspace = true` inheritor of a table it scans — or beside a workspace member this run did not read (a `members` glob, or a member outside the project or behind a symbolic link, which member discovery drops) is refused `redirect_cargo_lockless_dependents`, whose detail names the remedies (commit a lockfile, or `--mode vendored`); a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json` (plus, v5.0 #353/#514, every lock a project under the root restores into: a member project's `packages.lock.json`, a per-project `packages..lock.json`, a literal `NuGetLockFilePath` — each pinned with the root config; a `NuGetLockFilePath` it cannot evaluate warns `redirect_nuget_lock_path_unresolved` and a project tree it cannot list warns `redirect_nuget_lock_unreadable`, both skipping the nuget redirect with nothing written), `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and, for a Gradle build, every settings, build, `buildSrc`, included-build, applied and plugin-source script, version catalog and lock file the script graph reaches, plus `gradle/verification-metadata.xml`, `gradle/wrapper/gradle-wrapper.properties` and the owned `.socket/gradle/` files), and the sbt build files (`socket-patch.sbt`, `socket-patch-vendor.sbt`, `build.sbt`, `project/build.properties`, `.sbtopts`, `.jvmopts`; `build.sbt.lock` and the Mill / scala-cli build files `build.mill`, `build.mill.yaml`, `build.sc`, `.mill-version`, `project.scala` for their presence only) — read, never edited; `socket-patch.sbt` is the only sbt file hosted mode writes (see **Hosted sbt** below). **npm-family flavor coverage**: package-lock / npm-shrinkwrap (a package the project patches itself with npm ≥ 12.1's native `npm patch` — a root `patchedDependencies` key, or the lock entry's `patched` record — is left on its registry entry in every npm lock, `redirect_npm_patched_dependency_skipped`), pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic (a yarn 2+ install migrates a v1 `yarn.lock` and drops its pins, so a run whose v1 lock carries a hosted pin warns `redirect_yarn_classic_berry_migration_risk` — the hosted twin of the vendored `yarn_classic_berry_migration_risk` — unless the root `package.json`, read as advisory input, declares `"packageManager": "yarn@1…"`), **yarn berry** (the pin yarn writes for a root `resolutions` entry: the root `package.json` — edited only beside a berry `yarn.lock` — gains one `"@npm:": ""` selector per locked range (`redirect_yarn_berry_resolution` edits), and only that `yarn.lock` entry is re-keyed `"@"` with the same `resolution:` + `yarnBerry10c0` checksum (`redirect_yarn_berry_entry`), moved to yarn's key order; never an `npm:` locator, whose fetcher sends npm registry auth to the patch host, nor a tarball locator under an `npm:` key, which hardened mode rejects (YN0078). An older release's `npm:::__archiveUrl=` pin is still recognized and is re-pinned on the next run; rollback rebuilds the key from the selectors and drops them. Refused, nothing written: a user-authored `resolutions` entry for the package `redirect_yarn_berry_resolutions_conflict`, no root manifest `redirect_yarn_berry_manifest_missing`, a builtin `patch:` entry wrapping the same descriptor `redirect_yarn_berry_shared_descriptor`, an artifact URL yarn cannot fetch as a tarball `redirect_yarn_berry_artifact_url_unsupported`; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found` (or `redirect_bun_non_registry_entry_skipped` when the only same-version entry is a user URL / `file:` tarball, #497), a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`), and **vlt** (`vlt-lock.json` without `lockfileVersion`, `0` or `1`; see the vlt hosted-mode contract below). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when the `repo-state.json` beside a rewritten lock exists (`common/config/rush/repo-state.json` for the common lock, `common/config/subspaces//repo-state.json` for a subspace lock; the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives), and `redirect_pnpm_trust_lockfile` carries the Rush pnpm >=11 install remedy (see the trust paragraph above). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`; when `.mvn/wrapper/maven-wrapper.properties` pins a Maven older than 3.9.4, which ignores those files, the additive warning `redirect_maven_trusted_checksums_unenforced`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a reactor root (a `` / `` declaration, a profile's included, or a Maven 4 root that lists none but whose subprojects Maven discovers: model version not `4.0.0`, `pom` packaging, a direct subdirectory holding a `pom.xml`, and `maven.project.discoverSubprojects` not turned off in `.mvn/maven.config`; each such subdirectory's `pom.xml` is read for this, never edited) is refused whole with `redirect_maven_multimodule_unsupported`: `pom.xml` and `.mvn/` are left untouched and the dep is not counted as redirected (a Gradle build beside it is still planned by the Gradle rewriter, but a dep in a root with a `pom.xml` is confirmed only when the pom pins it too), since a module's own literal `` would shadow a root pin (use `--mode vendored`; `vex` omits a hosted pin in a reactor root as `vex_maven_reactor_root`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), a `` variant (sources, tests, a native build) left at its version (`redirect_maven_classifier_unsupported`; the grant serves the main jar only, and the dep is skipped when a variant other than `sources` / `javadoc` names the patched release), a versioned declaration inside `` (a literal or a `${property}`) left as-is (`redirect_maven_profile_dependency_unpatched`; the dep is skipped when a profile holds its only declaration), a `${property}` groupId / artifactId resolved from the pom's own `` before matching (one the pom does not define, which may be the patched GA and whose own version would then win, refuses the dep with nothing written: `redirect_maven_coordinate_unresolved`), and a pom not readable as one left untouched (`redirect_maven_pom_unreadable`). Matches and anchors skip comments, CDATA, ``, ``, ``, `` and `` (the scope `vex` reads), and an existing self-closed `` / `` is expanded in place, never duplicated; an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). **gradle** (v5.0) is automated wiring, no longer a pasted snippet: the owned settings script `.socket/gradle/socket-patch.hosted.settings.gradle` with its index `.socket/gradle/hosted-index.tsv`, one apply line per build's settings file, every lock entry of the GA moved to the suffixed version, and the suffixed component in an existing `gradle/verification-metadata.xml`. A refused dep writes nothing and keeps `redirect_gradle_manual_snippet` as its fallback; same-GAV grants are refused (`redirect_gradle_same_gav_unsupported`). Rules, refusals and codes: [Gradle builds](#gradle-builds-v50). -**Hosted sbt (v5.0, additive)**: an sbt build root (`project/build.properties` naming an `sbt.version`, 0.13.18 or later) is wired through ONE generated root file, `socket-patch.sbt` — no user file is edited. It pins every granted Maven patch build-wide (a `ThisBuild` `dependencyOverrides +=` of the Socket-only `-socket.` version plus a `file:` resolver over `.socket/sbt-hosted/maven2/`, moved ahead of the default repositories on sbt 0.13 / 1.x so an unreachable one never blocks it offline), downloads the pinned pom and jar there on the first sbt load (sha256-checked, gitignored by the file itself), and installs a load-time verifier that fails `update` when any project resolves another version or a pinned artifact whose bytes are not pinned. Edits: `redirect_sbt_pin` (added), `redirect_sbt_pin_updated` (an existing row replaced: same GA and base under a new uuid, or the same uuid with new served values; `original` names the previous uuid and version), `redirect_sbt_pin_rechecked` (an existing row re-verified after the build's dependencies changed: its dependency digest is recorded anew, `original`/`new` are `{deps}`). The load-time verifier also fails `update` when a project declares a pinned GA at a version newer than the pin's base (the build-wide override would otherwise force it back down). A new pin is gated on sbt's own resolution records under `target/` (never the machine-wide cache): run-level stops wire nothing, warn once and exit 0 — `redirect_sbt_no_resolution_evidence` (none; run `sbt update` first; always the in-memory engine's answer), `redirect_sbt_resolution_incomplete` (a declared project left no evidence, or the project definitions cannot be read statically), `redirect_sbt_resolution_stale` (a build source is newer than some project's evidence: each project is dated by its own newest record, so a partial `sbt /update` does not vouch for the others). Per-patch refusals (never confirmed): `redirect_sbt_missing_override` (no `maven2` override or no suffixed version), `redirect_sbt_integrity_missing` (jar or pom sha256 missing), `redirect_sbt_unsafe_value` (a value unsafe in a Scala literal, or an index URL not naming the uuid), `redirect_sbt_version_conflict` (some project resolves another version, or a build source declares the GA newer than the patch's base), `redirect_sbt_override_conflict` (two patches for one GA in a run, or another base already pinned), `redirect_sbt_vendored_conflict` (the GA is pinned by `socket-patch-vendor.sbt`, or that file cannot be parsed — then every Maven patch), `redirect_sbt_owned_file_modified` / `redirect_sbt_owned_file_foreign` (`socket-patch.sbt` edited, or not socket-patch's — every Maven patch), `redirect_sbt_owned_file_unreadable` (a whole-run refusal: `socket-patch.sbt` is on disk but cannot be read as UTF-8 text, so writing it would replace it; nothing is written), `redirect_sbt_unsupported_version`, `redirect_sbt_build_root_unknown` (sbt files but no versioned build root — every Maven patch), `redirect_sbt_overrides_assignment` / `redirect_sbt_resolvers_assignment` (a build source reassigns `dependencyOverrides` / `resolvers` with `:=`, `~=` or `--=`), `redirect_sbt_dependency_lock_present` (a `build.sbt.lock`), `redirect_sbt_scala_runtime_unsupported` (`org.scala-lang`), `redirect_sbt_classifier_unsupported`; a GA no library configuration resolves is skipped silently (`redirect_sbt_meta_build_only` when only the meta-build resolves it). Advisories: `redirect_sbt_version_untested` (sbt 2.1+, still wired), `redirect_sbt_override_build_repos` (`sbt.override.build.repos=true`), `redirect_maven_pom_ignored_sbt_build` (a `pom.xml` beside the sbt build, which sbt never reads; the Maven rewriter still edits it for the Maven build). A re-run keeps an existing row and re-checks it. When the build's dependency digest changed since the pin, evidence resolved after the change (fresh, newer than the generated file) re-verifies it and the row's digest is refreshed (`redirect_sbt_pin_rechecked`); the uuid is NOT confirmed on `redirect_sbt_pin_declared_newer` (a build source now declares the GA newer than the pin's base; the row stays, sbt's load-time verifier fails the build, and the remedy is `socket-patch rollback` or declaring the base again), `redirect_sbt_pin_unverifiable` (the digest changed and the evidence predates the change, or the digest cannot be computed: run `sbt update`, then re-run socket-patch), `redirect_sbt_override_shadowed` (the evidence still resolves the base version) or `redirect_sbt_resolved_elsewhere` (the pinned version resolves from outside the pin repository from a file whose sha256 is not the pinned jar's; a copy holding the pinned bytes, such as the Ivy cache a second checkout reads, is fine — at most 64 pinned artifact files of up to 256 MiB are hashed, anything else counts as elsewhere), and also when a build source now reassigns `dependencyOverrides` / `resolvers` or a `build.sbt.lock` appeared (the same `redirect_sbt_overrides_assignment` / `redirect_sbt_resolvers_assignment` / `redirect_sbt_dependency_lock_present` codes; the row stays and sbt's load-time verifier fails the build). For a pure sbt root (no `pom.xml` / Gradle script beside it), maven confirmation is decided only by the sbt rewriter's report; on a mixed root a uuid the sbt rewriter refused is still confirmed by the Maven rewriter's own `pom.xml` pin (the generated sbt files never prove a pin by substring). **Mill and scala-cli** are guidance only: per Maven patch `redirect_mill_manual_snippet` / `redirect_scala_cli_manual_snippet` carry a paste-able snippet (repository + forced suffixed version), nothing is written or confirmed, and a pure Mill / scala-cli root gets no `redirect_maven_no_pom`; there, a Maven patch the server sent without a `maven2` registry override gets `redirect_maven_missing_override` instead of a snippet (with a `pom.xml` beside the Mill / scala-cli files the pom rewriter reports it). `rollback` / `remove` restore `socket-patch.sbt` offline (the rows removed, the file deleted with its last pin; the gitignored downloads are left). Manifest-less VEX reads every strictly parsed pin as a hosted reference but grants it the lockfile basis only when the local evidence shows every recorded version of the GA is the pinned one and every recorded artifact hashes to a pinned sha256 (else `sbt_resolution_unverified`, and the suffixed copy sbt downloaded into `.socket/sbt-hosted` is its only basis). Where the build provably does not apply the pin, that copy proves nothing and the patch is omitted (`vex_sbt_pin_bypassed`): a build source reassigns `dependencyOverrides` or `resolvers` (`:=`, `~=`, `--=`) or sets `sbt.override.build.repos=true`, whether or not any evidence exists, or evidence newer than every build source and the generated file records another version of the GA, or an artifact of the pinned version whose bytes are not pinned. Stale or partial evidence, project definitions that cannot be read statically and a `build.sbt.lock` only withhold the lockfile basis. +**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`, and the suffixed copy sbt downloaded into `.socket/sbt-hosted` is its only basis). Where the build provably does not use the pinned bytes, that copy proves nothing and the patch is omitted (`vex_sbt_pin_bypassed`): a build source reassigns `dependencyOverrides` (`:=`, `~=`, `--=`; replacing the generated override), whether or not any evidence exists, or a resolution record dated after the generated files and every build source shows the GA's base version (the override shadowed), or the pinned version from outside the pin repository with bytes that are not pinned (judged exactly as the hosted rewriter re-checks a pin, `redirect_sbt_override_shadowed` / `redirect_sbt_resolved_elsewhere`). A record older than the wiring (a project or cross build not re-resolved since) proves nothing, and current records that verify the pin always keep it. A `resolvers` reassignment or `sbt.override.build.repos=true` alone is no bypass: the override still forces the Socket-only version and the load-time verifier checks its bytes, so the build runs the pinned bytes or fails. Stale or partial evidence, project definitions that cannot be read statically and a `build.sbt.lock` only withhold the lockfile basis. A file under a generated name without socket-patch's header is read as the root build definition sbt loads it as. **Non-UTF-8 candidate files (#721)**: the rewriters edit UTF-8 text only. A candidate file that exists but is not UTF-8 (for example a UTF-16 `requirements.txt`, which is what Windows PowerShell 5.1's `pip freeze >` writes and which pip installs from) is never read as absent. When a candidate of its ecosystem could rewrite it, the run is refused with `candidate_file_unreadable` (exit 1, `--dry-run` included), the message names the file, and nothing is written; the remedy is to re-save the file as UTF-8. Two exceptions keep their own refusals. A Gradle build file the Gradle planner reaches gets that planner's per-build refusal (`redirect_gradle_build_file_unreadable`, exit 0) and the rest of the run goes ahead, and with no readable Gradle build a stray Gradle file (a lock, a nested script) is never rewritten, so it does not refuse the run, while a non-UTF-8 root `settings.gradle(.kts)` or `build.gradle(.kts)` still refuses it (it may be the build itself). An unreadable `socket-patch.sbt` is refused with `redirect_sbt_owned_file_unreadable`. A vendored→hosted takeover checks this before it reverts anything, so a refused run leaves the vendored wiring, ledger entry and artifact byte-identical. Vendored mode likewise refuses a non-UTF-8 `requirements.txt` or `-r` include by name (`pypi_no_requirements`) instead of wiring around it. Lock-only discovery reads `requirements.txt` and its in-root `-r` includes the way pip decodes them (a UTF-16 or UTF-32 byte-order mark selects that encoding), so such a project's pins are still found instead of reporting "No packages found". @@ -2678,7 +2678,7 @@ match it, withholds the statement: | `vex_deno_lock_copy` | run warning and `failed[].reason` | An npm pin is wired, but `deno.lock` locks the same `name@version`, which `deno install` installs without the wiring; no statement while it does. `vendor --check` and `scan` still treat the wiring as live. | | `vex_maven_reactor_root` | run warning and `failed[].reason` | A hosted Maven pin sits in a root `pom.xml` that declares `` / `` (a profile's included), where a module's own literal `` overrides it; only the root pom is read, so no statement (#261). `list`, `rollback` and `remove` still find the pin. | | `vex_maven_classifier_unpatched` | run warning and `failed[].reason` | A hosted Maven pin's GA also has a live executable `` copy (not `sources` / `javadoc`) at a version other than the pin, which the classifier-less pin does not retarget and the grant does not serve; no statement while it does. `list`, `rollback` and `remove` still find the pin. | -| `vex_sbt_pin_bypassed` | run warning and `failed[].reason` | A hosted sbt pin (`socket-patch.sbt`) the build provably does not apply: a build source reassigns `dependencyOverrides` (replacing the generated override, so the build resolves the version it declares) or `resolvers` / sets `sbt.override.build.repos=true` (dropping the generated resolver), or current resolution evidence (newer than every build source and the generated file) records another version of the GA or other bytes at the pinned version. The copy downloaded into `.socket/sbt-hosted` then proves nothing, so no statement. `list`, `rollback` and `remove` still find the pin. | +| `vex_sbt_pin_bypassed` | run warning and `failed[].reason` | A hosted sbt pin (`socket-patch.sbt`) whose bytes the build provably does not use, and that no current evidence verifies: a build source reassigns `dependencyOverrides` (replacing the generated override), or a resolution record newer than the generated files and every build source shows the GA's base version, or other bytes at the pinned version from outside the pin repository. The copy downloaded into `.socket/sbt-hosted` then proves nothing, so no statement. `list`, `rollback` and `remove` still find the pin. | | `vex_gradle_derived_cache_unchecked` | run warning | The derived-cache walk was cut short (a very large transforms cache); the statement is still emitted, the copies the walk did reach were checked. | A vendored Gradle entry attests only while its wiring is live (apply line, index rows, @@ -2689,9 +2689,12 @@ its committed files route the build to the vendored copy; otherwise the entry is as `vendor_jvm_shape_unsupported` ("cannot establish JVM wiring") with the reason (VEX liveness only: `vendor --revert` and `scan --prune` still see the wiring): `sbt_pin_conflict` (the committed hosted `socket-patch.sbt` pins the same GA under -another patch, so the build may resolve that pin instead, or no longer parses strictly), +another patch, so the build may resolve that pin instead, no longer parses strictly, or is +a user's own file under that name, which sbt loads like any root `.sbt`), `scala_cli_tree_uncommitted` (the index has no row for the patch's jar or pom, or the committed tree file is missing or does not hash to its row: without the tree's pom, Coursier reads the module from the next repository, unpatched jar included) and -`scala_cli_repository_shadowed` (an input declares a repository or a direct `url=` for -the patched group, as `vendor` refuses with `vendor_scala_cli_repository_shadowed`). +`scala_cli_repository_shadowed` (the conditions `vendor` refuses with +`vendor_scala_cli_repository_shadowed`: an input declares a repository or a direct `url=` +for the patched group, or Bloop evidence newer than every input and the wiring resolved +the GAV outside the tree). diff --git a/crates/socket-patch-cli/src/commands/vex_sources.rs b/crates/socket-patch-cli/src/commands/vex_sources.rs index cab841624..b49230059 100644 --- a/crates/socket-patch-cli/src/commands/vex_sources.rs +++ b/crates/socket-patch-cli/src/commands/vex_sources.rs @@ -232,10 +232,10 @@ const NOTE_MAVEN_REACTOR_ROOT: &str = "vex_maven_reactor_root"; /// classifier copy at another version, which the pin does not reach /// (`vex::Unattested`). const NOTE_MAVEN_CLASSIFIER_UNPATCHED: &str = "vex_maven_classifier_unpatched"; -/// Omission tag and note: a hosted sbt pin the build provably does not -/// apply (a `dependencyOverrides` / `resolvers` reassignment, or current -/// evidence resolving another version or other bytes), so the downloaded -/// copy proves nothing (`vex::Unattested`). +/// Omission tag and note: a hosted sbt pin whose bytes the build provably +/// does not use (a `dependencyOverrides` reassignment, or current evidence +/// resolving another version or other bytes), so the downloaded copy +/// proves nothing (`vex::Unattested`). const NOTE_SBT_PIN_BYPASSED: &str = "vex_sbt_pin_bypassed"; /// The omission tag of an [`Unattested`](socket_patch_core::vex::Unattested) diff --git a/crates/socket-patch-cli/tests/e2e_sbt_hosted.rs b/crates/socket-patch-cli/tests/e2e_sbt_hosted.rs index dd6444303..36d31bfd1 100644 --- a/crates/socket-patch-cli/tests/e2e_sbt_hosted.rs +++ b/crates/socket-patch-cli/tests/e2e_sbt_hosted.rs @@ -430,17 +430,43 @@ fn hosted_vex_without_evidence_is_unverified() { ); } +/// The suffixed copy sbt downloads into `.socket/sbt-hosted` at load. +fn write_installed_copy(project: &Path) -> PathBuf { + let copy = project + .join(HOSTED_REPO_REL) + .join("com/google/code/gson/gson") + .join(SV); + std::fs::create_dir_all(©).unwrap(); + std::fs::write(copy.join(format!("gson-{SV}.jar")), JAR).unwrap(); + std::fs::write(copy.join(format!("gson-{SV}.pom")), POM).unwrap(); + copy +} + +/// `(attested, vex_sbt_pin_bypassed, envelope)` of a `vex` run over +/// `project`. +fn vex_verdict(home: &SbtHome, project: &Path) -> (bool, bool, serde_json::Value) { + let api = Api::start(); + api.grant(UUID, GROUP, ARTIFACT, BASE); + let (_, json) = vex(home, project, &api.server.uri()); + let doc = std::fs::read_to_string(project.join("vex.json")).unwrap_or_default(); + let bypassed = all_codes(&json).contains(&"vex_sbt_pin_bypassed".to_string()); + (doc.contains("\"not_affected\""), bypassed, json) +} + /// An unverified pin (no evidence) keeps the installed-copy basis: the /// suffixed copy sbt downloaded into `.socket/sbt-hosted` at load attests -/// it. A build source reassigning `dependencyOverrides` or `resolvers` -/// drops the pin where it applies, so the same copy proves nothing about -/// what the build runs: the patch is omitted with `vex_sbt_pin_bypassed`. +/// it. A `dependencyOverrides` reassignment replaces the generated override +/// where it applies, so the same copy proves nothing about what the build +/// runs: the patch is omitted with `vex_sbt_pin_bypassed`. A `resolvers` +/// reassignment does not: the override still forces the Socket-only +/// version and sbt's load-time verifier checks its bytes, so that build +/// runs the pinned bytes or fails. #[test] -fn hosted_vex_installed_copy_is_withdrawn_by_a_pin_dropping_edit() { - for edit in [ - None, - Some("dependencyOverrides := Seq()\n"), - Some("resolvers := Seq(Resolver.mavenLocal)\n"), +fn hosted_vex_installed_copy_is_withdrawn_by_an_overrides_reassignment() { + for (edit, withdrawn) in [ + (None, false), + (Some("dependencyOverrides := Seq()\n"), true), + (Some("resolvers := Seq(Resolver.mavenLocal)\n"), false), ] { let tmp = tempfile::tempdir().unwrap(); let home = SbtHome::new(tmp.path()); @@ -450,29 +476,50 @@ fn hosted_vex_installed_copy_is_withdrawn_by_a_pin_dropping_edit() { build.push_str(edit); std::fs::write(project.join("build.sbt"), build).unwrap(); } - let copy = project - .join(HOSTED_REPO_REL) - .join("com/google/code/gson/gson") - .join(SV); - std::fs::create_dir_all(©).unwrap(); - std::fs::write(copy.join(format!("gson-{SV}.jar")), JAR).unwrap(); - std::fs::write(copy.join(format!("gson-{SV}.pom")), POM).unwrap(); - let api = Api::start(); - api.grant(UUID, GROUP, ARTIFACT, BASE); - let (_, json) = vex(&home, &project, &api.server.uri()); - let codes = all_codes(&json); + write_installed_copy(&project); + let (attested, bypassed, json) = vex_verdict(&home, &project); assert!( - codes.contains(&"sbt_resolution_unverified".to_string()), + all_codes(&json).contains(&"sbt_resolution_unverified".to_string()), "{edit:?}: {json}" ); - let doc = std::fs::read_to_string(project.join("vex.json")).unwrap_or_default(); - let attested = doc.contains("\"not_affected\""); - let bypassed = codes.contains(&"vex_sbt_pin_bypassed".to_string()); assert_eq!( (attested, bypassed), - (edit.is_none(), edit.is_some()), - "{edit:?}: {json}\n{doc}" + (!withdrawn, withdrawn), + "{edit:?}: {json}" + ); + } +} + +/// Post-wiring evidence that verifies the pin is never overruled by a +/// static reading of the build: `sbt.override.build.repos=true` (no +/// evidence blocker, so the lockfile basis stands) or a self-referencing +/// `dependencyOverrides :=` (an evidence blocker, so the installed copy +/// attests). +#[test] +fn hosted_vex_never_withdraws_a_pin_current_evidence_verifies() { + for (file, edit) in [ + (".sbtopts", "-Dsbt.override.build.repos=true\n"), + ( + "build.sbt", + "dependencyOverrides := dependencyOverrides.value\n", + ), + ] { + let tmp = tempfile::tempdir().unwrap(); + let home = SbtHome::new(tmp.path()); + let project = evidence_project(tmp.path()); + let mut text = std::fs::read_to_string(project.join(file)).unwrap_or_default(); + text.push_str(edit); + std::fs::write(project.join(file), text).unwrap(); + std::fs::write(project.join(HOSTED_FILE), expected_file(&project)).unwrap(); + let copy = write_installed_copy(&project); + post_update_evidence( + &project, + &std::fs::canonicalize(©) + .unwrap() + .join(format!("gson-{SV}.jar")), ); + let (attested, bypassed, json) = vex_verdict(&home, &project); + assert!(attested && !bypassed, "{file}: {json}"); } } diff --git a/crates/socket-patch-core/src/crawlers/sbt_evidence.rs b/crates/socket-patch-core/src/crawlers/sbt_evidence.rs index 009ce3929..b6f6ffcc7 100644 --- a/crates/socket-patch-core/src/crawlers/sbt_evidence.rs +++ b/crates/socket-patch-core/src/crawlers/sbt_evidence.rs @@ -34,7 +34,7 @@ use crate::formats::sbt::build::{ declared_projects, source_kind, SourceKind, BUILD_PROPERTIES, BUILD_SBT, }; use crate::formats::sbt::evidence::{classify_path, is_meta_record, ResolutionDoc}; -use crate::formats::sbt::owned_file::{HOSTED_FILE, VENDORED_FILE}; +use crate::formats::sbt::owned_file::{is_foreign, HOSTED_FILE, VENDORED_FILE}; use crate::formats::sbt::JvmResolution; use crate::utils::fs::read_regular_to_bytes_sync; @@ -81,6 +81,17 @@ pub struct SbtEvidence { /// A generated file ([`HOSTED_FILE`] / [`VENDORED_FILE`]) is newer than /// the newest evidence: sbt has not resolved since the last wiring. pub wiring_newer: bool, + /// Each library evidence file's date: its root-relative path (as its + /// evidence ids name it) to the newer of its own and its sibling + /// `inputs`' modification time. One record can then be judged against + /// the wiring and the sources, where [`Self::stale`] and + /// [`Self::wiring_newer`] judge whole projects or the whole build. + pub record_dates: std::collections::BTreeMap, + /// The newest generated file's modification time. + pub generated: Option, + /// The newest build source's modification time (what [`Self::stale`] + /// compares against). + pub newest_source: Option, } /// The evidence under `root`, `None` when there is none (or a cap was hit, @@ -131,6 +142,11 @@ pub fn scan(root: &Path) -> Result { continue; } newer(&mut out.newest_evidence, *modified); + if let Some(at) = *modified { + let file = output.clone().unwrap_or_else(|| rel.clone()); + let date = out.record_dates.entry(file).or_insert(at); + *date = (*date).max(at); + } newer( per_project .entry((record.project, record.project_is_id)) @@ -147,6 +163,8 @@ pub fn scan(root: &Path) -> Result { .is_some_and(|s| oldest_project.is_some_and(|o| s > o)); out.wiring_newer = walk.generated.is_some_and(|g| g > newest); } + out.generated = walk.generated; + out.newest_source = walk.newest_source; Ok(out) } @@ -513,17 +531,30 @@ impl Walk<'_> { } /// A non-evidence file above any `target/`: a build source, or a - /// generated file (dated only). + /// generated file (dated only). A file under a generated name without + /// socket-patch's header is not ours: sbt loads it like any root + /// `*.sbt`, so it is a build definition + /// (`formats::sbt::build::scan_build_sources` reads it as one). One + /// that cannot be read keeps the generated file's treatment. fn file(&mut self, rel: &str, meta: &std::fs::Metadata) -> Result<(), String> { - if rel == HOSTED_FILE || rel == VENDORED_FILE { - newer(&mut self.generated, meta.modified().ok()); - return Ok(()); - } - let Some(kind) = source_kind(rel) else { - return Ok(()); - }; - let Some(bytes) = self.read(rel, meta, false)? else { - return Ok(()); + let (kind, bytes) = if rel == HOSTED_FILE || rel == VENDORED_FILE { + match self.read(rel, meta, false).ok().flatten() { + Some(bytes) if is_foreign(&String::from_utf8_lossy(&bytes)) => { + (SourceKind::Definition, bytes) + } + _ => { + newer(&mut self.generated, meta.modified().ok()); + return Ok(()); + } + } + } else { + let Some(kind) = source_kind(rel) else { + return Ok(()); + }; + let Some(bytes) = self.read(rel, meta, false)? else { + return Ok(()); + }; + (kind, bytes) }; if matches!( kind, diff --git a/crates/socket-patch-core/src/crawlers/sbt_evidence/tests.rs b/crates/socket-patch-core/src/crawlers/sbt_evidence/tests.rs index d2a78133b..c09c3e762 100644 --- a/crates/socket-patch-core/src/crawlers/sbt_evidence/tests.rs +++ b/crates/socket-patch-core/src/crawlers/sbt_evidence/tests.rs @@ -227,13 +227,25 @@ fn staleness_compares_sources_with_the_newest_output_or_inputs() { assert!(scan(root).unwrap().stale); // The generated files date the wiring, never the build. set_mtime(&props, at(10)); - let hosted = write(root, HOSTED_FILE, b""); + let hosted = write(root, HOSTED_FILE, b"// Generated by socket-patch.\n"); set_mtime(&hosted, at(60)); let e = scan(root).unwrap(); assert!(!e.stale && e.wiring_newer); assert!(!e.build_sources.iter().any(|(r, _)| r == HOSTED_FILE)); + assert_eq!((e.generated, e.newest_source), (Some(at(60)), Some(at(30)))); set_mtime(&hosted, at(1)); assert!(!scan(root).unwrap().wiring_newer); + // A user's own file under the name is a build definition sbt loads: + // a source (dating and scanned), never the wiring. + write(root, HOSTED_FILE, b"dependencyOverrides := Seq()\n"); + set_mtime(&hosted, at(60)); + let e = scan(root).unwrap(); + assert!(e.stale && !e.wiring_newer && e.generated.is_none()); + assert!(e.build_sources.iter().any(|(r, _)| r == HOSTED_FILE)); + assert_eq!( + crate::formats::sbt::build::scan_build_sources(&e.build_sources).overrides_assignment, + [format!("{HOSTED_FILE}:1")] + ); } #[test] @@ -462,7 +474,7 @@ fn meta_build_records_never_date_the_evidence() { set_mtime(&lib_out, at(40)); assert!(!scan(&root).unwrap().stale, "{root_name}: {meta}"); // The wiring is dated against the library records too. - let hosted = write(&root, HOSTED_FILE, b""); + let hosted = write(&root, HOSTED_FILE, b"// Generated by socket-patch.\n"); set_mtime(&hosted, at(45)); set_mtime(&meta_out, at(50)); assert!(scan(&root).unwrap().wiring_newer, "{root_name}: {meta}"); diff --git a/crates/socket-patch-core/src/formats/sbt/build.rs b/crates/socket-patch-core/src/formats/sbt/build.rs index 4505a3150..d7e696dd1 100644 --- a/crates/socket-patch-core/src/formats/sbt/build.rs +++ b/crates/socket-patch-core/src/formats/sbt/build.rs @@ -567,7 +567,11 @@ pub fn scan_build_sources(sources: &[(String, String)]) -> BuildFindings { let line_of = |nl: &[usize], at: usize| nl.partition_point(|&i| i < at) + 1; let mut out = BuildFindings::default(); for (rel, text) in sources { - match source_kind(rel) { + // A user's own file under a generated name is a root definition + // like any other (socket-patch's own file is never a source). + let foreign = + (rel == HOSTED_FILE || rel == VENDORED_FILE) && super::owned_file::is_foreign(text); + match source_kind(rel).or(foreign.then_some(SourceKind::Definition)) { Some(SourceKind::Definition | SourceKind::Subproject) => { let skeleton = lex(text).skeleton; let nl = newlines(&skeleton); @@ -916,7 +920,12 @@ val name = "project" "sbt.version=1.9.9\nsbt.override.build.repos = true\n", ), ("a/build.sbt.lock", "{}"), - (HOSTED_FILE, "dependencyOverrides := Seq()\n"), + ( + HOSTED_FILE, + "// Generated by socket-patch.\ndependencyOverrides := Seq()\n", + ), + // A user's own file under the vendored name. + (VENDORED_FILE, "dependencyOverrides := Seq()\n"), ]); let found = scan_build_sources(&sources); assert_eq!( @@ -926,7 +935,8 @@ val name = "project" "build.sbt:2", "build.sbt:3", "build.sbt:4", - "a/build.sbt:1" + "a/build.sbt:1", + "socket-patch-vendor.sbt:1" ] ); assert_eq!( diff --git a/crates/socket-patch-core/src/formats/sbt/owned_file.rs b/crates/socket-patch-core/src/formats/sbt/owned_file.rs index e8d6def41..a0d0b4f1f 100644 --- a/crates/socket-patch-core/src/formats/sbt/owned_file.rs +++ b/crates/socket-patch-core/src/formats/sbt/owned_file.rs @@ -445,6 +445,15 @@ fn first_differing_line(text: &str, fixed: &str) -> usize { + 1 } +/// Whether `text`, found under a generated file's name, lacks the +/// socket-patch header (after an optional BOM): a user's own file, which +/// sbt loads like any root `*.sbt` ([`OwnedFileError::Foreign`]). +pub fn is_foreign(text: &str) -> bool { + !text + .trim_start_matches('\u{feff}') + .starts_with(HEADER_PREFIX) +} + /// Strict parse of a generated file of `mode`. The syntax line comes from /// the installer the file carries. /// @@ -458,10 +467,10 @@ fn first_differing_line(text: &str, fixed: &str) -> usize { /// [`validate_values`]; and the text re-renders byte-identically (modulo /// CRLF), which also fixes the order and one pin per GA. pub fn parse(mode: SbtFileMode, text: &str) -> Result { - let unbommed = text.trim_start_matches('\u{feff}'); - if !unbommed.starts_with(HEADER_PREFIX) { + if is_foreign(text) { return Err(OwnedFileError::Foreign); } + let unbommed = text.trim_start_matches('\u{feff}'); if unbommed.len() != text.len() { return Err(modified("the file starts with a byte-order mark")); } diff --git a/crates/socket-patch-core/src/vendor/jvm/apply.rs b/crates/socket-patch-core/src/vendor/jvm/apply.rs index f19f1dc47..c6e916ef1 100644 --- a/crates/socket-patch-core/src/vendor/jvm/apply.rs +++ b/crates/socket-patch-core/src/vendor/jvm/apply.rs @@ -997,8 +997,10 @@ pub fn recorded_tree_files(entry: &VendorEntry) -> BTreeSet { /// [`entry_wired_checked`]). pub fn entry_wired(root: &Path, entry: &VendorEntry) -> bool { // Failure to read does not prove that deleting a tree is safe. Attestation - // uses the checked variant and reports the diagnostic instead. - entry_wired_checked(root, entry).unwrap_or(true) + // uses the checked variant and reports the diagnostic instead, so the + // sbt / scala-cli attestability checks (whose every `Err` would read as + // `true` here) are not run. + entry_wiring_checked(root, entry, false).unwrap_or(true) } /// Whether the project still references the JVM `entry`'s tree (its diff --git a/crates/socket-patch-core/src/vendor/jvm/coursier_gate.rs b/crates/socket-patch-core/src/vendor/jvm/coursier_gate.rs index 8bc797801..342d6901d 100644 --- a/crates/socket-patch-core/src/vendor/jvm/coursier_gate.rs +++ b/crates/socket-patch-core/src/vendor/jvm/coursier_gate.rs @@ -67,7 +67,7 @@ pub(crate) fn gate( // adding one must refuse even before the next build refreshes evidence. let early_directive = || { let sources = evidence.map(|e| e.sources.as_slice()).unwrap_or_default(); - directive_refusal(project_root, sources, g, a) + directive_detail(project_root, sources, g, a).map(shadowed) }; let Some(e) = evidence.filter(|e| !e.resolution.is_empty()) else { if vendored { @@ -140,33 +140,25 @@ pub(crate) fn gate( )) } } - if let Some(stop) = directive_refusal(project_root, &e.sources, g, a) { - return Err(stop); + if let Some(why) = directive_detail(project_root, &e.sources, g, a) { + return Err(shadowed(why)); } - if let Some(path) = resolved_elsewhere(project_root, e, g, a, base) { - return Err(refuse( - "vendor_scala_cli_repository_shadowed", - format!( - "the last build after vendoring resolved {g}:{a}:{base} from {} instead of {}; \ - a repository passed on the command line (`-r`) or declared elsewhere wins", - path.display(), - layout::COURSIER_TREE - ), - )); + if let Some(why) = elsewhere_detail(project_root, e, g, a, base) { + return Err(shadowed(why)); } Ok(Vec::new()) } -/// `vendor_scala_cli_repository_shadowed` when an input declares a -/// repository or pins `g` to a direct `url=`. -fn directive_refusal( - project_root: &Path, - sources: &[PathBuf], - g: &str, - a: &str, -) -> Option { +/// The refusal `vendor_scala_cli_repository_shadowed` with `detail`. +fn shadowed(detail: String) -> GateStop { + refuse("vendor_scala_cli_repository_shadowed", detail) +} + +/// Why the tree does not serve `g:a` because an input declares a +/// repository or pins `g` to a direct `url=`, if one does. +fn directive_detail(project_root: &Path, sources: &[PathBuf], g: &str, a: &str) -> Option { let (file, found) = repository_directive(project_root, sources, g)?; - let detail = match found { + Some(match found { Directive::Repository => format!( "{file} declares a repository; scala-cli consults it before the vendored tree, so \ a copy of {g}:{a} there would win silently. Move it to COURSIER_REPOSITORIES \ @@ -177,27 +169,44 @@ fn directive_refusal( instead of resolving it from any repository, so the vendored tree would never \ serve it. Drop the `url=` or use hosted mode" ), - }; - Some(refuse("vendor_scala_cli_repository_shadowed", detail)) + }) } -/// The gate's `vendor_scala_cli_repository_shadowed` directive check over -/// the build at `project_root` as it stands, for `vex` liveness -/// (`scala_cli::attestable_checked`): the detail naming the input that -/// declares a repository or pins `g` to a direct `url=`, if any. The -/// inputs are the directory's own plus, when evidence lists them, the -/// out-of-root ones it builds. -pub(crate) fn shadowing_directive(project_root: &Path, g: &str, a: &str) -> Option { +/// Why the last build after vendoring did not resolve `g:a:base` from the +/// tree ([`resolved_elsewhere`]), if it did not. +fn elsewhere_detail( + project_root: &Path, + e: &ScalaEvidence, + g: &str, + a: &str, + base: &str, +) -> Option { + let path = resolved_elsewhere(project_root, e, g, a, base)?; + Some(format!( + "the last build after vendoring resolved {g}:{a}:{base} from {} instead of {}; a \ + repository passed on the command line (`-r`) or declared elsewhere wins", + path.display(), + layout::COURSIER_TREE + )) +} + +/// The gate's `vendor_scala_cli_repository_shadowed` conditions over the +/// vendored build at `project_root` as it stands, for `vex` liveness +/// (`scala_cli::attestable_checked`): an input declaring a repository or +/// pinning `g` to a direct `url=` (the directory's own inputs plus, when +/// evidence lists them, the out-of-root ones it builds), or evidence that +/// postdates every input and the wiring and resolved `g:a:base` outside the +/// tree. Stale evidence proves nothing either way. +pub(crate) fn vendored_bypass(project_root: &Path, g: &str, a: &str, base: &str) -> Option { let evidence = scala_evidence::discover(project_root); let sources = evidence .as_ref() .map(|e| e.sources.as_slice()) .unwrap_or_default(); - match directive_refusal(project_root, sources, g, a)? { - GateStop::Refuse(r) => Some(r.detail), - GateStop::Skip(w) => Some(w.detail), - GateStop::Silent => None, - } + directive_detail(project_root, sources, g, a).or_else(|| { + let e = evidence.as_ref().filter(|e| !e.stale)?; + elsewhere_detail(project_root, e, g, a, base) + }) } /// Whether the committed tree's index already lists `g:a:base` (under any diff --git a/crates/socket-patch-core/src/vendor/jvm/coursier_tree.rs b/crates/socket-patch-core/src/vendor/jvm/coursier_tree.rs index 53c4b70e0..b686476d4 100644 --- a/crates/socket-patch-core/src/vendor/jvm/coursier_tree.rs +++ b/crates/socket-patch-core/src/vendor/jvm/coursier_tree.rs @@ -227,12 +227,21 @@ pub fn render_index_like(rows: &[IndexRow], crlf: bool) -> String { /// Whether the index lists `c` (its GAV under its uuid). `Ok(false)` when /// there is no index; `Err` when it is malformed. pub fn indexed(read: ReadFn<'_>, c: &Coords<'_>) -> Result { + Ok(!patch_rows(read, c)?.is_empty()) +} + +/// The committed index's rows of the patch at `c` (none without an index); +/// `Err` for an index this module did not write. +pub fn patch_rows(read: ReadFn<'_>, c: &Coords<'_>) -> Result, JvmRefusal> { let Some(bytes) = read(INDEX_REL) else { - return Ok(false); + return Ok(Vec::new()); }; let gav = format!("{}:{}:{}", c.group_id, c.artifact_id, c.version); let rows = parse_index(&bytes).map_err(index_refusal)?; - Ok(rows.iter().any(|r| r.gav == gav && r.uuid == c.uuid)) + Ok(rows + .into_iter() + .filter(|r| r.gav == gav && r.uuid == c.uuid) + .collect()) } fn index_refusal(why: String) -> JvmRefusal { diff --git a/crates/socket-patch-core/src/vendor/jvm/sbt.rs b/crates/socket-patch-core/src/vendor/jvm/sbt.rs index 2c9e982d0..fd923f469 100644 --- a/crates/socket-patch-core/src/vendor/jvm/sbt.rs +++ b/crates/socket-patch-core/src/vendor/jvm/sbt.rs @@ -600,8 +600,9 @@ pub fn wired_checked(read: ReadFn<'_>, c: &Coords<'_>) -> Result, c: &Coords<'_>) -> Result<(), JvmRefusal> { @@ -612,12 +613,17 @@ pub fn attestable_checked(read: ReadFn<'_>, c: &Coords<'_>) -> Result<(), JvmRef let (g, a) = (c.group_id, c.artifact_id); let hosted = match read_owned(read, SbtFileMode::Hosted) { Ok(file) => file, - Err(OwnedFileError::Foreign) => None, - Err(OwnedFileError::Modified(why)) => { + Err(e) => { + let what = match e { + OwnedFileError::Modified(why) => { + format!("is not what socket-patch generated ({why})") + } + OwnedFileError::Foreign => "is a build file socket-patch did not write".into(), + }; return Err(conflict(format!( - "{HOSTED_FILE} is not what socket-patch generated ({why}), so it may pin \ - {g}:{a} too; restore or remove it" - ))) + "{HOSTED_FILE} {what}, and sbt loads it like any root .sbt file, so it may \ + pin {g}:{a} too; restore, rename or remove it" + ))); } }; let others: Vec = hosted @@ -1591,11 +1597,16 @@ mod tests { }; std::fs::write(root.join(HOSTED_FILE), render(&same).unwrap()).unwrap(); assert_eq!(apply::entry_wired_checked(root, &e), Ok(true)); - // An edited hosted file, but not a user's own file under the name. + // An edited hosted file. std::fs::write(root.join(HOSTED_FILE), format!("{hosted_text}// edit\n")).unwrap(); conflict("modified hosted file"); - std::fs::write(root.join(HOSTED_FILE), "lazy val x = 1\n").unwrap(); - assert_eq!(apply::entry_wired_checked(root, &e), Ok(true)); + // A user's own file under the name is loaded like any root .sbt. + std::fs::write( + root.join(HOSTED_FILE), + "dependencyOverrides += \"org.apache.commons\" % \"commons-text\" % \"1.9\"\n", + ) + .unwrap(); + conflict("foreign hosted file"); std::fs::remove_file(root.join(HOSTED_FILE)).unwrap(); assert_eq!(apply::entry_wired_checked(root, &e), Ok(true)); } diff --git a/crates/socket-patch-core/src/vendor/jvm/scala_cli.rs b/crates/socket-patch-core/src/vendor/jvm/scala_cli.rs index a62aca92f..9972c8142 100644 --- a/crates/socket-patch-core/src/vendor/jvm/scala_cli.rs +++ b/crates/socket-patch-core/src/vendor/jvm/scala_cli.rs @@ -245,13 +245,15 @@ pub fn wired_checked(read: ReadFn<'_>, c: &Coords<'_>) -> Result coursier_tree::parse_index(&bytes).map_err(|why| { - not_attestable( - "coursier_index_unreadable", - format!("{INDEX_REL}: {why}; restore it from git"), - ) - })?, - None => Vec::new(), - }; + let rows = coursier_tree::patch_rows(read, c)?; let gav = format!("{}:{}:{}", c.group_id, c.artifact_id, c.version); - let version_dir = layout::version_dir(c.group_id, c.artifact_id, c.version); - for ext in ["jar", "pom"] { - let rel = format!("{version_dir}/{}-{}.{ext}", c.artifact_id, c.version); - let path = format!("{}/{rel}", layout::COURSIER_TREE); - let row = rows - .iter() - .find(|r| r.gav == gav && r.uuid == c.uuid && r.rel == rel); - let why = match (row, read(&path)) { - (None, _) => format!("{INDEX_REL} lists no {ext} of patch {}", c.uuid), - (Some(_), None) => format!("{path} is missing"), - (Some(row), Some(bytes)) if super::sha256_hex(&bytes) != row.sha256 => { - format!("{path} does not hash to its {INDEX_REL} row") - } - (Some(_), Some(_)) => continue, - }; - return Err(not_attestable( + let dir = coursier_tree::tree_dir(c); + let restore = "restore .socket/vendor/coursier from version control or re-run socket-patch \ + vendor"; + let (jar, pom, _) = coursier_tree::committed(read, c).ok_or_else(|| { + not_attestable( "scala_cli_tree_uncommitted", format!( - "{why}, so scala-cli may resolve {gav} past the vendored tree; restore \ - .socket/vendor/coursier from git or re-run socket-patch vendor" + "{dir} lacks the jar or pom of {gav}, so scala-cli may resolve it past the \ + vendored tree; {restore}" ), - )); + ) + })?; + for (ext, bytes) in [("jar", jar), ("pom", pom)] { + let file = format!("{}-{}.{ext}", c.artifact_id, c.version); + let row = rows + .iter() + .find(|r| r.rel.rsplit_once('/').is_some_and(|(_, leaf)| leaf == file)); + if row.is_none_or(|r| r.sha256 != super::sha256_hex(&bytes)) { + return Err(not_attestable( + "scala_cli_tree_uncommitted", + format!( + "{INDEX_REL} does not vouch for {dir}/{file} (no row of patch {}, or \ + another sha256), so scala-cli may resolve {gav} past the vendored tree; \ + {restore}", + c.uuid + ), + )); + } } - if let Some(why) = super::coursier_gate::shadowing_directive(root, c.group_id, c.artifact_id) { + if let Some(why) = + super::coursier_gate::vendored_bypass(root, c.group_id, c.artifact_id, c.version) + { return Err(not_attestable("scala_cli_repository_shadowed", why)); } Ok(()) @@ -680,5 +682,57 @@ mod tests { check_entry(root, &entry, None).unwrap(); std::fs::write(root.join("main.scala"), "x\n").unwrap(); assert_eq!(entry_wired_checked(root, &entry), Ok(true)); + + // Bloop evidence of a build after vendoring (it lists the root + // file), newer than every input, resolving the GAV outside the + // tree: a command-line `-r` or a repository declared elsewhere won. + let ws = root.canonicalize().unwrap(); + let sources: Vec<_> = [PROJECT_FILE, "main.scala", ROOT_FILE] + .iter() + .map(|f| ws.join(f)) + .collect(); + let bloop = |jar: &std::path::Path| { + serde_json::json!({ "version": "1.4.0", "project": { + "name": "proj_0123456789", "directory": ws.join(".scala-build"), + "workspaceDir": ws, "sources": sources, "dependencies": [], "classpath": [], + "resolution": { "modules": [{ "organization": "com.typesafe", "name": "config", + "version": "1.4.3", "artifacts": [{ "name": "config", "path": jar }] }] }, + "tags": ["library"], + } }) + .to_string() + }; + let ev = ws + .join(crate::crawlers::scala_evidence::BLOOP_DIR) + .join("proj_0123456789.json"); + std::fs::create_dir_all(ev.parent().unwrap()).unwrap(); + let date = |path: &std::path::Path, at: std::time::SystemTime| { + std::fs::File::options() + .write(true) + .open(path) + .unwrap() + .set_modified(at) + .unwrap() + }; + let past = std::time::SystemTime::now() - std::time::Duration::from_secs(3600); + let write_evidence = |jar: &std::path::Path, at: std::time::SystemTime| { + std::fs::write(&ev, bloop(jar)).unwrap(); + for s in &sources { + date(s, past); + } + date(&ev, at); + }; + let cached = ws.join("cache/config-1.4.3.jar"); + write_evidence(&cached, past + std::time::Duration::from_secs(60)); + reason("scala_cli_repository_shadowed"); + // Older than an input, it proves nothing either way. + write_evidence(&cached, past - std::time::Duration::from_secs(60)); + assert_eq!(entry_wired_checked(root, &entry), Ok(true)); + // Resolved from the tree, it is the patch. + write_evidence( + &ws.join(coursier_tree::tree_dir(&p.coords())) + .join("config-1.4.3.jar"), + past + std::time::Duration::from_secs(60), + ); + assert_eq!(entry_wired_checked(root, &entry), Ok(true)); } } diff --git a/crates/socket-patch-core/src/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index 5ede1c71f..ef4372054 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -606,11 +606,12 @@ pub enum UnattestedKind { /// version: the classifier-less pin does not retarget it and the grant /// serves only the main jar, so the build runs the public classifier jar. MavenClassifierUnpatched, - /// A hosted sbt pin (`socket-patch.sbt`) the build provably does not - /// apply: a build source reassigns `dependencyOverrides` or `resolvers` - /// (or sets `sbt.override.build.repos`), dropping the generated - /// override or resolver, or current resolution evidence records another - /// version or other bytes of the GA. The downloaded copy under + /// A hosted sbt pin (`socket-patch.sbt`) whose bytes the build provably + /// does not use, and that no current evidence verifies: a build source + /// reassigns `dependencyOverrides` (replacing the generated override), + /// or a resolution record dated after the wiring and every build source + /// shows the GA's base version, or the pinned version from outside the + /// pin repository with other bytes. The downloaded copy under /// `.socket/sbt-hosted` then proves nothing about what the build runs. SbtPinBypassed, } diff --git a/crates/socket-patch-core/src/vex/discover/sbt.rs b/crates/socket-patch-core/src/vex/discover/sbt.rs index f9bd19546..847eb51d9 100644 --- a/crates/socket-patch-core/src/vex/discover/sbt.rs +++ b/crates/socket-patch-core/src/vex/discover/sbt.rs @@ -16,11 +16,12 @@ //! hosted file downloads). //! //! That copy proves sbt loaded the hosted file once, not that the build -//! resolves it. Where the build provably does not apply the pin -//! ([`bypass`]: a build edit dropping the generated override or resolver, -//! or current evidence resolving another version or other bytes), the ref -//! is also marked [`Unattested`] (`SbtPinBypassed`), so no basis attests -//! it. +//! resolves it. Where the build provably does not use the pinned bytes +//! ([`bypass`]: a `dependencyOverrides` reassignment replacing the +//! generated override, or evidence dated after the wiring and the sources +//! still resolving the base version, or other bytes) and no such evidence +//! verifies the pin, the ref is also marked [`Unattested`] +//! (`SbtPinBypassed`), so no basis attests it. //! //! The check is on content, not location, like the generated file's own //! load-time verifier: Ivy legitimately serves a second checkout from the @@ -37,12 +38,14 @@ //! //! [`Unattested`]: super::Unattested -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; use super::{DiscoverCtx, Discovery, PatchedRef, UnattestedKind, DIAG_REF_INVALID}; +use crate::formats::sbt::build::BuildFindings; use crate::formats::sbt::owned_file::{ - parse, OwnedFileError, SbtFileMode, SbtPin, HOSTED_FILE, VENDORED_FILE, VENDORED_REPO_REL, + parse, OwnedFileError, SbtFileMode, SbtPin, HOSTED_FILE, HOSTED_REPO_REL, VENDORED_FILE, + VENDORED_REPO_REL, }; use crate::formats::sbt::JvmResolution; use crate::utils::purl::maven_purl; @@ -93,125 +96,180 @@ struct BuildState { /// Why the evidence cannot vouch for any pin even where it shows one /// ([`evidence_blocker`]). blocker: Option, - /// A build edit that keeps the generated pin from applying - /// ([`edit_bypass`]), read off the build sources whether or not any - /// evidence exists. - edit: Option, - /// The evidence postdates every build source and the generated files, - /// so what it records is what the build resolves now. - current: bool, + /// A `dependencyOverrides` reassignment ([`overrides_reassigned`]), + /// read off the build sources whether or not any evidence exists. + overrides: Option, + /// The hosted pin repository as sbt's evidence spells it (canonical, + /// `/.socket/sbt-hosted/maven2`). + repo: Option, + /// The evidence files dated after the generated files and every build + /// source: each one records what its project resolves now, with the + /// pin in place. An older record (a project or cross build not + /// re-resolved since) proves nothing about the pin either way. + current: BTreeSet, } -/// The build's state under `root`: its sources scanned, its evidence read -/// (`sbt_evidence::discover`'s rule: no evidence file, no evidence). +/// The build's state under `root`: its sources scanned once, its evidence +/// read (`sbt_evidence::discover`'s rule: no evidence file, no evidence). async fn build_state(root: &Path) -> BuildState { let root = root.to_path_buf(); tokio::task::spawn_blocking(move || { let Ok(e) = crate::crawlers::sbt_evidence::scan(&root) else { return BuildState::default(); }; - let edit = edit_bypass(&crate::formats::sbt::build::scan_build_sources( - &e.build_sources, - )); + let findings = crate::formats::sbt::build::scan_build_sources(&e.build_sources); + let overrides = overrides_reassigned(&findings); + let repo = std::fs::canonicalize(&root) + .ok() + .map(|c| crate::crawlers::sbt_evidence::strip_verbatim(&c).join(HOSTED_REPO_REL)); if e.files.is_empty() { return BuildState { - edit, + overrides, + repo, ..BuildState::default() }; } let res = crate::crawlers::sbt_evidence::resolution(&e); - let blocker = res.as_ref().and_then(|res| evidence_blocker(&e, res)); + let blocker = res + .as_ref() + .and_then(|res| evidence_blocker(&e, res, &findings)); + let since = e.generated.max(e.newest_source); + let current = e + .record_dates + .iter() + .filter(|(_, at)| since.is_none_or(|s| **at > s)) + .map(|(rel, _)| rel.clone()) + .collect(); BuildState { res, blocker, - edit, - current: !e.stale && !e.wiring_newer, + overrides, + repo, + current, } }) .await .unwrap_or_default() } -/// The build edit under which the generated file pins nothing where the -/// edit applies, so no copy (installed, cached or resolved) shows that the -/// build uses the pinned bytes: a `dependencyOverrides` reassignment -/// replaces the generated override (the build resolves the version it -/// declares), and a `resolvers` reassignment or `sbt.override.build.repos` -/// drops the generated resolver (the pinned version resolves from another -/// repository, or not at all). The other build-wide blockers -/// ([`evidence_blocker`]) only make the evidence too old or too partial to -/// verify a pin, and a `build.sbt.lock` checks the resolution without -/// changing it: under none of them does the pin stop applying. -fn edit_bypass(findings: &crate::formats::sbt::build::BuildFindings) -> Option { - if !findings.overrides_assignment.is_empty() { - return Some(format!( - "{} reassign(s) `dependencyOverrides`, which replaces socket-patch's override where \ - it applies, so the build resolves the version it declares there (append with `+=` \ - instead)", +/// A build source reassigning `dependencyOverrides`: it replaces the +/// generated override where it applies (both a blocker of the evidence and, +/// with no current evidence verifying the pin, a bypass of the pin). +fn overrides_reassigned(findings: &BuildFindings) -> Option { + (!findings.overrides_assignment.is_empty()).then(|| { + format!( + "{} reassign(s) `dependencyOverrides`, which replaces the pin where it applies", findings.overrides_assignment.join(", ") - )); - } - if !findings.resolvers_assignment.is_empty() { - return Some(format!( - "{} reassign(s) `resolvers`, which drops socket-patch's resolver where it applies, \ - so the pinned version does not resolve from it there (append with `+=` instead)", - findings.resolvers_assignment.join(", ") - )); + ) + }) +} + +/// `res` cut down to `pin`'s GA as the [`BuildState::current`] records +/// show it: each version by its current records only, and the pinned +/// version's artifact files only while every record resolving that version +/// is current (an artifact path names no record). +fn current_view(res: &JvmResolution, pin: &SbtPin, current: &BTreeSet) -> JvmResolution { + let ga = (pin.group.clone(), pin.artifact.clone()); + let is_current = |id: &String| { + id.rsplit_once(':') + .is_some_and(|(record, _)| current.contains(record)) + }; + let mut view = JvmResolution::default(); + let Some(versions) = res.modules.get(&ga) else { + return view; + }; + for (version, ids) in versions { + let now: BTreeSet = ids.iter().filter(|id| is_current(id)).cloned().collect(); + if !now.is_empty() { + view.modules + .entry(ga.clone()) + .or_default() + .insert(version.clone(), now); + } } - if !findings.override_build_repos.is_empty() { - return Some(format!( - "{} set(s) `sbt.override.build.repos=true`, so the launcher's repositories replace \ - the build's, socket-patch's resolver among them", - findings.override_build_repos.join(", ") - )); + let key = (pin.group.clone(), pin.artifact.clone(), pin.sv.clone()); + let pinned_ids = versions.get(&pin.sv); + if pinned_ids.is_some_and(|ids| !ids.is_empty() && ids.iter().all(is_current)) { + if let Some(paths) = res.artifacts.get(&key) { + view.artifacts.insert(key, paths.clone()); + } } - None + view } /// Why the build provably does not use `pin`'s bytes, whatever copy is -/// installed: a build edit ([`edit_bypass`]), or current evidence -/// ([`BuildState::current`]) recording another version of the GA, or a -/// readable artifact of the pinned version whose bytes are not pinned. -/// Evidence older than a build source or the generated files proves -/// nothing either way (it may predate the pin), and neither does an -/// artifact file that is gone (an evicted cache). +/// installed, or `None`. Current evidence that verifies the pin ([`verify`] +/// over [`current_view`]) always wins. Otherwise: a `dependencyOverrides` +/// reassignment (the override is replaced, so the build resolves another +/// version, or fails the load-time verifier), or current evidence judged +/// exactly as the hosted rewriter re-checks an existing pin +/// (`formats::sbt::gate::check_existing`): the GA's base version (the +/// override shadowed), or the pinned version from outside the pin +/// repository with bytes that are not pinned. An artifact that cannot be +/// read now proves nothing. (Another forced version is no proof to either: +/// the load-time verifier fails that `update`.) +/// +/// A `resolvers` reassignment or `sbt.override.build.repos` is no bypass on +/// its own: the override still forces the Socket-only version, and the +/// load-time verifier fails `update` on any other version or unpinned +/// bytes, so such a build runs the pinned bytes or fails. async fn bypass(pin: &SbtPin, build: &BuildState, hashes: &mut Hashes) -> Option { - if let Some(edit) = &build.edit { - return Some(edit.clone()); + use crate::formats::sbt::gate::{check_existing, GateRefusal, GateWarning}; + let now = build + .res + .as_ref() + .map(|res| current_view(res, pin, &build.current)); + if let Some(now) = &now { + if verify(pin, Some(now), hashes).await.is_ok() { + return None; + } } - let res = build.res.as_ref().filter(|_| build.current)?; + if let Some(why) = &build.overrides { + return Some(why.clone()); + } + let (now, repo) = (now?, build.repo.as_deref()?); + let key = (pin.group.clone(), pin.artifact.clone(), pin.sv.clone()); + let mut known = BTreeMap::new(); + for path in now.artifacts.get(&key).into_iter().flatten() { + known.insert(path.clone(), hashes.of(path).await); + } + let pinned = |path: &Path| { + known + .get(path) + .cloned() + .flatten() + .is_none_or(|got| got == pin.jar_sha256 || got == pin.pom_sha256) + }; let ga = format!("{}:{}", pin.group, pin.artifact); - if let Some(other) = res - .versions(&pin.group, &pin.artifact) - .into_iter() - .find(|v| *v != pin.sv) - { - return Some(format!( - "the build's current resolution evidence resolves {ga}:{other}, not the pinned {} \ - (something in the build forces that version, or a project the pin does not reach \ - resolves it)", - pin.sv - )); - } - let paths = res - .artifacts - .get(&(pin.group.clone(), pin.artifact.clone(), pin.sv.clone())) - .into_iter() - .flatten(); - for path in paths { - if let Some(got) = hashes.of(path).await { - if got != pin.jar_sha256 && got != pin.pom_sha256 { - return Some(format!( - "the build's current resolution evidence resolves {ga}:{} to {} with \ - sha256 {got}, which is not pinned (a copy another repository serves, \ - such as `mavenLocal` or an Ivy-local publish)", - pin.sv, - path.display() - )); - } - } + match check_existing( + Some(&now), + &pin.group, + &pin.artifact, + &pin.sv, + repo, + &pinned, + ) { + Err(GateRefusal::ResolvedElsewhere { paths }) => Some(format!( + "the build's current resolution evidence resolves {ga}:{} from {}, outside the \ + pin repository and not the pinned bytes", + pin.sv, + paths + .iter() + .map(|p| p.display().to_string()) + .collect::>() + .join(", ") + )), + Err(_) => None, + Ok(warnings) => warnings.into_iter().find_map(|w| match w { + GateWarning::OverrideShadowed { evidence } => Some(format!( + "the build's current resolution evidence still resolves {ga} at its base \ + version in {}, not the pinned {}", + evidence.join(", "), + pin.sv + )), + GateWarning::MetaBuildOnly => None, + }), } - None } /// The build-wide conditions under which evidence showing the pin is not @@ -224,8 +282,9 @@ async fn bypass(pin: &SbtPin, build: &BuildState, hashes: &mut Hashes) -> Option fn evidence_blocker( e: &crate::crawlers::sbt_evidence::SbtEvidence, res: &JvmResolution, + findings: &BuildFindings, ) -> Option { - use crate::formats::sbt::build::{declared_projects, scan_build_sources}; + use crate::formats::sbt::build::declared_projects; if e.stale { return Some( "a build source changed after the last `sbt update`; run `sbt update`".to_string(), @@ -249,12 +308,8 @@ fn evidence_blocker( missing.join(", ") )); } - let findings = scan_build_sources(&e.build_sources); - if !findings.overrides_assignment.is_empty() { - return Some(format!( - "{} reassign(s) `dependencyOverrides`, which replaces the pin where it applies", - findings.overrides_assignment.join(", ") - )); + if let Some(why) = overrides_reassigned(findings) { + return Some(why); } if !findings.resolvers_assignment.is_empty() { return Some(format!( @@ -432,8 +487,13 @@ async fn hosted_ref( } }; // Unverified, the ref still attests from the installed copy; where the - // build provably does not apply the pin, from nothing. - if let Some(why) = bypass(pin, build, hashes).await { + // build provably does not apply the pin, from nothing. A pin the + // evidence verifies is never withdrawn. + let bypassed = match &locked { + Some(_) => None, + None => bypass(pin, build, hashes).await, + }; + if let Some(why) = bypassed { out.unattested( &purl, &pin.uuid, @@ -560,23 +620,27 @@ mod tests { all.projects_seen = [".", "core", "app"].map(String::from).into(); let mut core_only = all.clone(); core_only.projects_seen.remove("app"); + let blocker = |e: &SbtEvidence, res: &JvmResolution| { + let findings = crate::formats::sbt::build::scan_build_sources(&e.build_sources); + evidence_blocker(e, res, &findings) + }; - assert_eq!(evidence_blocker(&e(&[], false), &all), None); - let why = evidence_blocker(&e(&[], false), &core_only).unwrap(); + assert_eq!(blocker(&e(&[], false), &all), None); + let why = blocker(&e(&[], false), &core_only).unwrap(); assert!(why.contains("app"), "{why}"); - let why = evidence_blocker(&e(&[], true), &all).unwrap(); + let why = blocker(&e(&[], true), &all).unwrap(); assert!(why.contains("sbt update"), "{why}"); let overrides = [( "app/build.sbt", "dependencyOverrides := Seq(\"org.apache.commons\" % \"commons-text\" % \"1.9\")\n", )]; - let why = evidence_blocker(&e(&overrides, false), &all).unwrap(); + let why = blocker(&e(&overrides, false), &all).unwrap(); assert!(why.contains("app/build.sbt:1"), "{why}"); let resolvers = [("app/build.sbt", "resolvers := Nil\n")]; - let why = evidence_blocker(&e(&resolvers, false), &all).unwrap(); + let why = blocker(&e(&resolvers, false), &all).unwrap(); assert!(why.contains("`resolvers`"), "{why}"); let lock = [("build.sbt.lock", "{}\n")]; - let why = evidence_blocker(&e(&lock, false), &all).unwrap(); + let why = blocker(&e(&lock, false), &all).unwrap(); assert!(why.contains("build.sbt.lock"), "{why}"); } @@ -639,141 +703,173 @@ mod tests { ); } - /// Only the edits that drop the generated override or resolver mean the - /// pin is not applied; staleness, partial evidence and a lock do not. - #[test] - fn edit_bypass_is_the_pin_dropping_edits_only() { - use crate::formats::sbt::build::BuildFindings; - assert_eq!(edit_bypass(&BuildFindings::default()), None); - let lock = BuildFindings { - dependency_lock: vec!["build.sbt.lock".into()], - ..Default::default() - }; - assert_eq!(edit_bypass(&lock), None); - for (findings, want) in [ - ( - BuildFindings { - overrides_assignment: vec!["a/build.sbt:1".into()], - ..Default::default() - }, - "`dependencyOverrides`", - ), - ( - BuildFindings { - resolvers_assignment: vec!["build.sbt:2".into()], - ..Default::default() - }, - "`resolvers`", - ), - ( - BuildFindings { - override_build_repos: vec![".sbtopts:1".into()], - ..Default::default() - }, - "sbt.override.build.repos", - ), - ] { - let why = edit_bypass(&findings).unwrap(); - assert!(why.contains(want), "{why}"); + /// One record `rel` resolving lang3 at `v` (its id `rel:compile`), with + /// `artifact` as the pinned version's file. + fn record(r: &mut JvmResolution, rel: &str, v: &str, artifact: Option<&Path>) { + let ga = ( + "org.apache.commons".to_string(), + "commons-lang3".to_string(), + ); + r.modules + .entry(ga.clone()) + .or_default() + .entry(v.to_string()) + .or_default() + .insert(format!("{rel}:compile")); + if let Some(p) = artifact { + r.artifacts + .entry((ga.0, ga.1, v.to_string())) + .or_default() + .insert(p.to_path_buf()); } } - /// The installed-copy fallback is withdrawn by an edit whatever the - /// evidence, and by evidence only when it postdates the sources and the - /// generated file and shows another version or other bytes. + /// The installed-copy fallback is withdrawn only on proof: a + /// `dependencyOverrides` reassignment, or a record dated after the + /// wiring and the sources (judged as the hosted rewriter re-checks a + /// pin) showing the base version or other bytes, and never while such a + /// record verifies the pin. A record older than the wiring (a project + /// or cross build not re-resolved since) proves nothing. #[tokio::test] - async fn bypass_needs_an_edit_or_current_evidence() { + async fn bypass_needs_proof_from_current_records() { let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path().join("repo"); let jar = tmp.path().join("x.jar"); std::fs::write(&jar, b"patched").unwrap(); let sha = hex::encode(Sha256::digest(b"patched")); let evil = tmp.path().join("evil.jar"); std::fs::write(&evil, b"evil").unwrap(); let gone = tmp.path().join("gone.jar"); - let state = |res: Option, current: bool| BuildState { - res, - current, + let p = pin(&sha); + let mut h = Hashes::default(); + let state = |res: JvmResolution, current: &[&str]| BuildState { + res: Some(res), + repo: Some(repo.clone()), + current: current.iter().map(|s| s.to_string()).collect(), ..Default::default() }; - let mut h = Hashes::default(); - let p = pin(&sha); + let overrides = Some("build.sbt:1 reassign(s) `dependencyOverrides`".to_string()); assert_eq!(bypass(&p, &BuildState::default(), &mut h).await, None); let edited = BuildState { - edit: Some("build.sbt:1 reassign(s) `dependencyOverrides`".into()), + overrides: overrides.clone(), ..Default::default() }; let why = bypass(&p, &edited, &mut h).await.unwrap(); assert!(why.contains("build.sbt:1"), "{why}"); - // The pinned version and bytes: nothing bypasses. - let ok = res(&[SV], Some(&jar)); - assert_eq!(bypass(&p, &state(Some(ok), true), &mut h).await, None); - // Another version: only current evidence counts. - let shadowed = res(&[SV, "3.11"], Some(&jar)); + + // A current record verifying the pin wins over the edit. + let mut verified = JvmResolution::default(); + record(&mut verified, "b/target/out", SV, Some(&jar)); + let verified_edited = BuildState { + overrides: overrides.clone(), + ..state(verified.clone(), &["b/target/out"]) + }; + assert_eq!(bypass(&p, &verified_edited, &mut h).await, None); + // A stale record of another project beside it changes nothing. + let mut mixed = verified.clone(); + record(&mut mixed, "a/target/out", "3.11", None); assert_eq!( - bypass(&p, &state(Some(shadowed.clone()), false), &mut h).await, + bypass(&p, &state(mixed.clone(), &["b/target/out"]), &mut h).await, None ); - let why = bypass(&p, &state(Some(shadowed), true), &mut h) + // Current, it shows the build resolving the base. + let why = bypass(&p, &state(mixed, &["a/target/out", "b/target/out"]), &mut h) .await .unwrap(); - assert!(why.contains("3.11, not the pinned"), "{why}"); - let base_only = res(&["3.11"], None); - assert!(bypass(&p, &state(Some(base_only), true), &mut h) - .await - .is_some()); - // Other bytes at the pinned version: the same. - let elsewhere = res(&[SV], Some(&evil)); + assert!( + why.contains("base version") && why.contains("a/target/out"), + "{why}" + ); + // Another version a project forces is judged as the rewriter does: + // no proof (sbt's load-time verifier fails that `update`). + let mut forced = JvmResolution::default(); + record(&mut forced, "a/target/out", "3.12", None); assert_eq!( - bypass(&p, &state(Some(elsewhere.clone()), false), &mut h).await, + bypass(&p, &state(forced, &["a/target/out"]), &mut h).await, None ); - let why = bypass(&p, &state(Some(elsewhere), true), &mut h) + + // Other bytes at the pinned version, outside the pin repository. + let mut elsewhere = JvmResolution::default(); + record(&mut elsewhere, "b/target/out", SV, Some(&evil)); + let why = bypass(&p, &state(elsewhere.clone(), &["b/target/out"]), &mut h) .await .unwrap(); - assert!(why.contains("which is not pinned"), "{why}"); - // A recorded file that is gone, or no resolution of the GA at all, - // proves nothing. - let evicted = res(&[SV], Some(&gone)); - assert_eq!(bypass(&p, &state(Some(evicted), true), &mut h).await, None); + assert!(why.contains("not the pinned bytes"), "{why}"); + assert_eq!(bypass(&p, &state(elsewhere, &[]), &mut h).await, None); + // A recorded file that is gone, or no resolution of the GA, proves + // nothing. + let mut evicted = JvmResolution::default(); + record(&mut evicted, "b/target/out", SV, Some(&gone)); assert_eq!( - bypass(&p, &state(Some(res(&[], None)), true), &mut h).await, + bypass(&p, &state(evicted, &["b/target/out"]), &mut h).await, + None + ); + assert_eq!( + bypass(&p, &state(JvmResolution::default(), &[]), &mut h).await, None ); } - /// A hosted file at the root of an sbt build with no evidence (a fresh - /// clone), over `build_sbt`. - fn hosted_build(build_sbt: &str) -> tempfile::TempDir { + /// A hosted file pinning `ga` (`group`, `artifact`, `base`) at the root + /// of `root`, an sbt build. + fn write_hosted(root: &Path, group: &str, artifact: &str, base: &str) { use crate::formats::sbt::owned_file::{render, SbtLine, SbtOwnedFile}; - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - std::fs::create_dir_all(root.join("project")).unwrap(); - std::fs::write(root.join("project/build.properties"), "sbt.version=1.9.9\n").unwrap(); - std::fs::write(root.join("build.sbt"), build_sbt).unwrap(); + let pin = SbtPin { + group: group.into(), + artifact: artifact.into(), + base: base.into(), + sv: format!("{base}-socket.abcdef12"), + ..pin(&"b".repeat(64)) + }; let file = render(&SbtOwnedFile { mode: SbtFileMode::Hosted, line: SbtLine::Sbt1, crlf: false, - pins: [(UUID.to_string(), pin(&"b".repeat(64)))].into(), + pins: [(UUID.to_string(), pin)].into(), }) .unwrap(); std::fs::write(root.join(HOSTED_FILE), file).unwrap(); - tmp } - /// A build source reassigning `dependencyOverrides` (or `resolvers`) - /// drops the pin where it applies: the hosted ref stays (rollback, - /// remove and list find it) but is marked unattested, so the copy sbt - /// downloaded into `.socket/sbt-hosted` cannot attest it. Without the - /// edit, the unverified ref keeps its installed-copy basis. + fn set_mtime(path: &Path, at: std::time::SystemTime) { + std::fs::File::options() + .write(true) + .open(path) + .unwrap() + .set_modified(at) + .unwrap(); + } + + /// The build's own evidence: of the 1.9.9 test-compile build (root + /// aggregating `a`, `b`, `c`; only `b` resolves gson 2.8.9), wired + /// with a gson pin after `b` last resolved, and every other project + /// re-resolved since. `b`'s record predates the pin, so its 2.8.9 is + /// no proof, however fresh the other projects' records are; once `b` + /// resolves again with the pin in place and still shows 2.8.9, it is. #[tokio::test] - async fn a_pin_dropping_edit_unattests_the_hosted_ref() { - const PURL: &str = "pkg:maven/org.apache.commons/commons-lang3@3.11"; - let plain = hosted_build( - "libraryDependencies += \"org.apache.commons\" % \"commons-lang3\" % \"3.11\"\n", + async fn a_record_older_than_the_pin_never_withdraws_it() { + use std::time::{Duration, SystemTime}; + const PURL: &str = "pkg:maven/com.google.code.gson/gson@2.8.9"; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("x"); + crate::crawlers::sbt_evidence::stage_fixture( + &Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/sbt/evidence/1.9.9/test-compile"), + &root, ); - let out = super::super::discover_patched_refs(plain.path()).await; + write_hosted(&root, "com.google.code.gson", "gson", "2.8.9"); + let now = SystemTime::now(); + set_mtime(&root.join(HOSTED_FILE), now - Duration::from_secs(1800)); + let output = |project: &str| { + root.join(project) + .join("target/scala-2.12/update/update_cache_2.12/output") + }; + for project in ["", "a", "c"] { + set_mtime(&output(project), now); + } + let out = super::super::discover_patched_refs(&root).await; assert!( out.refs.iter().any(|r| r.uuid == UUID && r.purl == PURL), "{:?}", @@ -781,31 +877,97 @@ mod tests { ); assert!(out.unattested.is_empty(), "{:?}", out.unattested); - for (edit, want) in [ + set_mtime(&output("b"), now); + let out = super::super::discover_patched_refs(&root).await; + let [u] = out.unattested.as_slice() else { + panic!("{:?}", out.unattested); + }; + assert_eq!(u.kind, UnattestedKind::SbtPinBypassed); + assert!(u.detail.contains("b/target"), "{}", u.detail); + } + + /// A hosted file at the root of an sbt build with no evidence (a fresh + /// clone), over `build_sbt`. + fn hosted_build(build_sbt: &str) -> tempfile::TempDir { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::create_dir_all(root.join("project")).unwrap(); + std::fs::write(root.join("project/build.properties"), "sbt.version=1.9.9\n").unwrap(); + std::fs::write(root.join("build.sbt"), build_sbt).unwrap(); + write_hosted(root, "org.apache.commons", "commons-lang3", "3.11"); + tmp + } + + const LANG3: &str = + "libraryDependencies += \"org.apache.commons\" % \"commons-lang3\" % \"3.11\"\n"; + + /// The unattested mark on the hosted lang3 ref discovery finds in + /// `root`, if any (the ref itself always stays). + async fn mark(root: &Path) -> Option { + const PURL: &str = "pkg:maven/org.apache.commons/commons-lang3@3.11"; + let out = super::super::discover_patched_refs(root).await; + assert!( + out.refs.iter().any(|r| r.uuid == UUID && r.purl == PURL), + "the ref stays: {:?}", + out.refs + ); + assert!(out.unattested.len() <= 1, "{:?}", out.unattested); + let u = out.unattested.into_iter().next()?; + assert_eq!( + (u.kind, u.uuid.as_str(), u.purl.as_str(), u.file.as_path()), ( - "dependencyOverrides := Seq(\"org.apache.commons\" % \"commons-lang3\" % \"3.11\")\n", - "`dependencyOverrides`", - ), - ("resolvers := Nil\n", "`resolvers`"), - ] { - let edited = hosted_build(&format!( - "libraryDependencies += \"org.apache.commons\" % \"commons-lang3\" % \"3.11\"\n{edit}" - )); - let out = super::super::discover_patched_refs(edited.path()).await; - assert!( - out.refs.iter().any(|r| r.uuid == UUID && r.purl == PURL), - "the ref stays: {:?}", - out.refs - ); - let [u] = out.unattested.as_slice() else { - panic!("{edit}: {:?}", out.unattested); - }; - assert_eq!( - (u.kind, u.uuid.as_str(), u.purl.as_str()), - (UnattestedKind::SbtPinBypassed, UUID, PURL) - ); - assert_eq!(u.file, Path::new(HOSTED_FILE)); - assert!(u.detail.contains("build.sbt:2") && u.detail.contains(want), "{}", u.detail); - } + UnattestedKind::SbtPinBypassed, + UUID, + PURL, + Path::new(HOSTED_FILE) + ) + ); + Some(u) + } + + /// With no evidence, only a `dependencyOverrides` reassignment withdraws + /// the installed-copy basis: it replaces the generated override. A + /// `resolvers` reassignment or `sbt.override.build.repos` leaves the + /// override forcing the Socket-only version and the load-time verifier + /// checking its bytes, so the build runs the pinned bytes or fails. + #[tokio::test] + async fn only_an_overrides_reassignment_withdraws_without_evidence() { + assert!(mark(hosted_build(LANG3).path()).await.is_none()); + let edited = hosted_build(&format!( + "{LANG3}dependencyOverrides := Seq(\"org.apache.commons\" % \"commons-lang3\" % \"3.11\")\n" + )); + let u = mark(edited.path()).await.unwrap(); + assert!( + u.detail.contains("build.sbt:2") && u.detail.contains("`dependencyOverrides`"), + "{}", + u.detail + ); + let resolvers = hosted_build(&format!("{LANG3}resolvers := Nil\n")); + assert!(mark(resolvers.path()).await.is_none()); + let repos = hosted_build(LANG3); + std::fs::write( + repos.path().join(".sbtopts"), + "-Dsbt.override.build.repos=true\n", + ) + .unwrap(); + assert!(mark(repos.path()).await.is_none()); + } + + /// A file under the vendored name that socket-patch did not write is a + /// root definition sbt loads like any other: its reassignment counts. + #[tokio::test] + async fn a_foreign_generated_name_is_scanned_as_a_build_source() { + let tmp = hosted_build(LANG3); + std::fs::write( + tmp.path().join(VENDORED_FILE), + "dependencyOverrides := Seq()\n", + ) + .unwrap(); + let u = mark(tmp.path()).await.unwrap(); + assert!( + u.detail.contains("socket-patch-vendor.sbt:1"), + "{}", + u.detail + ); } } diff --git a/docs/ecosystems.md b/docs/ecosystems.md index bb6998933..bec33c50a 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -1198,10 +1198,10 @@ digest; a build that now declares the GA newer than the patched base is refused (`redirect_sbt_pin_declared_newer`: roll the patch back, or declare the base again). `rollback` / `remove` restore the file offline. `vex` attests a pin that post-wiring evidence verifies, or else from -the copy sbt downloaded into `.socket/sbt-hosted/`, but never one the build -provably bypasses: a `dependencyOverrides` / `resolvers` reassignment or -`sbt.override.build.repos=true`, or current evidence resolving another version -or other bytes (`vex_sbt_pin_bypassed`). The full code list is in +the copy sbt downloaded into `.socket/sbt-hosted/`, but never one whose bytes +the build provably does not use: a `dependencyOverrides` reassignment, or +evidence recorded after the wiring that still resolves the base version or other bytes +(`vex_sbt_pin_bypassed`). The full code list is in [`CLI_CONTRACT.md`](../crates/socket-patch-cli/CLI_CONTRACT.md) (**Hosted sbt**). From 68dec858a58eeef9a6bf409de320c5d6992ee0ad Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 5 Nov 2026 23:31:49 -0600 Subject: [PATCH 3/4] Treat an onLoad reassignment that drops the sbt installer as a bypass The resolver and forced-version decisions rely on the generated file's `onLoad` installer: its `update` verifier fails a build that does not resolve the pinned bytes, and its resolver order serves the pin repository first. A build source that reassigns `onLoad` (`:=`, `~=`, `--=`, in any scope) without keeping the previous value drops both. - `scan_build_sources` reports such edits as `BuildFindings::onload_assignment`. A `:=` whose right-hand side reads `onLoad`'s own `.value` (the generated idiom), or a `~=` that composes (`andThen` / `compose`), keeps the installer and is not reported. The finding round-trips through the resolution document. - VEX hosted sbt treats it like a `dependencyOverrides` reassignment (`pin_dropped`). It is an evidence blocker and, unless current evidence verifies the pin, it withdraws the installed-copy basis (`SbtPinBypassed`). - Vendored sbt liveness: `sbt_onload_reassigned`, over every build source the evidence walk reads. - Planners agree: the hosted rewriter refuses new pins and leaves existing ones unconfirmed (`redirect_sbt_onload_assignment`). The vendored planner refuses a new pin (`vendor_sbt_onload_assignment`). Co-Authored-By: Claude Opus 5.5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 11 ++- .../src/commands/vex_sources.rs | 3 +- .../socket-patch-cli/tests/e2e_sbt_hosted.rs | 12 ++- .../src/formats/sbt/build.rs | 88 +++++++++++++++++ .../src/formats/sbt/evidence.rs | 4 + .../src/patch/redirect/sbt.rs | 61 ++++++++++++ .../socket-patch-core/src/vendor/jvm/apply.rs | 2 +- .../socket-patch-core/src/vendor/jvm/sbt.rs | 88 ++++++++++++++++- .../socket-patch-core/src/vex/discover/mod.rs | 3 +- .../socket-patch-core/src/vex/discover/sbt.rs | 96 ++++++++++++++----- docs/ecosystems.md | 3 +- 11 files changed, 339 insertions(+), 32 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 62478a464..e44d07262 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -331,7 +331,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate, `bun.lock` / `bun.lockb`, and `vlt-lock.json` with `vlt.json` and `node_modules/.vlt-lock.json` read only), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; the hosted redirect is a `sparse+` registry, which cargo loads only from **1.68**, so `rust-toolchain.toml` / `rust-toolchain` are read too (never edited) and a project whose declared floor — the lowest `rust-version` of the root and the member manifests read here (a `rust-version.workspace = true` inheritor reads `[workspace.package]`), else the channel rustup picks from the root (the legacy `rust-toolchain` over `rust-toolchain.toml`; a dated `nightly-`/`beta-YYYY-MM-DD` maps to its release train) — is below 1.68 is refused `redirect_cargo_old_cargo` per crate (nothing written, an earlier run's pins left in place with the `rollback` that drops them named; `--mode vendored` builds on cargo 1.41+), while an undeclared or rolling-channel floor still redirects (toolchain files in ancestor directories are not read); **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. With NO `Cargo.lock` there is no resolved graph to ask, so the dependents question is answered from the manifests instead: a crate declared beside any other dependency — anything but a path dependency on a manifest this run also pins, or a `workspace = true` inheritor of a table it scans — or beside a workspace member this run did not read (a `members` glob, or a member outside the project or behind a symbolic link, which member discovery drops) is refused `redirect_cargo_lockless_dependents`, whose detail names the remedies (commit a lockfile, or `--mode vendored`); a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json` (plus, v5.0 #353/#514, every lock a project under the root restores into: a member project's `packages.lock.json`, a per-project `packages..lock.json`, a literal `NuGetLockFilePath` — each pinned with the root config; a `NuGetLockFilePath` it cannot evaluate warns `redirect_nuget_lock_path_unresolved` and a project tree it cannot list warns `redirect_nuget_lock_unreadable`, both skipping the nuget redirect with nothing written), `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and, for a Gradle build, every settings, build, `buildSrc`, included-build, applied and plugin-source script, version catalog and lock file the script graph reaches, plus `gradle/verification-metadata.xml`, `gradle/wrapper/gradle-wrapper.properties` and the owned `.socket/gradle/` files), and the sbt build files (`socket-patch.sbt`, `socket-patch-vendor.sbt`, `build.sbt`, `project/build.properties`, `.sbtopts`, `.jvmopts`; `build.sbt.lock` and the Mill / scala-cli build files `build.mill`, `build.mill.yaml`, `build.sc`, `.mill-version`, `project.scala` for their presence only) — read, never edited; `socket-patch.sbt` is the only sbt file hosted mode writes (see **Hosted sbt** below). **npm-family flavor coverage**: package-lock / npm-shrinkwrap (a package the project patches itself with npm ≥ 12.1's native `npm patch` — a root `patchedDependencies` key, or the lock entry's `patched` record — is left on its registry entry in every npm lock, `redirect_npm_patched_dependency_skipped`), pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic (a yarn 2+ install migrates a v1 `yarn.lock` and drops its pins, so a run whose v1 lock carries a hosted pin warns `redirect_yarn_classic_berry_migration_risk` — the hosted twin of the vendored `yarn_classic_berry_migration_risk` — unless the root `package.json`, read as advisory input, declares `"packageManager": "yarn@1…"`), **yarn berry** (the pin yarn writes for a root `resolutions` entry: the root `package.json` — edited only beside a berry `yarn.lock` — gains one `"@npm:": ""` selector per locked range (`redirect_yarn_berry_resolution` edits), and only that `yarn.lock` entry is re-keyed `"@"` with the same `resolution:` + `yarnBerry10c0` checksum (`redirect_yarn_berry_entry`), moved to yarn's key order; never an `npm:` locator, whose fetcher sends npm registry auth to the patch host, nor a tarball locator under an `npm:` key, which hardened mode rejects (YN0078). An older release's `npm:::__archiveUrl=` pin is still recognized and is re-pinned on the next run; rollback rebuilds the key from the selectors and drops them. Refused, nothing written: a user-authored `resolutions` entry for the package `redirect_yarn_berry_resolutions_conflict`, no root manifest `redirect_yarn_berry_manifest_missing`, a builtin `patch:` entry wrapping the same descriptor `redirect_yarn_berry_shared_descriptor`, an artifact URL yarn cannot fetch as a tarball `redirect_yarn_berry_artifact_url_unsupported`; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found` (or `redirect_bun_non_registry_entry_skipped` when the only same-version entry is a user URL / `file:` tarball, #497), a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`), and **vlt** (`vlt-lock.json` without `lockfileVersion`, `0` or `1`; see the vlt hosted-mode contract below). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when the `repo-state.json` beside a rewritten lock exists (`common/config/rush/repo-state.json` for the common lock, `common/config/subspaces//repo-state.json` for a subspace lock; the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives), and `redirect_pnpm_trust_lockfile` carries the Rush pnpm >=11 install remedy (see the trust paragraph above). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`; when `.mvn/wrapper/maven-wrapper.properties` pins a Maven older than 3.9.4, which ignores those files, the additive warning `redirect_maven_trusted_checksums_unenforced`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a reactor root (a `` / `` declaration, a profile's included, or a Maven 4 root that lists none but whose subprojects Maven discovers: model version not `4.0.0`, `pom` packaging, a direct subdirectory holding a `pom.xml`, and `maven.project.discoverSubprojects` not turned off in `.mvn/maven.config`; each such subdirectory's `pom.xml` is read for this, never edited) is refused whole with `redirect_maven_multimodule_unsupported`: `pom.xml` and `.mvn/` are left untouched and the dep is not counted as redirected (a Gradle build beside it is still planned by the Gradle rewriter, but a dep in a root with a `pom.xml` is confirmed only when the pom pins it too), since a module's own literal `` would shadow a root pin (use `--mode vendored`; `vex` omits a hosted pin in a reactor root as `vex_maven_reactor_root`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), a `` variant (sources, tests, a native build) left at its version (`redirect_maven_classifier_unsupported`; the grant serves the main jar only, and the dep is skipped when a variant other than `sources` / `javadoc` names the patched release), a versioned declaration inside `` (a literal or a `${property}`) left as-is (`redirect_maven_profile_dependency_unpatched`; the dep is skipped when a profile holds its only declaration), a `${property}` groupId / artifactId resolved from the pom's own `` before matching (one the pom does not define, which may be the patched GA and whose own version would then win, refuses the dep with nothing written: `redirect_maven_coordinate_unresolved`), and a pom not readable as one left untouched (`redirect_maven_pom_unreadable`). Matches and anchors skip comments, CDATA, ``, ``, ``, `` and `` (the scope `vex` reads), and an existing self-closed `` / `` is expanded in place, never duplicated; an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). **gradle** (v5.0) is automated wiring, no longer a pasted snippet: the owned settings script `.socket/gradle/socket-patch.hosted.settings.gradle` with its index `.socket/gradle/hosted-index.tsv`, one apply line per build's settings file, every lock entry of the GA moved to the suffixed version, and the suffixed component in an existing `gradle/verification-metadata.xml`. A refused dep writes nothing and keeps `redirect_gradle_manual_snippet` as its fallback; same-GAV grants are refused (`redirect_gradle_same_gav_unsupported`). Rules, refusals and codes: [Gradle builds](#gradle-builds-v50). -**Hosted sbt (v5.0, additive)**: an sbt build root (`project/build.properties` naming an `sbt.version`, 0.13.18 or later) is wired through ONE generated root file, `socket-patch.sbt` — no user file is edited. It pins every granted Maven patch build-wide (a `ThisBuild` `dependencyOverrides +=` of the Socket-only `-socket.` version plus a `file:` resolver over `.socket/sbt-hosted/maven2/`, moved ahead of the default repositories on sbt 0.13 / 1.x so an unreachable one never blocks it offline), downloads the pinned pom and jar there on the first sbt load (sha256-checked, gitignored by the file itself), and installs a load-time verifier that fails `update` when any project resolves another version or a pinned artifact whose bytes are not pinned. Edits: `redirect_sbt_pin` (added), `redirect_sbt_pin_updated` (an existing row replaced: same GA and base under a new uuid, or the same uuid with new served values; `original` names the previous uuid and version), `redirect_sbt_pin_rechecked` (an existing row re-verified after the build's dependencies changed: its dependency digest is recorded anew, `original`/`new` are `{deps}`). The load-time verifier also fails `update` when a project declares a pinned GA at a version newer than the pin's base (the build-wide override would otherwise force it back down). A new pin is gated on sbt's own resolution records under `target/` (never the machine-wide cache): run-level stops wire nothing, warn once and exit 0 — `redirect_sbt_no_resolution_evidence` (none; run `sbt update` first; always the in-memory engine's answer), `redirect_sbt_resolution_incomplete` (a declared project left no evidence, or the project definitions cannot be read statically), `redirect_sbt_resolution_stale` (a build source is newer than some project's evidence: each project is dated by its own newest record, so a partial `sbt /update` does not vouch for the others). Per-patch refusals (never confirmed): `redirect_sbt_missing_override` (no `maven2` override or no suffixed version), `redirect_sbt_integrity_missing` (jar or pom sha256 missing), `redirect_sbt_unsafe_value` (a value unsafe in a Scala literal, or an index URL not naming the uuid), `redirect_sbt_version_conflict` (some project resolves another version, or a build source declares the GA newer than the patch's base), `redirect_sbt_override_conflict` (two patches for one GA in a run, or another base already pinned), `redirect_sbt_vendored_conflict` (the GA is pinned by `socket-patch-vendor.sbt`, or that file cannot be parsed — then every Maven patch), `redirect_sbt_owned_file_modified` / `redirect_sbt_owned_file_foreign` (`socket-patch.sbt` edited, or not socket-patch's — every Maven patch), `redirect_sbt_owned_file_unreadable` (a whole-run refusal: `socket-patch.sbt` is on disk but cannot be read as UTF-8 text, so writing it would replace it; nothing is written), `redirect_sbt_unsupported_version`, `redirect_sbt_build_root_unknown` (sbt files but no versioned build root — every Maven patch), `redirect_sbt_overrides_assignment` / `redirect_sbt_resolvers_assignment` (a build source reassigns `dependencyOverrides` / `resolvers` with `:=`, `~=` or `--=`), `redirect_sbt_dependency_lock_present` (a `build.sbt.lock`), `redirect_sbt_scala_runtime_unsupported` (`org.scala-lang`), `redirect_sbt_classifier_unsupported`; a GA no library configuration resolves is skipped silently (`redirect_sbt_meta_build_only` when only the meta-build resolves it). Advisories: `redirect_sbt_version_untested` (sbt 2.1+, still wired), `redirect_sbt_override_build_repos` (`sbt.override.build.repos=true`), `redirect_maven_pom_ignored_sbt_build` (a `pom.xml` beside the sbt build, which sbt never reads; the Maven rewriter still edits it for the Maven build). A re-run keeps an existing row and re-checks it. When the build's dependency digest changed since the pin, evidence resolved after the change (fresh, newer than the generated file) re-verifies it and the row's digest is refreshed (`redirect_sbt_pin_rechecked`); the uuid is NOT confirmed on `redirect_sbt_pin_declared_newer` (a build source now declares the GA newer than the pin's base; the row stays, sbt's load-time verifier fails the build, and the remedy is `socket-patch rollback` or declaring the base again), `redirect_sbt_pin_unverifiable` (the digest changed and the evidence predates the change, or the digest cannot be computed: run `sbt update`, then re-run socket-patch), `redirect_sbt_override_shadowed` (the evidence still resolves the base version) or `redirect_sbt_resolved_elsewhere` (the pinned version resolves from outside the pin repository from a file whose sha256 is not the pinned jar's; a copy holding the pinned bytes, such as the Ivy cache a second checkout reads, is fine — at most 64 pinned artifact files of up to 256 MiB are hashed, anything else counts as elsewhere), and also when a build source now reassigns `dependencyOverrides` / `resolvers` or a `build.sbt.lock` appeared (the same `redirect_sbt_overrides_assignment` / `redirect_sbt_resolvers_assignment` / `redirect_sbt_dependency_lock_present` codes; the row stays and sbt's load-time verifier fails the build). For a pure sbt root (no `pom.xml` / Gradle script beside it), maven confirmation is decided only by the sbt rewriter's report; on a mixed root a uuid the sbt rewriter refused is still confirmed by the Maven rewriter's own `pom.xml` pin (the generated sbt files never prove a pin by substring). **Mill and scala-cli** are guidance only: per Maven patch `redirect_mill_manual_snippet` / `redirect_scala_cli_manual_snippet` carry a paste-able snippet (repository + forced suffixed version), nothing is written or confirmed, and a pure Mill / scala-cli root gets no `redirect_maven_no_pom`; there, a Maven patch the server sent without a `maven2` registry override gets `redirect_maven_missing_override` instead of a snippet (with a `pom.xml` beside the Mill / scala-cli files the pom rewriter reports it). `rollback` / `remove` restore `socket-patch.sbt` offline (the rows removed, the file deleted with its last pin; the gitignored downloads are left). Manifest-less VEX reads every strictly parsed pin as a hosted reference but grants it the lockfile basis only when the local evidence shows every recorded version of the GA is the pinned one and every recorded artifact hashes to a pinned sha256 (else `sbt_resolution_unverified`, and the suffixed copy sbt downloaded into `.socket/sbt-hosted` is its only basis). Where the build provably does not use the pinned bytes, that copy proves nothing and the patch is omitted (`vex_sbt_pin_bypassed`): a build source reassigns `dependencyOverrides` (`:=`, `~=`, `--=`; replacing the generated override), whether or not any evidence exists, or a resolution record dated after the generated files and every build source shows the GA's base version (the override shadowed), or the pinned version from outside the pin repository with bytes that are not pinned (judged exactly as the hosted rewriter re-checks a pin, `redirect_sbt_override_shadowed` / `redirect_sbt_resolved_elsewhere`). A record older than the wiring (a project or cross build not re-resolved since) proves nothing, and current records that verify the pin always keep it. A `resolvers` reassignment or `sbt.override.build.repos=true` alone is no bypass: the override still forces the Socket-only version and the load-time verifier checks its bytes, so the build runs the pinned bytes or fails. Stale or partial evidence, project definitions that cannot be read statically and a `build.sbt.lock` only withhold the lockfile basis. A file under a generated name without socket-patch's header is read as the root build definition sbt loads it as. +**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_onload_assignment` (a build source reassigns `onLoad`, in any scope, with `:=`, `~=` or `--=` without keeping its previous value — a `:=` reading `(Global / onLoad).value`, or a `~=` that composes with `andThen` / `compose`, keeps it — which drops the generated file's load-time verifier and resolver order), `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` / `onLoad` or a `build.sbt.lock` appeared (the same `redirect_sbt_overrides_assignment` / `redirect_sbt_resolvers_assignment` / `redirect_sbt_onload_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`, and the suffixed copy sbt downloaded into `.socket/sbt-hosted` is its only basis). Where the build provably does not use the pinned bytes, that copy proves nothing and the patch is omitted (`vex_sbt_pin_bypassed`): a build source reassigns `dependencyOverrides` (`:=`, `~=`, `--=`; replacing the generated override) or reassigns `onLoad` without keeping its previous value (dropping the installer that holds the load-time verifier and resolver order), whether or not any evidence exists, or a resolution record dated after the generated files and every build source shows the GA's base version (the override shadowed), or the pinned version from outside the pin repository with bytes that are not pinned (judged exactly as the hosted rewriter re-checks a pin, `redirect_sbt_override_shadowed` / `redirect_sbt_resolved_elsewhere`). A record older than the wiring (a project or cross build not re-resolved since) proves nothing, and current records that verify the pin always keep it. A `resolvers` reassignment or `sbt.override.build.repos=true` alone is no bypass: the override still forces the Socket-only version and the load-time verifier checks its bytes, so the build runs the pinned bytes or fails (which is why an `onLoad` reassignment dropping that verifier is one). Stale or partial evidence, project definitions that cannot be read statically and a `build.sbt.lock` only withhold the lockfile basis. A file under a generated name without socket-patch's header is read as the root build definition sbt loads it as. **Non-UTF-8 candidate files (#721)**: the rewriters edit UTF-8 text only. A candidate file that exists but is not UTF-8 (for example a UTF-16 `requirements.txt`, which is what Windows PowerShell 5.1's `pip freeze >` writes and which pip installs from) is never read as absent. When a candidate of its ecosystem could rewrite it, the run is refused with `candidate_file_unreadable` (exit 1, `--dry-run` included), the message names the file, and nothing is written; the remedy is to re-save the file as UTF-8. Two exceptions keep their own refusals. A Gradle build file the Gradle planner reaches gets that planner's per-build refusal (`redirect_gradle_build_file_unreadable`, exit 0) and the rest of the run goes ahead, and with no readable Gradle build a stray Gradle file (a lock, a nested script) is never rewritten, so it does not refuse the run, while a non-UTF-8 root `settings.gradle(.kts)` or `build.gradle(.kts)` still refuses it (it may be the build itself). An unreadable `socket-patch.sbt` is refused with `redirect_sbt_owned_file_unreadable`. A vendored→hosted takeover checks this before it reverts anything, so a refused run leaves the vendored wiring, ledger entry and artifact byte-identical. Vendored mode likewise refuses a non-UTF-8 `requirements.txt` or `-r` include by name (`pypi_no_requirements`) instead of wiring around it. Lock-only discovery reads `requirements.txt` and its in-root `-r` includes the way pip decodes them (a UTF-16 or UTF-32 byte-order mark selects that encoding), so such a project's pins are still found instead of reporting "No packages found". @@ -2363,7 +2363,9 @@ GA is pinned), `vendor_sbt_unsafe_value`, and, for a new pin only, `vendor_sbt_overrides_assignment` / `vendor_sbt_resolvers_assignment` (`dependencyOverrides` / `resolvers` assigned with `:=` or `~=` in `build.sbt`, `project/plugins.sbt`, `project/Dependencies.scala`, `project/Build.scala` or a -declared subproject's `build.sbt`) and `vendor_sbt_dependency_lock_present`. +declared subproject's `build.sbt`), `vendor_sbt_onload_assignment` (`onLoad` +reassigned there without keeping its previous value, which drops the generated +file's load-time verifier) and `vendor_sbt_dependency_lock_present`. Warnings (still wired): `vendor_sbt_pom_ignored` (a single-module `pom.xml` beside the build), `vendor_sbt_version_untested` (sbt 2.1 and later), `vendor_sbt_override_build_repos` (`sbt.override.build.repos=true` in @@ -2678,7 +2680,7 @@ match it, withholds the statement: | `vex_deno_lock_copy` | run warning and `failed[].reason` | An npm pin is wired, but `deno.lock` locks the same `name@version`, which `deno install` installs without the wiring; no statement while it does. `vendor --check` and `scan` still treat the wiring as live. | | `vex_maven_reactor_root` | run warning and `failed[].reason` | A hosted Maven pin sits in a root `pom.xml` that declares `` / `` (a profile's included), where a module's own literal `` overrides it; only the root pom is read, so no statement (#261). `list`, `rollback` and `remove` still find the pin. | | `vex_maven_classifier_unpatched` | run warning and `failed[].reason` | A hosted Maven pin's GA also has a live executable `` copy (not `sources` / `javadoc`) at a version other than the pin, which the classifier-less pin does not retarget and the grant does not serve; no statement while it does. `list`, `rollback` and `remove` still find the pin. | -| `vex_sbt_pin_bypassed` | run warning and `failed[].reason` | A hosted sbt pin (`socket-patch.sbt`) whose bytes the build provably does not use, and that no current evidence verifies: a build source reassigns `dependencyOverrides` (replacing the generated override), or a resolution record newer than the generated files and every build source shows the GA's base version, or other bytes at the pinned version from outside the pin repository. The copy downloaded into `.socket/sbt-hosted` then proves nothing, so no statement. `list`, `rollback` and `remove` still find the pin. | +| `vex_sbt_pin_bypassed` | run warning and `failed[].reason` | A hosted sbt pin (`socket-patch.sbt`) whose bytes the build provably does not use, and that no current evidence verifies: a build source reassigns `dependencyOverrides` (replacing the generated override) or reassigns `onLoad` without keeping its previous value (dropping the load-time verifier and resolver order), or a resolution record newer than the generated files and every build source shows the GA's base version, or other bytes at the pinned version from outside the pin repository. The copy downloaded into `.socket/sbt-hosted` then proves nothing, so no statement. `list`, `rollback` and `remove` still find the pin. | | `vex_gradle_derived_cache_unchecked` | run warning | The derived-cache walk was cut short (a very large transforms cache); the statement is still emitted, the copies the walk did reach were checked. | A vendored Gradle entry attests only while its wiring is live (apply line, index rows, @@ -2691,6 +2693,9 @@ liveness only: `vendor --revert` and `scan --prune` still see the wiring): `sbt_pin_conflict` (the committed hosted `socket-patch.sbt` pins the same GA under another patch, so the build may resolve that pin instead, no longer parses strictly, or is a user's own file under that name, which sbt loads like any root `.sbt`), +`sbt_onload_reassigned` (a build source reassigns `onLoad` without keeping its previous +value, dropping `socket-patch-vendor.sbt`'s load-time verifier and resolver order, so +nothing stops another copy from building), `scala_cli_tree_uncommitted` (the index has no row for the patch's jar or pom, or the committed tree file is missing or does not hash to its row: without the tree's pom, Coursier reads the module from the next repository, unpatched jar included) and diff --git a/crates/socket-patch-cli/src/commands/vex_sources.rs b/crates/socket-patch-cli/src/commands/vex_sources.rs index b49230059..a1ace9f5d 100644 --- a/crates/socket-patch-cli/src/commands/vex_sources.rs +++ b/crates/socket-patch-cli/src/commands/vex_sources.rs @@ -233,7 +233,8 @@ const NOTE_MAVEN_REACTOR_ROOT: &str = "vex_maven_reactor_root"; /// (`vex::Unattested`). const NOTE_MAVEN_CLASSIFIER_UNPATCHED: &str = "vex_maven_classifier_unpatched"; /// Omission tag and note: a hosted sbt pin whose bytes the build provably -/// does not use (a `dependencyOverrides` reassignment, or current evidence +/// does not use (a `dependencyOverrides` reassignment, an `onLoad` one +/// dropping the load-time verifier, or current evidence /// resolving another version or other bytes), so the downloaded copy /// proves nothing (`vex::Unattested`). const NOTE_SBT_PIN_BYPASSED: &str = "vex_sbt_pin_bypassed"; diff --git a/crates/socket-patch-cli/tests/e2e_sbt_hosted.rs b/crates/socket-patch-cli/tests/e2e_sbt_hosted.rs index 36d31bfd1..0951295cb 100644 --- a/crates/socket-patch-cli/tests/e2e_sbt_hosted.rs +++ b/crates/socket-patch-cli/tests/e2e_sbt_hosted.rs @@ -460,13 +460,23 @@ fn vex_verdict(home: &SbtHome, project: &Path) -> (bool, bool, serde_json::Value /// runs: the patch is omitted with `vex_sbt_pin_bypassed`. A `resolvers` /// reassignment does not: the override still forces the Socket-only /// version and sbt's load-time verifier checks its bytes, so that build -/// runs the pinned bytes or fails. +/// runs the pinned bytes or fails — unless an `onLoad` reassignment drops +/// the installer that verifier lives in, which withdraws the pin too. #[test] fn hosted_vex_installed_copy_is_withdrawn_by_an_overrides_reassignment() { for (edit, withdrawn) in [ (None, false), (Some("dependencyOverrides := Seq()\n"), true), (Some("resolvers := Seq(Resolver.mavenLocal)\n"), false), + // Dropping the generated installer removes that verifier. + ( + Some("resolvers := Seq(Resolver.mavenLocal)\nGlobal / onLoad := identity\n"), + true, + ), + ( + Some("Global / onLoad := (Global / onLoad).value andThen (s => s)\n"), + false, + ), ] { let tmp = tempfile::tempdir().unwrap(); let home = SbtHome::new(tmp.path()); diff --git a/crates/socket-patch-core/src/formats/sbt/build.rs b/crates/socket-patch-core/src/formats/sbt/build.rs index d7e696dd1..9e9ec4e0f 100644 --- a/crates/socket-patch-core/src/formats/sbt/build.rs +++ b/crates/socket-patch-core/src/formats/sbt/build.rs @@ -542,6 +542,34 @@ pub struct BuildFindings { pub override_build_repos: Vec, /// A `build.sbt.lock` (sbt-dependency-lock), which a pin invalidates. pub dependency_lock: Vec, + /// `onLoad :=` / `~=` / `--=`, in any scope (`Global / onLoad`, + /// `onLoad in Global`, ...), that does not keep the previous value: it + /// drops the generated file's installer, whose `update` verifier fails + /// a build that does not resolve the pinned bytes and whose resolver + /// order puts the pin repository first. A `:=` whose right-hand side + /// reads `onLoad`'s own `.value` (the generated file's own idiom, + /// `(Global / onLoad).value andThen ...`), or a `~=` that composes + /// (`andThen` / `compose`), keeps it. + pub onload_assignment: Vec, +} + +/// The expression `skeleton` starts with (a setting's right-hand side): +/// up to the first `,` or line end outside brackets, or the bracket that +/// closes around it. Leading whitespace and line breaks are skipped (a +/// line ending in `:=` continues). +fn expression(skeleton: &str) -> &str { + let start = skeleton.len() - skeleton.trim_start().len(); + let mut depth = 0usize; + for (i, ch) in skeleton[start..].char_indices() { + match ch { + '(' | '[' | '{' => depth += 1, + ')' | ']' | '}' if depth == 0 => return &skeleton[start..start + i], + ')' | ']' | '}' => depth -= 1, + ',' | '\n' if depth == 0 => return &skeleton[start..start + i], + _ => {} + } + } + &skeleton[start..] } /// The build edits that defeat the generated file; see [`BuildFindings`]. @@ -560,6 +588,12 @@ pub fn scan_build_sources(sources: &[(String, String)]) -> BuildFindings { static OVERRIDE_REPOS: LazyLock = LazyLock::new(|| { Regex::new(r"(?m)^[^#!\n]*sbt\.override\.build\.repos\s*=\s*true\b").expect("regex") }); + static ONLOAD: LazyLock = + LazyLock::new(|| Regex::new(&format!(r"\bonLoad\b{SCOPE}(:=|~=|--=)")).expect("regex")); + static ONLOAD_VALUE: LazyLock = + LazyLock::new(|| Regex::new(&format!(r"\bonLoad\b{SCOPE}\.\s*value\b")).expect("regex")); + static COMPOSES: LazyLock = + LazyLock::new(|| Regex::new(r"\b(?:andThen|compose)\b").expect("regex")); // 1-based line of byte offset `at`, from the text's newline offsets (a // binary search, so many matches in a large file are not quadratic). let newlines = @@ -583,6 +617,19 @@ pub fn scan_build_sources(sources: &[(String, String)]) -> BuildFindings { out.resolvers_assignment .push(format!("{rel}:{}", line_of(&nl, m.start()))); } + for c in ONLOAD.captures_iter(&skeleton) { + let (whole, op) = (c.get(0).expect("match"), &c[1]); + let rhs = expression(&skeleton[whole.end()..]); + let keeps = match op { + ":=" => ONLOAD_VALUE.is_match(rhs), + "~=" => COMPOSES.is_match(rhs), + _ => false, + }; + if !keeps { + out.onload_assignment + .push(format!("{rel}:{}", line_of(&nl, whole.start()))); + } + } } Some(SourceKind::Opts | SourceKind::Properties) => { let nl = newlines(text); @@ -950,4 +997,45 @@ val name = "project" assert_eq!(found.dependency_lock, ["a/build.sbt.lock"]); assert_eq!(scan_build_sources(&[]), BuildFindings::default()); } + + /// An `onLoad` reassignment drops the generated installer unless it + /// keeps the previous value; any scope spelling, any file a definition. + #[test] + fn scan_finds_onload_reassignments_that_drop_the_installer() { + let dropped = "Global / onLoad := { s => s }\n\ + onLoad in Global := identity\n\ + onLoad.in(Global) := identity\n\ + ThisBuild / onLoad ~= (_ => identity)\n\ + Global / onLoad :=\n identity\n\ + lazy val a = project.settings(onLoad := identity, x := (onLoad).value)\n"; + let kept = "Global / onLoad := (Global / onLoad).value andThen { s => s }\n\ + onLoad in Global := (onLoad in Global).value.andThen(f)\n\ + Global / onLoad := {\n val prev = (Global / onLoad).value\n prev andThen f\n}\n\ + Global / onLoad ~= (_ andThen f)\n\ + // Global / onLoad := identity\n\ + val s = \"onLoad := identity\"\n\ + onLoadMessage := \"hi\"\n"; + let found = scan_build_sources(&src(&[ + ("build.sbt", dropped), + ( + "project/Build.scala", + "object B { Global / onLoad := identity }", + ), + ("project/plugins.sbt", "Global / onLoad := identity\n"), + ("keep.sbt", kept), + ])); + assert_eq!( + found.onload_assignment, + [ + "build.sbt:1", + "build.sbt:2", + "build.sbt:3", + "build.sbt:4", + "build.sbt:5", + "build.sbt:7", + "project/Build.scala:1" + ] + ); + assert_eq!(found.overrides_assignment, Vec::::new()); + } } diff --git a/crates/socket-patch-core/src/formats/sbt/evidence.rs b/crates/socket-patch-core/src/formats/sbt/evidence.rs index 539ca3456..3541d8a2b 100644 --- a/crates/socket-patch-core/src/formats/sbt/evidence.rs +++ b/crates/socket-patch-core/src/formats/sbt/evidence.rs @@ -605,6 +605,8 @@ struct WireFindings { override_build_repos: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] dependency_lock: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + onload_assignment: Vec, } type GavPaths = ((String, String, String), BTreeSet); @@ -687,6 +689,7 @@ impl ResolutionDoc { resolvers_assignment: f.resolvers_assignment.clone(), override_build_repos: f.override_build_repos.clone(), dependency_lock: f.dependency_lock.clone(), + onload_assignment: f.onload_assignment.clone(), } }), artifact_sha256: self.artifact_sha256.clone().into_iter().collect(), @@ -744,6 +747,7 @@ impl ResolutionDoc { resolvers_assignment: f.resolvers_assignment, override_build_repos: f.override_build_repos, dependency_lock: f.dependency_lock, + onload_assignment: f.onload_assignment, }, artifact_sha256: wire.artifact_sha256.into_iter().collect(), declared_deps: wire diff --git a/crates/socket-patch-core/src/patch/redirect/sbt.rs b/crates/socket-patch-core/src/patch/redirect/sbt.rs index a35841985..555ba4af6 100644 --- a/crates/socket-patch-core/src/patch/redirect/sbt.rs +++ b/crates/socket-patch-core/src/patch/redirect/sbt.rs @@ -371,6 +371,10 @@ pub fn rewrite_sbt( &doc.findings.override_build_repos, ), (&mut findings.dependency_lock, &doc.findings.dependency_lock), + ( + &mut findings.onload_assignment, + &doc.findings.onload_assignment, + ), ] { mine.extend(theirs.iter().cloned()); mine.sort(); @@ -506,6 +510,16 @@ pub fn rewrite_sbt( findings.resolvers_assignment.join(", ") ), )) + } else if !findings.onload_assignment.is_empty() { + Some(( + "redirect_sbt_onload_assignment", + format!( + "{} reassign(s) onLoad without keeping its previous value, which drops \ + socket-patch's load-time verifier and resolver order; compose instead \ + (`Global / onLoad := (Global / onLoad).value andThen ...`)", + findings.onload_assignment.join(", ") + ), + )) } else if !findings.dependency_lock.is_empty() { Some(( "redirect_sbt_dependency_lock_present", @@ -1160,4 +1174,51 @@ mod tests { "{rerun:?}" ); } + + /// An `onLoad` reassignment that drops the generated installer refuses + /// a new pin and keeps an existing one unconfirmed, like a + /// `dependencyOverrides` one; a composition that keeps it does not. + #[test] + fn an_onload_reassignment_refuses_the_pin() { + let dir = concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/sbt/redirect/basic" + ); + let doc = std::fs::read_to_string(format!("{dir}/resolution.json")).unwrap(); + let (files, overrides) = basic_case(Some(&doc)); + let mut first = RewriteResult::default(); + rewrite_sbt(&files, &overrides, &mut first); + let wired = first.files[HOSTED_FILE].clone(); + let codes = |r: &RewriteResult| -> Vec { + r.warnings.iter().map(|w| w.code.clone()).collect() + }; + for existing in [false, true] { + let mut edited = files.clone(); + edited + .get_mut(BUILD_SBT) + .unwrap() + .push_str("\nGlobal / onLoad := identity\n"); + if existing { + edited.insert(HOSTED_FILE.to_string(), wired.clone()); + } + let mut result = RewriteResult::default(); + rewrite_sbt(&edited, &overrides, &mut result); + assert!( + codes(&result).contains(&"redirect_sbt_onload_assignment".to_string()) + && result.confirmed_sbt_uuids.is_empty(), + "{existing}: {result:?}" + ); + let mut composed = files.clone(); + composed + .get_mut(BUILD_SBT) + .unwrap() + .push_str("\nGlobal / onLoad := (Global / onLoad).value andThen (s => s)\n"); + let mut result = RewriteResult::default(); + rewrite_sbt(&composed, &overrides, &mut result); + assert!( + !codes(&result).contains(&"redirect_sbt_onload_assignment".to_string()), + "{existing}: {result:?}" + ); + } + } } diff --git a/crates/socket-patch-core/src/vendor/jvm/apply.rs b/crates/socket-patch-core/src/vendor/jvm/apply.rs index c6e916ef1..9c322749b 100644 --- a/crates/socket-patch-core/src/vendor/jvm/apply.rs +++ b/crates/socket-patch-core/src/vendor/jvm/apply.rs @@ -1064,7 +1064,7 @@ fn entry_wiring_checked(root: &Path, entry: &VendorEntry, attest: bool) -> Resul let list = |dir: &str| reader.list(dir); let wired = if sbt::owns(&entry.wiring) { match sbt::wired_checked(&read, &c) { - Ok(true) if attest => sbt::attestable_checked(&read, &c).map(|()| true), + Ok(true) if attest => sbt::attestable_checked(root, &read, &c).map(|()| true), other => other, } .map_err(|e| e.detail) diff --git a/crates/socket-patch-core/src/vendor/jvm/sbt.rs b/crates/socket-patch-core/src/vendor/jvm/sbt.rs index fd923f469..2f2067c0e 100644 --- a/crates/socket-patch-core/src/vendor/jvm/sbt.rs +++ b/crates/socket-patch-core/src/vendor/jvm/sbt.rs @@ -362,6 +362,14 @@ pub fn plan_with_digest( "use `resolvers +=` instead", )); } + if !findings.onload_assignment.is_empty() { + return Err(refuse( + "vendor_sbt_onload_assignment", + "an `onLoad :=` assignment that does not keep the previous value", + &findings.onload_assignment, + "compose instead (`Global / onLoad := (Global / onLoad).value andThen ...`)", + )); + } if !findings.dependency_lock.is_empty() { return Err(refuse( "vendor_sbt_dependency_lock_present", @@ -605,7 +613,19 @@ pub fn wired_checked(read: ReadFn<'_>, c: &Coords<'_>) -> Result, c: &Coords<'_>) -> Result<(), JvmRefusal> { +/// +/// And a build source under `root` reassigning `onLoad` without keeping +/// its previous value (`sbt_onload_reassigned`): that drops the generated +/// installer, whose `update` verifier fails a build that does not resolve +/// the vendored bytes and whose resolver order serves the tree first, so +/// nothing stops another copy from building silently. The build sources +/// are the evidence walk's (every root `*.sbt`, `project/*.scala`, each +/// subproject's), or, when the walk hits a cap, the planner's fixed set. +pub fn attestable_checked( + root: &std::path::Path, + read: ReadFn<'_>, + c: &Coords<'_>, +) -> Result<(), JvmRefusal> { let conflict = |detail: String| JvmRefusal { code: "vendor_jvm_shape_unsupported", detail: format!("reason: sbt_pin_conflict: {detail}"), @@ -639,6 +659,22 @@ pub fn attestable_checked(read: ReadFn<'_>, c: &Coords<'_>) -> Result<(), JvmRef others.join(", ") ))); } + let sources = crate::crawlers::sbt_evidence::scan(root) + .map(|e| e.build_sources) + .unwrap_or_else(|_| build_sources(read)); + let onload = scan_build_sources(&sources).onload_assignment; + if !onload.is_empty() { + return Err(JvmRefusal { + code: "vendor_jvm_shape_unsupported", + detail: format!( + "reason: sbt_onload_reassigned: {} reassign(s) `onLoad` without keeping its \ + previous value, which drops {BUILD_FILE}'s load-time verifier and resolver \ + order; compose instead (`Global / onLoad := (Global / onLoad).value andThen \ + ...`)", + onload.join(", ") + ), + }); + } Ok(()) } @@ -1041,6 +1077,16 @@ mod tests { ), "vendor_sbt_resolvers_assignment", ), + ( + build( + "1.9.9", + &[( + "project/Build.scala", + "object B { onLoad in Global := identity }", + )], + ), + "vendor_sbt_onload_assignment", + ), ] { assert_eq!(code(plan_over(&files, &p)), expected); } @@ -1610,4 +1656,44 @@ mod tests { std::fs::remove_file(root.join(HOSTED_FILE)).unwrap(); assert_eq!(apply::entry_wired_checked(root, &e), Ok(true)); } + + /// An `onLoad` reassignment that drops the generated installer (its + /// `update` verifier and resolver order) is not attestable, in any + /// build source the evidence walk reads; the wiring itself stays live + /// and in sync. A composition that keeps it changes nothing. + #[tokio::test] + async fn liveness_refuses_an_onload_reassignment() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write(root, "1.9.9", &[]); + let mut ledger = BTreeMap::new(); + vendor(root, Shape::Sbt, &text_patch(UUID_A), &mut ledger) + .await + .unwrap(); + let e = ledger.values().next().unwrap().clone(); + assert_eq!(apply::entry_wired_checked(root, &e), Ok(true)); + for (rel, text) in [ + ("global.sbt", "Global / onLoad := identity\n"), + ( + "project/Build.scala", + "object B { onLoad in Global := identity }\n", + ), + ] { + std::fs::create_dir_all(root.join(rel).parent().unwrap()).unwrap(); + std::fs::write(root.join(rel), text).unwrap(); + let err = apply::entry_wired_checked(root, &e).unwrap_err(); + assert!( + err.contains("reason: sbt_onload_reassigned:") && err.contains(rel), + "{err}" + ); + assert!(apply::entry_references(root, &e)); + std::fs::write( + root.join(rel), + "Global / onLoad := (Global / onLoad).value andThen (s => s)\n", + ) + .unwrap(); + assert_eq!(apply::entry_wired_checked(root, &e), Ok(true), "{rel}"); + std::fs::remove_file(root.join(rel)).unwrap(); + } + } } diff --git a/crates/socket-patch-core/src/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index ef4372054..9198ed8be 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -608,7 +608,8 @@ pub enum UnattestedKind { MavenClassifierUnpatched, /// A hosted sbt pin (`socket-patch.sbt`) whose bytes the build provably /// does not use, and that no current evidence verifies: a build source - /// reassigns `dependencyOverrides` (replacing the generated override), + /// reassigns `dependencyOverrides` (replacing the generated override) + /// or `onLoad` without keeping it (dropping the load-time verifier), /// or a resolution record dated after the wiring and every build source /// shows the GA's base version, or the pinned version from outside the /// pin repository with other bytes. The downloaded copy under diff --git a/crates/socket-patch-core/src/vex/discover/sbt.rs b/crates/socket-patch-core/src/vex/discover/sbt.rs index 847eb51d9..37dd500f0 100644 --- a/crates/socket-patch-core/src/vex/discover/sbt.rs +++ b/crates/socket-patch-core/src/vex/discover/sbt.rs @@ -96,9 +96,10 @@ struct BuildState { /// Why the evidence cannot vouch for any pin even where it shows one /// ([`evidence_blocker`]). blocker: Option, - /// A `dependencyOverrides` reassignment ([`overrides_reassigned`]), - /// read off the build sources whether or not any evidence exists. - overrides: Option, + /// A build edit dropping the generated override or installer + /// ([`pin_dropped`]), read off the build sources whether or not any + /// evidence exists. + dropped: Option, /// The hosted pin repository as sbt's evidence spells it (canonical, /// `/.socket/sbt-hosted/maven2`). repo: Option, @@ -118,13 +119,13 @@ async fn build_state(root: &Path) -> BuildState { return BuildState::default(); }; let findings = crate::formats::sbt::build::scan_build_sources(&e.build_sources); - let overrides = overrides_reassigned(&findings); + let dropped = pin_dropped(&findings); let repo = std::fs::canonicalize(&root) .ok() .map(|c| crate::crawlers::sbt_evidence::strip_verbatim(&c).join(HOSTED_REPO_REL)); if e.files.is_empty() { return BuildState { - overrides, + dropped, repo, ..BuildState::default() }; @@ -143,7 +144,7 @@ async fn build_state(root: &Path) -> BuildState { BuildState { res, blocker, - overrides, + dropped, repo, current, } @@ -152,14 +153,27 @@ async fn build_state(root: &Path) -> BuildState { .unwrap_or_default() } -/// A build source reassigning `dependencyOverrides`: it replaces the -/// generated override where it applies (both a blocker of the evidence and, -/// with no current evidence verifying the pin, a bypass of the pin). -fn overrides_reassigned(findings: &BuildFindings) -> Option { - (!findings.overrides_assignment.is_empty()).then(|| { - format!( +/// A build edit that drops what makes the pin hold (both a blocker of the +/// evidence and, with no current evidence verifying the pin, a bypass of +/// it): a `dependencyOverrides` reassignment replaces the generated +/// override where it applies, and an `onLoad` reassignment that does not +/// keep the previous value drops the generated installer, whose `update` +/// verifier fails a build that does not resolve the pinned bytes and whose +/// resolver order serves the pin repository first. Without the installer, +/// nothing stops another repository's copy or a forced version from +/// building silently. +fn pin_dropped(findings: &BuildFindings) -> Option { + if !findings.overrides_assignment.is_empty() { + return Some(format!( "{} reassign(s) `dependencyOverrides`, which replaces the pin where it applies", findings.overrides_assignment.join(", ") + )); + } + (!findings.onload_assignment.is_empty()).then(|| { + format!( + "{} reassign(s) `onLoad` without keeping its previous value, which drops \ + socket-patch's load-time verifier and resolver order", + findings.onload_assignment.join(", ") ) }) } @@ -199,9 +213,9 @@ fn current_view(res: &JvmResolution, pin: &SbtPin, current: &BTreeSet) - /// Why the build provably does not use `pin`'s bytes, whatever copy is /// installed, or `None`. Current evidence that verifies the pin ([`verify`] -/// over [`current_view`]) always wins. Otherwise: a `dependencyOverrides` -/// reassignment (the override is replaced, so the build resolves another -/// version, or fails the load-time verifier), or current evidence judged +/// over [`current_view`]) always wins. Otherwise: a build edit dropping +/// the pin ([`pin_dropped`]: the override replaced, or the load-time +/// verifier and resolver order removed), or current evidence judged /// exactly as the hosted rewriter re-checks an existing pin /// (`formats::sbt::gate::check_existing`): the GA's base version (the /// override shadowed), or the pinned version from outside the pin @@ -212,7 +226,8 @@ fn current_view(res: &JvmResolution, pin: &SbtPin, current: &BTreeSet) - /// A `resolvers` reassignment or `sbt.override.build.repos` is no bypass on /// its own: the override still forces the Socket-only version, and the /// load-time verifier fails `update` on any other version or unpinned -/// bytes, so such a build runs the pinned bytes or fails. +/// bytes, so such a build runs the pinned bytes or fails. (Without that +/// verifier, an `onLoad` reassignment is the bypass.) async fn bypass(pin: &SbtPin, build: &BuildState, hashes: &mut Hashes) -> Option { use crate::formats::sbt::gate::{check_existing, GateRefusal, GateWarning}; let now = build @@ -224,7 +239,7 @@ async fn bypass(pin: &SbtPin, build: &BuildState, hashes: &mut Hashes) -> Option return None; } } - if let Some(why) = &build.overrides { + if let Some(why) = &build.dropped { return Some(why.clone()); } let (now, repo) = (now?, build.repo.as_deref()?); @@ -277,8 +292,8 @@ async fn bypass(pin: &SbtPin, build: &BuildState, hashes: &mut Hashes) -> Option /// new pin on): a build source newer than some project's evidence, a /// declared project with no evidence (only part of the build resolved), a /// project definition that cannot be read statically, a build source -/// reassigning `dependencyOverrides` (it replaces the generated override -/// where it applies) or `resolvers`, or a `build.sbt.lock`. +/// dropping the pin ([`pin_dropped`]) or reassigning `resolvers`, or a +/// `build.sbt.lock`. fn evidence_blocker( e: &crate::crawlers::sbt_evidence::SbtEvidence, res: &JvmResolution, @@ -308,7 +323,7 @@ fn evidence_blocker( missing.join(", ") )); } - if let Some(why) = overrides_reassigned(findings) { + if let Some(why) = pin_dropped(findings) { return Some(why); } if !findings.resolvers_assignment.is_empty() { @@ -639,6 +654,12 @@ mod tests { let resolvers = [("app/build.sbt", "resolvers := Nil\n")]; let why = blocker(&e(&resolvers, false), &all).unwrap(); assert!(why.contains("`resolvers`"), "{why}"); + let onload = [("app/build.sbt", "Global / onLoad := identity\n")]; + let why = blocker(&e(&onload, false), &all).unwrap(); + assert!( + why.contains("app/build.sbt:1") && why.contains("`onLoad`"), + "{why}" + ); let lock = [("build.sbt.lock", "{}\n")]; let why = blocker(&e(&lock, false), &all).unwrap(); assert!(why.contains("build.sbt.lock"), "{why}"); @@ -748,11 +769,11 @@ mod tests { current: current.iter().map(|s| s.to_string()).collect(), ..Default::default() }; - let overrides = Some("build.sbt:1 reassign(s) `dependencyOverrides`".to_string()); + let dropped = Some("build.sbt:1 reassign(s) `dependencyOverrides`".to_string()); assert_eq!(bypass(&p, &BuildState::default(), &mut h).await, None); let edited = BuildState { - overrides: overrides.clone(), + dropped: dropped.clone(), ..Default::default() }; let why = bypass(&p, &edited, &mut h).await.unwrap(); @@ -762,7 +783,7 @@ mod tests { let mut verified = JvmResolution::default(); record(&mut verified, "b/target/out", SV, Some(&jar)); let verified_edited = BuildState { - overrides: overrides.clone(), + dropped: dropped.clone(), ..state(verified.clone(), &["b/target/out"]) }; assert_eq!(bypass(&p, &verified_edited, &mut h).await, None); @@ -953,6 +974,35 @@ mod tests { assert!(mark(repos.path()).await.is_none()); } + /// An `onLoad` reassignment that drops the generated installer removes + /// the verifier (and the resolver order) the resolver and forced-version + /// decisions rely on: like a `dependencyOverrides` reassignment, it + /// withdraws the installed-copy basis and blocks the lockfile basis. A + /// composition that keeps the installer does neither. + #[tokio::test] + async fn an_onload_reassignment_withdraws_like_an_overrides_one() { + let dropped = hosted_build(&format!("{LANG3}Global / onLoad := identity\n")); + let u = mark(dropped.path()).await.unwrap(); + assert!( + u.detail.contains("build.sbt:2") && u.detail.contains("`onLoad`"), + "{}", + u.detail + ); + let kept = hosted_build(&format!( + "{LANG3}Global / onLoad := (Global / onLoad).value andThen (s => s)\n" + )); + assert!(mark(kept.path()).await.is_none()); + // The same edit with a resolvers reassignment: still the onLoad one. + let both = hosted_build(&format!( + "{LANG3}resolvers := Nil\nonLoad in Global ~= (_ => identity)\n" + )); + assert!(mark(both.path()) + .await + .unwrap() + .detail + .contains("build.sbt:3")); + } + /// A file under the vendored name that socket-patch did not write is a /// root definition sbt loads like any other: its reassignment counts. #[tokio::test] diff --git a/docs/ecosystems.md b/docs/ecosystems.md index bec33c50a..3d3ab53ea 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -1199,7 +1199,8 @@ refused (`redirect_sbt_pin_declared_newer`: roll the patch back, or declare the base again). `rollback` / `remove` restore the file offline. `vex` attests a pin that post-wiring evidence verifies, or else from the copy sbt downloaded into `.socket/sbt-hosted/`, but never one whose bytes -the build provably does not use: a `dependencyOverrides` reassignment, or +the build provably does not use: a `dependencyOverrides` reassignment, an +`onLoad` reassignment that drops the generated load-time verifier, or evidence recorded after the wiring that still resolves the base version or other bytes (`vex_sbt_pin_bypassed`). The full code list is in [`CLI_CONTRACT.md`](../crates/socket-patch-cli/CLI_CONTRACT.md) (**Hosted From 03408ab70de2132a08e43d2313c543db6bbdc592 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 18:28:35 +0000 Subject: [PATCH 4/4] Let fresh sbt evidence overrule an edit only when it covers the build Second review round on the sbt installed-copy fallback. - `bypass` returned "no bypass" as soon as the current records verified the pin, before looking at a proven build edit (`dependencyOverrides` or `onLoad` reassignment). With only some projects re-resolved since the edit, the edit may apply to a project whose record is stale, so the installed copy was attested while that project builds unpatched. Verified evidence now overrules an edit only when every record is current and every declared project has one (`BuildState::whole`). - The `onLoad` scan's keep-check was loose: `:=` reading `.value` and dropping it, or `~= (_ => identity andThen f)`, counted as keeping the installer. `:=` now needs the `.value` read and a composition; `~=` must compose its own argument. sbt 0.13's `<<=` is recognized for `onLoad`, `dependencyOverrides` and the resolver settings. Co-Authored-By: Claude Opus 5.5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../src/formats/sbt/build.rs | 51 +++++++++++++++---- .../socket-patch-core/src/vex/discover/sbt.rs | 34 +++++++++++-- 3 files changed, 73 insertions(+), 14 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index e44d07262..649eccd10 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -331,7 +331,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate, `bun.lock` / `bun.lockb`, and `vlt-lock.json` with `vlt.json` and `node_modules/.vlt-lock.json` read only), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; the hosted redirect is a `sparse+` registry, which cargo loads only from **1.68**, so `rust-toolchain.toml` / `rust-toolchain` are read too (never edited) and a project whose declared floor — the lowest `rust-version` of the root and the member manifests read here (a `rust-version.workspace = true` inheritor reads `[workspace.package]`), else the channel rustup picks from the root (the legacy `rust-toolchain` over `rust-toolchain.toml`; a dated `nightly-`/`beta-YYYY-MM-DD` maps to its release train) — is below 1.68 is refused `redirect_cargo_old_cargo` per crate (nothing written, an earlier run's pins left in place with the `rollback` that drops them named; `--mode vendored` builds on cargo 1.41+), while an undeclared or rolling-channel floor still redirects (toolchain files in ancestor directories are not read); **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. With NO `Cargo.lock` there is no resolved graph to ask, so the dependents question is answered from the manifests instead: a crate declared beside any other dependency — anything but a path dependency on a manifest this run also pins, or a `workspace = true` inheritor of a table it scans — or beside a workspace member this run did not read (a `members` glob, or a member outside the project or behind a symbolic link, which member discovery drops) is refused `redirect_cargo_lockless_dependents`, whose detail names the remedies (commit a lockfile, or `--mode vendored`); a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json` (plus, v5.0 #353/#514, every lock a project under the root restores into: a member project's `packages.lock.json`, a per-project `packages..lock.json`, a literal `NuGetLockFilePath` — each pinned with the root config; a `NuGetLockFilePath` it cannot evaluate warns `redirect_nuget_lock_path_unresolved` and a project tree it cannot list warns `redirect_nuget_lock_unreadable`, both skipping the nuget redirect with nothing written), `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and, for a Gradle build, every settings, build, `buildSrc`, included-build, applied and plugin-source script, version catalog and lock file the script graph reaches, plus `gradle/verification-metadata.xml`, `gradle/wrapper/gradle-wrapper.properties` and the owned `.socket/gradle/` files), and the sbt build files (`socket-patch.sbt`, `socket-patch-vendor.sbt`, `build.sbt`, `project/build.properties`, `.sbtopts`, `.jvmopts`; `build.sbt.lock` and the Mill / scala-cli build files `build.mill`, `build.mill.yaml`, `build.sc`, `.mill-version`, `project.scala` for their presence only) — read, never edited; `socket-patch.sbt` is the only sbt file hosted mode writes (see **Hosted sbt** below). **npm-family flavor coverage**: package-lock / npm-shrinkwrap (a package the project patches itself with npm ≥ 12.1's native `npm patch` — a root `patchedDependencies` key, or the lock entry's `patched` record — is left on its registry entry in every npm lock, `redirect_npm_patched_dependency_skipped`), pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic (a yarn 2+ install migrates a v1 `yarn.lock` and drops its pins, so a run whose v1 lock carries a hosted pin warns `redirect_yarn_classic_berry_migration_risk` — the hosted twin of the vendored `yarn_classic_berry_migration_risk` — unless the root `package.json`, read as advisory input, declares `"packageManager": "yarn@1…"`), **yarn berry** (the pin yarn writes for a root `resolutions` entry: the root `package.json` — edited only beside a berry `yarn.lock` — gains one `"@npm:": ""` selector per locked range (`redirect_yarn_berry_resolution` edits), and only that `yarn.lock` entry is re-keyed `"@"` with the same `resolution:` + `yarnBerry10c0` checksum (`redirect_yarn_berry_entry`), moved to yarn's key order; never an `npm:` locator, whose fetcher sends npm registry auth to the patch host, nor a tarball locator under an `npm:` key, which hardened mode rejects (YN0078). An older release's `npm:::__archiveUrl=` pin is still recognized and is re-pinned on the next run; rollback rebuilds the key from the selectors and drops them. Refused, nothing written: a user-authored `resolutions` entry for the package `redirect_yarn_berry_resolutions_conflict`, no root manifest `redirect_yarn_berry_manifest_missing`, a builtin `patch:` entry wrapping the same descriptor `redirect_yarn_berry_shared_descriptor`, an artifact URL yarn cannot fetch as a tarball `redirect_yarn_berry_artifact_url_unsupported`; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found` (or `redirect_bun_non_registry_entry_skipped` when the only same-version entry is a user URL / `file:` tarball, #497), a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`), and **vlt** (`vlt-lock.json` without `lockfileVersion`, `0` or `1`; see the vlt hosted-mode contract below). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when the `repo-state.json` beside a rewritten lock exists (`common/config/rush/repo-state.json` for the common lock, `common/config/subspaces//repo-state.json` for a subspace lock; the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives), and `redirect_pnpm_trust_lockfile` carries the Rush pnpm >=11 install remedy (see the trust paragraph above). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`; when `.mvn/wrapper/maven-wrapper.properties` pins a Maven older than 3.9.4, which ignores those files, the additive warning `redirect_maven_trusted_checksums_unenforced`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a reactor root (a `` / `` declaration, a profile's included, or a Maven 4 root that lists none but whose subprojects Maven discovers: model version not `4.0.0`, `pom` packaging, a direct subdirectory holding a `pom.xml`, and `maven.project.discoverSubprojects` not turned off in `.mvn/maven.config`; each such subdirectory's `pom.xml` is read for this, never edited) is refused whole with `redirect_maven_multimodule_unsupported`: `pom.xml` and `.mvn/` are left untouched and the dep is not counted as redirected (a Gradle build beside it is still planned by the Gradle rewriter, but a dep in a root with a `pom.xml` is confirmed only when the pom pins it too), since a module's own literal `` would shadow a root pin (use `--mode vendored`; `vex` omits a hosted pin in a reactor root as `vex_maven_reactor_root`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), a `` variant (sources, tests, a native build) left at its version (`redirect_maven_classifier_unsupported`; the grant serves the main jar only, and the dep is skipped when a variant other than `sources` / `javadoc` names the patched release), a versioned declaration inside `` (a literal or a `${property}`) left as-is (`redirect_maven_profile_dependency_unpatched`; the dep is skipped when a profile holds its only declaration), a `${property}` groupId / artifactId resolved from the pom's own `` before matching (one the pom does not define, which may be the patched GA and whose own version would then win, refuses the dep with nothing written: `redirect_maven_coordinate_unresolved`), and a pom not readable as one left untouched (`redirect_maven_pom_unreadable`). Matches and anchors skip comments, CDATA, ``, ``, ``, `` and `` (the scope `vex` reads), and an existing self-closed `` / `` is expanded in place, never duplicated; an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). **gradle** (v5.0) is automated wiring, no longer a pasted snippet: the owned settings script `.socket/gradle/socket-patch.hosted.settings.gradle` with its index `.socket/gradle/hosted-index.tsv`, one apply line per build's settings file, every lock entry of the GA moved to the suffixed version, and the suffixed component in an existing `gradle/verification-metadata.xml`. A refused dep writes nothing and keeps `redirect_gradle_manual_snippet` as its fallback; same-GAV grants are refused (`redirect_gradle_same_gav_unsupported`). Rules, refusals and codes: [Gradle builds](#gradle-builds-v50). -**Hosted sbt (v5.0, additive)**: an sbt build root (`project/build.properties` naming an `sbt.version`, 0.13.18 or later) is wired through ONE generated root file, `socket-patch.sbt` — no user file is edited. It pins every granted Maven patch build-wide (a `ThisBuild` `dependencyOverrides +=` of the Socket-only `-socket.` version plus a `file:` resolver over `.socket/sbt-hosted/maven2/`, moved ahead of the default repositories on sbt 0.13 / 1.x so an unreachable one never blocks it offline), downloads the pinned pom and jar there on the first sbt load (sha256-checked, gitignored by the file itself), and installs a load-time verifier that fails `update` when any project resolves another version or a pinned artifact whose bytes are not pinned. Edits: `redirect_sbt_pin` (added), `redirect_sbt_pin_updated` (an existing row replaced: same GA and base under a new uuid, or the same uuid with new served values; `original` names the previous uuid and version), `redirect_sbt_pin_rechecked` (an existing row re-verified after the build's dependencies changed: its dependency digest is recorded anew, `original`/`new` are `{deps}`). The load-time verifier also fails `update` when a project declares a pinned GA at a version newer than the pin's base (the build-wide override would otherwise force it back down). A new pin is gated on sbt's own resolution records under `target/` (never the machine-wide cache): run-level stops wire nothing, warn once and exit 0 — `redirect_sbt_no_resolution_evidence` (none; run `sbt update` first; always the in-memory engine's answer), `redirect_sbt_resolution_incomplete` (a declared project left no evidence, or the project definitions cannot be read statically), `redirect_sbt_resolution_stale` (a build source is newer than some project's evidence: each project is dated by its own newest record, so a partial `sbt /update` does not vouch for the others). Per-patch refusals (never confirmed): `redirect_sbt_missing_override` (no `maven2` override or no suffixed version), `redirect_sbt_integrity_missing` (jar or pom sha256 missing), `redirect_sbt_unsafe_value` (a value unsafe in a Scala literal, or an index URL not naming the uuid), `redirect_sbt_version_conflict` (some project resolves another version, or a build source declares the GA newer than the patch's base), `redirect_sbt_override_conflict` (two patches for one GA in a run, or another base already pinned), `redirect_sbt_vendored_conflict` (the GA is pinned by `socket-patch-vendor.sbt`, or that file cannot be parsed — then every Maven patch), `redirect_sbt_owned_file_modified` / `redirect_sbt_owned_file_foreign` (`socket-patch.sbt` edited, or not socket-patch's — every Maven patch), `redirect_sbt_owned_file_unreadable` (a whole-run refusal: `socket-patch.sbt` is on disk but cannot be read as UTF-8 text, so writing it would replace it; nothing is written), `redirect_sbt_unsupported_version`, `redirect_sbt_build_root_unknown` (sbt files but no versioned build root — every Maven patch), `redirect_sbt_overrides_assignment` / `redirect_sbt_resolvers_assignment` (a build source reassigns `dependencyOverrides` / `resolvers` with `:=`, `~=` or `--=`), `redirect_sbt_onload_assignment` (a build source reassigns `onLoad`, in any scope, with `:=`, `~=` or `--=` without keeping its previous value — a `:=` reading `(Global / onLoad).value`, or a `~=` that composes with `andThen` / `compose`, keeps it — which drops the generated file's load-time verifier and resolver order), `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` / `onLoad` or a `build.sbt.lock` appeared (the same `redirect_sbt_overrides_assignment` / `redirect_sbt_resolvers_assignment` / `redirect_sbt_onload_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`, and the suffixed copy sbt downloaded into `.socket/sbt-hosted` is its only basis). Where the build provably does not use the pinned bytes, that copy proves nothing and the patch is omitted (`vex_sbt_pin_bypassed`): a build source reassigns `dependencyOverrides` (`:=`, `~=`, `--=`; replacing the generated override) or reassigns `onLoad` without keeping its previous value (dropping the installer that holds the load-time verifier and resolver order), whether or not any evidence exists, or a resolution record dated after the generated files and every build source shows the GA's base version (the override shadowed), or the pinned version from outside the pin repository with bytes that are not pinned (judged exactly as the hosted rewriter re-checks a pin, `redirect_sbt_override_shadowed` / `redirect_sbt_resolved_elsewhere`). A record older than the wiring (a project or cross build not re-resolved since) proves nothing, and current records that verify the pin always keep it. A `resolvers` reassignment or `sbt.override.build.repos=true` alone is no bypass: the override still forces the Socket-only version and the load-time verifier checks its bytes, so the build runs the pinned bytes or fails (which is why an `onLoad` reassignment dropping that verifier is one). Stale or partial evidence, project definitions that cannot be read statically and a `build.sbt.lock` only withhold the lockfile basis. A file under a generated name without socket-patch's header is read as the root build definition sbt loads it as. +**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_onload_assignment` (a build source reassigns `onLoad`, in any scope, with `:=`, `~=`, `--=` or `<<=` without keeping its previous value — a `:=` reading `(Global / onLoad).value`, or a `~=` that composes with `andThen` / `compose`, keeps it — which drops the generated file's load-time verifier and resolver order), `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` / `onLoad` or a `build.sbt.lock` appeared (the same `redirect_sbt_overrides_assignment` / `redirect_sbt_resolvers_assignment` / `redirect_sbt_onload_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`, and the suffixed copy sbt downloaded into `.socket/sbt-hosted` is its only basis). Where the build provably does not use the pinned bytes, that copy proves nothing and the patch is omitted (`vex_sbt_pin_bypassed`): a build source reassigns `dependencyOverrides` (`:=`, `~=`, `--=`; replacing the generated override) or reassigns `onLoad` without keeping its previous value (dropping the installer that holds the load-time verifier and resolver order), whether or not any evidence exists, or a resolution record dated after the generated files and every build source shows the GA's base version (the override shadowed), or the pinned version from outside the pin repository with bytes that are not pinned (judged exactly as the hosted rewriter re-checks a pin, `redirect_sbt_override_shadowed` / `redirect_sbt_resolved_elsewhere`). A record older than the wiring (a project or cross build not re-resolved since) proves nothing, and current records that verify the pin always keep it. A `resolvers` reassignment or `sbt.override.build.repos=true` alone is no bypass: the override still forces the Socket-only version and the load-time verifier checks its bytes, so the build runs the pinned bytes or fails (which is why an `onLoad` reassignment dropping that verifier is one). Stale or partial evidence, project definitions that cannot be read statically and a `build.sbt.lock` only withhold the lockfile basis. A file under a generated name without socket-patch's header is read as the root build definition sbt loads it as. **Non-UTF-8 candidate files (#721)**: the rewriters edit UTF-8 text only. A candidate file that exists but is not UTF-8 (for example a UTF-16 `requirements.txt`, which is what Windows PowerShell 5.1's `pip freeze >` writes and which pip installs from) is never read as absent. When a candidate of its ecosystem could rewrite it, the run is refused with `candidate_file_unreadable` (exit 1, `--dry-run` included), the message names the file, and nothing is written; the remedy is to re-save the file as UTF-8. Two exceptions keep their own refusals. A Gradle build file the Gradle planner reaches gets that planner's per-build refusal (`redirect_gradle_build_file_unreadable`, exit 0) and the rest of the run goes ahead, and with no readable Gradle build a stray Gradle file (a lock, a nested script) is never rewritten, so it does not refuse the run, while a non-UTF-8 root `settings.gradle(.kts)` or `build.gradle(.kts)` still refuses it (it may be the build itself). An unreadable `socket-patch.sbt` is refused with `redirect_sbt_owned_file_unreadable`. A vendored→hosted takeover checks this before it reverts anything, so a refused run leaves the vendored wiring, ledger entry and artifact byte-identical. Vendored mode likewise refuses a non-UTF-8 `requirements.txt` or `-r` include by name (`pypi_no_requirements`) instead of wiring around it. Lock-only discovery reads `requirements.txt` and its in-root `-r` includes the way pip decodes them (a UTF-16 or UTF-32 byte-order mark selects that encoding), so such a project's pins are still found instead of reporting "No packages found". diff --git a/crates/socket-patch-core/src/formats/sbt/build.rs b/crates/socket-patch-core/src/formats/sbt/build.rs index 9e9ec4e0f..e4a43fa5b 100644 --- a/crates/socket-patch-core/src/formats/sbt/build.rs +++ b/crates/socket-patch-core/src/formats/sbt/build.rs @@ -577,11 +577,11 @@ fn expression(skeleton: &str) -> &str { pub fn scan_build_sources(sources: &[(String, String)]) -> BuildFindings { const SCOPE: &str = r"(?:\s+in\s+\w+|\s*\.\s*in\s*\(\s*\w+\s*\))?\s*\)?\s*"; static OVERRIDES: LazyLock = LazyLock::new(|| { - Regex::new(&format!(r"\bdependencyOverrides\b{SCOPE}(?::=|~=|--=)")).expect("regex") + Regex::new(&format!(r"\bdependencyOverrides\b{SCOPE}(?::=|~=|--=|<<=)")).expect("regex") }); static RESOLVERS: LazyLock = LazyLock::new(|| { Regex::new(&format!( - r"\b(?:resolvers|externalResolvers|fullResolvers)\b{SCOPE}(?::=|~=)" + r"\b(?:resolvers|externalResolvers|fullResolvers)\b{SCOPE}(?::=|~=|<<=)" )) .expect("regex") }); @@ -589,11 +589,22 @@ pub fn scan_build_sources(sources: &[(String, String)]) -> BuildFindings { Regex::new(r"(?m)^[^#!\n]*sbt\.override\.build\.repos\s*=\s*true\b").expect("regex") }); static ONLOAD: LazyLock = - LazyLock::new(|| Regex::new(&format!(r"\bonLoad\b{SCOPE}(:=|~=|--=)")).expect("regex")); + LazyLock::new(|| Regex::new(&format!(r"\bonLoad\b{SCOPE}(:=|~=|--=|<<=)")).expect("regex")); + // `:=` keeps the previous hook only by reading its `.value` AND + // composing (a `.value` read and then dropped does not keep it). static ONLOAD_VALUE: LazyLock = LazyLock::new(|| Regex::new(&format!(r"\bonLoad\b{SCOPE}\.\s*value\b")).expect("regex")); static COMPOSES: LazyLock = LazyLock::new(|| Regex::new(r"\b(?:andThen|compose)\b").expect("regex")); + // `~=` keeps it only by composing its own argument: `_ andThen f`, + // `prev => prev andThen f` (or `.andThen(f)`, `compose`). + static COMPOSES_ARG: LazyLock = LazyLock::new(|| { + Regex::new(r"^\s*\(?\s*(?:_|(\w+)\s*=>\s*\(?\s*(\w+))\s*\.?\s*(?:andThen|compose)\b") + .expect("regex") + }); + // `<<=` (sbt 0.13) keeps it by reading the previous hook. + static ONLOAD_READ: LazyLock = + LazyLock::new(|| Regex::new(r"\bonLoad\b").expect("regex")); // 1-based line of byte offset `at`, from the text's newline offsets (a // binary search, so many matches in a large file are not quadratic). let newlines = @@ -620,11 +631,18 @@ pub fn scan_build_sources(sources: &[(String, String)]) -> BuildFindings { for c in ONLOAD.captures_iter(&skeleton) { let (whole, op) = (c.get(0).expect("match"), &c[1]); let rhs = expression(&skeleton[whole.end()..]); - let keeps = match op { - ":=" => ONLOAD_VALUE.is_match(rhs), - "~=" => COMPOSES.is_match(rhs), - _ => false, - }; + let keeps = + match op { + ":=" => ONLOAD_VALUE.is_match(rhs) && COMPOSES.is_match(rhs), + "~=" => COMPOSES_ARG.captures(rhs).is_some_and(|c| { + match (c.get(1), c.get(2)) { + (Some(param), Some(used)) => param.as_str() == used.as_str(), + _ => true, + } + }), + "<<=" => ONLOAD_READ.is_match(rhs), + _ => false, + }; if !keeps { out.onload_assignment .push(format!("{rel}:{}", line_of(&nl, whole.start()))); @@ -1007,11 +1025,16 @@ val name = "project" onLoad.in(Global) := identity\n\ ThisBuild / onLoad ~= (_ => identity)\n\ Global / onLoad :=\n identity\n\ - lazy val a = project.settings(onLoad := identity, x := (onLoad).value)\n"; + lazy val a = project.settings(onLoad := identity, x := (onLoad).value)\n\ + Global / onLoad := { val _ = (Global / onLoad).value; identity }\n\ + Global / onLoad ~= (_ => identity andThen f)\n\ + onLoad in Global <<= Def.setting(identity)\n"; let kept = "Global / onLoad := (Global / onLoad).value andThen { s => s }\n\ onLoad in Global := (onLoad in Global).value.andThen(f)\n\ Global / onLoad := {\n val prev = (Global / onLoad).value\n prev andThen f\n}\n\ Global / onLoad ~= (_ andThen f)\n\ + Global / onLoad ~= (prev => prev.andThen(f))\n\ + onLoad in Global <<= (onLoad in Global)(_ andThen f)\n\ // Global / onLoad := identity\n\ val s = \"onLoad := identity\"\n\ onLoadMessage := \"hi\"\n"; @@ -1033,9 +1056,19 @@ val name = "project" "build.sbt:4", "build.sbt:5", "build.sbt:7", + "build.sbt:8", + "build.sbt:9", + "build.sbt:10", "project/Build.scala:1" ] ); assert_eq!(found.overrides_assignment, Vec::::new()); + // sbt 0.13's `<<=` replaces a setting too. + let found = scan_build_sources(&src(&[( + "build.sbt", + "dependencyOverrides <<= Def.setting(Set.empty)\nresolvers <<= Def.setting(Nil)\n", + )])); + assert_eq!(found.overrides_assignment, ["build.sbt:1"]); + assert_eq!(found.resolvers_assignment, ["build.sbt:2"]); } } diff --git a/crates/socket-patch-core/src/vex/discover/sbt.rs b/crates/socket-patch-core/src/vex/discover/sbt.rs index 37dd500f0..f0d166f89 100644 --- a/crates/socket-patch-core/src/vex/discover/sbt.rs +++ b/crates/socket-patch-core/src/vex/discover/sbt.rs @@ -108,6 +108,10 @@ struct BuildState { /// pin in place. An older record (a project or cross build not /// re-resolved since) proves nothing about the pin either way. current: BTreeSet, + /// Every record is [`Self::current`] and every declared project has + /// one: only then does evidence verifying a pin speak for the whole + /// build, and so overrule a build edit dropping it ([`bypass`]). + whole: bool, } /// The build's state under `root`: its sources scanned once, its evidence @@ -135,18 +139,25 @@ async fn build_state(root: &Path) -> BuildState { .as_ref() .and_then(|res| evidence_blocker(&e, res, &findings)); let since = e.generated.max(e.newest_source); - let current = e + let current: BTreeSet = e .record_dates .iter() .filter(|(_, at)| since.is_none_or(|s| **at > s)) .map(|(rel, _)| rel.clone()) .collect(); + let whole = current.len() == e.record_dates.len() + && res.as_ref().is_some_and(|res| { + crate::formats::sbt::build::declared_projects(&e.build_sources).is_some_and( + |declared| declared.values().all(|dir| res.projects_seen.contains(dir)), + ) + }); BuildState { res, blocker, dropped, repo, current, + whole, } }) .await @@ -213,7 +224,9 @@ fn current_view(res: &JvmResolution, pin: &SbtPin, current: &BTreeSet) - /// Why the build provably does not use `pin`'s bytes, whatever copy is /// installed, or `None`. Current evidence that verifies the pin ([`verify`] -/// over [`current_view`]) always wins. Otherwise: a build edit dropping +/// over [`current_view`]) wins, over a build edit too when it covers the +/// whole build ([`BuildState::whole`]): a project not re-resolved since +/// the edit may be the one it applies to. Otherwise: a build edit dropping /// the pin ([`pin_dropped`]: the override replaced, or the load-time /// verifier and resolver order removed), or current evidence judged /// exactly as the hosted rewriter re-checks an existing pin @@ -235,7 +248,8 @@ async fn bypass(pin: &SbtPin, build: &BuildState, hashes: &mut Hashes) -> Option .as_ref() .map(|res| current_view(res, pin, &build.current)); if let Some(now) = &now { - if verify(pin, Some(now), hashes).await.is_ok() { + if (build.dropped.is_none() || build.whole) && verify(pin, Some(now), hashes).await.is_ok() + { return None; } } @@ -779,14 +793,26 @@ mod tests { let why = bypass(&p, &edited, &mut h).await.unwrap(); assert!(why.contains("build.sbt:1"), "{why}"); - // A current record verifying the pin wins over the edit. + // A current record verifying the pin wins over the edit when the + // evidence covers the whole build. let mut verified = JvmResolution::default(); record(&mut verified, "b/target/out", SV, Some(&jar)); let verified_edited = BuildState { dropped: dropped.clone(), + whole: true, ..state(verified.clone(), &["b/target/out"]) }; assert_eq!(bypass(&p, &verified_edited, &mut h).await, None); + // Not when another project was not re-resolved since the edit (it + // may be the one the edit applies to): the edit is proof. + let mut partial = verified.clone(); + record(&mut partial, "a/target/out", "3.11", None); + let partial_edited = BuildState { + dropped: dropped.clone(), + ..state(partial, &["b/target/out"]) + }; + let why = bypass(&p, &partial_edited, &mut h).await.unwrap(); + assert!(why.contains("build.sbt:1"), "{why}"); // A stale record of another project beside it changes nothing. let mut mixed = verified.clone(); record(&mut mixed, "a/target/out", "3.11", None);