Skip to content

Stop vendored Gradle liveness attesting a version the lockfile overrides - #2004

Draft
Mikola Lysenko (mikolalysenko) wants to merge 2 commits into
mainfrom
fix/vendored-gradle-lock-liveness
Draft

Mikola Lysenko (mikolalysenko) wants to merge 2 commits into
mainfrom
fix/vendored-gradle-lock-liveness

Conversation

@mikolalysenko

@mikolalysenko Mikola Lysenko (mikolalysenko) commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Vendored Gradle keeps the original GAV, routes it to the committed repository with exclusiveContent, and leaves lock files unchanged. Neither the vendored planner nor its VEX liveness (vendor::jvm::gradle::wired / wired_checked, which re-plan and require no degraded warning) read any lock file. If gradle.lockfile (or another lock of the build) pins the patched GA at a different version, Gradle resolves that locked version (or fails lock validation), yet vendored liveness still attested the patch. Hosted discovery already checks every lock (vex/discover/gradle.rs).

Fix

The planner in vendor/jvm/gradle.rs now runs check_locks after check_graph. It reads the build's lock inventory, which is now shared with hosted discovery: ScriptGraph::read_locks, covering gradle.lockfile, buildscript-gradle.lockfile, settings-gradle.lockfile and the legacy gradle/dependency-locks/*.lockfile of every project of every build. It also reads every custom lockFile the graph can follow (ScriptGraph::custom_lock_files).

Each problem below adds a vendor_jvm_degraded warning. wired requires a re-plan with no degraded warning, so each one also withholds VEX attestation. The patch is still applied.

Reason When
gradle_lock_overrides_vendored An entry for the GA is at a version no vendored row serves (neither this patch's version nor another vendored version of the GA).
gradle_settings_lock_unrouted A settings-gradle.lockfile entry exists, but that build's settings script does not hold the in-block exclusiveContent entry for the GAV in every block that needs one (buildscript, and pluginManagement when there are settings plugins{}).
gradle_lock_unreadable A listed lock file, or a followed custom one, can't be read as UTF-8 text: I/O error, refused or outside the checkout, missing, or not UTF-8.
gradle_custom_lock_file A lockFile location can't be followed statically. Only a literal file('…') or "$projectDir/…" in a project's own build script, outside allprojects / subprojects / configure / project(…) blocks and inside the checkout, can be followed.

How hosted's rules map to vendored (also in the doc comment). Hosted pins a suffixed version: a lock below the base is invalid, and a lock above it is LockAboveBase (unattested). Vendored keeps the original GAV, so a lock entry is served only at a vendored version. Any other locked version, above or below, means the build doesn't consume the patch.

Hosted rejects every settings-classpath lock, because its script runs after that classpath resolves. Vendored instead routes that classpath with in-block entries, so it accepts a settings lock only when the settings script actually holds them:

  • Vendoring: the entry may already be committed, or written by this plan (Committed::OrPlanned).
  • Liveness: the entry must already be committed (Committed::Only); one a re-plan would write doesn't count.

Anything else is rejected, as hosted rejects it.

Docs: the reason codes are added to the vendored table in CLI_CONTRACT.md, with a paragraph in docs/design/maven-vendoring.md.

Review findings (/code-review high)

  1. False attestation of a settings lock without the in-block entry: confirmed, fixed. See the table and mapping above. Test: settings_lock_needs_the_committed_in_block_entry. A settings lock is accepted with the committed entry. If the entry is stripped, liveness fails even though a re-plan would write it again. A settings script with no in-block entry is rejected (also covered in lock_at_another_version_withholds_liveness).
  2. Two vendored versions of one GA degraded each other: confirmed, fixed. A lock at a version the vendored index serves is no longer an override. Test: two_vendored_versions_of_a_ga_do_not_override_each_other.
  3. Degradation is build-wide: kept conservative, by design. A Gradle lock file belongs to one project and lists exactly the configurations that resolve each module, so only entries for the GA count; other locks are ignored. A project that locks the GA at another version (an unrelated tools/ test classpath included) does resolve the GA, just at that version, so that part of the build doesn't consume the patch. Hosted, which is ground truth, also withholds the whole build for one above-base lock. What can't be decided statically is whether Gradle reads a lock at all (whether that project enables dependencyLocking), so every lock of the build counts. The message now names the locking configurations.
  4. Unreadable locks skipped (fail open): confirmed, fixed. Paths come from the directory listing, so a listed lock that read can't return (I/O error, refused symlink outside the checkout, non-UTF-8) now fails closed. Test: unreadable_lock_files_fail_closed.
  5. Unreadable locks reported as an override: confirmed, fixed. They now get gradle_lock_unreadable with an accurate message, documented in CLI_CONTRACT.md.
  6. Every lockFile degraded every patch: confirmed, fixed. A followable custom lock is read and parsed like any lock. A missing one fails closed, since it was set explicitly. Only an unfollowable one is gradle_custom_lock_file. Tests: custom_lock_file_is_followed_when_static, plus the path-resolution cases in gradle::graph::tests::custom_lock_file_and_settings_classpath. Hosted keeps its blanket rejection of lockFile, because the hosted planner can't rewrite such a lock.
  7. Perf, locks re-read on every plan: refuted as a blocker, noted for follow-up. The lock pass reuses the ScriptGraph that plan already builds. Per call it lists two directories per project and reads each lock once. That is the same order as the existing per-call cost, since every plan call (one per patch, and one per entry in vex liveness) already re-collects and re-tokenizes the whole script graph. Lock files are small line-oriented text. A real per-root cache has to cover the graph and the locks together, and it needs a planning context threaded through plan, plan_with_external, wired, entry_wired_checked and the CLI vex loop. That is better done as its own change than bolted onto locks alone.
  8. Duplicated lock gathering: confirmed, fixed. ScriptGraph::read_locks is now the single inventory. Hosted discovery and the vendored check both use it, ScriptGraph::custom_lock_file is built on custom_lock_files, and is_settings_lock moved to gradle::locks (re-exported from patch::redirect::gradle). The hosted planner's lock rewrite still lists files through the same lockfile_paths because it needs the raw text to edit.

Tests

  • All 5 lock tests in vendor::jvm::gradle::tests fail against the previous commit's check_locks, verified by splicing the old function back in. They pass with this change:
    • lock_at_another_version_withholds_liveness
    • settings_lock_needs_the_committed_in_block_entry
    • two_vendored_versions_of_a_ga_do_not_override_each_other
    • unreadable_lock_files_fail_closed
    • custom_lock_file_is_followed_when_static
  • lock_at_another_version_withholds_liveness also fails on origin/main.
  • cargo test -p socket-patch-core --lib -- vendor::jvm:: vex::discover:: gradle::: 775 passed.
  • cargo test -p socket-patch-core --lib -- vex::discover::testing::golden::tests::committed_fixture_corpus_matches_golden vendor:: patch::redirect::gradle: 2892 passed. No golden changes.
  • cargo clippy --locked --workspace --all-features -- -D warnings and cargo fmt --all -- --check are clean.

Not done

  • No planner refusal. Degrading is the smaller, clearly-correct change. A refusal when every governing lock pins another version could follow later, alongside gradle_range_excludes_vendored.
  • No lock rewriting. Vendored keeps the original GAV, so a lock at the vendored version needs no edit.
  • Wider liveness gap left for later. wired ignores re-plan writes in general: a settings-classpath declaration of the GA whose in-block entry was removed after vendoring still attests when there is no settings lock. This PR closes that only for the locked case (finding 1).
  • Per-root planning cache. See finding 7.

🤖 Generated with Claude Code

Vendored Gradle keeps the original GAV and leaves lock files unchanged,
but neither its planner nor its VEX liveness (`wired`, which re-plans)
read any lock file. A `gradle.lockfile` pinning the patched GA at another
version makes Gradle resolve that version (or fail lock validation), yet
vendored liveness still attested the patch.

The planner now reads every lock file of every project of the build with
the shared `gradle::locks` reader (per-project, buildscript, settings and
legacy `gradle/dependency-locks/` files, via `ScriptGraph::lockfile_paths`)
and degrades with `gradle_lock_overrides_vendored` when any of them locks
the patched GA at a version other than the vendored one, or is not UTF-8.
A custom `lockFile` location is degraded with `gradle_custom_lock_file`.
Since `wired` requires a re-plan with no degraded warning, both withhold
VEX attestation; vendoring itself still applies.

Mapping from hosted: hosted pins a suffixed version (below-base lock is
invalid, above-base unattested); vendored serves only its own version, so
any other locked version is unattested. Hosted rejects every settings
classpath lock because its script runs too late; vendored wires that
classpath with an in-block exclusiveContent entry, so a settings lock at
the vendored version is served.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review findings on the vendored Gradle lock check:

- A settings-classpath lock is accepted only when that build's settings
  script holds the in-block exclusiveContent entry for the GAV in every
  block that needs one. Liveness (`wired`) now re-plans with
  `Committed::Only`, so it counts only the committed entry, never one a
  re-plan would write; vendoring counts the entry it writes. Otherwise
  `gradle_settings_lock_unrouted`, as hosted rejects it.
- A lock at another version of the GA that the vendored index also
  serves is not an override, so two vendored versions of one GA no
  longer degrade each other.
- A listed lock file that cannot be read as UTF-8 text (I/O error,
  refused symlink, non-UTF-8) fails closed with its own reason,
  `gradle_lock_unreadable`, instead of being skipped.
- A custom `lockFile` set as a literal `file(...)` (or
  `"$projectDir/..."`) in a project's own build script, inside the
  checkout, is read and checked like any lock (missing fails closed);
  only an unfollowable one is `gradle_custom_lock_file`.
- One lock inventory: `ScriptGraph::read_locks` (and
  `custom_lock_files`), used by hosted discovery and the vendored check;
  `is_settings_lock` moves to `gradle::locks`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant