diff --git a/.gitattributes b/.gitattributes index e27b01a69..a15657cec 100644 --- a/.gitattributes +++ b/.gitattributes @@ -30,6 +30,7 @@ crates/socket-patch-core/tests/fixtures/vendor/** -text # the replayed wiring files and the expected revert. crates/socket-patch-cli/tests/fixtures/legacy-ledgers/** -text -# The owned Gradle settings script is embedded with include_str! and -# written into user repos byte for byte; a CRLF checkout would change it. +# The owned Gradle settings scripts (vendored and hosted) are embedded with +# include_str! and written into user repos byte for byte; a CRLF checkout would change it. crates/socket-patch-core/src/vendor/jvm/socket-patch.settings.gradle -text +crates/socket-patch-core/src/patch/redirect/socket-patch.hosted.settings.gradle -text diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0c7e3de4..1d626633d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1088,30 +1088,45 @@ jobs: # 3.9 (trusted checksums), 4.0 rc. Hosted also runs both sides of # the trusted-checksums floor: 3.9.3 (last release that ignores # the .mvn/checksums pin) and 3.9.4 (first that enforces it). - - {os: ubuntu-latest, suite: e2e_redirect_maven_build, maven: '3.6.3'} - - {os: ubuntu-latest, suite: e2e_redirect_maven_build, maven: '3.8.9'} - - {os: ubuntu-latest, suite: e2e_redirect_maven_build, maven: '3.9.3'} - - {os: ubuntu-latest, suite: e2e_redirect_maven_build, maven: '3.9.4'} - - {os: ubuntu-latest, suite: e2e_redirect_maven_build, maven: '3.9.16'} - - {os: ubuntu-latest, suite: e2e_redirect_maven_build, maven: '4.0.0-rc-6'} - - {os: macos-latest, suite: e2e_redirect_maven_build, maven: '3.9.16'} - - {os: ubuntu-latest, suite: e2e_vendor_maven_build, maven: '3.6.3'} - - {os: ubuntu-latest, suite: e2e_vendor_maven_build, maven: '3.8.9'} - - {os: ubuntu-latest, suite: e2e_vendor_maven_build, maven: '3.9.16'} - - {os: ubuntu-latest, suite: e2e_vendor_maven_build, maven: '4.0.0-rc-6'} - - {os: macos-latest, suite: e2e_vendor_maven_build, maven: '3.9.16'} - - {os: ubuntu-latest, suite: e2e_vendor_jvm_build, maven: '3.6.3', test_filter: '--ignored maven_reactor'} - - {os: ubuntu-latest, suite: e2e_vendor_jvm_build, maven: '3.8.9', test_filter: '--ignored maven_reactor'} - - {os: ubuntu-latest, suite: e2e_vendor_jvm_build, maven: '3.9.2', test_filter: '--ignored maven_reactor'} - - {os: ubuntu-latest, suite: e2e_vendor_jvm_build, maven: '3.9.16', test_filter: '--ignored maven_reactor'} - - {os: ubuntu-latest, suite: e2e_vendor_jvm_build, maven: '4.0.0-rc-6', test_filter: '--ignored maven_reactor'} - - {os: macos-latest, suite: e2e_vendor_jvm_build, maven: '3.9.16', test_filter: '--ignored maven_reactor'} - - {os: windows-latest, suite: e2e_vendor_jvm_build, maven: '3.9.16', test_filter: '--ignored maven_reactor'} - - {os: ubuntu-latest, suite: e2e_vendor_jvm_build, gradle: '6.9.4', java: '11', test_filter: '--ignored gradle_multi_project'} - - {os: ubuntu-latest, suite: e2e_vendor_jvm_build, gradle: '7.6.4', java: '17', test_filter: '--ignored gradle_multi_project'} - - {os: ubuntu-latest, suite: e2e_vendor_jvm_build, gradle: '8.14.3', java: '17', test_filter: '--ignored gradle_multi_project'} - - {os: ubuntu-latest, suite: e2e_vendor_jvm_build, gradle: '9.8.0', java: '17', test_filter: '--ignored gradle_multi_project'} - - {os: windows-latest, suite: e2e_vendor_jvm_build, gradle: '8.14.3', java: '17', test_filter: '--ignored gradle_multi_project'} + - {os: ubuntu-latest, suite: e2e_redirect_maven_build, jvm_tool: maven, maven: '3.6.3'} + - {os: ubuntu-latest, suite: e2e_redirect_maven_build, jvm_tool: maven, maven: '3.8.9'} + - {os: ubuntu-latest, suite: e2e_redirect_maven_build, jvm_tool: maven, maven: '3.9.3'} + - {os: ubuntu-latest, suite: e2e_redirect_maven_build, jvm_tool: maven, maven: '3.9.4'} + - {os: ubuntu-latest, suite: e2e_redirect_maven_build, jvm_tool: maven, maven: '3.9.16'} + - {os: ubuntu-latest, suite: e2e_redirect_maven_build, jvm_tool: maven, maven: '4.0.0-rc-6'} + - {os: macos-latest, suite: e2e_redirect_maven_build, jvm_tool: maven, maven: '3.9.16'} + - {os: ubuntu-latest, suite: e2e_vendor_maven_build, jvm_tool: maven, maven: '3.6.3'} + - {os: ubuntu-latest, suite: e2e_vendor_maven_build, jvm_tool: maven, maven: '3.8.9'} + - {os: ubuntu-latest, suite: e2e_vendor_maven_build, jvm_tool: maven, maven: '3.9.16'} + - {os: ubuntu-latest, suite: e2e_vendor_maven_build, jvm_tool: maven, maven: '4.0.0-rc-6'} + - {os: macos-latest, suite: e2e_vendor_maven_build, jvm_tool: maven, maven: '3.9.16'} + - {os: ubuntu-latest, suite: e2e_vendor_jvm_build, jvm_tool: maven, maven: '3.6.3', test_filter: '--ignored maven_reactor'} + - {os: ubuntu-latest, suite: e2e_vendor_jvm_build, jvm_tool: maven, maven: '3.8.9', test_filter: '--ignored maven_reactor'} + - {os: ubuntu-latest, suite: e2e_vendor_jvm_build, jvm_tool: maven, maven: '3.9.2', test_filter: '--ignored maven_reactor'} + - {os: ubuntu-latest, suite: e2e_vendor_jvm_build, jvm_tool: maven, maven: '3.9.16', test_filter: '--ignored maven_reactor'} + - {os: ubuntu-latest, suite: e2e_vendor_jvm_build, jvm_tool: maven, maven: '4.0.0-rc-6', test_filter: '--ignored maven_reactor'} + - {os: macos-latest, suite: e2e_vendor_jvm_build, jvm_tool: maven, maven: '3.9.16', test_filter: '--ignored maven_reactor'} + - {os: windows-latest, suite: e2e_vendor_jvm_build, jvm_tool: maven, maven: '3.9.16', test_filter: '--ignored maven_reactor'} + # Real-Gradle capstones, PR tier: one leg per Gradle line x {agent + + # hosted, vendor + multi-project} on ubuntu, each on its line's LTS + # JDK. Every other OS x line x mode cell (and the JDK-ceiling, + # configuration-cache, Isolated Projects and real-Central rows) runs + # in gradle-compatibility.yml. `suite` lists every binary the leg + # runs (space-separated); the libtest filters select by the prefix + # contract ci-e2e-bundle.py enforces. Every suite has landed, so no + # row sets `allow_empty` (test_ci_gradle_prefixes.py keeps it that + # way): a missing suite fails the leg, and every suite must run at + # least one test on its own. Maven is installed only where a + # selected test needs it (gradle_vendor_395's mixed root). + - {os: ubuntu-latest, suite: 'e2e_gradle_discovery_build e2e_gradle_agent_build e2e_redirect_gradle_build', jvm_tool: gradle, gradle: '6.9.4', java: '11', test_filter: '--ignored gradle_agent_ gradle_hosted_'} + - {os: ubuntu-latest, suite: 'e2e_vendor_gradle_build e2e_vendor_jvm_build', jvm_tool: gradle, gradle: '6.9.4', java: '11', test_filter: '--ignored gradle_vendor_ gradle_multi_project'} + - {os: ubuntu-latest, suite: 'e2e_gradle_discovery_build e2e_gradle_agent_build e2e_redirect_gradle_build', jvm_tool: gradle, gradle: '7.6.6', java: '17', test_filter: '--ignored gradle_agent_ gradle_hosted_'} + - {os: ubuntu-latest, suite: 'e2e_vendor_gradle_build e2e_vendor_jvm_build', jvm_tool: gradle, gradle: '7.6.6', java: '17', test_filter: '--ignored gradle_vendor_ gradle_multi_project'} + - {os: ubuntu-latest, suite: 'e2e_gradle_discovery_build e2e_gradle_agent_build e2e_redirect_gradle_build', jvm_tool: gradle, gradle: '8.14.3', java: '21', test_filter: '--ignored gradle_agent_ gradle_hosted_'} + - {os: ubuntu-latest, suite: 'e2e_vendor_gradle_build e2e_vendor_jvm_build', jvm_tool: gradle, gradle: '8.14.3', java: '21', test_filter: '--ignored gradle_vendor_ gradle_multi_project'} + - {os: ubuntu-latest, suite: 'e2e_gradle_discovery_build e2e_gradle_agent_build e2e_redirect_gradle_build', jvm_tool: gradle, gradle: '9.8.0', java: '21', test_filter: '--ignored gradle_agent_ gradle_hosted_'} + - {os: ubuntu-latest, suite: 'e2e_vendor_gradle_build e2e_vendor_jvm_build', jvm_tool: gradle, gradle: '9.8.0', java: '21', test_filter: '--ignored gradle_vendor_ gradle_multi_project'} + - {os: windows-latest, suite: e2e_vendor_jvm_build, jvm_tool: gradle, gradle: '8.14.3', java: '17', test_filter: '--ignored gradle_multi_project'} # Real .NET SDK capstones: hosted + vendored nuget, one leg per SDK # major (the suite pins the major through a sandbox global.json): # the oldest and newest here, 7-9 on ubuntu in e2e-full. @@ -1281,15 +1296,55 @@ jobs: php-version: '8.2' tools: composer:${{ matrix.composer }} - - name: Setup Java (Maven and Gradle legs) - if: matrix.maven != '' || matrix.gradle != '' + - name: Select the JVM toolchain (JVM legs) + id: jvm + if: matrix.jvm_tool != '' + # `jvm_tool` (gradle | maven | sbt) marks a JVM leg. The JDK comes + # from the runner image (JAVA_HOME__X64 / _arm64: 8, 11, 17 and 21 + # on every hosted OS) when it has the pinned feature release, else + # from setup-java below. Maven is needed by the Maven legs and by the + # Gradle legs whose filter selects gradle_vendor_* (gradle_vendor_395 + # builds the mixed root's pom with it). The multi-project capstone + # reads the Gradle cache and the agent / hosted Gradle legs run + # without Maven, so the windows multi-project row proves both. + shell: bash + env: + JVM_TOOL: ${{ matrix.jvm_tool }} + JAVA_FEATURE: ${{ matrix.java || '17' }} + TEST_FILTER: ${{ matrix.test_filter }} + run: | + set -euo pipefail + home='' + for arch in X64 arm64 ARM64; do + var="JAVA_HOME_${JAVA_FEATURE}_${arch}" + if [ -n "${!var:-}" ]; then home="${!var}"; break; fi + done + if [ -n "$home" ]; then + bin="$home/bin" + if [ "$RUNNER_OS" = Windows ]; then bin="$home\\bin"; fi + echo "JAVA_HOME=$home" >> "$GITHUB_ENV" + echo "$bin" >> "$GITHUB_PATH" + echo "runner-jdk=true" >> "$GITHUB_OUTPUT" + echo "JDK $JAVA_FEATURE from the runner image: $home" + else + echo "runner-jdk=false" >> "$GITHUB_OUTPUT" + fi + maven=false + case "$JVM_TOOL" in + maven) maven=true ;; + gradle) case " $TEST_FILTER " in *gradle_vendor_*) maven=true ;; esac ;; + esac + echo "maven=$maven" >> "$GITHUB_OUTPUT" + + - name: Setup Java (JDK not on the runner image) + if: matrix.jvm_tool != '' && steps.jvm.outputs.runner-jdk != 'true' uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: ${{ matrix.java || '17' }} - name: Install Maven ${{ matrix.maven || '3.9.16' }} - if: matrix.maven != '' || matrix.gradle != '' + if: steps.jvm.outputs.maven == 'true' # Straight from the Apache archive (sha512-verified), so a leg gets # exactly the release it names rather than the runner's Maven. shell: bash @@ -1424,26 +1479,59 @@ jobs: SOCKET_PATCH_BUNDLER_E2E_VERSION: ${{ matrix.bundler }} SOCKET_PATCH_COMPOSER_E2E_REQUIRED: ${{ matrix.composer != '' && '1' || '' }} SOCKET_PATCH_COMPOSER_E2E_VERSION: ${{ matrix.composer }} - SOCKET_PATCH_MAVEN_E2E_REQUIRED: ${{ (matrix.maven != '' || matrix.gradle != '') && '1' || '' }} - SOCKET_PATCH_MAVEN_E2E_VERSION: ${{ matrix.maven || (matrix.gradle != '' && '3.9.16') || '' }} + SOCKET_PATCH_MAVEN_E2E_REQUIRED: ${{ steps.jvm.outputs.maven == 'true' && '1' || '' }} + SOCKET_PATCH_MAVEN_E2E_VERSION: ${{ steps.jvm.outputs.maven == 'true' && (matrix.maven || '3.9.16') || '' }} SOCKET_PATCH_GRADLE_E2E_REQUIRED: ${{ matrix.gradle != '' && '1' || '' }} SOCKET_PATCH_GRADLE_E2E_VERSION: ${{ matrix.gradle }} + SOCKET_PATCH_GRADLE_E2E_PROBE_DIR: ${{ matrix.gradle != '' && format('{0}/target/gradle-probe', github.workspace) || '' }} SOCKET_PATCH_DOTNET_E2E_REQUIRED: ${{ matrix.dotnet != '' && '1' || '' }} SOCKET_PATCH_DOTNET_E2E_VERSION: ${{ matrix.dotnet }} SOCKET_PATCH_DENO_E2E_REQUIRED: ${{ matrix.deno != '' && '1' || '' }} SOCKET_PATCH_DENO_E2E_VERSION: ${{ matrix.deno }} E2E_SUITE: ${{ matrix.suite }} E2E_TEST_FILTER: ${{ matrix.test_filter || '--ignored' }} + E2E_JVM_TOOL: ${{ matrix.jvm_tool }} + E2E_ALLOW_EMPTY: ${{ matrix.allow_empty }} shell: bash # Runs from the package root with CARGO_MANIFEST_DIR set, as - # `cargo test` would. + # `cargo test` would. `suite` may name several binaries; an + # `allow_empty` row skips the ones whose test file has not landed. + # Every suite a Gradle leg runs must run at least one test (per + # suite, so one suite's tests never hide another's empty filter). run: | + set -uo pipefail exe='' if [ "$RUNNER_OS" = Windows ]; then exe=.exe; fi cd crates/socket-patch-cli export CARGO_MANIFEST_DIR="$PWD" - # shellcheck disable=SC2086 # the filter is several libtest arguments - "../../target/e2e-bin/$E2E_SUITE$exe" $E2E_TEST_FILTER + status=0 + for suite in $E2E_SUITE; do + if [ "$E2E_ALLOW_EMPTY" = true ] && [ ! -f "tests/$suite.rs" ] && [ ! -f "tests/$suite/main.rs" ]; then + echo "::notice::$suite has not landed yet; skipped (allow_empty)" + continue + fi + log="../../target/e2e-$suite.log" + # shellcheck disable=SC2086 # the filter is several libtest arguments + if ! "../../target/e2e-bin/$suite$exe" $E2E_TEST_FILTER 2>&1 | tee "$log"; then + status=1 + continue + fi + passed=$(sed -n 's/^test result: .* \([0-9][0-9]*\) passed;.*/\1/p' "$log" | head -n 1) + if [ "$E2E_JVM_TOOL" = gradle ] && [ "${passed:-0}" = 0 ]; then + echo "::error::$suite ran no test in this Gradle leg ($E2E_TEST_FILTER)" + status=1 + fi + done + exit "$status" + + - name: Upload the Gradle probe reports + if: always() && matrix.gradle != '' + uses: ./.github/actions/upload-artifact + with: + name: gradle-probe-pr-${{ matrix.os }}-${{ matrix.gradle }}-${{ strategy.job-index }} + path: target/gradle-probe/ + if-no-files-found: ignore + retention-days: 14 - name: Run vlt e2e tests if: matrix.vlt != '' diff --git a/.github/workflows/gradle-compatibility.yml b/.github/workflows/gradle-compatibility.yml new file mode 100644 index 000000000..ec462116d --- /dev/null +++ b/.github/workflows/gradle-compatibility.yml @@ -0,0 +1,356 @@ +name: Gradle patch compatibility + +# The full real-Gradle grid: every hosted OS x every supported Gradle line x +# every socket-patch mode (agent, hosted, vendored), plus rows the PR tier +# never runs. ci.yml's `e2e` job keeps a lean ubuntu-only PR tier (one leg +# per Gradle line x {agent + hosted, vendor + multi-project}). +# +# Grid (`cells`, 36): {ubuntu, macos (arm64), windows} x +# 6.9.4 / JDK 11, 7.6.6 / JDK 17, 8.14.3 / JDK 21, 9.8.0 / JDK 21 +# x {agent, hosted, vendor}. +# Extra ubuntu rows (`extras`): +# * JDK ceilings — the newest JDK each line runs on: 6.9 <= 15, +# 7.6 <= 19, 8.14 <= 24 (9.x runs on 17-25; the grid's 21 is its LTS). +# * Configuration cache — 9.8.0 with --configuration-cache, hosted and +# vendor. +# * Isolated Projects — 9.8.0 with +# -Dorg.gradle.unsafe.isolated-projects=true, hosted; recording only +# (continue-on-error), the probe report is the deliverable. +# * Real Central — 8.14.3 against the real Maven Central (#511 derived +# maven-metadata.xml, #487 pgp-only verification entries). +# +# 9.8.0 is the current release on services.gradle.org (re-checked +# 2026-10-02; bump it here and in ci.yml together when a newer 9.x ships). +# 7.6.6 is the last 7.x. Every distribution is sha256-checked against +# services.gradle.org before use. +# +# Each mode runs its suites' `#[ignore]` tests by name prefix (the contract +# scripts/ci-e2e-bundle.py --check enforces): agent = e2e_gradle_discovery_build +# + e2e_gradle_agent_build (`gradle_agent_`), hosted = e2e_redirect_gradle_build +# (`gradle_hosted_`), vendor = e2e_vendor_gradle_build + e2e_vendor_jvm_build +# (`gradle_vendor_`, `gradle_multi_project`); a row's `suites` / `test_filter` +# narrow that. A suite whose test file has not landed yet is skipped; every +# landed suite a cell runs must run at least one test. +# +# Unlike ci.yml's e2e-build (scripts/ci-e2e-bundle.py reads ci.yml's rows), +# `build` compiles exactly the Gradle suites once per OS and the cells +# download them. Every cell uploads its JSON probe reports +# (gradle_build_common::probe_report: Gradle / JDK version, resolved jar path +# and sha256, hash-dir naming, refresh / RO-cache / transform canaries). + +on: + pull_request: + paths: + - '.github/workflows/gradle-compatibility.yml' + - 'scripts/ci-e2e-bundle.py' + - 'Cargo.lock' + - 'Cargo.toml' + - 'crates/*/Cargo.toml' + - 'crates/socket-patch-core/src/gradle/**' + - 'crates/socket-patch-core/src/crawlers/jvm_cache.rs' + - 'crates/socket-patch-core/src/crawlers/maven_crawler.rs' + - 'crates/socket-patch-core/src/crawlers/gradle_cache.rs' + - 'crates/socket-patch-core/src/vendor/jvm/**' + - 'crates/socket-patch-core/src/vendor/maven_repo.rs' + - 'crates/socket-patch-core/src/vendor/redownload.rs' + - 'crates/socket-patch-core/src/patch/redirect/gradle.rs' + - 'crates/socket-patch-core/src/patch/redirect/*.gradle' + - 'crates/socket-patch-core/src/patch/redirect/mod.rs' + - 'crates/socket-patch-core/src/patch/redirect/upstream/gradle.rs' + - 'crates/socket-patch-core/src/patch/sidecars/maven.rs' + - 'crates/socket-patch-core/src/patch/jvm_jar.rs' + - 'crates/socket-patch-core/src/hosted/**' + - 'crates/socket-patch-core/src/vex/**' + - 'crates/socket-patch-cli/src/ecosystem_dispatch.rs' + - 'crates/socket-patch-cli/src/commands/apply.rs' + - 'crates/socket-patch-cli/src/commands/rollback.rs' + - 'crates/socket-patch-cli/src/commands/remove.rs' + - 'crates/socket-patch-cli/src/commands/vex.rs' + - 'crates/socket-patch-cli/src/commands/vex_consumed.rs' + - 'crates/socket-patch-cli/src/commands/vendor.rs' + - 'crates/socket-patch-cli/src/commands/scan/**' + - 'crates/socket-patch-cli/tests/gradle_*' + - 'crates/socket-patch-cli/tests/gradle_*/**' + - 'crates/socket-patch-cli/tests/e2e_*gradle*' + - 'crates/socket-patch-cli/tests/e2e_*gradle*/**' + - 'crates/socket-patch-cli/tests/e2e_vendor_jvm_build.rs' + - 'crates/socket-patch-cli/tests/jvm_fixture_repo/**' + - 'crates/socket-patch-cli/tests/hosted_maven_common/**' + - 'crates/socket-patch-cli/tests/maven_build_common/**' + - 'crates/socket-patch-cli/tests/prebuilt_common/**' + - 'crates/socket-patch-cli/tests/common/**' + schedule: + # Nightly, off ci.yml's 05:41. + - cron: '17 4 * * *' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: gradle-compat-${{ github.event.pull_request.number || github.ref }}${{ github.event_name == 'schedule' && '-nightly' || '' }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + SOCKET_NO_CONFIG: '1' + SOCKET_NO_UPDATE_CHECK: '1' + +jobs: + build: + name: build ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + env: + CARGO_PROFILE_DEV_DEBUG: '0' + CARGO_INCREMENTAL: '0' + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Install Rust + run: rustup show + + - name: Cache cargo + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + key: gradle-compat + save-if: ${{ github.ref == 'refs/heads/main' }} + + - name: Check the Gradle test-name prefixes + run: python3 scripts/ci-e2e-bundle.py --check + + - name: Compile the CLI and the landed Gradle suites + id: compile + shell: bash + run: | + set -euo pipefail + suites='' + targets=() + for suite in e2e_gradle_discovery_build e2e_gradle_agent_build e2e_redirect_gradle_build e2e_vendor_gradle_build e2e_vendor_jvm_build; do + if [ -f "crates/socket-patch-cli/tests/$suite.rs" ] || [ -f "crates/socket-patch-cli/tests/$suite/main.rs" ]; then + suites="$suites $suite" + targets+=(--test "$suite") + fi + done + echo "suites=$suites" >> "$GITHUB_OUTPUT" + cargo build --locked -p socket-patch-cli --bin socket-patch + cargo test --locked -p socket-patch-cli --no-run --message-format=json-render-diagnostics "${targets[@]}" > target-build.json + + - name: Bundle the binaries the cells run + shell: bash + env: + BUNDLE_OS: ${{ matrix.os }} + BUNDLE_SUITES: ${{ steps.compile.outputs.suites }} + run: | + # shellcheck disable=SC2086 # the suite list is several arguments + python3 scripts/ci-e2e-bundle.py --os "$BUNDLE_OS" --cargo-json target-build.json --dest target/gradle-bin --suites $BUNDLE_SUITES + + - uses: ./.github/actions/upload-artifact + with: + name: gradle-bin-${{ matrix.os }} + path: target/gradle-bin/ + if-no-files-found: error + retention-days: 3 + + cells: + name: gradle ${{ matrix.gradle }} / jdk ${{ matrix.java }} / ${{ matrix.mode }} / ${{ matrix.os }} + needs: [build] + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + gradle: ['6.9.4', '7.6.6', '8.14.3', '9.8.0'] + mode: [agent, hosted, vendor] + # Each line's LTS JDK (extends every cell of that line); the empty + # keys are the `extras` knobs the shared steps read. + include: + - {gradle: '6.9.4', java: '11', label: '', gradle_args: '', real_central: '', suites: '', test_filter: ''} + - {gradle: '7.6.6', java: '17', label: '', gradle_args: '', real_central: '', suites: '', test_filter: ''} + - {gradle: '8.14.3', java: '21', label: '', gradle_args: '', real_central: '', suites: '', test_filter: ''} + - {gradle: '9.8.0', java: '21', label: '', gradle_args: '', real_central: '', suites: '', test_filter: ''} + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + steps: &cell-steps + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Download the Gradle suites + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: gradle-bin-${{ matrix.os }}* + merge-multiple: true + path: target/gradle-bin + + - name: Stage the CLI where the test binaries expect it + # `CARGO_BIN_EXE_socket-patch` was baked in at compile time as + # /target/debug/socket-patch. + shell: bash + run: | + set -euo pipefail + exe='' + if [ "$RUNNER_OS" = Windows ]; then exe=.exe; fi + chmod +x target/gradle-bin/* || true + mkdir -p target/debug target/tmp target/gradle-probe + cp "target/gradle-bin/socket-patch$exe" "target/debug/socket-patch$exe" + + - name: Select the JDK + id: jdk + # The runner image's JDK when it has the feature release (8, 11, 17 + # and 21 on every hosted OS), else setup-java (the ceiling rows). + shell: bash + env: + JAVA_FEATURE: ${{ matrix.java }} + run: | + set -euo pipefail + home='' + for arch in X64 arm64 ARM64; do + var="JAVA_HOME_${JAVA_FEATURE}_${arch}" + if [ -n "${!var:-}" ]; then home="${!var}"; break; fi + done + if [ -n "$home" ]; then + bin="$home/bin" + if [ "$RUNNER_OS" = Windows ]; then bin="$home\\bin"; fi + echo "JAVA_HOME=$home" >> "$GITHUB_ENV" + echo "$bin" >> "$GITHUB_PATH" + echo "runner-jdk=true" >> "$GITHUB_OUTPUT" + else + echo "runner-jdk=false" >> "$GITHUB_OUTPUT" + fi + + - name: Setup Java ${{ matrix.java }} + if: steps.jdk.outputs.runner-jdk != 'true' + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + with: + # Temurin never shipped JDK 15 (the 6.9 ceiling); Zulu did. + distribution: ${{ matrix.java == '15' && 'zulu' || 'temurin' }} + java-version: ${{ matrix.java }} + + - name: Install Maven 3.9.16 (vendor cells) + # gradle_vendor_395 builds its mixed root's pom with Maven; the + # multi-project capstone reads the Gradle cache and needs none. + if: matrix.mode == 'vendor' + shell: bash + env: + MAVEN_VERSION: '3.9.16' + run: | + url="https://archive.apache.org/dist/maven/maven-3/${MAVEN_VERSION}/binaries/apache-maven-${MAVEN_VERSION}-bin.tar.gz" + curl -fsSL --retry 3 "$url" -o "$RUNNER_TEMP/maven.tgz" + curl -fsSL --retry 3 "$url.sha512" -o "$RUNNER_TEMP/maven.sha512" + python -c 'import hashlib, pathlib, os; p=pathlib.Path(os.environ["RUNNER_TEMP"]); assert hashlib.sha512((p/"maven.tgz").read_bytes()).hexdigest() == (p/"maven.sha512").read_text().split()[0]' + python -m tarfile -e "$RUNNER_TEMP/maven.tgz" "$RUNNER_TEMP" + launcher="$RUNNER_TEMP/apache-maven-${MAVEN_VERSION}/bin/mvn" + if [ "$RUNNER_OS" = Windows ]; then launcher="${launcher}.cmd"; fi + { + echo "SOCKET_PATCH_MAVEN_E2E_MVN=$launcher" + echo "SOCKET_PATCH_MAVEN_E2E_VERSION=$MAVEN_VERSION" + echo "SOCKET_PATCH_MAVEN_E2E_REQUIRED=1" + } >> "$GITHUB_ENV" + + - name: Install Gradle ${{ matrix.gradle }} + shell: bash + env: + GRADLE_VERSION: ${{ matrix.gradle }} + run: | + url="https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip" + curl -fsSL --retry 3 "$url" -o "$RUNNER_TEMP/gradle.zip" + curl -fsSL --retry 3 "$url.sha256" -o "$RUNNER_TEMP/gradle.sha256" + python -c 'import hashlib, pathlib, os; p=pathlib.Path(os.environ["RUNNER_TEMP"]); assert hashlib.sha256((p/"gradle.zip").read_bytes()).hexdigest() == (p/"gradle.sha256").read_text().strip()' + unzip -q "$RUNNER_TEMP/gradle.zip" -d "$RUNNER_TEMP" + launcher="$RUNNER_TEMP/gradle-${GRADLE_VERSION}/bin/gradle" + if [ "$RUNNER_OS" = Windows ]; then launcher="${launcher}.bat"; fi + echo "SOCKET_PATCH_GRADLE_E2E_GRADLE=$launcher" >> "$GITHUB_ENV" + + - name: Run the ${{ matrix.mode }} suites + shell: bash + env: + SOCKET_PATCH_GRADLE_E2E_REQUIRED: '1' + SOCKET_PATCH_GRADLE_E2E_VERSION: ${{ matrix.gradle }} + SOCKET_PATCH_GRADLE_E2E_ARGS: ${{ matrix.gradle_args }} + SOCKET_PATCH_GRADLE_E2E_REAL_CENTRAL: ${{ matrix.real_central }} + SOCKET_PATCH_GRADLE_E2E_PROBE_DIR: ${{ github.workspace }}/target/gradle-probe + CELL_MODE: ${{ matrix.mode }} + CELL_SUITES: ${{ matrix.suites }} + CELL_FILTER: ${{ matrix.test_filter }} + run: | + set -uo pipefail + exe='' + if [ "$RUNNER_OS" = Windows ]; then exe=.exe; fi + case "$CELL_MODE" in + agent) suites='e2e_gradle_discovery_build e2e_gradle_agent_build'; filter='gradle_agent_' ;; + hosted) suites='e2e_redirect_gradle_build'; filter='gradle_hosted_' ;; + vendor) suites='e2e_vendor_gradle_build e2e_vendor_jvm_build'; filter='gradle_vendor_ gradle_multi_project' ;; + *) echo "::error::unknown mode $CELL_MODE"; exit 1 ;; + esac + if [ -n "$CELL_SUITES" ]; then suites="$CELL_SUITES"; fi + if [ -n "$CELL_FILTER" ]; then filter="$CELL_FILTER"; fi + cd crates/socket-patch-cli + export CARGO_MANIFEST_DIR="$PWD" + status=0 + for suite in $suites; do + if [ ! -f "tests/$suite.rs" ] && [ ! -f "tests/$suite/main.rs" ]; then + echo "::notice::$suite has not landed yet; skipped" + continue + fi + log="../../target/gradle-$suite.log" + # shellcheck disable=SC2086 # the filter is several libtest arguments + if ! "../../target/gradle-bin/$suite$exe" --ignored --nocapture $filter 2>&1 | tee "$log"; then + status=1 + continue + fi + # Per suite: another suite's tests must not hide one whose + # filter selects nothing. + passed=$(sed -n 's/^test result: .* \([0-9][0-9]*\) passed;.*/\1/p' "$log" | head -n 1) + if [ "${passed:-0}" = 0 ]; then + echo "::error::$suite ran no test in the $CELL_MODE cell ($filter)" + status=1 + fi + done + exit "$status" + + - name: Upload the probe reports + if: always() + uses: ./.github/actions/upload-artifact + with: + name: gradle-probe-${{ matrix.os }}-${{ matrix.gradle }}-jdk${{ matrix.java }}-${{ matrix.mode }}${{ matrix.label && format('-{0}', matrix.label) || '' }} + path: target/gradle-probe/ + if-no-files-found: ignore + retention-days: 30 + + extras: + name: gradle ${{ matrix.gradle }} / jdk ${{ matrix.java }} / ${{ matrix.mode }} / ${{ matrix.label }} + needs: [build] + strategy: + fail-fast: false + matrix: + include: + # JDK ceilings. + - {os: ubuntu-latest, gradle: '6.9.4', java: '15', mode: agent, label: jdk-ceiling} + - {os: ubuntu-latest, gradle: '6.9.4', java: '15', mode: hosted, label: jdk-ceiling} + - {os: ubuntu-latest, gradle: '6.9.4', java: '15', mode: vendor, label: jdk-ceiling} + - {os: ubuntu-latest, gradle: '7.6.6', java: '19', mode: agent, label: jdk-ceiling} + - {os: ubuntu-latest, gradle: '7.6.6', java: '19', mode: hosted, label: jdk-ceiling} + - {os: ubuntu-latest, gradle: '7.6.6', java: '19', mode: vendor, label: jdk-ceiling} + - {os: ubuntu-latest, gradle: '8.14.3', java: '24', mode: agent, label: jdk-ceiling} + - {os: ubuntu-latest, gradle: '8.14.3', java: '24', mode: hosted, label: jdk-ceiling} + - {os: ubuntu-latest, gradle: '8.14.3', java: '24', mode: vendor, label: jdk-ceiling} + # Configuration cache. + - {os: ubuntu-latest, gradle: '9.8.0', java: '21', mode: hosted, label: configuration-cache, gradle_args: '--configuration-cache'} + - {os: ubuntu-latest, gradle: '9.8.0', java: '21', mode: vendor, label: configuration-cache, gradle_args: '--configuration-cache'} + # Isolated Projects (recording only). + - {os: ubuntu-latest, gradle: '9.8.0', java: '21', mode: hosted, label: isolated-projects, gradle_args: '-Dorg.gradle.unsafe.isolated-projects=true', record_only: 'true'} + # The real Maven Central (#511, #487). + # Only the suite that owns those tests: e2e_vendor_jvm_build has none, + # and every landed suite a cell runs must run a test. + - {os: ubuntu-latest, gradle: '8.14.3', java: '21', mode: vendor, label: real-central, real_central: '1', suites: 'e2e_vendor_gradle_build', test_filter: 'gradle_vendor_511 gradle_vendor_487'} + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + continue-on-error: ${{ matrix.record_only == 'true' }} + steps: *cell-steps diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 69fb09df9..90bb865b7 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -161,9 +161,9 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Vendored entries and the rest of the CLI.** Because nothing is in the manifest, vendored patches are invisible to `apply` (nothing to apply in place) but fully visible to `list` (listed from the ledger, labeled `Mode: vendored (recorded in .socket/vendor/state.json)` in human mode, exit 0 on a vendored-only project), `vex` (attested from the embedded records while a lockfile still wires the artifact — see "Manifest-less VEX"), `repair` (health-checked and rebuilt from the ledger), and `scan --prune` (lockfile-driven reconcile). They are exempt from standalone `vendor`'s manifest reconcile (`reconcile_dropped` never touches `detached` entries) and exit via `remove ` (which reverts them), `vendor --revert`, or `rollback`, whose vendored leg reverts every in-scope ledger entry (unscoped and identifier-scoped runs; path-scoped runs reach them only when an installed copy matches). -`scan --mode hosted` swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither counted nor attested. **No ledger (v5.0)**: hosted mode writes ONLY the lockfile / registry-config edits — `.socket/vendor/redirect-state.json` is never written (on success or failure), and a pre-v5 one on disk is ignored (never read for planning, never quarantined, left byte-identical). The lockfiles are the only record of a hosted patch: `list`, `vex`, `rollback`, `remove`, `vendor` and `repair` all discover the hosted pins from them (a hosted URL counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin), and commit-ready output is just the lockfile / config changes. Cargo and golang are confirmed only by their rewriter's own report (`confirmed_cargo_uuids` / `confirmed_golang_uuids`): a golang dep counts only when its go.mod `replace M V => patch.socket.dev/gopatch/ ` and both go.sum lines are in place, never because the patch-server origin or leftover go.sum lines appear somewhere. A golang module that go.mod does not require and go.sum does not list at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written). Only the exact module `patch.socket.dev/gopatch/` is socket-owned; any other module path is refused with `redirect_golang_untrusted_module_path`. A vendored golang module is taken over like cargo and the npm family: its vendor wiring, committed copy and ledger entry are reverted first (`redirect_takeover_reverted_vendored`). A vendored PyPI package (requirements.txt, Poetry, Pipenv, uv, Hatch, PDM, pylock) is taken over the same way: its vendored wiring is restored to the recorded registry entry, its ledger entry and wheel are removed, and only then is it redirected. The Python rewriters treat any non-registry source as user-authored, so without the revert they refused socket-patch's own vendored source and left the project vendored. A takeover revert that leaves vendored wiring in place is refused with `redirect_vendored_revert_failed`. That covers a drift-skipped record (`vendor_lock_entry_drifted`) and a reverted file that still references the artifact (`vendor_revert_residual_reference`). The ledger entry and artifact are kept, and the package stays vendored and skipped. `--dry-run` predicts the same refusal from the same signals instead of previewing `redirect_would_revert_vendored`. A taken-over package whose wiring was reverted but that was then not pinned to hosted now installs the unpatched registry release in both modes. Causes include a refused lock, unavailable hosted wheel metadata, or a vendored ledger update that failed after the revert (refused with `redirect_vendored_revert_failed`). It is reported as `redirect_takeover_unpatched` with `status: "partial_failure"` and exit 1, never as success. That warning also prints under `--silent`. Human output prints no `Migrated …` progress line for the package and no "keep the hosted patches" next steps. Re-runs over already-rewritten output plan from the current lock text and are idempotent (exit 0, lock unchanged). **Lock (v5.0)**: the hosted engine acquires `<.socket>/apply.lock` around its first wet write (the takeover pre-reverts) — not on `--dry-run`, and not when the run would write nothing (zero redirects, all skipped) — so previews and no-op runs never create `.socket/`; contention is `lock_held` and a lock-file I/O fault (a read-only project root, a file squatting on `.socket/`) is `lock_io` — both exit 1, refused BEFORE any project file is written, and rendered like every other lock holder: human `Error (): ` on stderr (+ the `--lock-timeout` hint for a live holder); JSON keeps the hosted shape — top-level `status: "error"`, `errorCode: "lock_held" | "lock_io"`, a string `error`, and `redirect: {mode: "hosted"}` retained (NOT the vendored `error: {code, message}` object). **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: hosted `scan` prints the results table and update detection like the other modes, then rewrites without a prompt (scan never prompts); `--dry-run` previews through the engine, and a detail fetch that leaves nothing to redirect enters the engine as a no-op (`Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine; a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_gem_bundle_gemfile_unsupported` (gem: `BUNDLE_GEMFILE` — `BUNDLE_GEMFILE:` in the bundler app config, which outranks the environment variable as in `Bundler::Settings`, else the environment variable — names a manifest other than the project's `Gemfile` / `gems.rb`, so no gem is redirected or attested; a value naming one of those two selects that pair even when the other spelling is present), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds), `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang. Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. **yarn berry line endings (v5.0)**: yarn writes a NEW `yarn.lock` with the OS line ending (`os.EOL` — CRLF on Windows) and keeps an existing lock's majority ending on every later write, and a `core.autocrlf` checkout turns an LF lock CRLF on any OS — so a uniformly CRLF lock is rewritten in its own ending: every untouched byte (a leading BOM included) round-trips (and `rollback`'s upstream restore keeps the lock's own ending). A lock that MIXES CRLF and LF (or holds a bare CR) has no single ending to keep — yarn's own `--immutable` check rejects it too (YN0028) — so it is refused untouched with `redirect_yarn_berry_mixed_line_endings` (the detail names `yarn install`, which normalizes it). This replaces v4's `redirect_yarn_berry_crlf_unsupported`, which refused every CRLF lock and is no longer emitted. A vendored→hosted takeover runs these berry gates (mixed line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) BEFORE reverting a vendored berry purl — wet and `--dry-run` alike — so a refused purl keeps its vendored wiring, ledger entry and artifact byte-identical and is skipped with the gate's code (never announced as `redirect_takeover_reverted_vendored` and then left unpatched in both modes). +`scan --mode hosted` swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither counted nor attested. **No ledger (v5.0)**: hosted mode writes ONLY the lockfile / registry-config edits — `.socket/vendor/redirect-state.json` is never written (on success or failure), and a pre-v5 one on disk is ignored (never read for planning, never quarantined, left byte-identical). The lockfiles are the only record of a hosted patch: `list`, `vex`, `rollback`, `remove`, `vendor` and `repair` all discover the hosted pins from them (a hosted URL counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin), and commit-ready output is just the lockfile / config changes. Cargo and golang are confirmed only by their rewriter's own report (`confirmed_cargo_uuids` / `confirmed_golang_uuids`): a golang dep counts only when its go.mod `replace M V => patch.socket.dev/gopatch/ ` and both go.sum lines are in place, never because the patch-server origin or leftover go.sum lines appear somewhere. Gradle is confirmed the same way (`confirmed_gradle_uuids`): only when the final files hold the owned script, the index row, the live apply line in every build's settings file and the suffixed version in every lock entry of the GA (see [Gradle builds](#gradle-builds-v50)). A golang module that go.mod does not require and go.sum does not list at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written). Only the exact module `patch.socket.dev/gopatch/` is socket-owned; any other module path is refused with `redirect_golang_untrusted_module_path`. A vendored golang module is taken over like cargo and the npm family: its vendor wiring, committed copy and ledger entry are reverted first (`redirect_takeover_reverted_vendored`). A vendored PyPI package (requirements.txt, Poetry, Pipenv, uv, Hatch, PDM, pylock) is taken over the same way: its vendored wiring is restored to the recorded registry entry, its ledger entry and wheel are removed, and only then is it redirected. The Python rewriters treat any non-registry source as user-authored, so without the revert they refused socket-patch's own vendored source and left the project vendored. A takeover revert that leaves vendored wiring in place is refused with `redirect_vendored_revert_failed`. That covers a drift-skipped record (`vendor_lock_entry_drifted`) and a reverted file that still references the artifact (`vendor_revert_residual_reference`). The ledger entry and artifact are kept, and the package stays vendored and skipped. `--dry-run` predicts the same refusal from the same signals instead of previewing `redirect_would_revert_vendored`. A taken-over package whose wiring was reverted but that was then not pinned to hosted now installs the unpatched registry release in both modes. Causes include a refused lock, unavailable hosted wheel metadata, or a vendored ledger update that failed after the revert (refused with `redirect_vendored_revert_failed`). It is reported as `redirect_takeover_unpatched` with `status: "partial_failure"` and exit 1, never as success. That warning also prints under `--silent`. Human output prints no `Migrated …` progress line for the package and no "keep the hosted patches" next steps. Re-runs over already-rewritten output plan from the current lock text and are idempotent (exit 0, lock unchanged). **Lock (v5.0)**: the hosted engine acquires `<.socket>/apply.lock` around its first wet write (the takeover pre-reverts) — not on `--dry-run`, and not when the run would write nothing (zero redirects, all skipped) — so previews and no-op runs never create `.socket/`; contention is `lock_held` and a lock-file I/O fault (a read-only project root, a file squatting on `.socket/`) is `lock_io` — both exit 1, refused BEFORE any project file is written, and rendered like every other lock holder: human `Error (): ` on stderr (+ the `--lock-timeout` hint for a live holder); JSON keeps the hosted shape — top-level `status: "error"`, `errorCode: "lock_held" | "lock_io"`, a string `error`, and `redirect: {mode: "hosted"}` retained (NOT the vendored `error: {code, message}` object). **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: hosted `scan` prints the results table and update detection like the other modes, then rewrites without a prompt (scan never prompts); `--dry-run` previews through the engine, and a detail fetch that leaves nothing to redirect enters the engine as a no-op (`Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine; a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_gem_bundle_gemfile_unsupported` (gem: `BUNDLE_GEMFILE` — `BUNDLE_GEMFILE:` in the bundler app config, which outranks the environment variable as in `Bundler::Settings`, else the environment variable — names a manifest other than the project's `Gemfile` / `gems.rb`, so no gem is redirected or attested; a value naming one of those two selects that pair even when the other spelling is present), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds), `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang. Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. **yarn berry line endings (v5.0)**: yarn writes a NEW `yarn.lock` with the OS line ending (`os.EOL` — CRLF on Windows) and keeps an existing lock's majority ending on every later write, and a `core.autocrlf` checkout turns an LF lock CRLF on any OS — so a uniformly CRLF lock is rewritten in its own ending: every untouched byte (a leading BOM included) round-trips (and `rollback`'s upstream restore keeps the lock's own ending). A lock that MIXES CRLF and LF (or holds a bare CR) has no single ending to keep — yarn's own `--immutable` check rejects it too (YN0028) — so it is refused untouched with `redirect_yarn_berry_mixed_line_endings` (the detail names `yarn install`, which normalizes it). This replaces v4's `redirect_yarn_berry_crlf_unsupported`, which refused every CRLF lock and is no longer emitted. A vendored→hosted takeover runs these berry gates (mixed line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) BEFORE reverting a vendored berry purl — wet and `--dry-run` alike — so a refused purl keeps its vendored wiring, ledger entry and artifact byte-identical and is skipped with the gate's code (never announced as `redirect_takeover_reverted_vendored` and then left unpatched in both modes). -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; **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`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and the Gradle build scripts read only to trigger the manual-snippet warning). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic, **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`, 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 `common/config/rush/repo-state.json` exists (the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives). **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 non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). +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; **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`, `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). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic, **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`, 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 `common/config/rush/repo-state.json` exists (the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives). **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 non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and 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). **Gem stale-install guard (additive warning — the canonical narrative; other mentions point here)**: the gem hosted rewrite is pure Gemfile/lock text, so a gem ALREADY materialized under the project's bundle paths keeps its upstream bytes — the next `bundle install` prints `Using ` and never refetches, on **every** bundler major (live-verified 2026-08-19 on 1.17.3 / 2.7.2 / 4.0.18: bundler 4's CHECKSUMS verify at download time only, and nothing is downloaded; `bundle install --force`/`--redownload` re-install from the stale cached `.gem` instead of re-fetching — bundler 1 silently, bundler 4 with an exit-37 checksum refusal that still leaves the upstream bytes installed; the **verified** remedy is removing the installed dir + cache `.gem` + `specifications` entry, then `bundle install`). After the rewrite, a hosted run therefore probes the installed-gem discovery paths (the same ruby-crawler discovery `apply` uses, honoring `--global`/`--global-prefix` like scan's own discovery) for each confirmed gem redirect and judges the materialization against the patch record's `afterHash` file map. Judgment rules: records are found **by uuid** among this run's fetched records (v5.0: hosted mode persists no records, so a purl whose `/patches/view` fetch failed this run is not judged; the warning re-fires on every re-scan whose fetch succeeds, until the stale materialization is gone); a materialization with every file at `afterHash` is already patched and never warns (an agent→hosted migration stays quiet by construction), and when several confirmed variant purls resolve to one installed dir, ANY of them judging it patched keeps it quiet; staleness needs **positive evidence** — at least one record file whose bytes were actually read and hash to neither state's expectation — so missing or unreadable files never produce a warning. Warnings emit `redirect_gem_stale_install` (JSON `redirect.warnings[]` + a code-tagged stderr line) in three flavors: a PROJECT-LOCAL dir gets the verified delete-list remedy (installed dir, cache `.gem`, `specifications` entry — plus the project's committed `/.gem` when present and not proven to be the patched artifact, since bundler installs from its cache dir in preference to fetching); a SHARED gem-env home gets a caveat that the home is shared machine-wide and prefers migrating the project to a local bundle path over deleting shared files; and a committed cache-dir archive whose sha256 differs from the patched artifact's warns standalone even with no installed dir at all (a fresh checkout with a committed stale cache re-materializes the upstream bytes forever). A stale-flagged purl is additionally **excluded from the same run's `--vex` `assume_applied` set** — the envelope must never attest a CVE its own warning says is live; the purl falls back to normal installed-tree verification (a patched install still attests, a stale one is omitted). The cache dir is bundler's `cache_path` setting (`Bundler.app_cache`), resolved in `Bundler::Settings` priority: `BUNDLE_CACHE_PATH:` in the bundler app config (`$BUNDLE_APP_CONFIG/config`, else `.bundle/config`) first, then the `BUNDLE_CACHE_PATH` environment variable, else `vendor/cache`; a relative value is read against the project root. With `BUNDLE_IGNORE_CONFIG` set (any value) bundler reads no config file, so the app config is skipped here too and only the environment and the default count — the same holds for the `BUNDLE_GEMFILE:` app-config setting. The probe is read-only (nothing is deleted) and skipped on `--dry-run` — deliberately explicit, since nothing was rewritten. Exit code and `status` are unchanged (warning-only, the hosted-refusal posture); a same-run `--vex` may still fail on "nothing to attest" per the embedded-VEX contract. @@ -369,6 +369,7 @@ Discovery is read-only, never touches the network, and never fails the run: a ma | gem | `Gemfile.lock` and `gems.locked` (the `Gemfile` / `gems.rb` only to cross-check a merged multi-remote `GEM` section) | a `GEM` section whose remote ends `patch-registry/gem//` | `PATH` remote `.socket/vendor/gem//-` | `CHECKSUMS` sha256, required only when the lock has a `CHECKSUMS` section (bundler ≥ 2.6) | | composer | `composer.lock` (`packages` + `packages-dev`) | `dist.url` on the patch host | `dist: {type: "path", url: ".socket/vendor/composer//…", reference: ""}` | `dist.shasum`, required | | maven | `pom.xml` (+ `.mvn/maven.config`, `.mvn/checksums/checksums.sha256`) | a dependency version `-socket.` matching exactly ONE `socket-patch-` repository on the patch host | `socket-patch-vendor-` repository + exactly one jar under `.socket/vendor/maven//` with a matching `.sha1` | Trusted Checksums line when enabled; not required (the suffixed version is the pin) | +| gradle (v5.0) | `.socket/gradle/hosted-index.tsv`, the owned hosted script, and every settings script and lock file the script graph reaches | an index row whose repository URL is on the patch host and names the row's uuid, live only while the owned script is intact, every build's settings file applies it with the current index digest, every lock entry of the GA is the suffixed version, no `settings-gradle.lockfile` names the GA and no build script sets a custom `lockFile` (otherwise `patched_ref_invalid`) | none here: a vendored Gradle entry is gated by its ledger entry's wiring check | the suffixed copies installed in the Gradle cache; required (never the lock basis), because the script lets a higher upstream version resolve | | nuget | the first of `nuget.config` / `NuGet.config` / `NuGet.Config`, + `packages.lock.json` | source `socket-patch-` + its exclusive exact-id ``; version from `packages.lock.json` | the same mapping onto `.socket/vendor/nuget/`; version from the lock, else the feed's single nupkg | `contentHash`, required | | deno | none | — (no hosted mode) | — (no vendored backend) | — | @@ -387,7 +388,7 @@ Recognition rules that hold for every ecosystem: |---|---|---| | Vendored: a lockfile/config wires a `.socket/vendor` artifact, or a live vendor ledger entry | The **committed artifact** is hashed against the record's `afterHash`. The ledger entry is used when it names the wired artifact (it carries the dir-artifact inventory); otherwise an entry is synthesized from the reference. A present installed tree with different bytes only warns `vendored_tree_out_of_sync`. | `(vendored)` | | Hosted: a discovered patch-host reference (or a live pre-v5 redirect-ledger record) | The installed copies the build **consumes** through the hosted wiring are hash-verified when any exist: the Go replacement module, never the pristine `M@v` in the module cache; the Socket-registry cargo source dir; maven's suffixed version. Installed evidence wins: `hash_mismatch` / `not_applied` are omitted. With **nothing installed**, a discovered reference whose lock pins the artifact (or whose format's rewriter never writes a pin) attests from that pin, which is the same evidence as in-run `scan --mode hosted --vex`. A pre-v5 ledger-only record, or a reference whose required pin is missing, stays `package_not_found`. So do purls that `--ecosystems` kept out of the crawl, because "not installed" has to mean the crawler looked. | `(redirected)` | -| Agent: a manifest record with no live hosted/vendored wiring | The installed tree, unchanged. **Every** installed copy the crawler finds for the purl (npm nests duplicates of one `name@version`) must hash to the patched bytes, as `apply` patches every copy. One unpatched copy omits the purl with that copy's tag (`not_applied` / `hash_mismatch`). | none | +| Agent: a manifest record with no live hosted/vendored wiring | The installed tree, unchanged. **Every** installed copy the crawler finds for the purl (npm nests duplicates of one `name@version`) must hash to the patched bytes, as `apply` patches every copy (Maven: every copy a build consumes, Gradle hash dirs included — see [Gradle builds](#gradle-builds-v50)). One unpatched copy omits the purl with that copy's tag (`not_applied` / `hash_mismatch`). | none | **Liveness gates.** These gates run before hashing, and `--no-verify` / `--vex-no-verify` skips only the hashing, never the gates: @@ -1643,3 +1644,262 @@ jar/POM copies for conflicting bytes. `--maven-config auto|none` is a global vendoring option so scan/get/repair receive it too; omission preserves the ledger's recorded choice. Switching existing auto-config wiring to `none` requires reverting it first. `none` cannot be combined with a repository ban. + +## Gradle builds (v5.0) + +Gradle is part of the `maven` ecosystem: Gradle-resolved artifacts are Maven PURLs +(`pkg:maven//@`), and every mode below works on Gradle 6.8 +or newer, Groovy and Kotlin DSL alike. The per-mode guide is +[docs/ecosystems.md](../../docs/ecosystems.md#gradle). Every code in this section is +additive (MINOR); `test_gradle_contract_codes` (`tests/contract_gradle_codes.rs`) +fails when the source emits a Gradle or JVM code this section does not name. + +### Discovery (`scan`, every mode) + +The crawler reads the Gradle user home's `caches/modules-2/files-2.1` and, when set, +the read-only cache `$GRADLE_RO_DEP_CACHE/modules-2/files-2.1` (scanned, never +written), before the Maven local repository. The user home is resolved the way the +JVM does it: `-Dgradle.user.home` in `GRADLE_OPTS`, else `GRADLE_USER_HOME`, else +`/.gradle`, where `` is the passwd entry's directory on Unix (not +`$HOME`) and `USERPROFILE` / `HOME` on Windows. One Gradle version directory holds a +hash directory per download (named by the file's sha1, leading zeros possibly +dropped); every hash directory holding a record's files is a separate installed copy. + +For a Gradle-only build, `~/.m2` is a scan root only when the build can read it: +`mavenLocal()` (or `mavenLocal { … }`) in any settings, build, `buildSrc`, +included-build, applied or init script makes it one; a script or init script that +cannot be read literally makes the gate undetermined, and the repository is scanned. +Run-level `warnings[]` entries for these carry an additive `level` field (`info` or +`warn`; every other code has none); human mode prints `info` as `Note:` lines, which +`--silent` suppresses: + +| Code | Level | Meaning | +|---|---|---| +| `gradle_build_ignores_m2` | `warn` | The build declares no `mavenLocal()`, and modules its locks (or the manifest) name exist only in `~/.m2`; they are not scanned. | +| `gradle_maven_local_undetermined` | `info` | `mavenLocal()` could not be ruled out (an unreadable script or init script), so `~/.m2` stays a scan root. | +| `gradle_user_home_differs` | `info` | The passwd home differs from `$HOME`, so Gradle's cache is not under `$HOME/.gradle`. Never emitted with `--global-prefix`. | + +A package crawled from a Gradle cache gets an additive `inLock` boolean when the +`--cwd` is a Gradle build whose lock files were read (local and global runs): `true` +when a `gradle.lockfile` / `buildscript-gradle.lockfile` / `settings-gradle.lockfile` +or a legacy `gradle/dependency-locks/*.lockfile` names that GAV. It annotates and +never filters. + +### Agent mode (`apply`, `scan --mode agent`) + +`apply` writes every copy a build consumes: each `~/.m2` version directory the build +reads and each Gradle hash directory that holds the record's files. A Gradle version +directory holding none of the record's files is not an install of it +(`package_not_installed`). One holding only some of them is: the held files are +patched and the missing ones fail that copy as not found, as on `~/.m2` (the build +still loads the held jar). A record keyed by jar members (`-.jar/`, +#264) swaps the whole jar for the patch service's build of it, in one transaction +across every consumed copy: one download, one backup per distinct original under +`.socket/jvm-originals/`, and a failed write puts every copy already swapped back. +`rollback` (and `remove`) restores every writable copy that still holds the record's +patched bytes: never the read-only cache, but also a `~/.m2` copy a Gradle-only build +no longer reads (an earlier apply wrote it while the build declared `mavenLocal()`, +or before v5.0 gated `~/.m2`; leaving it would strand the shared jar patched once +`remove` drops the record). It restores from the backup, else, online and for a +Gradle copy only, from an upstream download that hashes to the copy's hash +directory. Such an unread `~/.m2` copy never fails the run: one that holds bytes +that are neither side of the record (another build applied a different patch there, +or `mvn install` rebuilt it), lacks a file, or is a swapped jar whose original this +project never backed up is left as it is with `gradle_m2_copy_not_restored`, and +`remove` still drops the record. + +Run-level `warnings[]` codes (a refusal is also a `failed` event whose `error` +starts with the code): + +| Code | Run result | Meaning | +|---|---|---| +| `gradle_verification_metadata_present` | refused, exit 1 | `gradle/verification-metadata.xml` exists; rewritten cache bytes would fail (or, with key-only trust, slip past) Gradle's dependency verification. Nothing is written; use `--mode vendored` or `--mode hosted`. | +| `gradle_build_ignores_m2` | refused, exit 1 | The only installed copy is in `~/.m2`, which this Gradle-only build never reads. Nothing is patched; build once so Gradle caches it, then apply again. | +| `gradle_m2_may_be_unconsumed` | warning | A Gradle-only build that declares `mavenLocal()` (or may) has no Gradle cache copy, so only the `~/.m2` copy was patched. Gradle takes a module from the first declared repository that has it: with another repository before `mavenLocal()`, the next build downloads the unpatched jar. Run the build once and apply again. | +| `gradle_ro_cache_shadows` | exit 1 | The read-only cache holds a copy that is never written and that Gradle may read first. Writable copies are still patched. | +| `gradle_copy_unexpected_bytes` | refused, exit 1 | A hash directory's file is the pristine download (its sha1 names the directory) but neither side of the record, or a hash directory holds a variant's files whose bytes no variant was made for. That copy is left unpatched and the run fails (changed in v5.0: it used to only warn), since the build still loads it. | +| `gradle_transform_copy_stale` | that copy fails | After the write, Gradle still holds a copy derived from the pristine jar (`caches/transforms-*`, `caches/jars-*`, instrumented jars). Run `gradle --stop`, delete those directories, apply again. | +| `gradle_transform_copy_unverified` | warning | A same-named derived copy is neither the pristine nor the patched jar and is older than the patch, or the derived-cache walk was cut short. | +| `gradle_jar_locked_by_daemon` | that copy fails | Windows only: the jar is held open (errors 32, 33, 303, or 5 on an existing writable file), normally by a Gradle daemon. Also reported by rollback. Run `gradle --stop` and retry. | +| `jvm_agent_service_required` | refused, exit 1 | A member-keyed record needs the patch service's jar, and the run is offline, the service is disabled or pending, or it did not provide one. Nothing is written. | +| `jvm_agent_service_integrity` | refused, exit 1 | The service jar failed transfer-integrity verification. Nothing is written. | +| `jvm_agent_service_jar_mismatch` | refused, exit 1 | The service jar does not carry the record's `afterHash` members, is unreadable, or differs from the installed jar outside the patched members. Nothing is written. | +| `jvm_jar_backup_failed` | refused, exit 1 | The original jar could not be backed up before the swap. Nothing is written. | +| `gradle_rollback_hash_mismatch` | rollback: that copy fails | A file restored into a Gradle hash directory does not hash to the directory's name (the sha1 Gradle verified on download): the before-blob is not that download. The file is left as it is and the result fails; delete that hash directory so Gradle downloads it again. | +| `jvm_jar_backup_missing` | rollback: that copy fails | No backup of the original jar exists (and, for a Gradle copy, no upstream download matched its hash directory, or the run is offline). The copy is left as it is. | +| `gradle_m2_copy_not_restored` | rollback / remove: warning | A `~/.m2` copy this Gradle-only build does not read could not be restored (its bytes are neither side of the record, a file is missing, or it is a swapped jar with no backup in this project). It is left as it is and does not fail the run. A file that is there but cannot be read (permissions, not a regular file) may still hold the patched bytes, so it fails the run and `remove` keeps the record. | + +Each patched Gradle copy's sidecar record (`PatchEvent.sidecar`) carries an advisory +instead of a checksum-file rewrite (a `files-2.1` copy has none): +`gradle_refresh_reverts` (info: `--refresh-dependencies` downloads a fresh copy), +`gradle_daemon_stale` (info: a registered daemon of this user home may still hold the +old jar; `gradle --stop`), `gradle_global_cache_shared` (info: every build of the user +home sees the patch) and `gradle_jar_locked_by_daemon` (error, above). A `~/.m2` copy's +`.sha1` / `.md5` files that described the pre-patch bytes are rewritten to the patched +ones, and back on rollback; a checksum file that already disagreed is left alone. + +### Hosted mode (`scan --mode hosted`) + +A Gradle build (a root `settings.gradle[.kts]` or `build.gradle[.kts]`) gets automated +wiring; the pasted `exclusiveContent` snippet of v4 is now only the refusal fallback. +The planner writes, all committed with the build: + +- `.socket/gradle/socket-patch.hosted.settings.gradle` (static, changes only with a + CLI release), `.socket/gradle/hosted-index.tsv` (one row per pinned GA: + `g:a:base`, suffixed version, repository URL, jar and pom sha256, uuid) and + `.socket/gradle/.gitattributes` (`* -text`); +- one apply line in every settings file of the checkout's builds (root, `buildSrc`, + each literal `includeBuild`), `apply from: '.socket/gradle/socket-patch.hosted.settings.gradle' // socket-patch-hosted ` + (Kotlin: `apply(from = …)`), carrying the index digest so a changed index + invalidates the configuration cache; a settings file the planner had to create + carries ` created` after the digest, and only such files are deleted on restore; +- every lock entry of the GA in every build's lock files moved from the base to the + suffixed version (each line keeps its line ending); +- the suffixed component in an existing `gradle/verification-metadata.xml` (never + created). + +The script routes the suffixed version to its Socket repository with +`exclusiveContent`, substitutes every request whose selector admits the base (direct, +transitive, ranges, dynamic and rich versions), rejects every other candidate at or +below the base, and fails the build (`socket-patch: … resolved …`) if anything still +resolves there; it also checks the jar's sha256 against the index. A request whose +selector does not admit the base (an explicit newer version, a lock or `strictly` above +the base, a transitive bump) is left alone and resolves above the base (a newer +upstream fix is never downgraded); `vex` withholds the attestation when a lock entry +records such a version (`vex_gradle_lock_above_base`), and otherwise judges the +installed suffixed copies. A dynamic or range selector that admits the base is pinned +like a lock: it resolves the patched version even after a newer upstream release +appears (the Socket repository lists no versions), reported as +`redirect_gradle_dynamic_selector_pinned`. A `latest.release` / `latest.integration` +request is refused (`redirect_gradle_latest_selector`): whether it admits the base +depends on what the repositories list, so the script cannot rewrite it, and with every +upstream candidate at or below the base rejected it fails to resolve until upstream +ships a newer release. Detached +configurations (`configurations.detachedConfiguration`) are not reached. + +A dep is **confirmed** (`redirected`, attested) only when the final files hold the +socket-patch script, the live apply line with the current digest in every target +settings file, the index row, and in every lock entry of the GA the suffixed version +or a release above the base (`confirmed_gradle_uuids`); anything else is not counted. `list`, `vex`, `rollback`, +`remove`, `vendor` and `repair` discover hosted Gradle pins from the index under the +same rules (a settings-classpath lock naming the GA, a stale digest, a custom +`lockFile`, a non-Socket URL, a lock at another version, or any build- or GA-level +refusal of the planner holding now — a settings-classpath declaration, an +`includeBuild` it cannot follow, an Android / KMP plugin, a classifier request, a user +`exclusiveContent` rule — is `patched_ref_invalid`, no reference). A lock entry above +the base is still a reference, so `list`, `rollback` and `remove` find the pin, but +`vex` omits it (`vex_gradle_lock_above_base`, as a run warning and as the +`failed[].reason`): that build resolves the newer upstream release, not the patch. +`rollback` / +`remove` restore without the network: lock entries back to +the base, the row out of the index, the verification component out when it is still +exactly what the planner wrote (else `gradle_verification_component_left`), and the +owned files and apply lines once no row is left. + +`scan --mode hosted` over a vendored Gradle entry runs this planner's checks first +(`takeover_refusal`, wet and `--dry-run` alike): a refused purl keeps its vendored +patch byte-identical and is skipped with the refusal code, whose detail says so. An +eject (`vendor` over hosted pins) snapshots every Gradle wiring file before it +restores, so a failed vendor step rolls the whole build back byte-exact. + +Refusals write nothing for the dep and are followed by `redirect_gradle_manual_snippet` +(a per-DSL snippet applying the owned script's rules for this dep: `exclusiveContent` +for the suffixed version, every request whose selector admits the base rewritten to it +— by dependency substitution on the strictly / require / prefer constraint, so a +`prefer`-only rich version is covered, and by `eachDependency` — and every other +candidate at or below the base rejected; it declares no dependency): + +| Code | Cause | +|---|---| +| `redirect_gradle_version_unsupported` | The wrapper pins Gradle below 6.8. | +| `redirect_gradle_android_or_kmp` | An Android or Kotlin Multiplatform plugin, in any script or catalog `[plugins]`. | +| `redirect_gradle_include_build_unresolved` | An `includeBuild` the script graph cannot follow. | +| `redirect_gradle_lock_location_unknown` | A build script sets a custom `lockFile`. | +| `redirect_gradle_build_file_unreadable` | A settings, build, catalog, lock or owned file the script graph reaches exists but cannot be read as UTF-8 text (permissions, encoding, not a regular file). The planner refuses rather than take it for absent, which would create a settings file over the user's. The hosted writer also refuses, as a whole-run refusal, to write a settings file it did not read over one on disk. | +| `redirect_gradle_index_malformed` | `.socket/gradle/hosted-index.tsv` does not parse. | +| `redirect_gradle_same_gav_unsupported` | The grant serves the original GAV (no `mavenSuffixedVersion` / `mavenPomSha256`), which cannot be pinned fail-closed. | +| `redirect_gradle_override_invalid` | The grant has unsafe coordinates, a non-canonical uuid, a wrong suffix, a non-https repository, a missing digest, or no maven2 repository. | +| `redirect_gradle_vendored_conflict` | The GA is vendored (`.socket/vendor/gradle-index.tsv`); `vendor --revert` first. | +| `redirect_gradle_settings_classpath` | The GA is on a settings-script classpath (declared, or named in any `settings-gradle.lockfile`), which resolves before the script runs. | +| `redirect_gradle_classifier_declared` | A declaration requests a classifier the Socket repository does not serve. | +| `redirect_gradle_range_declared` | A `strictly` constraint excludes the patched base version and admits nothing above it. | +| `redirect_gradle_latest_selector` | A declaration requests `latest.release` / `latest.integration`, which the pin cannot rewrite; the build would fail until upstream ships a release above the base. Declare the base version explicitly and scan again. | +| `redirect_gradle_exclusive_content_conflict` | A user `exclusiveContent` rule routes the group to another repository. | +| `redirect_gradle_version_conflict` | The index already pins the GA at another base, or two patches pin it in one run. | +| `redirect_gradle_lock_conflict` | A lock entry names the GA below the base (and not at the suffixed version); re-lock (`--write-locks`) first. A lock above the base is a newer upstream release the pin lets resolve, not a conflict. | +| `redirect_gradle_verification_unparseable` | `gradle/verification-metadata.xml` cannot be edited. | + +Warnings on a confirmed run: `redirect_gradle_detached_configs_unguarded` (always: +detached configurations are not reached), `redirect_gradle_dynamic_selector_pinned` (a +declaration's dynamic or range selector admits the base, so it stays on the patched +version while the patch is in place), `redirect_gradle_verification_component_left` +(a replaced patch's verification component was edited by hand, so it is kept; an +unedited one is removed with its row), `redirect_gradle_unscanned_build_logic` +(build logic the graph could not follow, so a declaration, lock or repository there +is unchecked) and `redirect_gradle_module_metadata_unavailable` (the grant carries no +`mavenModuleSha256`: the Socket repository serves no suffixed `.module`, so Gradle +falls back to the pom and the upstream module's variants and capabilities are not +applied). When it does, the suffixed `.module` and its digest go into the +verification component, also on a rescan of a component written before the service +served it. File edits in `rewrittenFiles` / the edit list carry the kinds +`redirect_gradle_hosted_index`, `redirect_gradle_hosted_script`, +`redirect_gradle_gitattributes`, `redirect_gradle_settings_apply`, +`redirect_gradle_lock_entry` and `redirect_gradle_verification_component`. The +`RewriteResult` keeps `gradle_uuids`, `confirmed_gradle_uuids` and +`refused_gradle_uuids` (every maven uuid of a Gradle build lands in exactly one of the +last two). + +### Vendored mode (`vendor`, `scan --mode vendored`, `get --mode vendored`) + +See [Vendored JVM support](#vendored-jvm-support-v5) and the +[JVM design](../../docs/design/maven-vendoring.md#gradle). Gradle keeps the original +coordinates: the GAV is committed under `.socket/vendor/gradle/`, indexed by +`.socket/vendor/gradle-index.tsv` and served by `.socket/gradle/socket-patch.settings.gradle` +through one apply line per settings file. Results use these codes; the detail starts +with `reason: : `: + +| Code | Effect | Gradle reasons | +|---|---|---| +| `vendor_jvm_shape_unsupported` | refusal, nothing written | `gradle_below_6_8`, `android_or_kmp` (also `available-at` module redirects), `gradle_exclusive_content_conflict` (a user rule claiming the module, in any script), `gradle_range_excludes_vendored` (no declared selector admits the vendored version), `gradle_verification_unparseable`, `gradle_index_unreadable`, `build_file_unreadable` (including a non-UTF-8 settings file), `build_file_outside_root`, `not_build_root` (run from a directory an ancestor settings file includes or may include, from a project with no settings file of its own below one, or below an ancestor settings file that is not UTF-8; `repair` refuses there too), `no_build_file` | +| `vendor_jvm_upstream_unavailable` | refusal | `classifier_unavailable` (a declared classifier jar no cache or registry has), `module_unavailable` (the pom declares Gradle module metadata that cannot be sourced), `pom_unavailable`, `verification_metadata_unavailable` | +| `vendor_jvm_degraded` | applied, VEX withheld | `gradle_unscanned_build_logic`, `unwired_build_logic` (a nonliteral included build), `settings_plugins_unwired`, `classifier_unpatched_copy` (a classifier jar carries an unpatched copy of a patched member), `verification_parent_chain_unhandled`, `legacy_maven_root` (a single-POM root next to a Gradle build whose ledger already holds a single-POM entry; the Gradle build stays unpatched until `vendor --revert` and vendor again) | +| `vendor_jvm_note` | applied, informational | `range_declared` (a range, prefix or rich selector lists versions from the derived `maven-metadata.xml`), `ide_sources_unavailable` | +| `vendor_jvm_upstream_unverified` | warning | Upstream metadata was taken offline and not authenticated against registry checksums. | +| `vendor_gradle_unsupported` | refusal | Legacy single-POM path only: a Gradle project with no `pom.xml` that the JVM backend did not route. | + +A root holding both `pom.xml` and a Gradle build vendors both in one ledger entry +(#395); either half refusing writes nothing. A derived +`.socket/vendor/gradle///maven-metadata.xml` keeps range, +prefix and rich selectors on the vendored version (#511). Existing pgp-only +verification entries get a `sha256` beside them (#487), and so do the classifier jars +the tree serves (a declared classifier, the IDE sources): the vendored repository +serves no signatures, so each gets its upstream `sha256` unless its entry already +holds a checksum. The owned script, index, +derived metadata and `.gitattributes` are compared line-ending blind (#429). Ledger +fragments use the kinds `gradle_settings_fragment`, `gradle_verification_fragment`, +`gradle_derived_metadata`, `jvm_owned_file`, `jvm_vendor_tree`, `jvm_created_dir` +and `jvm_upstream_status`; they are internal to the ledger and read back only by this +CLI. + +### VEX + +`vex` re-hashes every copy a build consumes (`~/.m2` copies the build reads, every +Gradle hash directory holding the record's files, and the suffixed copies of a +hosted pin) and attests only when all of them carry the patch. A Gradle version +directory that holds none of the record's files is ignored; one that holds only some +of them is judged, and its missing files withhold the statement. A derived copy +(`caches/transforms-*`, `caches/jars-*`, instrumented jars) proven to come from the +pristine jar, or a same-named one that is older than the patched jar and does not +match it, withholds the statement: + +| Code | Kind | Meaning | +|---|---|---| +| `vex_gradle_unpatched_copy` | run warning | A copy a build may load does not carry the patch; no statement until every copy does. | +| `gradle_unpatched_copy` | `failed[].reason` | The purl the warning above withheld. | +| `vex_gradle_lock_above_base` | run warning and `failed[].reason` | A hosted pin is wired, but a lock file records a release above its base, which that build resolves instead of the patch; no statement until it is re-locked or the patch is rolled back. | +| `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`. diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index f5918a3dd..cfd788a2a 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -2,9 +2,7 @@ use clap::Args; use socket_patch_core::api::blob_fetcher::get_missing_blobs; use socket_patch_core::api::client::{get_api_client_with_overrides, ApiClient}; use socket_patch_core::crawlers::ruby_crawler::config_path_ignored_warning; -use socket_patch_core::crawlers::{ - detect_npm_pkg_manager, Ecosystem, NpmPkgManager, RubyCrawler, -}; +use socket_patch_core::crawlers::{detect_npm_pkg_manager, Ecosystem, NpmPkgManager, RubyCrawler}; use socket_patch_core::manifest::operations::read_manifest; use socket_patch_core::manifest::schema::{PatchFileInfo, PatchManifest, PatchRecord}; use socket_patch_core::patch::apply::{ @@ -14,11 +12,12 @@ use socket_patch_core::patch::apply_lock::LockGuard; use socket_patch_core::patch::redirect::golang_local::{ apply_go_redirect, reconcile_go_redirects, verify_go_redirect_state, }; +use socket_patch_core::patch::sidecars::{maven as maven_sidecars, SidecarAdvisoryCode}; use socket_patch_core::telemetry::{track_patch_applied, track_patch_apply_failed}; use socket_patch_core::utils::purl::parse_golang_purl; use socket_patch_core::utils::purl::{normalize_purl, purl_eq, strip_purl_qualifiers}; use socket_patch_core::vendor::purl_keys_cover; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -28,7 +27,7 @@ use crate::commands::lock_cli::acquire_or_emit; use crate::commands::vex::{ generate_vex_from_manifest_path, generate_vex_without_manifest, ManifestlessVex, VexEmbedArgs, }; -use crate::ecosystem_dispatch::{find_all_packages_for_purls, partition_purls}; +use crate::ecosystem_dispatch::{find_all_packages_for_purls, partition_purls, JvmScope}; use crate::json_envelope::{ AppliedVia, Command, Envelope, EnvelopeError, PatchAction, PatchEvent, PatchEventFile, RunWarning, Status, VexSummary, @@ -210,8 +209,13 @@ fn format_mismatch_fetch_result(downloaded: usize, needed: usize) -> String { /// mismatched files still need their afterHash blobs. The variant gate /// mirrors the apply loop's representative check PER COPY for gem and /// PyPI (which patch every copy, and two envs can hold different wheels -/// of one release), and against the FIRST copy for Maven: a variant's -/// files are probed only on the copies it is attempted on. +/// of one release), and against the FIRST copy otherwise: a variant's +/// files are probed only on the copies it is attempted on. Maven's Gradle +/// copies are version dirs whose files sit in hash dirs, so each one is +/// first expanded into the hash dirs holding the record's files (as +/// `apply_maven_base` does) and gated and probed per hash dir; probing the +/// version dir itself would only ever find nothing, and a drifted Gradle +/// copy would never queue the afterHash blob its write needs. /// /// Only a mismatched file whose afterHash blob is NOT staged can queue a /// fetch, so the probe first decides that with metadata probes alone and @@ -264,21 +268,40 @@ async fn mismatch_blob_gaps( || records .first() .is_some_and(|(key, _)| key.as_str() != stripped)); - // The copies the apply loop gates per copy: gem and PyPI patch - // every copy, each against its own representative check; Maven - // gates (and patches) only the first. - let gate_copies: &[PathBuf] = if matches!( - Ecosystem::from_purl(purl), - Some(Ecosystem::Gem | Ecosystem::Pypi) - ) { - pkg_paths.as_slice() - } else { - std::slice::from_ref(first_path) - }; + let maven = Ecosystem::from_purl(purl) == Some(Ecosystem::Maven); for (_, record) in records { if !can_queue(record) { continue; } + // Maven: every Gradle version dir expanded into the hash dirs + // holding the record's files. + let expanded: Vec = if maven { + pkg_paths + .iter() + .flat_map(|p| { + socket_patch_core::crawlers::gradle_cache::installed_copies( + p, + &record.files, + ) + .into_iter() + .map(|(dir, _)| dir) + }) + .collect() + } else { + Vec::new() + }; + let pkg_paths: &[PathBuf] = if maven { &expanded } else { pkg_paths }; + // The copies the apply loop gates per copy: gem and PyPI patch + // every copy, each against its own representative check, and + // Maven each hash dir; the rest gate on the first. + let gate_copies: &[PathBuf] = if matches!( + Ecosystem::from_purl(purl), + Some(Ecosystem::Gem | Ecosystem::Pypi | Ecosystem::Maven) + ) { + pkg_paths + } else { + std::slice::from_ref(first_path) + }; // Copies this variant is attempted on: a copy whose installed // distribution is another variant (two envs can hold different // wheels of one release) is skipped there by the apply loop. @@ -1164,6 +1187,19 @@ pub(crate) async fn run_locked( // `events[]` by `purl` for per-package context. if let Some(ref sidecar) = result.sidecar { env.sidecars.push(sidecar.clone()); + // A Gradle cache copy's other Info advisories + // (daemon, shared user home) ride their own + // records. + if sidecar + .advisory + .as_ref() + .is_some_and(|a| a.code == SidecarAdvisoryCode::GradleRefreshReverts) + { + env.sidecars.extend(maven_sidecars::gradle_extra_records( + &result.package_key, + Path::new(&result.package_path), + )); + } } } // Manifest entries that targeted in-scope ecosystems but @@ -1900,6 +1936,21 @@ async fn apply_patches_inner( let mut applied_base_purls: HashSet = HashSet::new(); + // Maven: the run's JVM caches (which copies a build consumes) and the + // patch service a member-keyed record's whole-jar swap downloads from, + // resolved once and only when a Maven patch is in scope. + let jvm_scope = if partitioned.contains_key(&Ecosystem::Maven) { + Some(JvmScope::of(&args.common).await) + } else { + None + }; + let jvm_service = jvm_scope.as_ref().map(|_| { + args.common + .vendor_service_config(Some(client.clone()), client.uses_public_proxy()) + }); + + let jvm_derived = socket_patch_core::patch::jvm_jar::DerivedCache::default(); + // PURL order, so the per-package Error/Warning lines, the results and // the `Patched packages:` block read the same on every run (the map is // a `HashMap`). @@ -1936,6 +1987,37 @@ async fn apply_patches_inner( continue; } + // Maven: every copy a build consumes (`~/.m2` and each Gradle + // cache, version dirs expanded into their hash dirs), with the + // Gradle guards — see `apply_maven_base`. + if let Some(scope) = jvm_scope + .as_ref() + .filter(|_| Ecosystem::from_purl(purl) == Some(Ecosystem::Maven)) + { + let maven = MavenBase { + args, + manifest: &manifest, + base_purl: &base_purl, + variants: &variants, + pkg_paths, + scope, + sources: &sources, + policy, + service: jvm_service.as_ref(), + socket_dir: &socket_dir, + derived: &jvm_derived, + }; + let out = Box::pin(apply_maven_base(&maven)).await; + has_errors |= out.failed; + matched_manifest_purls.extend(out.matched); + run_warnings.extend(out.warnings); + if out.applied { + applied_base_purls.insert(base_purl.clone()); + } + results.extend(out.results); + continue; + } + // Patch EVERY coexisting gem store copy and every PyPI // site-packages copy (the npm multi-copy precedent): leaving // the other copy pristine is a silent false "applied" for @@ -1949,9 +2031,8 @@ async fn apply_patches_inner( // global scope, #501), and rollback already restores every // copy. A PyPI path that only ALIASES another (a symlinked // site-packages) is collapsed by canonical path, so one - // install is never patched twice. Maven keeps the - // one-representative contract: its crawler resolves one - // install dir per version. + // install is never patched twice. Maven never reaches here: + // `apply_maven_base` above patches its every consumed copy. let pypi_copies: Vec; let copy_paths: &[PathBuf] = match Ecosystem::from_purl(purl) { Some(Ecosystem::Gem) => pkg_paths.as_slice(), @@ -2286,6 +2367,504 @@ async fn apply_patches_inner( }) } +/// One Maven base purl for [`apply_maven_base`]. +struct MavenBase<'a> { + args: &'a ApplyArgs, + manifest: &'a PatchManifest, + base_purl: &'a str, + /// The manifest's (qualified) purls of this base. + variants: &'a [String], + /// Every installed copy the resolver found. + pkg_paths: &'a [PathBuf], + scope: &'a JvmScope, + sources: &'a PatchSources<'a>, + policy: MismatchPolicy, + service: Option<&'a socket_patch_core::vendor::VendorServiceConfig>, + socket_dir: &'a Path, + /// The run's derived-cache walks, one per Gradle user home. + derived: &'a socket_patch_core::patch::jvm_jar::DerivedCache, +} + +/// What [`apply_maven_base`] reports back to the apply loop. +#[derive(Default)] +struct MavenApplied { + results: Vec, + /// Variants that reached apply (or a refusal naming them). + matched: Vec, + warnings: Vec, + /// The run fails (exit 1). + failed: bool, + /// Some copy ended patched. + applied: bool, +} + +impl MavenApplied { + /// A refusal of `purl` with nothing written: a Failed event carrying + /// `code` and a run warning with the same code. + fn refuse(&mut self, purl: &str, path: &Path, code: &str, detail: String) { + self.results.push(ApplyResult { + package_key: purl.to_string(), + package_path: path.display().to_string(), + success: false, + files_verified: Vec::new(), + files_patched: Vec::new(), + applied_via: HashMap::new(), + error: Some(format!("{code}: {detail}")), + sidecar: None, + }); + self.warn(code, detail); + self.failed = true; + } + + fn warn(&mut self, code: &str, detail: String) { + self.warnings.push(RunWarning { + code: code.to_string(), + detail, + }); + } + + /// Record one copy's result: printed when it failed, a Windows + /// daemon lock surfaced as its own code. + fn record(&mut self, args: &ApplyArgs, result: ApplyResult) { + warn_mismatch_overwrites(&result, &args.common); + if let Some(advisory) = result + .sidecar + .as_ref() + .and_then(|s| s.advisory.as_ref()) + .filter(|a| a.code == SidecarAdvisoryCode::GradleJarLockedByDaemon) + { + self.warn("gradle_jar_locked_by_daemon", advisory.message.clone()); + } + if result.success { + self.applied = true; + } else { + self.failed = true; + if args.prints_errors() { + eprintln!( + "{}", + format_patch_failure( + &result.package_key, + result.error.as_deref().unwrap_or("unknown error") + ) + ); + } + } + self.results.push(result); + } +} + +/// Apply one Maven base purl's manifest variants to every installed copy a +/// build consumes (#551): each `~/.m2` version dir and each Gradle +/// `files-2.1` version dir, the latter expanded into the hash directories +/// holding the record's files (`gradle_cache::installed_copies`). A +/// member-keyed record swaps the whole jar instead (`jvm_jar`). +/// +/// Gradle guards, each with its own run-warning code: +/// +/// * `gradle_verification_metadata_present` — the build verifies its +/// dependencies (`gradle/verification-metadata.xml`), which rewritten +/// cache bytes would fail or, with key-only trust, slip past: every +/// variant is refused, nothing written. +/// * `gradle_build_ignores_m2` — the only copy is in `~/.m2`, which this +/// Gradle-only build never reads: nothing applied, exit 1. +/// * `gradle_ro_cache_shadows` — a copy sits in the read-only cache, +/// which is never written and which Gradle may read first: the writable +/// copies are patched, the run still fails. +/// * `gradle_copy_unexpected_bytes` — a hash dir's file is the pristine +/// download (its sha1 names the dir) but not the bytes the record was +/// made for, or a hash dir holds files no release variant matches: that +/// copy is left alone and the run fails (the build still loads it). +/// * `gradle_transform_copy_stale` — after the write, Gradle still holds a +/// copy derived from the pristine jar (`caches/transforms-*`, `jars-*`): +/// that copy's result fails until it is cleared. +async fn apply_maven_base(m: &MavenBase<'_>) -> MavenApplied { + use socket_patch_core::crawlers::gradle_cache::{self, is_gradle_version_dir}; + use socket_patch_core::patch::jvm_jar::{self, JarSwap, RecordShape}; + + let args = m.args; + let mut out = MavenApplied::default(); + let variants: Vec<&String> = m + .variants + .iter() + .filter(|v| m.manifest.patches.contains_key(*v)) + .collect(); + let copies = m.scope.split(m.pkg_paths); + + if let Some(metadata) = &m.scope.verification_metadata { + for variant in &variants { + out.refuse( + variant, + metadata, + "gradle_verification_metadata_present", + format!( + "{}: {} turns on Gradle dependency verification, which checks the bytes \ + agent mode would rewrite in the Gradle cache; nothing was written. Use \ + `--mode vendored` or `--mode hosted` for this build.", + normalize_purl(variant), + metadata.display() + ), + ); + out.matched.push((*variant).clone()); + } + return out; + } + + if !copies.read_only.is_empty() { + let list: Vec = copies + .read_only + .iter() + .map(|p| p.display().to_string()) + .collect(); + out.warn( + "gradle_ro_cache_shadows", + format!( + "{}: the read-only Gradle cache holds a copy ({}) that socket-patch never \ + writes and Gradle may resolve before the patched user-home copy; rebuild \ + the read-only cache from a patched user home.", + normalize_purl(m.base_purl), + list.join(", ") + ), + ); + out.failed = true; + } + if copies.consumed.is_empty() { + if copies.read_only.is_empty() { + if let Some(m2) = copies.m2_ignored.first() { + out.refuse( + m.base_purl, + m2, + "gradle_build_ignores_m2", + format!( + "{}: the only installed copy is in the Maven local repository ({}), \ + which this Gradle build never reads (no mavenLocal()); nothing was \ + patched. Run the build once so Gradle caches the artifact, then apply \ + again.", + normalize_purl(m.base_purl), + m2.display() + ), + ); + } + } + out.matched.extend(variants.iter().map(|v| (*v).clone())); + return out; + } + + // A Gradle-only build that reads `~/.m2` (mavenLocal() declared or + // undetermined) but has no Gradle cache copy: the m2 copy is patched, + // yet Gradle takes a module from the FIRST declared repository that + // has it, so when another repository comes before mavenLocal() the + // next build downloads the pristine jar instead. Gradle never caches + // a mavenLocal() artifact in files-2.1, so this is also exactly what a + // build reading the module from mavenLocal() looks like: warn, not + // refuse. `vex` re-hashes the Gradle cache copy that build makes. + if matches!( + m.scope.gate, + Some(socket_patch_core::crawlers::maven_crawler::M2Gate::Declared(_)) + | Some(socket_patch_core::crawlers::maven_crawler::M2Gate::Undetermined(_)) + ) && copies.consumed.iter().all(|c| !is_gradle_version_dir(c)) + { + out.warn( + "gradle_m2_may_be_unconsumed", + format!( + "{}: the only patched copy is in the Maven local repository ({}). This Gradle build reads it only when no repository declared before mavenLocal() has the module; otherwise its next build downloads the unpatched jar. Run the build once and apply again so the Gradle cache copy is patched too.", + normalize_purl(m.base_purl), + copies + .consumed + .iter() + .map(|p| p.display().to_string()) + .collect::>() + .join(", ") + ), + ); + } + + let multi = variants.len() > 1 || variants.first().is_some_and(|v| **v != m.base_purl); + let gate_variants = !args.force && multi; + let mut attempted = false; + // Gradle hash dirs holding some variant's files, with those variants, + // and the ones some variant was attempted on: a dir held but never + // attempted holds bytes no variant was made for. + let mut held: BTreeMap> = BTreeMap::new(); + let mut hit: HashSet = HashSet::new(); + for variant in &variants { + let patch = &m.manifest.patches[*variant]; + if let RecordShape::Members { jar_leaf } = jvm_jar::classify(variant, &patch.files) { + // Every consumed copy's jar in ONE swap: one download, one + // backup, and a failed write puts every copy already swapped + // back — `~/.m2` and the Gradle cache alike. + let mut dirs = Vec::new(); + for copy in &copies.consumed { + for dir in jvm_jar::jar_copies(copy, &jar_leaf) { + if maven_sidecars::is_gradle_hash_dir(&dir) { + held.entry(dir.clone()) + .or_default() + .push((*variant).clone()); + } + if gate_variants + && !matches!( + jvm_jar::verify_members(&dir, &jar_leaf, &patch.files).await, + VerifyStatus::Ready | VerifyStatus::AlreadyPatched + ) + { + continue; + } + hit.insert(dir.clone()); + dirs.push(dir); + } + } + if dirs.is_empty() { + continue; + } + attempted = true; + out.matched.push((*variant).clone()); + let swap = JarSwap { + purl: variant, + uuid: &patch.uuid, + jar_leaf: &jar_leaf, + files: &patch.files, + socket_dir: m.socket_dir, + dry_run: args.common.dry_run, + }; + match Box::pin(jvm_jar::apply_jar_swap(&swap, &dirs, m.service)).await { + Err(refusal) => out.refuse(variant, &dirs[0], refusal.code, refusal.message), + Ok(results) => { + for mut result in results { + check_derived_copies(&mut out, &mut result, &jar_leaf, args, m.derived) + .await; + out.record(args, result); + } + } + } + continue; + } + + for copy in &copies.consumed { + // Leaf record: the hash dirs holding its files (the copy itself + // for `~/.m2`). A Gradle copy holding NONE of the record's + // files is not an install of it. One holding only some of them + // is: its held files are patched, and the keys no hash dir + // holds are applied against the version dir, where they are + // not found and fail the copy as they would on `~/.m2` (the + // build still loads the held jar, so a silent skip would leave + // it unpatched behind a clean exit). + let (targets, absent) = if is_gradle_version_dir(copy) { + let detailed = gradle_cache::installed_copies_detailed(copy, &patch.files); + if detailed.targets.is_empty() { + continue; + } + for (dir, _) in &detailed.targets { + held.entry(dir.clone()) + .or_default() + .push((*variant).clone()); + } + let absent: HashMap = detailed + .missing + .iter() + .filter_map(|k| patch.files.get(k).map(|info| (k.clone(), info.clone()))) + .collect(); + (detailed.targets, absent) + } else { + (vec![(copy.clone(), patch.files.clone())], HashMap::new()) + }; + let mut copy_attempted = false; + for (dir, files) in targets { + if gate_variants { + let status = match representative_file(&files) { + Some((name, info)) => { + Some(verify_file_patch(&dir, name, info).await.status) + } + None => None, + }; + if !variant_matches_installed(status.as_ref()) { + continue; + } + } + hit.insert(dir.clone()); + out.matched.push((*variant).clone()); + copy_attempted = true; + if let Some(detail) = unexpected_gradle_bytes(&dir, &files).await { + out.refuse(variant, &dir, "gradle_copy_unexpected_bytes", detail); + continue; + } + attempted = true; + let mut result = apply_package_patch( + variant, + &dir, + &files, + m.sources, + Some(&patch.uuid), + args.common.dry_run, + m.policy, + ) + .await; + for leaf in files.keys().filter(|k| k.ends_with(".jar")) { + check_derived_copies(&mut out, &mut result, leaf, args, m.derived).await; + } + out.record(args, result); + } + // The record's keys this Gradle copy lacks, once the variant + // was attempted on its held files: not found there (a failure + // under the default and strict policies, a skip under + // `--force`), as on a `~/.m2` copy missing them. + if copy_attempted && !absent.is_empty() { + attempted = true; + let result = apply_package_patch( + variant, + copy, + &absent, + m.sources, + Some(&patch.uuid), + args.common.dry_run, + m.policy, + ) + .await; + out.record(args, result); + } + } + } + // A Gradle hash dir that holds a variant's files but whose bytes no + // variant was made for: the build loads it unpatched. + for (dir, holders) in held { + if hit.contains(&dir) { + continue; + } + let mut holders = holders; + holders.sort(); + holders.dedup(); + out.matched.extend(holders.iter().cloned()); + out.refuse( + &holders[0], + &dir, + "gradle_copy_unexpected_bytes", + format!( + "{}: the Gradle cache copy {} holds bytes none of the manifest's variants ({}) \ + was made for; it was left unpatched.", + normalize_purl(m.base_purl), + dir.display(), + holders + .iter() + .map(|v| normalize_purl(v)) + .collect::>() + .join(", ") + ), + ); + } + out.matched.sort(); + out.matched.dedup(); + // Nothing attempted. Gradle version dirs that hold none of a record's + // files (a pom-only entry, another classifier) are not installs of it: + // the variants stay unmatched (`package_not_installed`). A `~/.m2` copy + // no variant matches is a different distribution: an error, as before. + let gradle_only = copies.consumed.iter().all(|c| is_gradle_version_dir(c)); + if !attempted && !out.failed && !gradle_only { + out.failed = true; + if args.prints_errors() { + eprintln!( + "{}", + format_patch_failure(m.base_purl, "no matching variant found") + ); + } + } + out +} + +/// `gradle_copy_unexpected_bytes`: a hash dir's file IS the pristine +/// download (its sha1 names the dir) but hashes to neither side of the +/// record — the record was made for other bytes. `None` when every file +/// is expected (or the dir is not a Gradle hash dir). +async fn unexpected_gradle_bytes( + dir: &Path, + files: &HashMap, +) -> Option { + use socket_patch_core::crawlers::gradle_cache::pristine; + use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; + if !maven_sidecars::is_gradle_hash_dir(dir) { + return None; + } + let hash_dir = dir.file_name()?.to_str()?; + for (leaf, info) in files { + let Ok(bytes) = socket_patch_core::utils::fs::read_regular_to_bytes(&dir.join(leaf)).await + else { + continue; + }; + let git = compute_git_sha256_from_bytes(&bytes); + if pristine(hash_dir, &bytes) && git != info.before_hash && git != info.after_hash { + return Some(format!( + "{}: the Gradle cache's pristine download is not the file this patch was made \ + for (its hash matches neither side of the patch); this copy was left \ + unpatched.", + dir.join(leaf).display() + )); + } + } + None +} + +/// After a Gradle hash dir's jar `jar_leaf` ends patched: the copies Gradle +/// derived from the PRISTINE jar (`caches/transforms-*`, `jars-*`, +/// instrumented jars) still serve the old bytes. Any proven one fails the +/// copy's result (`gradle_transform_copy_stale`); a same-named copy whose +/// bytes are neither the pristine nor the patched jar, or a walk cut short, +/// is reported unverified (`gradle_transform_copy_unverified`). Skipped on +/// a dry run and for any other directory. +async fn check_derived_copies( + out: &mut MavenApplied, + result: &mut ApplyResult, + jar_leaf: &str, + args: &ApplyArgs, + derived: &socket_patch_core::patch::jvm_jar::DerivedCache, +) { + let dir = PathBuf::from(&result.package_path); + let leaf = jar_leaf.trim_start_matches("package/").to_string(); + if args.common.dry_run || !result.success || !maven_sidecars::is_gradle_hash_dir(&dir) { + return; + } + let derived = derived.clone(); + let probe = move || socket_patch_core::patch::jvm_jar::derived_copies_in(&derived, &dir, &leaf); + let Some(verdict) = tokio::task::spawn_blocking(probe).await.ok().flatten() else { + return; + }; + let (stale, unknown, incomplete) = (verdict.stale, verdict.unverified, verdict.incomplete); + let list = |paths: &[PathBuf]| { + paths + .iter() + .map(|p| p.display().to_string()) + .collect::>() + .join(", ") + }; + if !stale.is_empty() { + let detail = format!( + "{}: Gradle keeps copies derived from the unpatched jar that builds may still \ + load ({}); run `gradle --stop`, delete those directories, and apply again.", + normalize_purl(&result.package_key), + list(&stale) + ); + result.success = false; + result.error = Some(format!("gradle_transform_copy_stale: {detail}")); + out.warn("gradle_transform_copy_stale", detail); + } + if !unknown.is_empty() || incomplete { + out.warn( + "gradle_transform_copy_unverified", + format!( + "{}: Gradle keeps copies derived from this jar that could not be matched to \ + the patched bytes{}{}; until they are cleared they may serve the old code.", + normalize_purl(&result.package_key), + if unknown.is_empty() { + String::new() + } else { + format!(" ({})", list(&unknown)) + }, + if incomplete { + " (the cache walk was incomplete)" + } else { + "" + } + ), + ); + } +} + /// The `package_not_installed` detail of a lockfile-resolved purl. const LOCKFILE_ONLY_DETAIL: &str = "Resolved by the project lockfile but not installed on this host (lockfile-only)"; @@ -2839,6 +3418,43 @@ mod tests { ); } + /// #646 review: a Gradle copy is a `files-2.1` version dir whose files + /// sit one level down in `/` hash dirs. A drifted jar there (not + /// pristine, not the record's beforeHash) must queue its afterHash + /// blob as a drifted `~/.m2` copy does: the default Warn policy + /// overwrites it with the full blob. + #[tokio::test] + async fn mismatch_blob_gaps_probes_gradle_hash_dirs() { + let dir = tempfile::tempdir().unwrap(); + let version = dir + .path() + .join(".gradle/caches/modules-2/files-2.1/com.example/victim/1.0"); + let hash = version.join("0123456789abcdef0123456789abcdef01234567"); + tokio::fs::create_dir_all(&hash).await.unwrap(); + tokio::fs::write(hash.join("victim-1.0.jar"), b"older patch bytes") + .await + .unwrap(); + let blobs = dir.path().join("blobs"); + tokio::fs::create_dir_all(&blobs).await.unwrap(); + let mut files = HashMap::new(); + files.insert( + "package/victim-1.0.jar".to_string(), + PatchFileInfo { + before_hash: "4".repeat(64), + after_hash: "5".repeat(64), + }, + ); + let manifest = manifest_with_record("pkg:maven/com.example/victim@1.0", files); + let mut all_packages = HashMap::new(); + all_packages.insert( + "pkg:maven/com.example/victim@1.0".to_string(), + vec![version.clone()], + ); + let needed = + mismatch_blob_gaps(&manifest, &all_packages, &HashSet::new(), &blobs, false).await; + assert_eq!(needed, HashSet::from(["5".repeat(64)])); + } + /// A QUALIFIED singleton (`?platform=`…) keeps the /// installed-distribution gate — it names one specific distribution, /// and the apply loop skips it when the representative file @@ -3427,4 +4043,48 @@ mod tests { assert!(is_stage_failure_code(&dl.code)); assert!(!is_stage_failure_code("gem_config_path_ignored")); } + + /// A FIFO squatting a patched leaf in a Gradle hash dir must not wedge + /// `unexpected_gradle_bytes`: a bare `tokio::fs::read` open(2)s it with + /// `O_RDONLY` and waits for a writer forever. The FIFO-safe reader + /// rejects it, so the leaf is skipped and the check returns promptly. + #[cfg(unix)] + #[tokio::test] + async fn unexpected_gradle_bytes_skips_fifo_instead_of_wedging() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp + .path() + .join("caches/modules-2/files-2.1/org.example/lib/1.0") + .join("a".repeat(40)); + std::fs::create_dir_all(&dir).unwrap(); + assert!(maven_sidecars::is_gradle_hash_dir(&dir)); + let leaf = "lib-1.0.jar"; + let c = std::ffi::CString::new(dir.join(leaf).to_str().unwrap()).unwrap(); + assert_eq!(unsafe { libc::mkfifo(c.as_ptr(), 0o600) }, 0); + let mut files = HashMap::new(); + files.insert( + leaf.to_string(), + PatchFileInfo { + before_hash: "1".repeat(64), + after_hash: "2".repeat(64), + }, + ); + let result = tokio::time::timeout( + std::time::Duration::from_secs(10), + unexpected_gradle_bytes(&dir, &files), + ) + .await; + if result.is_err() { + // Unblock a reader stuck in open(2) so the runtime can exit. + // O_NONBLOCK: with no reader waiting, a blocking write-open would + // itself wedge the suite instead of failing it. + use std::os::unix::fs::OpenOptionsExt; + let _ = std::fs::OpenOptions::new() + .write(true) + .custom_flags(libc::O_NONBLOCK) + .open(dir.join(leaf)); + panic!("unexpected_gradle_bytes must not wedge on a FIFO leaf"); + } + assert_eq!(result.unwrap(), None); + } } diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index 7c4708208..ecb1954bb 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -16,7 +16,7 @@ use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; pub(crate) use socket_patch_core::manifest::records::record_from_patch_response; use socket_patch_core::manifest::records::{build_patch_record, files_for_manifest}; use socket_patch_core::manifest::schema::{PatchFileInfo, PatchManifest, PatchRecord}; -use socket_patch_core::patch::apply::{is_valid_blob_hash, select_installed_variants}; +use socket_patch_core::patch::apply::{is_valid_blob_hash, select_installed_variants_any}; use socket_patch_core::patch::apply_lock::{LockError, LockGuard}; use socket_patch_core::telemetry::{track_patch_fetch_failed, track_patch_fetched}; use socket_patch_core::utils::concurrent::{api_concurrency_for, ordered_concurrent}; @@ -39,7 +39,8 @@ use crate::commands::vlt_preflight::{ vlt_refusal_for, vlt_vendor_preflight_selected, VltVendorRefusal, }; use crate::ecosystem_dispatch::{ - crawl_all_ecosystems, find_packages_for_rollback, partition_purls, + crawl_all_ecosystems, find_all_packages_for_rollback, find_packages_for_rollback, + partition_purls, }; use crate::ui::{print_json, select_one, SelectError}; @@ -1271,7 +1272,10 @@ async fn filter_to_installed_releases( // Release-variant PURLs only (PyPI / RubyGems / Maven); partition_purls // splits them by ecosystem, so no filter is needed. let partitioned = partition_purls(&all_qualified, None); - let paths = find_packages_for_rollback(&partitioned, crawler_options, true).await; + // Every copy: a Maven base can sit in `~/.m2` and in each Gradle cache, + // with different classifiers in each (narrowing takes a variant any copy + // holds); the other ecosystems narrow on their first copy, as before. + let paths = find_all_packages_for_rollback(&partitioned, crawler_options, true).await; // Every installed base's variant views, fetched concurrently (at most // `api_concurrency` in flight) in the order the loop below consumes @@ -1294,10 +1298,20 @@ async fn filter_to_installed_releases( )); for (base, variants) in multi { - // Any variant's resolved path works — they all map to the same - // installed package directory. - let pkg_path = variants.iter().find_map(|s| paths.get(&s.purl)).cloned(); - let Some(pkg_path) = pkg_path else { + // Any variant's resolved paths work — they all map to the same + // installed package directories. + let pkg_paths = variants + .iter() + .find_map(|s| paths.get(&s.purl)) + .filter(|p| !p.is_empty()) + .map(|p| { + if base.starts_with("pkg:maven/") { + p.clone() + } else { + p[..1].to_vec() + } + }); + let Some(pkg_paths) = pkg_paths else { // Not installed: cannot determine the relevant release. Keep // every variant so the patch is still obtainable. warnings.push(format!( @@ -1347,7 +1361,7 @@ async fn filter_to_installed_releases( // Keep every variant present on disk. PyPI/RubyGems install one // distribution per env (≤1 match); Maven classifier jars coexist // so several may match. - let matched = select_installed_variants(&pkg_path, &refs).await; + let matched = select_installed_variants_any(&pkg_paths, &refs).await; if matched.is_empty() { // Installed, but no variant matches the on-disk bytes. Fall // back to broad rather than silently dropping a package the diff --git a/crates/socket-patch-cli/src/commands/remove.rs b/crates/socket-patch-cli/src/commands/remove.rs index 0d4be4bb2..77333a1bd 100644 --- a/crates/socket-patch-cli/src/commands/remove.rs +++ b/crates/socket-patch-cli/src/commands/remove.rs @@ -17,9 +17,9 @@ use super::rollback::{ pin_before_hash_blobs, rollback_patches_inner, run_hosted_leg, sweep_failure, sweep_unused_artifacts, HostedLegOutcome, InnerSelection, }; -use crate::commands::vendored_backend::{RevertedEntry, VendorRevertStep, VendoredBackend}; use crate::args::{apply_env_toggles, GlobalArgs}; use crate::commands::lock_cli::acquire_or_emit; +use crate::commands::vendored_backend::{RevertedEntry, VendorRevertStep, VendoredBackend}; use crate::json_envelope::{Command, Envelope, EnvelopeError, PatchAction, PatchEvent, Status}; use crate::ui::plural; @@ -581,6 +581,7 @@ pub async fn run(args: RemoveArgs) -> i32 { // warning event rides the envelope. Empty under `--skip-rollback` // (no rollback ran, so nothing is known — semantics unchanged). let mut rollback_not_installed: Vec = Vec::new(); + let mut rollback_warnings: Vec<(String, String)> = Vec::new(); // Whether something was printed after the header listing, so the // manifest result below gets a separating blank line (and only then). let mut printed_progress = false; @@ -609,6 +610,7 @@ pub async fn run(args: RemoveArgs) -> i32 { { Ok(outcome) => { rollback_not_installed = outcome.not_installed; + rollback_warnings = outcome.warnings; if !outcome.success { track_patch_remove_failed( "Rollback failed during patch removal", @@ -652,6 +654,7 @@ pub async fn run(args: RemoveArgs) -> i32 { .iter() .filter(|r| r.success && !r.files_rolled_back.is_empty()) .count(); + print_hosted_leg_warnings(&args.common, &rollback_warnings); if loud { // Vendor-owned targets say nothing here: the vendored @@ -1056,12 +1059,15 @@ pub async fn run(args: RemoveArgs) -> i32 { env.record(ev); } env.warnings - .extend(hosted_leg_warnings.iter().map(|(code, detail)| { - crate::json_envelope::RunWarning { - code: code.clone(), - detail: detail.clone(), - } - })); + .extend( + rollback_warnings + .iter() + .chain(&hosted_leg_warnings) + .map(|(code, detail)| crate::json_envelope::RunWarning { + code: code.clone(), + detail: detail.clone(), + }), + ); // One Removed event per purl whose manifest entry was deleted // (Verified on --dry-run). for purl in &removed { diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index 5f4191038..d11154b1a 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -10,13 +10,13 @@ use socket_patch_core::manifest::operations::{ }; use socket_patch_core::manifest::schema::{PatchFileInfo, PatchManifest, PatchRecord}; use socket_patch_core::patch::apply::select_installed_variants; +use socket_patch_core::patch::redirect::upstream::HostedPin; use socket_patch_core::patch::rollback::{ cannot_rollback_error, rollback_package_patch, verify_file_rollback, RollbackResult, VerifyRollbackResult, VerifyRollbackStatus, }; use socket_patch_core::telemetry::{track_patch_rollback_failed, track_patch_rolled_back}; use socket_patch_core::utils::purl::{patch_matches, strip_purl_qualifiers}; -use socket_patch_core::patch::redirect::upstream::HostedPin; use socket_patch_core::vendor::{purl_keys_cover, RevertOpts, VendorState}; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; @@ -26,7 +26,7 @@ use crate::args::{apply_env_toggles, parse_bool_flag, GlobalArgs}; use crate::commands::apply::is_local_go; use crate::commands::lock_cli::acquire_or_emit; use crate::commands::vendored_backend::{RevertedEntry, VendorRevertStep, VendoredBackend}; -use crate::ecosystem_dispatch::{find_all_packages_for_rollback, partition_purls}; +use crate::ecosystem_dispatch::{find_all_packages_for_rollback, partition_purls, JvmScope}; use crate::json_envelope::Command as EnvelopeCommand; use crate::looks_like_uuid; use crate::ui::{plural, StatusLine}; @@ -490,6 +490,9 @@ pub(crate) struct RollbackOutcome { /// was restored, so nothing is removable and the GC must not sweep /// the revert data the retry needs. pub(crate) aborted: bool, + /// Run warnings `(code, detail)` for copies left alone without failing + /// the run (today: `gradle_m2_copy_not_restored`). + pub(crate) warnings: Vec<(String, String)>, } /// How `rollback_patches_inner` selects manifest entries. @@ -1026,7 +1029,8 @@ pub(crate) async fn run_hosted_leg(common: &GlobalArgs, pins: &[HostedPin]) -> H .iter() .map(|(code, detail)| (code.to_string(), detail.clone())), ); - out.edited_files.extend(outcome.reverted_files.iter().cloned()); + out.edited_files + .extend(outcome.reverted_files.iter().cloned()); let unwound: Vec<_> = vlt_targets .into_iter() .filter(|t| out.reverted.iter().any(|p| p == &t.purl)) @@ -1170,7 +1174,11 @@ pub async fn run(args: RollbackArgs) -> i32 { } else if !args.common.silent { println!( "{} the pre-v5 hosted ledger {}: no lockfile pins a hosted patch.", - if args.common.dry_run { "Would remove" } else { "Removed" }, + if args.common.dry_run { + "Would remove" + } else { + "Removed" + }, socket_patch_core::patch::redirect::REDIRECT_STATE_REL ); } @@ -1534,7 +1542,11 @@ pub async fn run(args: RollbackArgs) -> i32 { not_installed, narrowed_out, aborted, + warnings: agent_warnings, }) => { + // Copies left alone without failing the run (an unconsumed + // `~/.m2` copy: `gradle_m2_copy_not_restored`). + run_warnings.extend(agent_warnings); // ── vendored leg ───────────────────────────────────────────── // The in-scope ledger entries: unwire the lockfiles and (by // default) delete the artifacts + drop the entries. @@ -2149,6 +2161,7 @@ pub(crate) async fn rollback_patches_inner( not_installed: Vec::new(), narrowed_out: Vec::new(), aborted: false, + warnings: Vec::new(), }); } @@ -2175,6 +2188,7 @@ pub(crate) async fn rollback_patches_inner( not_installed: Vec::new(), narrowed_out: Vec::new(), aborted: false, + warnings: Vec::new(), }); } @@ -2281,10 +2295,48 @@ pub(crate) async fn rollback_patches_inner( // so they are pushed straight to `rollback_targets`. Only the // release-variant ecosystems (whose multiple qualified PURLs share ONE // install dir) go through the group + narrow path. - let mut rollback_targets: Vec<(&String, &PathBuf)> = Vec::new(); + let mut rollback_targets: Vec = Vec::new(); let mut groups: HashMap> = HashMap::new(); + // Maven: grouped by (base purl, copy) — `~/.m2` and each Gradle cache + // are distinct installs whose variants and state differ per copy. + let jvm_scope = if partitioned.contains_key(&Ecosystem::Maven) { + Some(JvmScope::of(common).await) + } else { + None + }; + let mut maven_groups: Vec<((String, PathBuf), Vec<&String>, bool)> = Vec::new(); for (purl, pkg_paths) in &all_packages_multi { - if Ecosystem::from_purl(purl).is_some_and(|e| e.supports_release_variants()) { + if let Some(scope) = jvm_scope + .as_ref() + .filter(|_| Ecosystem::from_purl(purl) == Some(Ecosystem::Maven)) + { + // Every writable copy: the read-only cache is never written, + // but a `~/.m2` copy this Gradle-only build no longer reads + // (`m2_ignored`) is still restored. An earlier apply wrote it + // (before the gate existed, or while `mavenLocal()` was + // declared); skipping it would leave the shared jar patched + // with no record to restore it from once `remove` drops the + // entry. Only bytes that verify as this record's afterHash + // are ever put back, and a Maven build that wants the patch + // re-applies it from its own manifest. Such a copy never + // decides the run's outcome, though (`CopyTarget::unconsumed_m2`): + // the build does not read it, so one another build re-patched + // or rebuilt, or whose backup lives in that other project, is + // left with a warning instead of failing this rollback/remove. + let copies = scope.split(pkg_paths); + let tagged = copies + .consumed + .iter() + .map(|p| (p, false)) + .chain(copies.m2_ignored.iter().map(|p| (p, true))); + for (pkg_path, unconsumed) in tagged { + let key = (strip_purl_qualifiers(purl).to_string(), pkg_path.clone()); + match maven_groups.iter_mut().find(|(k, _, _)| *k == key) { + Some((_, purls, _)) => purls.push(purl), + None => maven_groups.push((key, vec![purl], unconsumed)), + } + } + } else if Ecosystem::from_purl(purl).is_some_and(|e| e.supports_release_variants()) { for pkg_path in pkg_paths { groups .entry(strip_purl_qualifiers(purl).to_string()) @@ -2293,7 +2345,7 @@ pub(crate) async fn rollback_patches_inner( } } else { for pkg_path in pkg_paths { - rollback_targets.push((purl, pkg_path)); + rollback_targets.push(CopyTarget::plain(purl, pkg_path)); } } } @@ -2345,8 +2397,16 @@ pub(crate) async fn rollback_patches_inner( .collect() } }; - rollback_targets.extend(to_rollback); + rollback_targets.extend( + to_rollback + .into_iter() + .map(|(purl, path)| CopyTarget::plain(purl, path)), + ); } + let (maven_targets, maven_narrowed) = + maven_rollback_targets(&maven_groups, &filtered_manifest).await; + rollback_targets.extend(maven_targets); + narrowed_out.extend(maven_narrowed); narrowed_out.sort(); narrowed_out.dedup(); @@ -2365,7 +2425,7 @@ pub(crate) async fn rollback_patches_inner( // their rollback just drops the project-local redirect + copy and // reads no blobs, so a missing before-blob must not block an // offline redirect rollback. - let attempted_purls: HashSet<&str> = rollback_targets.iter().map(|(p, _)| p.as_str()).collect(); + let attempted_purls: HashSet<&str> = rollback_targets.iter().map(|t| t.purl.as_str()).collect(); let gate_manifest = before_blob_gate_manifest(&scoped_manifest, &attempted_purls, common); // Apply's `unmatched` twin: in-scope manifest entries the crawler found @@ -2441,6 +2501,15 @@ pub(crate) async fn rollback_patches_inner( .get(purl) .expect("gate manifest holds only attempted targets, which the crawler discovered") .clone(); + // Maven copies are probed where they are restored: the hash + // dirs (and `~/.m2` dirs) of the expanded targets. + if purl.starts_with("pkg:maven/") { + pkg_paths = rollback_targets + .iter() + .filter(|t| t.purl == *purl && t.jar_leaf.is_none()) + .map(|t| t.dir.clone()) + .collect(); + } // The engine also restores every pnpm/vlt store peer variant of // an npm copy, so each of those is a copy that may need a blob. if purl.starts_with("pkg:npm/") { @@ -2463,7 +2532,8 @@ pub(crate) async fn rollback_patches_inner( continue; } for pkg_path in &pkg_paths { - let v = verify_file_rollback(pkg_path, file, info, &blobs_path).await; + let file = maven_target_key(purl, pkg_path, file); + let v = verify_file_rollback(pkg_path, &file, info, &blobs_path).await; if v.status == VerifyRollbackStatus::MissingBlob { missing_blobs.insert(info.before_hash.clone()); blob_gated_purls.insert(purl.clone()); @@ -2517,6 +2587,7 @@ pub(crate) async fn rollback_patches_inner( not_installed, narrowed_out: Vec::new(), aborted: true, + warnings: Vec::new(), }); } @@ -2596,6 +2667,7 @@ pub(crate) async fn rollback_patches_inner( not_installed, narrowed_out: Vec::new(), aborted: true, + warnings: Vec::new(), }); } } @@ -2615,30 +2687,55 @@ pub(crate) async fn rollback_patches_inner( not_installed, narrowed_out: narrowed_out.clone(), aborted: false, + warnings: Vec::new(), }); } // Rollback patches let mut results: Vec = Vec::new(); let mut has_errors = false; + let mut warnings: Vec<(String, String)> = Vec::new(); - for (purl, pkg_path) in rollback_targets { + for target in &rollback_targets { + let (purl, pkg_path) = (&target.purl, &target.dir); let patch = match filtered_manifest.patches.get(purl) { Some(p) => p, None => continue, }; - // Local go drops the project-local `replace`-redirect; everything - // else — npm/pypi/gem and cargo (vendored or registry cache) — - // restores in place from before-blobs. - let result = match try_rollback_local_go(purl, pkg_path, patch, common).await { - Some(r) => r, - None => { - rollback_package_patch(purl, pkg_path, &patch.files, &blobs_path, common.dry_run) + // Local go drops the project-local `replace`-redirect; Maven + // restores each expanded copy (`rollback_maven_target`); + // everything else — npm/pypi/gem and cargo (vendored or registry + // cache) — restores in place from before-blobs. + let result = if purl.starts_with("pkg:maven/") { + Box::pin(rollback_maven_target( + target, + patch, + &blobs_path, + socket_dir, + common, + )) + .await + } else { + match try_rollback_local_go(purl, pkg_path, patch, common).await { + Some(r) => r, + None => { + rollback_package_patch( + purl, + pkg_path, + &patch.files, + &blobs_path, + common.dry_run, + ) .await + } } }; + if let Some(warning) = unconsumed_m2_skip(target, &result) { + warnings.push(warning); + continue; + } if !result.success { has_errors = true; // Under --silent (the summary muted) this line is the run's @@ -2693,9 +2790,335 @@ pub(crate) async fn rollback_patches_inner( not_installed, narrowed_out, aborted: false, + warnings, + }) +} + +/// One copy `rollback_patches_inner` restores. +#[derive(Debug, Clone)] +struct CopyTarget { + purl: String, + dir: PathBuf, + /// Maven: the record's files as joined onto `dir` (a Gradle hash dir's + /// keys are bare file names). `None`: the manifest record's files. + files: Option>, + /// Maven member-keyed record: the jar under `dir` to restore whole. + jar_leaf: Option, + /// A `~/.m2` copy this Gradle-only build never reads + /// (`JvmScope::split`'s `m2_ignored`). Restored when it holds this + /// record's patched bytes, but a copy that verifies as neither side + /// or has no backup here is left with a `gradle_m2_copy_not_restored` + /// warning (`unconsumed_m2_skip`), never a failure. + unconsumed_m2: bool, +} + +impl CopyTarget { + fn plain(purl: &str, dir: &Path) -> Self { + Self { + purl: purl.to_string(), + dir: dir.to_path_buf(), + files: None, + jar_leaf: None, + unconsumed_m2: false, + } + } +} + +/// The run warning that replaces a failed restore of an unconsumed +/// `~/.m2` copy ([`CopyTarget::unconsumed_m2`]), when it failed before +/// writing anything because the copy holds bytes that are neither side of +/// this record (another build re-patched it, or `mvn install` rebuilt it), +/// lacks a file, or is a swapped jar whose original this project never +/// backed up. `None` for any other result, which is reported as usual — +/// including a file that is there but cannot be read or stat'd, which may +/// still hold this record's patched bytes. +fn unconsumed_m2_skip(target: &CopyTarget, result: &RollbackResult) -> Option<(String, String)> { + if !target.unconsumed_m2 || result.success || !result.files_rolled_back.is_empty() { + return None; + } + // A file that is there but could not be read or stat'd (EACCES, EISDIR, + // ELOOP…) may still hold this record's patched bytes: leaving it would + // let `remove` drop the record and its before-blobs with the shared + // copy still patched, so it fails the run like any unverifiable copy. + if result + .files_verified + .iter() + .any(|v| v.status == VerifyRollbackStatus::NotFound && !v.is_absent()) + { + return None; + } + let refused = result + .files_verified + .iter() + .any(|v| v.status == VerifyRollbackStatus::HashMismatch || v.is_absent()) + || result + .error + .as_deref() + .is_some_and(|e| e.starts_with("jvm_jar_backup_missing")); + refused.then(|| { + ( + "gradle_m2_copy_not_restored".to_string(), + format!( + "{}: left the ~/.m2 copy at {} as it is; this Gradle-only build does not \ + read it ({})", + target.purl, + target.dir.display(), + result.error.as_deref().unwrap_or("it cannot be restored") + ), + ) }) } +/// The key `file` of a Maven record as it is joined onto `dir`: a Gradle +/// hash dir holds the bare file name (`package/` dropped). +fn maven_target_key(purl: &str, dir: &Path, file: &str) -> String { + if purl.starts_with("pkg:maven/") + && socket_patch_core::patch::sidecars::maven::is_gradle_hash_dir(dir) + { + file.trim_start_matches("package/").to_string() + } else { + file.to_string() + } +} + +/// Maven rollback targets, per `(base purl, copy)` group: the variants the +/// copy holds (`select_installed_variants`, which expands a Gradle version +/// dir through `installed_copies`), each expanded into the hash dirs +/// holding its files — or, for a member-keyed record, into every copy of +/// its jar. A copy holding none of a group's variants' files is not an +/// install of them and is skipped; one that holds files no variant +/// matches attempts every variant, so verification reports the mismatch. +/// Returns the targets and the variants no copy kept (narrowed out). +async fn maven_rollback_targets( + groups: &[((String, PathBuf), Vec<&String>, bool)], + manifest: &PatchManifest, +) -> (Vec, Vec) { + use socket_patch_core::crawlers::gradle_cache::{ + installed_copies_detailed, is_gradle_version_dir, + }; + use socket_patch_core::patch::jvm_jar::{self, RecordShape}; + + let mut targets = Vec::new(); + let mut considered: HashSet = HashSet::new(); + let mut kept: HashSet = HashSet::new(); + for ((_, copy), purls, unconsumed_m2) in groups { + let unconsumed_m2 = *unconsumed_m2; + let candidates: Vec<(&str, &HashMap)> = purls + .iter() + .filter_map(|purl| { + manifest + .patches + .get(*purl) + .map(|p| (purl.as_str(), &p.files)) + }) + .collect(); + considered.extend(candidates.iter().map(|(p, _)| p.to_string())); + let mut expanded: Vec<(String, Vec)> = Vec::new(); + for (purl, files) in &candidates { + let mut out = Vec::new(); + match jvm_jar::classify(purl, files) { + RecordShape::Members { jar_leaf } => { + for dir in jvm_jar::jar_copies(copy, &jar_leaf) { + out.push(CopyTarget { + purl: purl.to_string(), + dir, + files: None, + jar_leaf: Some(jar_leaf.clone()), + unconsumed_m2, + }); + } + } + RecordShape::Leaf if is_gradle_version_dir(copy) => { + for (dir, files) in installed_copies_detailed(copy, files).targets { + out.push(CopyTarget { + purl: purl.to_string(), + dir, + files: Some(files), + jar_leaf: None, + unconsumed_m2, + }); + } + } + RecordShape::Leaf => { + let present = files + .keys() + .any(|k| copy.join(k.trim_start_matches("package/")).exists()); + if present { + out.push(CopyTarget { + unconsumed_m2, + ..CopyTarget::plain(purl, copy) + }); + } + } + } + if !out.is_empty() { + expanded.push((purl.to_string(), out)); + } + } + if expanded.is_empty() { + continue; + } + let winners: HashSet = if candidates.len() == 1 { + expanded.iter().map(|(p, _)| p.clone()).collect() + } else { + let matched = select_installed_variants(copy, &candidates).await; + if matched.is_empty() { + expanded.iter().map(|(p, _)| p.clone()).collect() + } else { + matched + .iter() + .map(|&i| candidates[i].0.to_string()) + .collect() + } + }; + for (purl, out) in expanded { + if winners.contains(&purl) { + kept.insert(purl); + targets.extend(out); + } + } + } + let mut narrowed: Vec = considered.difference(&kept).cloned().collect(); + narrowed.sort(); + (targets, narrowed) +} + +/// Roll back one Maven target: a member-keyed record restores its whole +/// jar (`jvm_jar::rollback_jar_swap`); a leaf record restores its files +/// from before-blobs, `~/.m2` checksum files put back to the restored bytes. +/// A restored Gradle hash-dir file must hash to its directory's name (the +/// sha1 Gradle verified when it downloaded it): a before-blob that does not +/// is refused with `gradle_rollback_hash_mismatch` before anything is +/// written, so the patched file is left as it is. +async fn rollback_maven_target( + target: &CopyTarget, + patch: &PatchRecord, + blobs_path: &Path, + socket_dir: &Path, + common: &GlobalArgs, +) -> RollbackResult { + use socket_patch_core::crawlers::gradle_cache::pristine; + use socket_patch_core::patch::jvm_jar::{rollback_jar_swap, JarRestore}; + use socket_patch_core::patch::sidecars::{maven as maven_sidecars, SidecarRecord}; + + if let Some(jar_leaf) = &target.jar_leaf { + let restore = JarRestore { + purl: &target.purl, + jar_leaf, + files: &patch.files, + socket_dir, + dry_run: common.dry_run, + offline: common.offline, + }; + return rollback_jar_swap(&restore, std::slice::from_ref(&target.dir)) + .await + .into_iter() + .next() + .expect("one result per copy"); + } + let files = target.files.as_ref().unwrap_or(&patch.files); + let gradle = maven_sidecars::is_gradle_hash_dir(&target.dir); + let keys: Vec = files.keys().cloned().collect(); + let pre = if gradle || common.dry_run { + None + } else { + Some(maven_sidecars::snapshot(&target.dir, &keys).await) + }; + let hash = target + .dir + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_default(); + if gradle { + // Check the before-blobs before anything is written, so a refusal + // really leaves the patched file in place (and a re-run refuses + // again); read-only, so a dry run predicts it too. A blob that is + // missing, not a regular file or named by an invalid hash is left + // to `rollback_package_patch`, whose verify step refuses it without + // reading through it. + for (file, info) in files { + if !socket_patch_core::patch::apply::is_valid_blob_hash(&info.before_hash) { + continue; + } + let blob = blobs_path.join(&info.before_hash); + if !tokio::fs::symlink_metadata(&blob) + .await + .is_ok_and(|m| m.is_file()) + { + continue; + } + let Ok(bytes) = socket_patch_core::utils::fs::read_regular_to_bytes(&blob).await else { + continue; + }; + if !pristine(hash, &bytes) { + let path = target.dir.join(file.trim_start_matches("package/")); + return RollbackResult { + package_key: target.purl.clone(), + package_path: target.dir.display().to_string(), + success: false, + files_verified: Vec::new(), + files_rolled_back: Vec::new(), + error: Some(format!( + "gradle_rollback_hash_mismatch: the before-blob for {} does not hash \ + to its Gradle cache directory (it is not the bytes Gradle \ + downloaded); left as it is — delete {} and let Gradle download it \ + again.", + path.display(), + target.dir.display() + )), + sidecar: None, + }; + } + } + } + let mut result = + rollback_package_patch(&target.purl, &target.dir, files, blobs_path, common.dry_run).await; + if !result.success || common.dry_run { + return result; + } + if let Some(pre) = pre.filter(|p| !p.is_empty()) { + result.sidecar = Some(match maven_sidecars::resync(&target.dir, &pre).await { + Ok(files) => SidecarRecord { + purl: target.purl.clone(), + ecosystem: "maven".to_string(), + files, + advisory: None, + }, + Err(e) => SidecarRecord { + purl: target.purl.clone(), + ecosystem: "maven".to_string(), + files: Vec::new(), + advisory: Some(socket_patch_core::patch::sidecars::SidecarAdvisory { + code: + socket_patch_core::patch::sidecars::SidecarAdvisoryCode::SidecarFixupFailed, + severity: socket_patch_core::patch::sidecars::SidecarSeverity::Error, + message: format!("sidecar resync failed (rollback still applied): {e}"), + }), + }, + }); + } + if gradle { + for file in &result.files_rolled_back { + let path = target.dir.join(file.trim_start_matches("package/")); + let Ok(bytes) = socket_patch_core::utils::fs::read_regular_to_bytes(&path).await else { + continue; + }; + if !pristine(hash, &bytes) { + result.success = false; + result.error = Some(format!( + "gradle_rollback_hash_mismatch: {} does not hash to its Gradle cache \ + directory after the restore (the before-blob is not the bytes Gradle \ + downloaded) — delete {} and let Gradle download it again.", + path.display(), + target.dir.display() + )); + break; + } + } + } + result +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/socket-patch-cli/src/commands/scan/discovery.rs b/crates/socket-patch-cli/src/commands/scan/discovery.rs index b83f63990..f8e6b467c 100644 --- a/crates/socket-patch-cli/src/commands/scan/discovery.rs +++ b/crates/socket-patch-cli/src/commands/scan/discovery.rs @@ -269,6 +269,7 @@ pub(super) async fn preverify_vendor_baselines( vendor: Option<&HashMap>, status: &mut crate::ui::StatusLine, ) -> (HashSet, HashMap) { + use socket_patch_core::crawlers::gradle_cache; use socket_patch_core::manifest::schema::PatchFileInfo; use socket_patch_core::patch::apply::{verify_file_patch, VerifyStatus}; use socket_patch_core::vendor::lookup_entry; @@ -372,13 +373,19 @@ pub(super) async fn preverify_vendor_baselines( files } }; - for (file, info) in &files { - if info.before_hash.is_empty() { - continue; // a new file has no baseline to compare - } - if verify_file_patch(&pkg.path, file, info).await.status == VerifyStatus::HashMismatch { - mismatched.insert(patch.uuid.clone()); - break; + // A new file has no baseline to compare. + let files: HashMap = files + .into_iter() + .filter(|(_, info)| !info.before_hash.is_empty()) + .collect(); + // A Gradle version dir stands for every hash-dir copy of its files; + // any copy off the baseline is a mismatch. + 'copies: for (dir, files) in gradle_cache::installed_copies(&pkg.path, &files) { + for (file, info) in &files { + if verify_file_patch(&dir, file, info).await.status == VerifyStatus::HashMismatch { + mismatched.insert(patch.uuid.clone()); + break 'copies; + } } } } @@ -1828,6 +1835,63 @@ mod tests { assert!(views.is_empty(), "a 404'd view must not be cached"); } + /// A Gradle version dir is checked through every hash-dir copy of the + /// patched file: one copy off the baseline flags the patch, where the + /// version dir itself (no files of its own) would only be NotFound. + #[tokio::test] + async fn preverify_checks_every_gradle_hash_dir_copy() { + use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; + + let leaf = "commons-text-1.10.0.jar"; + let before = compute_git_sha256_from_bytes(b"pristine jar"); + let mock = wiremock::MockServer::start().await; + mount_patch_view( + &mock, + "u-gradle", + serde_json::json!({ + leaf: { "beforeHash": before, "afterHash": "c".repeat(64) }, + }), + ) + .await; + let client = api_client_for(&mock.uri()); + + let tmp = tempfile::tempdir().unwrap(); + let version_dir = tmp + .path() + .join("caches/modules-2/files-2.1/org.apache.commons/commons-text/1.10.0"); + for (hash, bytes) in [("0a1b", &b"pristine jar"[..]), ("ffee", b"other bytes")] { + std::fs::create_dir_all(version_dir.join(hash)).unwrap(); + std::fs::write(version_dir.join(hash).join(leaf), bytes).unwrap(); + } + let purl = "pkg:maven/org.apache.commons/commons-text@1.10.0"; + let crawled = vec![crawled_pkg("commons-text", purl, version_dir.clone())]; + let selected = vec![search_result("u-gradle", purl)]; + let run = |crawled: Vec| { + let (client, selected) = (&client, &selected); + async move { + preverify_vendor_baselines( + client, + selected, + &crawled, + &HashSet::new(), + None, + &mut crate::ui::StatusLine::new(Vec::new(), false, false, 80), + ) + .await + .0 + } + }; + assert_eq!( + run(crawled).await, + HashSet::from(["u-gradle".to_string()]), + "the off-baseline hash-dir copy flags the patch" + ); + + std::fs::write(version_dir.join("ffee").join(leaf), b"pristine jar").unwrap(); + let crawled = vec![crawled_pkg("commons-text", purl, version_dir)]; + assert!(run(crawled).await.is_empty(), "every copy on the baseline"); + } + #[tokio::test] async fn preverify_new_file_skip_is_per_file_not_per_patch() { // One patch, two files: a baseline-less new file AND a real diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 97e6866ce..0864fcd6e 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -1042,6 +1042,12 @@ pub(crate) async fn run_redirect_selected( if let Some(refusal) = engine::guard(&view, &done, &candidates) { return refuse(common, scan_result.take(), &refusal); } + // Defense in depth for the Gradle planner: a settings file it plans to + // CREATE (it never read one) must not already be on disk, or the + // atomic write would replace the user's settings with the apply line. + if let Some(refusal) = created_settings_over_existing(&common.cwd, &done) { + return refuse(common, scan_result.take(), &refusal); + } if !common.dry_run { let total = confirmed.len(); @@ -1579,6 +1585,8 @@ async fn vendored_takeover( // for those locks even though the rewriters never see these purls. let mut dry_run_locks: std::collections::HashMap> = std::collections::HashMap::new(); + // Maven takes over only a Gradle build's vendored JVM entry (its revert + // unplans the vendored Gradle wiring); a pom-only vendored entry stays. // PyPI: every Python rewriter (requirements.txt, Poetry, Pipenv, uv, // Hatch, PDM, pylock) refuses a non-registry source as user-authored, // including the vendored one socket-patch wrote itself, so a vendored @@ -1588,6 +1596,15 @@ async fn vendored_takeover( || p.starts_with("pkg:npm/") || p.starts_with("pkg:golang/") || p.starts_with("pkg:pypi/") + || p.starts_with("pkg:maven/") + }; + let gradle_jvm_entry = |entry: &socket_patch_core::vendor::VendorEntry| { + entry.ecosystem == "jvm" + && entry.wiring.iter().any(|w| { + w.file.ends_with(".gradle") + || w.file.ends_with(".gradle.kts") + || w.file == socket_patch_core::vendor::jvm::gradle::INDEX_REL + }) }; if !candidates.iter().any(|c| takeover_capable(&c.purl)) { // No takeover-capable candidates — nothing to reconcile. @@ -1612,7 +1629,13 @@ async fn vendored_takeover( .cloned(); (c, entry) }) + .filter(|(c, entry)| { + !c.purl.starts_with("pkg:maven/") || entry.as_ref().is_some_and(gradle_jvm_entry) + }) .collect(); + if takeover.is_empty() { + return Ok(out); + } // Compatibility must be known before the takeover removes a live // patch. In particular, a v0 workspace can keep an existing local // tuple even though hosted mode cannot replace it with a URL. Only @@ -1713,15 +1736,50 @@ async fn vendored_takeover( } else { None }; + // Gradle twin: the hosted Gradle planner refuses builds and grants the + // vendored backend accepts (a custom `lockFile`, a settings-classpath + // GA, a same-GAV or incomplete grant, ...). Each refusal must be known + // before the revert strips the live vendored patch, or the planner + // then writes nothing and the build resolves the unpatched upstream. + // Every Gradle JVM takeover purl is checked against the build on disk. + let gradle_takeover_refusals: std::collections::HashMap< + String, + socket_patch_core::patch::redirect::RewriteWarning, + > = if takeover.iter().any(|(c, entry)| { + c.purl.starts_with("pkg:maven/") && entry.as_ref().is_some_and(gradle_jvm_entry) + }) { + let build = + socket_patch_core::patch::redirect::gradle::read_build_from_disk(&common.cwd).await; + takeover + .iter() + .filter(|(c, entry)| { + c.purl.starts_with("pkg:maven/") && entry.as_ref().is_some_and(gradle_jvm_entry) + }) + .filter_map(|(c, _)| { + socket_patch_core::patch::redirect::gradle::takeover_refusal( + &build.files, + &build.unreadable, + &c.dep, + ) + .map(|w| (c.purl.clone(), w)) + }) + .collect() + } else { + std::collections::HashMap::new() + }; // The takeover refusal (if any) for one candidate: bun gates every - // npm purl, berry and vlt only their own vendored entries. Berry also - // runs the rewriter's per-dep grant gate (a grant without the berry - // cache checksum is skipped by the rewriter, so reverting first would - // leave the package in neither mode). A refused purl is never - // dispatched (see the loop), so its wiring is not a write target here. + // npm purl, berry and vlt only their own vendored entries, Gradle each + // of its own purls. Berry also runs the rewriter's per-dep grant gate (a + // grant without the berry cache checksum is skipped by the rewriter, so + // reverting first would leave the package in neither mode). A refused + // purl is never dispatched (see the loop), so its wiring is not a write + // target here. let takeover_refusal = |c: &Candidate, entry: Option<&socket_patch_core::vendor::VendorEntry>| -> Option { + if c.purl.starts_with("pkg:maven/") { + return gradle_takeover_refusals.get(&c.purl).cloned(); + } if !c.purl.starts_with("pkg:npm/") { return None; } @@ -2397,6 +2455,31 @@ pub(crate) fn npm_allow_remote_one_line(detail: &str) -> String { } } +/// The refusal for a Gradle settings file the hosted rewrite writes +/// without having read it (the planner took it for absent and creates it) +/// while one is on disk: writing it would replace the user's settings. +fn created_settings_over_existing( + cwd: &std::path::Path, + done: &socket_patch_core::hosted::engine::Rewritten, +) -> Option { + done.rewrite + .files + .keys() + .filter(|rel| { + let base = rel.rsplit('/').next().unwrap_or(rel); + matches!(base, "settings.gradle" | "settings.gradle.kts") + && !done.files.contains_key(rel.as_str()) + }) + .find(|rel| std::fs::symlink_metadata(cwd.join(rel)).is_ok()) + .map(|rel| socket_patch_core::hosted::engine::Refusal { + code: socket_patch_core::patch::redirect::gradle::UNREADABLE_REFUSAL_CODE.to_string(), + message: format!( + "{rel} exists but could not be read, so the hosted Gradle wiring would replace \ + it; make it a readable UTF-8 file and re-run; nothing was written" + ), + }) +} + #[cfg(test)] mod tests { use super::{ @@ -3851,6 +3934,13 @@ mod tests { "settings.gradle.kts", "build.gradle", "build.gradle.kts", + "gradle.lockfile", + "buildscript-gradle.lockfile", + "settings-gradle.lockfile", + "gradle/verification-metadata.xml", + "gradle/wrapper/gradle-wrapper.properties", + ".socket/gradle/hosted-index.tsv", + ".socket/gradle/socket-patch.hosted.settings.gradle", ] ); } diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index 8ce80d9f1..bfdf54394 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -1072,7 +1072,13 @@ fn layout_refusal_json(refusals: &[(String, String)]) -> serde_json::Value { serde_json::Value::Array( refusals .iter() - .map(|(code, detail)| serde_json::json!({ "code": code, "detail": detail })) + .map(|(code, detail)| { + let mut entry = serde_json::json!({ "code": code, "detail": detail }); + if let Some(level) = warning_level(code) { + entry["level"] = serde_json::json!(level); + } + entry + }) .collect(), ) } @@ -1118,6 +1124,188 @@ pub(super) const API_BATCH_FAILED: &str = "api_batch_failed"; /// prefix. (Every query failing is the discovery error envelope instead.) pub(super) const PATCH_DETAILS_FAILED: &str = "patch_details_failed"; +/// Run-level warning: a Gradle-only build that never declares +/// `mavenLocal()` locks (or has patch records for) module(s) that only the +/// Maven local repository holds. The build does not resolve from `~/.m2`, +/// so the scan leaves those copies out (#551). +pub(super) const GRADLE_BUILD_IGNORES_M2: &str = "gradle_build_ignores_m2"; + +/// Run-level advisory: the Maven local repository stays a scan root of a +/// Gradle build because `mavenLocal()` could not be ruled out (a script or +/// init script that could not be read literally). +pub(super) const GRADLE_MAVEN_LOCAL_UNDETERMINED: &str = "gradle_maven_local_undetermined"; + +/// Run-level advisory: Gradle takes its user home from the account's passwd +/// entry, which differs from `$HOME`, so its cache is not under +/// `$HOME/.gradle`. +pub(super) const GRADLE_USER_HOME_DIFFERS: &str = "gradle_user_home_differs"; + +/// What a scan learned about the Gradle side of discovery. +#[derive(Default)] +struct GradleScan { + /// `(code, detail)` run-level warnings. + notes: Vec<(String, String)>, + /// Base purls (normalized) of the packages crawled from a Gradle cache. + gradle_purls: HashSet, + /// Base purls the build's lock files name; `None` when they were not + /// read (no Gradle build at the cwd, or no Gradle-cached package to + /// annotate), so no `inLock` is reported. + locked: Option>, +} + +/// The level of a run-level warning code: `info` for the Gradle advisories +/// that need no action, `warn` for the Gradle warning, `None` (no `level` +/// field, printed as a warning) for every other code. +fn warning_level(code: &str) -> Option<&'static str> { + match code { + GRADLE_MAVEN_LOCAL_UNDETERMINED | GRADLE_USER_HOME_DIFFERS => Some("info"), + GRADLE_BUILD_IGNORES_M2 => Some("warn"), + _ => None, + } +} + +/// Print the run-level warnings to stderr: advisories as `Note:` (not +/// under `--silent`), everything else as `Warning:`. +fn print_layout_refusals(refusals: &[(String, String)], silent: bool) { + for (code, detail) in refusals { + if warning_level(code) == Some("info") { + if !silent { + eprintln!("Note: {detail}"); + } + } else { + eprintln!("Warning: {detail}"); + } + } +} + +/// The Gradle discovery notes and the lock-membership annotation for a +/// scan. `crawled` are the packages this run covers, `scanned` every purl +/// the crawl found, `manifest` the recorded patches. Locks only annotate: +/// they never filter what the scan reports. +async fn gradle_scan( + common: &GlobalArgs, + crawled: &[socket_patch_core::crawlers::CrawledPackage], + scanned: &HashSet, + manifest: Option<&PatchManifest>, +) -> GradleScan { + use socket_patch_core::crawlers::gradle_cache; + use socket_patch_core::crawlers::maven_crawler::{m2_gate, JvmEnv, M2Gate}; + + let mut out = GradleScan { + gradle_purls: crawled + .iter() + .filter(|p| gradle_cache::is_gradle_version_dir(&p.path)) + .map(|p| normalize_purl(strip_purl_qualifiers(&p.purl)).into_owned()) + .collect(), + ..GradleScan::default() + }; + if !common.ecosystem_selected(Ecosystem::Maven) { + return out; + } + let cwd = common.cwd.clone(); + let global = common.is_global(); + // An explicit cache root makes the user home Gradle would pick moot. + let prefixed = common.global_prefix.is_some(); + let manifest_gavs: Vec = manifest + .map(|m| { + m.patches + .keys() + .filter(|k| k.starts_with("pkg:maven/")) + .map(|k| normalize_purl(strip_purl_qualifiers(k)).into_owned()) + .collect() + }) + .unwrap_or_default(); + let want_locks = !out.gradle_purls.is_empty(); + let Ok((gate, locked, mismatch, env)) = tokio::task::spawn_blocking(move || { + let gradle_build = gradle_cache::has_gradle_marker(&cwd); + let env = JvmEnv::from_process(); + let gate = (!global && gradle_build).then(|| m2_gate(&cwd, &env)); + // The cwd's build locks annotate Gradle-cached packages in a global + // run too; without a Gradle build at the cwd there is nothing to + // say, so no annotation at all. + let locked: Option> = + (gradle_build && (want_locks || gate == Some(M2Gate::Ignored))).then(|| { + gradle_cache::locked_gavs(&cwd) + .into_iter() + .map(|(g, a, v)| format!("pkg:maven/{g}/{a}@{v}")) + .collect() + }); + let mismatch = (!prefixed && (global || gradle_build)) + .then(gradle_cache::home_mismatch) + .flatten(); + (gate, locked, mismatch, env) + }) + .await + else { + return out; + }; + + match gate { + Some(M2Gate::Undetermined(why)) => out.notes.push(( + GRADLE_MAVEN_LOCAL_UNDETERMINED.to_string(), + format!( + "the Maven local repository is scanned for this Gradle build because \ + mavenLocal() could not be ruled out ({why})" + ), + )), + Some(M2Gate::Ignored) => { + let mut candidates: Vec = locked + .iter() + .flatten() + .cloned() + .chain(manifest_gavs) + .filter(|p| !scanned.contains(p)) + .collect(); + candidates.sort(); + candidates.dedup(); + let only_m2: Vec = if candidates.is_empty() { + Vec::new() + } else { + let mut found: Vec = socket_patch_core::crawlers::MavenCrawler + .find_by_purls(&env.m2_repo, &candidates) + .await + .unwrap_or_default() + .into_keys() + .collect(); + found.sort(); + found + }; + if !only_m2.is_empty() { + const SHOWN: usize = 5; + let mut list = only_m2[..only_m2.len().min(SHOWN)].join(", "); + if only_m2.len() > SHOWN { + list.push_str(&format!(" and {} more", only_m2.len() - SHOWN)); + } + out.notes.push(( + GRADLE_BUILD_IGNORES_M2.to_string(), + format!( + "this Gradle build declares no mavenLocal(), so it does not resolve \ + from the Maven local repository ({}); {} found only there {} not \ + scanned: {list}", + env.m2_repo.display(), + plural(only_m2.len(), "module", "modules"), + if only_m2.len() == 1 { "is" } else { "are" }, + ), + )); + } + } + _ => {} + } + if let Some((home, passwd)) = mismatch { + out.notes.push(( + GRADLE_USER_HOME_DIFFERS.to_string(), + format!( + "Gradle's user home follows the account's home directory {} (not $HOME={}); \ + set GRADLE_USER_HOME to scan another Gradle cache", + passwd.display(), + home.display() + ), + )); + } + out.locked = want_locks.then_some(locked).flatten(); + out +} + /// The scanned purls whose HOSTED redirect wiring is still live: a hosted /// pin names the purl (`redirect_state`, the lockfiles' hosted state — see /// [`crate::commands::hosted_state_from_lockfiles`]) AND lockfile discovery @@ -1767,15 +1955,24 @@ async fn run_scan( .filter(|pkg| policy.admit_crawled(&pkg.purl)) .collect(); + // Gradle discovery notes (m2 gating, the user home) ride the run-level + // warnings; the lock set only annotates `packages[]` below. + let gradle = gradle_scan( + &args.common, + &filtered_crawled, + &scanned_purls, + update_manifest.as_deref(), + ) + .await; + layout_refusals.extend(gradle.notes.iter().cloned()); + let all_purls: Vec = filtered_crawled.iter().map(|p| p.purl.clone()).collect(); let package_count = all_purls.len(); if package_count == 0 { status.finish(); if human { - for (_, detail) in &layout_refusals { - eprintln!("Warning: {detail}"); - } + print_layout_refusals(&layout_refusals, args.common.silent); policy.print_warnings(args.common.silent); // Hosted mode already printed its own prune-ignored warning. if prune && !hosted && unwired_vendored.is_empty() { @@ -1922,9 +2119,7 @@ async fn run_scan( if !lockfile_only.purls.is_empty() { eprintln!("{}", render::lockfile_only_note(lockfile_only.purls.len())); } - for (_, detail) in &layout_refusals { - eprintln!("Warning: {detail}"); - } + print_layout_refusals(&layout_refusals, args.common.silent); policy.print_warnings(args.common.silent); } @@ -2178,6 +2373,17 @@ async fn run_scan( if is_lockfile_only { pkg["notInstalled"] = serde_json::json!(true); } + // Gradle-cached packages: whether the build's lock files + // name them (additive; an annotation, never a filter). + if let Some(base) = pkg["purl"] + .as_str() + .map(|p| normalize_purl(strip_purl_qualifiers(p)).into_owned()) + .filter(|base| gradle.gradle_purls.contains(base)) + { + if let Some(locked) = &gradle.locked { + pkg["inLock"] = serde_json::json!(locked.contains(&base)); + } + } } } diff --git a/crates/socket-patch-cli/src/commands/scan/policy.rs b/crates/socket-patch-cli/src/commands/scan/policy.rs index 0cd466bf5..98b1ecc45 100644 --- a/crates/socket-patch-cli/src/commands/scan/policy.rs +++ b/crates/socket-patch-cli/src/commands/scan/policy.rs @@ -90,6 +90,7 @@ pub(crate) fn dir_markers(dir: &Path) -> Vec { // No lockfile: the manifests say what the project is. markers = MANIFEST_MARKERS .iter() + .chain(socket_patch_core::crawlers::jvm_cache::JVM_PROJECT_MARKERS) .filter(|name| dir.join(name).is_file()) .map(|name| name.to_string()) .collect(); @@ -98,16 +99,15 @@ pub(crate) fn dir_markers(dir: &Path) -> Vec { markers } -/// Manifests that stand in as markers for a root with no lockfile. -const MANIFEST_MARKERS: [&str; 8] = [ +/// Manifests that stand in as markers for a root with no lockfile (plus +/// every JVM build file, `jvm_cache::JVM_PROJECT_MARKERS`). +const MANIFEST_MARKERS: [&str; 6] = [ "package.json", "pyproject.toml", "setup.py", "Cargo.toml", "composer.json", "Gemfile", - "pom.xml", - "build.gradle", ]; #[derive(Default)] diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 59be95b85..e6dc4e2ed 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -1015,9 +1015,10 @@ fn hosted_pins_in_scope(common: &GlobalArgs, pins: Vec) -> Vec std::io::Result> { @@ -1068,6 +1078,21 @@ impl EjectSnapshot { let mut rels: std::collections::BTreeSet = root_files.clone(); rels.extend(touched.iter().cloned()); rels.extend(Self::EXTRA.iter().map(|s| s.to_string())); + // A Gradle pin's restore also rewrites (or deletes) files below + // the root: every build's settings file and every build's lock + // files. The same files are where the vendored wiring goes. + if tokio::fs::symlink_metadata( + root.join(socket_patch_core::patch::redirect::gradle::HOSTED_INDEX_REL), + ) + .await + .is_ok() + { + let build = + socket_patch_core::patch::redirect::gradle::read_build_from_disk(root).await; + rels.extend(socket_patch_core::patch::redirect::gradle::wiring_files( + &build.files, + )); + } let mut files = Vec::with_capacity(rels.len()); for rel in rels { let bytes = match tokio::fs::read(root.join(&rel)).await { @@ -1090,7 +1115,13 @@ impl EjectSnapshot { for (rel, bytes) in &self.files { let path = self.root.join(rel); let result = match bytes { + // The upstream restore may have removed the file's + // directory with it (the hosted Gradle files under + // `.socket/gradle/`). Some(bytes) => { + if let Some(parent) = path.parent() { + let _ = tokio::fs::create_dir_all(parent).await; + } socket_patch_core::utils::fs::atomic_write_bytes_preserving_mode(&path, bytes) .await } diff --git a/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs b/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs index 20de36386..268540822 100644 --- a/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs +++ b/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs @@ -478,6 +478,22 @@ impl VendoredBackend<'_> { continue; } } + // Likewise a JVM entry's derived metadata and owned + // `.gitattributes`: rewritten when missing, offline. + if vendor::jvm::apply::is_jvm_entry(&entry) && !common.dry_run { + if let Err(e) = + vendor::redownload::restore_jvm_owned_files(&common.cwd, &entry).await + { + fail( + env, + common.json, + purl, + "vendor_artifact_unrepairable", + format!("cannot restore the vendored Gradle files: {e}"), + ); + continue; + } + } // Dir-shaped gem artifacts from pre-inventory vendors: // the health check could only verify the PATCHED members // — unpatched-file drift is invisible until a re-vendor diff --git a/crates/socket-patch-cli/src/commands/vex.rs b/crates/socket-patch-cli/src/commands/vex.rs index 43eff8991..97a0f6a48 100644 --- a/crates/socket-patch-cli/src/commands/vex.rs +++ b/crates/socket-patch-cli/src/commands/vex.rs @@ -32,7 +32,7 @@ use crate::commands::vex_sources::{ self, Plan, Sources, RECORD_MISMATCH, RECORD_UNAVAILABLE, REDIRECT_UNWIRED, VENDOR_UNWIRED, WIRING_CONFLICT, }; -use crate::ecosystem_dispatch::find_manifest_package_copies_reusing; +use crate::ecosystem_dispatch::{find_manifest_package_copies_reusing, JvmScope}; use crate::json_envelope::{Command, Envelope, EnvelopeError, PatchAction, PatchEvent, RunWarning}; use crate::ui::plural; @@ -556,6 +556,11 @@ async fn generate_vex( let copies = find_manifest_package_copies_reusing(&purls, common, quiet, params.npm_prior.as_ref()) .await; + // The record check: every copy of each purl; for Maven, every copy + // a build consumes (`~/.m2` unless the Gradle build never reads it, + // each Gradle cache, the read-only cache), re-hashed + // (`vex_copy_sets`). + let package_paths = vex_copy_sets(common, manifest, &copies).await; let go_patches = synthesize_go_patches(common, manifest, &plan.vendor_entries).await; // Hosted-basis purls are judged by the copies their build CONSUMES // (the Go replacement module, the Socket registry's cargo src dir, @@ -575,9 +580,12 @@ async fn generate_vex( go_patches, hosted, }; - let mut outcome = - socket_patch_core::vex::applied_patches_with_copies(manifest, &copies, Some(&vendor)) - .await; + let mut outcome = socket_patch_core::vex::applied_patches_with_copies( + manifest, + &package_paths, + Some(&vendor), + ) + .await; // Hosted lockfile basis: a DISCOVERED Socket-host reference whose // lock pins the artifact attests from that wiring when no installed // tree exists yet (a lockfile-only CI checkout) — the evidence the @@ -609,6 +617,15 @@ async fn generate_vex( !excused }); outcome.applied.extend(lockfile_attested); + withhold_unpatched_jvm_copies( + &mut outcome, + manifest, + &package_paths, + &plan.hosted, + common, + warnings, + ) + .await; outcome }; @@ -788,6 +805,173 @@ async fn generate_vex( }) } +/// The installed copies VEX judges per purl: every copy, except for a +/// Maven purl, whose copies are every one a build consumes ([`JvmScope`]): +/// `~/.m2` (unless this Gradle-only build never reads it — `mavenLocal()` +/// declared or undetermined keeps it), each Gradle cache and the read-only +/// cache, all re-hashed at VEX time. A Gradle version dir holding none of +/// the record's files (a pom-only entry, another classifier) is not an +/// install of it and is dropped, as apply does. One holding only some of +/// them is kept: the keys it lacks verify as not found, so the statement +/// is withheld while the build loads the held (unpatched) jar. +async fn vex_copy_sets( + common: &GlobalArgs, + manifest: &PatchManifest, + copies: &HashMap>, +) -> HashMap> { + use socket_patch_core::crawlers::gradle_cache::{ + installed_copies_detailed, is_gradle_version_dir, + }; + use socket_patch_core::patch::jvm_jar::{self, RecordShape}; + let holds = |purl: &str, path: &PathBuf| { + let Some(record) = manifest.patches.get(purl) else { + return true; + }; + if !is_gradle_version_dir(path) { + return true; + } + match jvm_jar::classify(purl, &record.files) { + RecordShape::Members { jar_leaf } => !jvm_jar::jar_copies(path, &jar_leaf).is_empty(), + RecordShape::Leaf => !installed_copies_detailed(path, &record.files) + .targets + .is_empty(), + } + }; + let maven = copies.keys().any(|p| p.starts_with("pkg:maven/")); + let scope = if maven { + Some(JvmScope::of(common).await) + } else { + None + }; + copies + .iter() + .map(|(purl, paths)| { + let paths = match scope.as_ref().filter(|_| purl.starts_with("pkg:maven/")) { + Some(scope) => { + let split = scope.split(paths); + split + .consumed + .into_iter() + .chain(split.read_only) + .filter(|p| holds(purl, p)) + .collect() + } + None => paths.clone(), + }; + (purl.clone(), paths) + }) + .collect() +} + +/// Maven / Gradle: withhold the statement of every purl a build may still +/// load unpatched, naming the copy (`vex_gradle_unpatched_copy`): a copy +/// the record check found unpatched while others are patched, and — for +/// an attested purl — any copy Gradle derived from the pristine jar outside +/// `files-2.1` (`caches/transforms-*`, `jars-*`) or one older than the +/// patched jar that cannot be matched to it. A derived-cache walk cut +/// short is a warning (`vex_gradle_derived_cache_unchecked`), not a reason +/// to withhold. +async fn withhold_unpatched_jvm_copies( + outcome: &mut VerifyOutcome, + manifest: &PatchManifest, + copies: &HashMap>, + hosted: &std::collections::BTreeMap, + common: &GlobalArgs, + warnings: &mut Vec, +) { + use socket_patch_core::crawlers::gradle_cache::is_gradle_version_dir; + use socket_patch_core::patch::jvm_jar::{self, RecordShape}; + + let mut unpatched = std::mem::take(&mut outcome.unpatched_copies); + unpatched.retain(|(purl, path)| { + is_gradle_version_dir(path) || copies.get(purl).is_some_and(|c| c.len() > 1) + }); + let mut derived: Vec<(String, PathBuf)> = Vec::new(); + let mut unchecked: Vec = Vec::new(); + let cache = jvm_jar::DerivedCache::default(); + for purl in &outcome.applied { + let (Some(record), Some(paths)) = (manifest.patches.get(purl), copies.get(purl)) else { + continue; + }; + if !purl.starts_with("pkg:maven/") || hosted.contains_key(purl) { + continue; + } + let leaves: Vec = match jvm_jar::classify(purl, &record.files) { + RecordShape::Members { jar_leaf } => vec![jar_leaf], + RecordShape::Leaf => record + .files + .keys() + .map(|k| k.trim_start_matches("package/").to_string()) + .filter(|k| k.ends_with(".jar")) + .collect(), + }; + for path in paths.iter().filter(|p| is_gradle_version_dir(p)) { + for leaf in &leaves { + for dir in jvm_jar::jar_copies(path, leaf) { + let (leaf, cache) = (leaf.clone(), cache.clone()); + let check = move || jvm_jar::derived_copies_in(&cache, &dir, &leaf); + let Some(verdict) = tokio::task::spawn_blocking(check).await.ok().flatten() + else { + continue; + }; + derived.extend( + verdict + .stale + .into_iter() + .chain(verdict.unverified) + .map(|p| (purl.clone(), p)), + ); + if verdict.incomplete { + unchecked.push(purl.clone()); + } + } + } + } + } + let withheld: std::collections::BTreeSet = + derived.iter().map(|(purl, _)| purl.clone()).collect(); + outcome.applied.retain(|purl| !withheld.contains(purl)); + outcome + .failed + .extend(withheld.iter().map(|purl| FailedPatch { + purl: purl.clone(), + reason: "gradle_unpatched_copy".to_string(), + })); + // A walk cut short (a huge Android/Kotlin transforms cache) proves + // nothing either way: said, not withheld — every copy it did reach was + // judged above. + unchecked.sort(); + unchecked.dedup(); + for purl in unchecked { + note_warning( + warnings, + common, + "vex_gradle_derived_cache_unchecked", + format!( + "{purl}: Gradle's derived caches (caches/transforms-*, jars-*) are too large to \ + check completely; a copy derived from the unpatched jar there would not be \ + seen (`gradle --stop` and clearing them removes any)." + ), + ); + } + unpatched.extend(derived); + unpatched.sort(); + unpatched.dedup(); + for (purl, path) in unpatched { + note_warning( + warnings, + common, + "vex_gradle_unpatched_copy", + format!( + "{purl}: {} is a copy a build may still load and it does not carry the patch; \ + no statement is emitted until every copy does (run `socket-patch apply`, or \ + `gradle --stop` and clear Gradle's derived caches).", + path.display() + ), + ); + } +} + /// Record a run-level advisory the way `update`/`vendor` do: stderr /// (`Warning: `) in human mode, and into `warnings` so the `--json` /// envelope — which silences stderr — carries it in `warnings[]` instead. diff --git a/crates/socket-patch-cli/src/commands/vex_consumed.rs b/crates/socket-patch-cli/src/commands/vex_consumed.rs index 22e5fd751..0d2989e53 100644 --- a/crates/socket-patch-cli/src/commands/vex_consumed.rs +++ b/crates/socket-patch-cli/src/commands/vex_consumed.rs @@ -22,7 +22,7 @@ //! |---|---|---| //! | golang | the REPLACEMENT module `$GOMODCACHE/patch.socket.dev/gopatch/@` (the ref's `url`, else go.mod's hosted `replace`) | the original `M@v` | //! | cargo | `registry/src/-/-` for the lock source's host; several such registries (one per patch uuid) are narrowed to the one whose cached `.crate` has the lock's pinned checksum. A `vendor/` source tree or `--global-prefix` is taken as given | crates.io's / any other registry's extraction | -//! | maven | `///-socket./` (the version the pom pins), its artifact files matched under the suffixed name | the `` version dir | +//! | maven | `///-socket./` (the version the pom or the hosted Gradle wiring pins) in `~/.m2` and every Gradle `files-2.1` holding it (hash dirs expanded), its artifact files matched under the suffixed name | the `` version dir | //! | npm | every `node_modules` copy the crawler finds (pnpm and vlt store copies included), every peer / modifier / registry variant of those in the same `.pnpm` / `.vlt` store, plus alias installs (`node_modules/` holding the package) in the root's and every workspace member's tree | — each serves some dependent: ALL must verify | //! | pypi | every copy in the crawler's environment set (the project's venvs when it has any, else the interpreters) | — any may be the one that runs the project: ALL must verify | //! | gem | every copy in bundler's gem path | — bundler loads whichever `Gem.path` home it hits first: ALL must verify | @@ -556,9 +556,14 @@ fn cached_crate(src: &Path, name: &str, version: &str) -> Option { /// The fail-closed hosted pom pins `-socket.`, a version only the Socket repository serves: maven resolves /// `~/.m2/…///`, never the `` dir (whose copy predates -/// the redirect or belongs to another project). The served files carry the -/// suffixed version in their names, so the record's `-…` files are -/// matched as `-…`. +/// the redirect or belongs to another project), and the hosted Gradle +/// wiring pins the same version into `files-2.1////`. Every +/// cache holding the suffixed version (`get_maven_copy_paths`: `~/.m2`, +/// then each Gradle cache, the read-only one included) is a consumed copy; +/// a Gradle version dir is expanded into its hash dirs at verify time +/// (`installed_copies`). The served files carry the suffixed version in +/// their names, so the record's `-…` files are matched as +/// `-…` — a member-keyed record checks the jar of that name. async fn maven_copies(options: &CrawlerOptions, purl: &str, wiring: &HostedWiring) -> HostedCopies { let Some((_, name, version)) = purl_parts(purl) else { return not_installed(); @@ -572,8 +577,9 @@ async fn maven_copies(options: &CrawlerOptions, purl: &str, wiring: &HostedWirin let suffixed = format!("{version}-socket.{hex8}"); let target = format!("pkg:maven/{group}/{artifact}@{suffixed}"); let crawler = MavenCrawler::new(); + let mut paths = Vec::new(); for repo in crawler - .get_maven_repo_paths(options) + .get_maven_copy_paths(options) .await .unwrap_or_default() { @@ -582,16 +588,21 @@ async fn maven_copies(options: &CrawlerOptions, purl: &str, wiring: &HostedWirin .await .unwrap_or_default(); if let Some(pkg) = found.get(&target) { - return HostedCopies { - paths: vec![pkg.path.clone()], - rename: Some(( - format!("{artifact}-{version}"), - format!("{artifact}-{suffixed}"), - )), - }; + if !paths.contains(&pkg.path) { + paths.push(pkg.path.clone()); + } } } - not_installed() + if paths.is_empty() { + return not_installed(); + } + HostedCopies { + paths, + rename: Some(( + format!("{artifact}-{version}"), + format!("{artifact}-{suffixed}"), + )), + } } #[cfg(test)] diff --git a/crates/socket-patch-cli/src/commands/vex_sources.rs b/crates/socket-patch-cli/src/commands/vex_sources.rs index 4a8e40406..370dc1cee 100644 --- a/crates/socket-patch-cli/src/commands/vex_sources.rs +++ b/crates/socket-patch-cli/src/commands/vex_sources.rs @@ -191,6 +191,11 @@ pub(crate) const NOTE_RECORD_FETCH_FAILED: &str = "vex_record_fetch_failed"; /// the retry (free patches only) — `get` / `scan`'s fallback. pub(crate) const NOTE_API_AUTH_FALLBACK: &str = "api_auth_fallback"; +/// Omission tag and note: a hosted Gradle pin is wired, but a lock file +/// records a release above its base, which that build resolves instead +/// (`vex::Unattested`). +pub(crate) const NOTE_LOCK_ABOVE_BASE: &str = "vex_gradle_lock_above_base"; + fn note(code: &'static str, detail: String) -> PlanNote { PlanNote { code, detail } } @@ -324,6 +329,29 @@ pub(crate) async fn plan(common: &GlobalArgs, sources: Sources, assume_live: &[S } } let superseded = attach_discovered(&mut cands, &discovery, &vendor, &conflicts); + // Wired, but a build bypasses the pin (`Unattested`: a Gradle lock + // above the hosted base resolves the newer upstream release): the ref + // keeps rollback, remove and list working, and the patch is omitted. + cands.retain(|c| { + let pkg = canonical_base_purl(&c.key); + let Some(u) = discovery + .unattested + .iter() + .find(|u| u.uuid == c.uuid && same_package(&u.purl, &pkg)) + else { + return true; + }; + gated.push(failed(&c.key, NOTE_LOCK_ABOVE_BASE)); + notes.push(note( + NOTE_LOCK_ABOVE_BASE, + format!( + "{}: patch {} is wired, but {}; not attested until that build resolves the \ + patch (re-lock it, or roll the patch back once upstream ships the fix)", + c.key, c.uuid, u.detail + ), + )); + false + }); for (key, old_uuid, wired) in &superseded { notes.push(note( NOTE_RECORD_SUPERSEDED, @@ -1516,6 +1544,47 @@ mod tests { assert_eq!(plan.hosted.len(), plan.redirected.len()); } + /// #646 review: a hosted ref discovery marks unattested (a Gradle lock + /// above the pin's base) is omitted with `vex_gradle_lock_above_base`, + /// while the same ref without the mark attests through its wiring. + #[tokio::test] + async fn an_unattested_hosted_ref_is_gated() { + const PURL: &str = "pkg:maven/com.socketfixture/victim@1.10.0"; + let tmp = tempfile::tempdir().unwrap(); + let mut redirect = RedirectState::new(); + redirect.records.insert(PURL.into(), record(U1)); + let mut found = discovery(vec![hosted_ref(PURL, U1, true)]); + found.unattested.push(socket_patch_core::vex::Unattested { + purl: PURL.into(), + uuid: U1.into(), + file: "b/gradle.lockfile".into(), + detail: "b/gradle.lockfile:1 locks it above the patched 1.10.0".into(), + }); + let sources = |discovery: Discovery| Sources { + manifest: PatchManifest::new(), + vendor: VendorState::new(), + redirect: Some(redirect.clone()), + discovery, + }; + let gated = plan(&common(tmp.path()), sources(found), &[]).await; + assert!(gated.view.patches.is_empty() && gated.hosted.is_empty()); + assert_eq!(gated.gated, vec![failed(PURL, NOTE_LOCK_ABOVE_BASE)]); + assert!( + gated.notes.iter().any(|n| n.code == NOTE_LOCK_ABOVE_BASE + && n.detail.contains("b/gradle.lockfile:1")), + "{:?}", + gated.notes + ); + let live = plan( + &common(tmp.path()), + sources(discovery(vec![hosted_ref(PURL, U1, true)])), + &[], + ) + .await; + assert!(live.gated.is_empty(), "{:?}", live.gated); + assert!(live.hosted.contains_key(PURL)); + } + /// Ledger-only records with no wiring anywhere are gated, except the /// manifest-owned one, which falls back to agent-mode verification; an /// in-run confirmed purl is live by construction. diff --git a/crates/socket-patch-cli/src/ecosystem_dispatch.rs b/crates/socket-patch-cli/src/ecosystem_dispatch.rs index 89b9d3a7c..973ddcde6 100644 --- a/crates/socket-patch-cli/src/ecosystem_dispatch.rs +++ b/crates/socket-patch-cli/src/ecosystem_dispatch.rs @@ -168,9 +168,9 @@ fn merge_first_wins( /// `gems/` layout, or an env `BUNDLE_PATH` store) — first-wins would drop /// the second copy, so apply would patch one store while the other bundler /// loads pristine bytes. Collapsing consumers still take the first -/// (highest-precedence) path; apply fans out per-copy for gem -/// only (PyPI/Maven keep their one-install-dir contract — see the apply -/// variant loop). +/// (highest-precedence) path; apply fans out per-copy for gem and Maven +/// (`~/.m2` and each Gradle cache are distinct installs some build reads; +/// PyPI keeps its one-install-dir contract — see the apply variant loop). fn merge_variant_copies( out: &mut HashMap>, _purls: &[String], @@ -336,7 +336,13 @@ async fn dispatch_find( options = options, silent = silent, crawler = MavenCrawler, - get_paths = get_maven_repo_paths, + // Every cache holding a copy: `~/.m2` first, then each Gradle + // `files-2.1` (the user home's and the read-only one), whose + // version dirs every join site expands through + // `gradle_cache::installed_copies`. `variant_merge` keeps every + // distinct copy (`push_path`), and the Maven join sites + // ([`JvmScope`]) split them into the copies a build consumes. + get_paths = get_maven_copy_paths, using_label = "Maven repository", err_label = "Maven packages", // Maven has per-classifier release variants @@ -622,6 +628,103 @@ pub async fn find_manifest_package_copies_reusing( .await } +// ── JVM copies ────────────────────────────────────────────────────────── + +/// What the Maven join sites (apply, rollback, vex) need to know about the +/// run's JVM caches, resolved once per run: which copies a build consumes, +/// which are read-only, and whether the build verifies its dependencies. +#[derive(Debug, Clone)] +pub(crate) struct JvmScope { + pub env: socket_patch_core::crawlers::maven_crawler::JvmEnv, + /// The local Gradle build's `m2_gate` (local mode only; `None` in a + /// global run, where every cache counts). + pub gate: Option, + /// `gradle/verification-metadata.xml` of the cwd's build (or the + /// ancestor build it belongs to), local mode only. + pub verification_metadata: Option, +} + +/// The installed copies of one Maven purl, split by how a build reads them. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct MavenCopies { + /// Writable copies some build consumes: `~/.m2` version dirs (unless + /// ignored) and Gradle user-home version dirs, in lookup order. + pub consumed: Vec, + /// Copies in the read-only Gradle cache (`GRADLE_RO_DEP_CACHE`): read + /// by Gradle, never written. + pub read_only: Vec, + /// `~/.m2` copies a Gradle-only build never reads (no `mavenLocal()`). + pub m2_ignored: Vec, +} + +impl JvmScope { + /// The scope of a run with `common`'s flags, over the process + /// environment. + pub(crate) async fn of(common: &GlobalArgs) -> Self { + use socket_patch_core::crawlers::gradle_cache; + use socket_patch_core::crawlers::maven_crawler::{m2_gate, JvmEnv}; + let env = JvmEnv::from_process(); + if common.is_global() { + return Self { + env, + gate: None, + verification_metadata: None, + }; + } + let cwd = common.cwd.clone(); + let probe_env = env.clone(); + let probe = move || { + let gate = m2_gate(&cwd, &probe_env); + let metadata = gradle_cache::build_roots(&cwd) + .into_iter() + .map(|root| root.join("gradle").join("verification-metadata.xml")) + .find(|p| p.is_file()); + (gate, metadata) + }; + // Script reads and stats: off the async workers. + let (gate, verification_metadata) = tokio::task::spawn_blocking(probe) + .await + .expect("JVM scope probe panicked"); + Self { + env, + gate: Some(gate), + verification_metadata, + } + } + + /// Whether a build of this run reads `~/.m2` (always, except a local + /// Gradle-only build that never declares `mavenLocal()`). + pub(crate) fn m2_consumed(&self) -> bool { + use socket_patch_core::crawlers::maven_crawler::M2Gate; + self.gate.as_ref() != Some(&M2Gate::Ignored) + } + + /// Whether `path` lies in the read-only Gradle cache. + pub(crate) fn is_read_only(&self, path: &std::path::Path) -> bool { + self.env + .gradle + .as_ref() + .and_then(|h| h.ro_files21.as_deref()) + .is_some_and(|ro| path.starts_with(ro)) + } + + /// Split the copies the resolver found for one purl. + pub(crate) fn split(&self, paths: &[PathBuf]) -> MavenCopies { + use socket_patch_core::crawlers::gradle_cache::is_gradle_version_dir; + let mut out = MavenCopies::default(); + for path in paths { + if self.is_read_only(path) { + out.read_only.push(path.clone()); + } else if !is_gradle_version_dir(path) && !self.m2_consumed() { + out.m2_ignored.push(path.clone()); + } else { + out.consumed.push(path.clone()); + } + } + out + } +} + /// Box the future `make` returns, constructing it inside this (non-async) /// frame so the caller's poll frame only ever holds the pointer. fn boxed<'a, T, F, Fut>(make: F) -> std::pin::Pin + 'a>> diff --git a/crates/socket-patch-cli/tests/common/jvm_env.rs b/crates/socket-patch-cli/tests/common/jvm_env.rs new file mode 100644 index 000000000..3130ca743 --- /dev/null +++ b/crates/socket-patch-cli/tests/common/jvm_env.rs @@ -0,0 +1,162 @@ +//! JVM build-tool isolation for the `socket-patch` children the tests spawn. +//! +//! The JVM crawlers resolve their caches from the environment: Gradle's user +//! home from `-Dgradle.user.home` in `GRADLE_OPTS` / `JAVA_OPTS`, then +//! `GRADLE_USER_HOME`, then `~/.gradle`; the read-only cache from +//! `GRADLE_RO_DEP_CACHE`; Maven's local repository from `~/.m2`. Inherited +//! as-is, a developer's real caches leak into every test that does not pin +//! them, and a machine with a warm `~/.gradle` sees packages a CI runner +//! does not. +//! +//! [`isolate_cli`] scrubs [`AMBIENT`] and points `HOME` / `USERPROFILE` at +//! [`stand_in_home`], an empty directory nothing writes to. On Unix Gradle +//! (and so the CLI) takes the user home from the passwd entry, not `$HOME`, +//! so `GRADLE_USER_HOME` is pinned to the stand-in's `.gradle` as well. A +//! test that wants a cache passes it as explicit env AFTER this call (the +//! caller's env lands last; see [`EXPLICIT`]). +//! +//! Shared by `common/mod.rs` and `prebuilt_common/mod.rs` (the latter pulls +//! it in with `#[path]`), so the two harnesses cannot drift. + +#![allow(dead_code)] + +use std::path::PathBuf; +use std::process::Command; + +/// Scrubbed from every CLI child by default. +pub const AMBIENT: &[&str] = &[ + "GRADLE_OPTS", + "JAVA_OPTS", + "GRADLE_USER_HOME", + "GRADLE_RO_DEP_CACHE", + "GRADLE_HOME", +]; + +/// The cache roots a test may hand the CLI explicitly (they survive +/// `prebuilt_common::prepare_command`'s scrub and are served by its fixture +/// server). +pub const EXPLICIT: &[&str] = &[ + "GRADLE_USER_HOME", + "GRADLE_RO_DEP_CACHE", + // sbt (Coursier / Ivy) registers COURSIER_CACHE here. +]; + +/// Toolchain locations that default to a path under the real home (the +/// list `cache_env::TOOLCHAIN_ROOTS` carries for package-manager children). +/// A CLI child that spawns `node` / `git` through a version-manager shim +/// still needs them once `HOME` moves. +const TOOLCHAIN_ROOTS: &[(&str, &str)] = &[ + ("RUSTUP_HOME", ".rustup"), + ("RBENV_ROOT", ".rbenv"), + ("PYENV_ROOT", ".pyenv"), + ("NVM_DIR", ".nvm"), + ("FNM_DIR", ".fnm"), + ("VOLTA_HOME", ".volta"), + ("ASDF_DIR", ".asdf"), + ("ASDF_DATA_DIR", ".asdf"), + ("SDKMAN_DIR", ".sdkman"), + ("MISE_DATA_DIR", ".local/share/mise"), + ("MISE_CONFIG_DIR", ".config/mise"), +]; + +/// The empty stand-in home every isolated CLI child gets. Per account +/// (`/tmp` is shared on Linux) and outside every test's scratch tree, so +/// the policy lookup's stop-at-home rule never fires inside a fixture. +pub fn stand_in_home() -> PathBuf { + let account: String = std::env::var("USER") + .or_else(|_| std::env::var("USERNAME")) + .unwrap_or_default() + .chars() + .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_') + .collect(); + let home = std::env::temp_dir().join(format!("socket-patch-cli-home-{account}")); + let _ = std::fs::create_dir_all(&home); + home +} + +/// Scrub [`AMBIENT`] from `cmd` and pin `HOME` / `USERPROFILE` to +/// [`stand_in_home`] (and `GRADLE_USER_HOME` to its `.gradle`, which nothing +/// creates), carrying the version-manager roots over. Call it before +/// applying a test's own env. +pub fn isolate_cli(cmd: &mut Command) -> &mut Command { + for key in AMBIENT { + cmd.env_remove(key); + } + let real = std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) + .filter(|p| !p.as_os_str().is_empty()); + if let Some(real) = real { + for (var, relative) in TOOLCHAIN_ROOTS { + if std::env::var_os(var).is_some() { + continue; + } + let path = real.join(relative); + if path.is_dir() { + cmd.env(var, path); + } + } + } + let home = stand_in_home(); + cmd.env("HOME", &home) + .env("USERPROFILE", &home) + .env("GRADLE_USER_HOME", home.join(".gradle")) +} + +mod jvm_env_selftests { + use super::*; + + /// A stray GRADLE_OPTS (and every other ambient JVM knob) never reaches + /// the child, the home is the stand-in, and an explicit cache applied + /// afterwards wins. + #[test] + fn isolate_cli_scrubs_ambient_jvm_env_and_pins_home() { + let mut cmd = Command::new("socket-patch"); + cmd.env("GRADLE_OPTS", "-Dgradle.user.home=/real/.gradle") + .env("JAVA_OPTS", "-Dgradle.user.home=/real/.gradle") + .env("GRADLE_RO_DEP_CACHE", "/real/ro"); + isolate_cli(&mut cmd); + cmd.env("GRADLE_USER_HOME", "/explicit/gradle-home"); + let envs: std::collections::HashMap> = cmd + .get_envs() + .map(|(k, v)| { + ( + k.to_string_lossy().into_owned(), + v.map(|v| v.to_string_lossy().into_owned()), + ) + }) + .collect(); + for key in [ + "GRADLE_OPTS", + "JAVA_OPTS", + "GRADLE_RO_DEP_CACHE", + "GRADLE_HOME", + ] { + assert_eq!(envs.get(key), Some(&None), "{key} must be scrubbed"); + } + let home = stand_in_home().to_string_lossy().into_owned(); + assert_eq!(envs["HOME"].as_deref(), Some(home.as_str())); + assert_eq!(envs["USERPROFILE"].as_deref(), Some(home.as_str())); + assert_eq!( + envs["GRADLE_USER_HOME"].as_deref(), + Some("/explicit/gradle-home") + ); + assert!(std::path::Path::new(&home).is_dir()); + } + + /// Without an explicit cache the Gradle user home is the stand-in's + /// `.gradle`: the CLI resolves Gradle's home from the passwd entry on + /// Unix, so pinning `HOME` alone would leak the real `~/.gradle`. + #[test] + fn isolate_cli_pins_the_gradle_user_home() { + let mut cmd = Command::new("socket-patch"); + cmd.env("GRADLE_USER_HOME", "/real/.gradle"); + isolate_cli(&mut cmd); + let home = cmd + .get_envs() + .find(|(k, _)| *k == "GRADLE_USER_HOME") + .and_then(|(_, v)| v) + .map(PathBuf::from); + assert_eq!(home, Some(stand_in_home().join(".gradle"))); + } +} diff --git a/crates/socket-patch-cli/tests/common/mod.rs b/crates/socket-patch-cli/tests/common/mod.rs index 7199ee0e6..b658642c2 100644 --- a/crates/socket-patch-cli/tests/common/mod.rs +++ b/crates/socket-patch-cli/tests/common/mod.rs @@ -27,6 +27,10 @@ use sha2::{Digest, Sha256}; /// `#[path = "common/cache_env.rs"] mod cache_env;`. pub mod cache_env; +/// JVM build-tool env scrub + stand-in home for the CLI children (shared +/// with `prebuilt_common`). +pub mod jvm_env; + // ── Binary discovery + invocation ───────────────────────────────────── /// Absolute path to the built `socket-patch` binary that cargo @@ -135,6 +139,10 @@ pub fn run_bin_with_env( // this force-set is the layer that holds there. Notifier tests opt back // in via caller env (which lands last). cmd.env("SOCKET_NO_UPDATE_CHECK", "1"); + // No ambient Gradle / JVM options and no real home: the JVM crawlers + // would otherwise read the developer's `~/.gradle` / `~/.m2` (and any + // `GRADLE_USER_HOME` / `GRADLE_RO_DEP_CACHE`) into every test. + jvm_env::isolate_cli(&mut cmd); // Caller-supplied env lands last so explicit injections (runtime // gates, discovery roots) survive the scrub. for (k, v) in env { diff --git a/crates/socket-patch-cli/tests/contract_gradle_codes.rs b/crates/socket-patch-cli/tests/contract_gradle_codes.rs new file mode 100644 index 000000000..5e5be53c3 --- /dev/null +++ b/crates/socket-patch-cli/tests/contract_gradle_codes.rs @@ -0,0 +1,176 @@ +//! Every Gradle / JVM code the source can emit is named in CLI_CONTRACT.md. +//! +//! The scan reads the non-test source of both crates and collects: +//! +//! * string literals with a Gradle or JVM code prefix (`redirect_gradle_`, +//! `vex_gradle_`, `gradle_`, `jvm_agent_`, `jvm_jar_`, `vendor_jvm_`, +//! `vendor_gradle_`), whole (`"code"`) or as a message prefix +//! (`"code: …"`); +//! * the `Gradle*` variants of `SidecarAdvisoryCode` (serialized +//! snake_case); +//! * the vendored Gradle planner's reasons (`degraded("…"`, `note("…"`, +//! `shape_refusal("…"`, `reason: …:`) in `vendor/jvm/{gradle,mod,apply}.rs` +//! and `vendor/maven_repo.rs`, minus the Maven-reactor-only ones. +//! +//! Each must appear in backticks in the contract. A new code fails here +//! until it is documented (the "Gradle builds (v5.0)" section). + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +fn crates_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .to_path_buf() +} + +/// Every `.rs` file under `dir`, minus test-only files. +fn sources(dir: &Path, out: &mut Vec) { + let mut entries: Vec<_> = std::fs::read_dir(dir) + .unwrap_or_else(|e| panic!("{}: {e}", dir.display())) + .map(|e| e.unwrap().path()) + .collect(); + entries.sort(); + for path in entries { + let name = path.file_name().unwrap().to_string_lossy().into_owned(); + if path.is_dir() { + if name != "testing" { + sources(&path, out); + } + } else if name.ends_with(".rs") && name != "tests.rs" && !name.ends_with("_tests.rs") { + out.push(path); + } + } +} + +/// `text` (CRLF already folded to LF) up to its inline test module. +/// Only a `#[cfg(test)] mod name {` body ends the scan: a +/// `#[cfg(test)] mod name;` declaration names a separate file and +/// leaves the rest of this one in scope. +fn non_test(text: &str) -> &str { + let inline = + regex::Regex::new(r"(?m)^#\[cfg\(test\)\]\n(?:pub(?:\([a-z]+\))? )?mod \w+ \{").unwrap(); + inline.find(text).map_or(text, |m| &text[..m.start()]) +} + +fn snake(camel: &str) -> String { + let mut out = String::new(); + for (i, c) in camel.chars().enumerate() { + if c.is_ascii_uppercase() { + if i > 0 { + out.push('_'); + } + out.push(c.to_ascii_lowercase()); + } else { + out.push(c); + } + } + out +} + +/// Files whose vendored `reason`s reach a Gradle build's `vendor --json`. +const VENDOR_REASON_FILES: &[&str] = &[ + "vendor/jvm/gradle.rs", + "vendor/jvm/mod.rs", + "vendor/jvm/apply.rs", + "vendor/maven_repo.rs", +]; + +/// Reasons in `VENDOR_REASON_FILES` that only a Maven reactor reaches. +const MAVEN_ONLY_REASONS: &[&str] = &[ + // `--maven-config=none` over recorded `.mvn/maven.config` wiring. + "maven_config_changed", +]; + +/// code -> the first file that emits it. +fn emitted_codes() -> BTreeMap { + let literal = regex::Regex::new( + r#""((?:redirect_gradle|vex_gradle|gradle|jvm_agent|jvm_jar|vendor_jvm|vendor_gradle)_[a-z0-9_]+)(?:"|: )"#, + ) + .unwrap(); + let reason = regex::Regex::new( + r#"(?:degraded|note|shape_refusal)\(\s*"([a-z0-9_]+)"|reason: ([a-z0-9_]+):"#, + ) + .unwrap(); + let advisory = regex::Regex::new(r"\b(Gradle[A-Za-z]+),").unwrap(); + let root = crates_dir(); + let mut files = Vec::new(); + for krate in ["socket-patch-core", "socket-patch-cli"] { + sources(&root.join(krate).join("src"), &mut files); + } + let mut out = BTreeMap::new(); + for path in files { + // A Windows checkout with core.autocrlf has CRLF files. + let text = std::fs::read_to_string(&path) + .unwrap() + .replace("\r\n", "\n"); + let body = non_test(&text); + let rel = path + .strip_prefix(&root) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + let mut add = |code: &str| { + out.entry(code.to_string()).or_insert_with(|| rel.clone()); + }; + for m in literal.captures_iter(body) { + add(&m[1]); + } + if VENDOR_REASON_FILES.iter().any(|f| rel.ends_with(f)) { + for m in reason.captures_iter(body) { + let code = m.get(1).or(m.get(2)).unwrap().as_str(); + if !MAVEN_ONLY_REASONS.contains(&code) { + add(code); + } + } + } + if let Some(start) = body.find("pub enum SidecarAdvisoryCode {") { + let block = &body[start..]; + let block = &block[..block.find("\n}").unwrap()]; + for m in advisory.captures_iter(block) { + add(&snake(&m[1])); + } + } + } + out +} + +#[test] +fn test_gradle_contract_codes() { + let contract = std::fs::read_to_string(crates_dir().join("socket-patch-cli/CLI_CONTRACT.md")) + .expect("read CLI_CONTRACT.md"); + let codes = emitted_codes(); + // The scan must keep finding the codes it was written for. + for known in [ + "redirect_gradle_manual_snippet", + "gradle_copy_unexpected_bytes", + "gradle_refresh_reverts", + "jvm_jar_backup_missing", + "vex_gradle_derived_cache_unchecked", + "classifier_unpatched_copy", + // Only in patch/redirect/mod.rs, past its `#[cfg(test)] mod x;` + // declarations. + "gradle_uuids", + // Vendored reasons emitted only from vendor/maven_repo.rs. + "not_build_root", + "legacy_maven_root", + "ide_sources_unavailable", + ] { + assert!( + codes.contains_key(known), + "the scan lost {known}: {codes:?}" + ); + } + let missing: Vec = codes + .iter() + .filter(|(code, _)| !contract.contains(&format!("`{code}`"))) + .map(|(code, file)| format!("{code} ({file})")) + .collect(); + assert!( + missing.is_empty(), + "codes emitted by the source but not documented in CLI_CONTRACT.md \ + (add them to \"Gradle builds (v5.0)\"):\n {}", + missing.join("\n ") + ); +} diff --git a/crates/socket-patch-cli/tests/e2e_gradle_agent_build.rs b/crates/socket-patch-cli/tests/e2e_gradle_agent_build.rs new file mode 100644 index 000000000..752e3acb7 --- /dev/null +++ b/crates/socket-patch-cli/tests/e2e_gradle_agent_build.rs @@ -0,0 +1,1102 @@ +//! Agent mode against real Gradle: Gradle resolves the deterministic fake +//! Central (`jvm_fixture_repo`) into a per-test user home, `socket-patch` +//! patches the cache in place, and every assertion is on the bytes Gradle +//! then CONSUMES — the `printRuntimeClasspath` marker task's jar, never only +//! the CLI's report. +//! +//! Every test is `#[ignore]` and prefixed `gradle_agent_` (the CI filter +//! contract, `scripts/ci-e2e-bundle.py`); toolchain selection is +//! `gradle_build_common`'s `SOCKET_PATCH_GRADLE_E2E_*` knobs. The canary +//! test records how each Gradle major actually treats its cache +//! (hash-dir naming, `--offline` reuse, `--refresh-dependencies`, daemons, +//! classifier jars, build logic, the build cache, the read-only cache) in a +//! JSON probe report per cell. + +#[path = "prebuilt_common/mod.rs"] +mod prebuilt_common; + +#[path = "gradle_build_common/mod.rs"] +mod gradle_build_common; + +#[path = "jvm_fixture_repo/mod.rs"] +mod jvm_fixture_repo; + +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use gradle_build_common::{ + assert_patched, dump, fixture_root, init_script, jar_member, mirror_init_script, ok, print_cp, + print_cp_task, probe_report, Dsl, Gradle, +}; +use jvm_fixture_repo::{ + notice, repo_path, victim_class, victim_marker, FakeCentral, GROUP, NOTICE, VICTIM, + VICTIM_CLASS_MEMBER, VICTIM_VERSION, +}; +use sha1::Digest as _; + +const SUITE: &str = "e2e_gradle_agent_build"; +const UUID: &str = "26400000-7b4e-4c1a-9f0e-2a3b4c5d6e7f"; +const PRODUCT: &str = "pkg:maven/com.example/app@1.0"; + +fn purl() -> String { + jvm_fixture_repo::victim_purl(VICTIM_VERSION) +} + +fn jar_leaf() -> String { + format!("{VICTIM}-{VICTIM_VERSION}.jar") +} + +fn coordinate() -> String { + format!("{GROUP}:{VICTIM}:{VICTIM_VERSION}") +} + +fn git_sha256(bytes: &[u8]) -> String { + socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes(bytes) +} + +fn sha1_hex(bytes: &[u8]) -> String { + hex::encode(sha1::Sha1::digest(bytes)) +} + +/// The fake Central's file at `path`. +fn central_file(path: &str) -> Vec { + jvm_fixture_repo::repository() + .remove(path) + .unwrap_or_else(|| panic!("the fixture serves no {path}")) +} + +fn pristine_jar() -> Vec { + central_file(&repo_path(VICTIM, VICTIM_VERSION, None, "jar")) +} + +fn patched_notice() -> Vec { + notice(&coordinate(), "patched").into_bytes() +} + +/// `jar` with NOTICE and `Victim.class` patched, every other member kept: +/// both the leaf record's whole-file patch and the patch service's build of +/// the member-keyed record. +fn patch_jar(jar: &[u8]) -> Vec { + use std::io::Read as _; + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(jar)).unwrap(); + let mut members = Vec::new(); + for i in 0..archive.len() { + let mut entry = archive.by_index(i).unwrap(); + let mut buf = Vec::new(); + entry.read_to_end(&mut buf).unwrap(); + let name = entry.name().to_string(); + let buf = if name == NOTICE { + patched_notice() + } else if name == VICTIM_CLASS_MEMBER { + victim_class(VICTIM_VERSION, "patched") + } else { + buf + }; + members.push((name, buf)); + } + jvm_fixture_repo::jar(&members) +} + +fn patched_jar() -> Vec { + patch_jar(&pristine_jar()) +} + +/// One real-Gradle cell: a project depending on `victim:1.10.0`, its own +/// Gradle user home (`/.gradle`), `~/.m2` and fake Central. +struct Cell { + _tmp: tempfile::TempDir, + root: PathBuf, + proj: PathBuf, + home: PathBuf, + m2: PathBuf, + /// `GRADLE_RO_DEP_CACHE` for Gradle and the CLI, when set. + ro: Option, + gradle: Gradle, + central: FakeCentral, + init: [String; 2], +} + +/// A cell whose build script declares `repos` (one per line) and appends +/// `extra`. `None`: no Gradle (skipped). +fn cell(repos: &str, extra: &str) -> Option { + let tmp = tempfile::tempdir().unwrap(); + let root = fixture_root(&tmp); + let home = root.join(".gradle"); + std::fs::create_dir_all(&home).unwrap(); + let gradle = Gradle::detect(SUITE, &home)?; + let central = FakeCentral::start(); + let init = init_script( + &root.join("init"), + "mirror.gradle", + &mirror_init_script(¢ral.uri(), None), + ); + let proj = root.join("proj"); + std::fs::create_dir_all(proj.join(".socket/blobs")).unwrap(); + std::fs::write(proj.join("settings.gradle"), "rootProject.name = 'app'\n").unwrap(); + std::fs::write( + proj.join("build.gradle"), + format!( + "plugins {{ id 'java' }}\nrepositories {{\n{repos}}}\n\ + dependencies {{ implementation '{}' }}\n{}{extra}", + coordinate(), + print_cp_task(Dsl::Groovy, "runtimeClasspath") + ), + ) + .unwrap(); + let m2 = root.join("m2"); + std::fs::create_dir_all(&m2).unwrap(); + Some(Cell { + _tmp: tmp, + root, + proj, + home, + m2, + ro: None, + gradle, + central, + init, + }) +} + +const CENTRAL: &str = " mavenCentral()\n"; +const LOCAL_FIRST: &str = " mavenLocal()\n mavenCentral()\n"; + +impl Cell { + fn gradle_env(&self) -> Vec<(&'static str, &OsStr)> { + self.ro + .iter() + .map(|ro| ("GRADLE_RO_DEP_CACHE", ro.as_os_str())) + .collect() + } + + /// `-Dmaven.repo.local=`: what Gradle's mavenLocal() reads (it does + /// not honor `MAVEN_REPO_LOCAL`, which the CLI reads). + fn m2_property(&self) -> String { + format!("-Dmaven.repo.local={}", self.m2.display()) + } + + /// `gradle printRuntimeClasspath` (mirror init script first). + fn build(&self, extra: &[&str]) -> Output { + let m2 = self.m2_property(); + let mut args: Vec<&str> = vec![&self.init[0], &self.init[1], &m2]; + args.extend_from_slice(extra); + args.push("printRuntimeClasspath"); + self.gradle + .run_env(&self.proj, &self.home, &args, &self.gradle_env()) + } + + /// The victim jar the build consumes, and its NOTICE. + fn consumed(&self, extra: &[&str], what: &str) -> (PathBuf, Vec) { + let out = self.build(extra); + let cp = print_cp(&out, what); + let jar = cp + .iter() + .find(|p| p.file_name().is_some_and(|n| n == jar_leaf().as_str())) + .unwrap_or_else(|| panic!("{what}: no {} on the classpath: {cp:?}", jar_leaf())) + .clone(); + let bytes = std::fs::read(&jar).unwrap(); + (jar, jar_member(&bytes, NOTICE).unwrap()) + } + + /// The build consumes the patched victim. + fn assert_consumes_patched(&self, extra: &[&str], what: &str) -> PathBuf { + assert_patched(&self.build(extra), VICTIM, NOTICE, &patched_notice(), what) + } + + /// The build consumes the pristine victim. + fn assert_consumes_pristine(&self, extra: &[&str], what: &str) -> PathBuf { + let pristine = notice(&coordinate(), "pristine").into_bytes(); + assert_patched(&self.build(extra), VICTIM, NOTICE, &pristine, what) + } + + /// The `files-2.1` version dir of the victim. + fn version_dir(&self) -> PathBuf { + self.home + .join(prebuilt_common::GRADLE_FILES21) + .join(GROUP) + .join(VICTIM) + .join(VICTIM_VERSION) + } + + /// Every hash dir of the version dir holding the victim jar. + fn hash_dirs(&self) -> Vec { + let mut dirs: Vec = std::fs::read_dir(self.version_dir()) + .into_iter() + .flatten() + .flatten() + .map(|e| e.path()) + .filter(|p| p.join(jar_leaf()).is_file()) + .collect(); + dirs.sort(); + dirs + } + + /// Copy the victim's pom, module and jar into `~/.m2`. + fn seed_m2(&self) -> PathBuf { + let dir = self + .m2 + .join("com/socketfixture/victim") + .join(VICTIM_VERSION); + std::fs::create_dir_all(&dir).unwrap(); + for ext in ["pom", "module", "jar"] { + let path = repo_path(VICTIM, VICTIM_VERSION, None, ext); + std::fs::write( + dir.join(path.rsplit('/').next().unwrap()), + central_file(&path), + ) + .unwrap(); + } + dir + } + + /// Write the manifest with `files` (`(key, before, after)`), every blob + /// committed. + fn manifest(&self, purl: &str, files: &[(String, Vec, Vec)]) { + // A rollback may have swept `.socket/` away. + std::fs::create_dir_all(self.proj.join(".socket/blobs")).unwrap(); + let mut entries = serde_json::Map::new(); + for (key, before, after) in files { + for bytes in [before, after] { + std::fs::write( + self.proj.join(".socket/blobs").join(git_sha256(bytes)), + bytes, + ) + .unwrap(); + } + entries.insert( + key.clone(), + serde_json::json!({ + "beforeHash": git_sha256(before), "afterHash": git_sha256(after), + }), + ); + } + std::fs::write( + self.proj.join(".socket/manifest.json"), + serde_json::to_string_pretty(&serde_json::json!({ "patches": { purl: { + "uuid": UUID, + "exportedAt": "2026-01-01T00:00:00Z", + "files": entries, + "vulnerabilities": { "GHSA-gr4d-1e55-0264": { + "cves": ["CVE-2026-0264"], "summary": "s", + "severity": "high", "description": "d" + } }, + "description": "gradle agent e2e", "license": "MIT", "tier": "free", + } } })) + .unwrap(), + ) + .unwrap(); + } + + /// The leaf record: the whole victim jar. + fn leaf_manifest(&self) { + self.manifest( + &purl(), + &[( + format!("package/{}", jar_leaf()), + pristine_jar(), + patched_jar(), + )], + ); + } + + /// The member-keyed record (#264): NOTICE and `Victim.class`. + fn member_manifest(&self) { + self.manifest( + &purl(), + &[ + ( + NOTICE.to_string(), + notice(&coordinate(), "pristine").into_bytes(), + patched_notice(), + ), + ( + VICTIM_CLASS_MEMBER.to_string(), + victim_class(VICTIM_VERSION, "pristine"), + victim_class(VICTIM_VERSION, "patched"), + ), + ], + ); + } + + /// `socket-patch --json --cwd ` under the cell's caches. + fn socket(&self, args: &[&str]) -> (Option, serde_json::Value) { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_socket-patch")); + for (k, _) in std::env::vars_os() { + if k.to_string_lossy().starts_with("SOCKET_") { + cmd.env_remove(&k); + } + } + prebuilt_common::jvm_env::isolate_cli(&mut cmd); + cmd.args(args) + .args(["--json", "--cwd", self.proj.to_str().unwrap()]) + .env("SOCKET_TELEMETRY_DISABLED", "1") + .env("SOCKET_NO_CONFIG", "1") + .env("GRADLE_USER_HOME", &self.home) + .env("MAVEN_REPO_LOCAL", &self.m2) + .env("SOCKET_MAVEN_REGISTRY", self.central.uri()) + .env_remove("M2_HOME"); + if let Some(ro) = &self.ro { + cmd.env("GRADLE_RO_DEP_CACHE", ro); + } + let out = cmd.output().expect("run socket-patch"); + let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + let json = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { + panic!( + "{args:?}: not one JSON document ({e})\n{stdout}\n{}", + String::from_utf8_lossy(&out.stderr) + ) + }); + (out.status.code(), json) + } + + fn socket_ok(&self, args: &[&str]) -> serde_json::Value { + let (code, json) = self.socket(args); + assert_eq!(code, Some(0), "{args:?}: {json}"); + json + } + + /// `vex`: the statement count (0 when no document was written) and the + /// envelope. + fn vex(&self, extra: &[&str]) -> (usize, serde_json::Value) { + let doc = self.root.join("vex.json"); + let _ = std::fs::remove_file(&doc); + let mut args = vec!["vex", "-O", doc.to_str().unwrap(), "--product", PRODUCT]; + args.extend_from_slice(extra); + let (_, json) = self.socket(&args); + let statements = std::fs::read(&doc) + .ok() + .and_then(|b| serde_json::from_slice::(&b).ok()) + .and_then(|d| d["statements"].as_array().map(Vec::len)) + .unwrap_or(0); + (statements, json) + } + + fn cell_name(&self, test: &str) -> String { + format!( + "{SUITE}-{test}-gradle-{}-{}", + self.gradle.version, + std::env::consts::OS + ) + } +} + +/// The warning codes of an envelope. +fn codes(json: &serde_json::Value) -> Vec { + json["warnings"] + .as_array() + .into_iter() + .flatten() + .filter_map(|w| w["code"].as_str().map(str::to_string)) + .collect() +} + +fn has(json: &serde_json::Value, code: &str) -> bool { + codes(json).iter().any(|c| c == code) +} + +/// Every file under `dir` with its sha1 (the cache trees a rollback must +/// restore byte for byte). +fn tree(dir: &Path) -> std::collections::BTreeMap { + let mut out = std::collections::BTreeMap::new(); + for entry in walkdir(dir) { + let rel = entry + .strip_prefix(dir) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + out.insert(rel, sha1_hex(&std::fs::read(&entry).unwrap())); + } + out +} + +fn walkdir(dir: &Path) -> Vec { + let mut out = Vec::new(); + let mut pending = vec![dir.to_path_buf()]; + while let Some(d) = pending.pop() { + for entry in std::fs::read_dir(&d).into_iter().flatten().flatten() { + let path = entry.path(); + if entry.file_type().is_ok_and(|t| t.is_dir()) { + pending.push(path); + } else { + out.push(path); + } + } + } + out.sort(); + out +} + +/// A patch service granting the member-keyed record: its jar is served by +/// the fake Central's patched-jar route, the grant by a mock of the +/// vendoring endpoint (its `uri()` is the `--vendor-url`). +fn patch_service( + central: &FakeCentral, + jar: &[u8], +) -> (tokio::runtime::Runtime, wiremock::MockServer) { + use base64::Engine as _; + use sha2::Digest as _; + let url = central.serve_patched_jar(UUID, VICTIM, VICTIM_VERSION, jar); + let sri = format!( + "sha512-{}", + base64::engine::general_purpose::STANDARD.encode(sha2::Sha512::digest(jar)) + ); + let rt = tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .enable_all() + .build() + .unwrap(); + let server = rt.block_on(async { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/patch/package")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "results": { UUID: { + "status": "granted", "purl": purl(), "url": url, + "artifacts": [{ "kind": "tarball", "url": url, "integrity": { "sha512": sri } }] + } } + }))) + .mount(&server) + .await; + server + }); + (rt, server) +} + +// ── #551 ──────────────────────────────────────────────────────────────── + +/// #551: a build without mavenLocal() consumes the Gradle cache copy; agent +/// mode patches exactly that (the pristine `~/.m2` copy beside it is not +/// read and not touched), and VEX attests. +#[test] +#[ignore = "real Gradle (SOCKET_PATCH_GRADLE_E2E_*)"] +fn gradle_agent_551_patches_consumed_jar() { + let Some(c) = cell(CENTRAL, "") else { return }; + let m2 = c.seed_m2(); + c.assert_consumes_pristine(&[], "first build"); + c.leaf_manifest(); + c.socket_ok(&["apply", "--offline"]); + let consumed = c.assert_consumes_patched(&[], "after apply"); + assert!( + consumed.starts_with(c.version_dir()), + "{}", + consumed.display() + ); + assert_eq!(std::fs::read(m2.join(jar_leaf())).unwrap(), pristine_jar()); + let (statements, json) = c.vex(&[]); + assert!(statements > 0, "{json}"); +} + +/// Control: with mavenLocal() first, Gradle consumes the `~/.m2` copy, and +/// agent mode patches it (and the Gradle copy, when there is one). +#[test] +#[ignore = "real Gradle (SOCKET_PATCH_GRADLE_E2E_*)"] +fn gradle_agent_551_mavenlocal_control() { + let Some(c) = cell(LOCAL_FIRST, "") else { + return; + }; + let m2 = c.seed_m2(); + let consumed = c.assert_consumes_pristine(&[], "first build"); + assert!(consumed.starts_with(&c.m2), "{}", consumed.display()); + c.leaf_manifest(); + c.socket_ok(&["apply", "--offline"]); + c.assert_consumes_patched(&[], "after apply"); + assert_eq!(std::fs::read(m2.join(jar_leaf())).unwrap(), patched_jar()); + let (statements, json) = c.vex(&[]); + assert!(statements > 0, "{json}"); +} + +/// mavenLocal() from a user-home init script: the build consumes `~/.m2`, +/// and agent mode sees the declaration and patches it. +#[test] +#[ignore = "real Gradle (SOCKET_PATCH_GRADLE_E2E_*)"] +fn gradle_agent_551_initd_mavenlocal() { + let Some(c) = cell(CENTRAL, "") else { return }; + std::fs::create_dir_all(c.home.join("init.d")).unwrap(); + std::fs::write( + c.home.join("init.d/local.gradle"), + "allprojects {\n repositories {\n mavenLocal()\n }\n}\n", + ) + .unwrap(); + let m2 = c.seed_m2(); + c.assert_consumes_pristine(&[], "first build"); + c.leaf_manifest(); + c.socket_ok(&["apply", "--offline"]); + assert_eq!(std::fs::read(m2.join(jar_leaf())).unwrap(), patched_jar()); + c.assert_consumes_patched(&[], "after apply"); +} + +// ── #264 ──────────────────────────────────────────────────────────────── + +/// A member-keyed record swaps in the patch service's whole jar: the build +/// consumes the patched members, the original is kept under +/// `.socket/jvm-originals/`, and rollback restores the cache byte for byte. +#[test] +#[ignore = "real Gradle (SOCKET_PATCH_GRADLE_E2E_*)"] +fn gradle_agent_264_member_record_whole_jar() { + let Some(c) = cell(CENTRAL, "") else { return }; + c.assert_consumes_pristine(&[], "first build"); + let before = tree(&c.version_dir()); + c.member_manifest(); + let (_rt, service) = patch_service(&c.central, &patched_jar()); + c.socket_ok(&["apply", "--vendor-url", &service.uri()]); + let consumed = c.assert_consumes_patched(&[], "after the swap"); + assert_eq!(std::fs::read(&consumed).unwrap(), patched_jar()); + let backup = c.proj.join(".socket/jvm-originals").join(format!( + "{}.jar", + jvm_fixture_repo::sha256_hex(&pristine_jar()) + )); + assert_eq!(std::fs::read(&backup).unwrap(), pristine_jar()); + let (statements, json) = c.vex(&[]); + assert!(statements > 0, "{json}"); + + c.socket_ok(&["rollback", "--offline"]); + assert_eq!(tree(&c.version_dir()), before); + c.assert_consumes_pristine(&[], "after rollback"); + + // Offline with no service: nothing is written. (Rollback dropped the + // manifest record; put it back.) + c.member_manifest(); + let (code, json) = c.socket(&["apply", "--offline"]); + assert_ne!(code, Some(0), "{json}"); + assert!(has(&json, "jvm_agent_service_required"), "{json}"); + assert_eq!(tree(&c.version_dir()), before); +} + +// ── rollback / remove ─────────────────────────────────────────────────── + +/// apply → rollback leaves the Gradle cache byte-identical, and the build +/// consumes the pristine jar again. +#[test] +#[ignore = "real Gradle (SOCKET_PATCH_GRADLE_E2E_*)"] +fn gradle_agent_rollback_byte_exact() { + let Some(c) = cell(CENTRAL, "") else { return }; + c.assert_consumes_pristine(&[], "first build"); + let before = tree(&c.version_dir()); + c.leaf_manifest(); + c.socket_ok(&["apply", "--offline"]); + c.assert_consumes_patched(&[], "after apply"); + c.socket_ok(&["rollback", "--offline"]); + assert_eq!(tree(&c.version_dir()), before); + for dir in c.hash_dirs() { + let name = dir.file_name().unwrap().to_str().unwrap().to_string(); + let jar = std::fs::read(dir.join(jar_leaf())).unwrap(); + assert!(socket_patch_core::crawlers::gradle_cache::pristine( + &name, &jar + )); + } + c.assert_consumes_pristine(&[], "after rollback"); +} + +/// `remove` restores every copy it patched — the Gradle cache and, with +/// mavenLocal() declared, `~/.m2` — byte for byte. +#[test] +#[ignore = "real Gradle (SOCKET_PATCH_GRADLE_E2E_*)"] +fn gradle_agent_remove_restores_all_copies() { + let Some(c) = cell(CENTRAL, "") else { return }; + c.assert_consumes_pristine(&[], "populate the Gradle cache"); + // Now read ~/.m2 too: both copies are consumed. + let script = std::fs::read_to_string(c.proj.join("build.gradle")).unwrap(); + std::fs::write( + c.proj.join("build.gradle"), + script.replace(CENTRAL, LOCAL_FIRST), + ) + .unwrap(); + let m2 = c.seed_m2(); + let gradle_before = tree(&c.version_dir()); + let m2_before = tree(&m2); + c.leaf_manifest(); + c.socket_ok(&["apply", "--offline"]); + assert_ne!(tree(&c.version_dir()), gradle_before); + assert_ne!(tree(&m2), m2_before); + c.assert_consumes_patched(&[], "after apply"); + c.socket_ok(&["remove", &purl(), "--offline"]); + assert_eq!(tree(&c.version_dir()), gradle_before); + assert_eq!(tree(&m2), m2_before); + c.assert_consumes_pristine(&[], "after remove"); +} + +// ── refusals ──────────────────────────────────────────────────────────── + +/// Dependency verification with key trust only (no component entry for the +/// victim) still refuses agent mode: nothing is written and the build +/// keeps consuming the pristine jar. +#[test] +#[ignore = "real Gradle (SOCKET_PATCH_GRADLE_E2E_*)"] +fn gradle_agent_verification_metadata_refuses() { + let Some(c) = cell(CENTRAL, "") else { return }; + c.assert_consumes_pristine(&[], "first build"); + let before = tree(&c.home.join("caches/modules-2/files-2.1")); + std::fs::create_dir_all(c.proj.join("gradle")).unwrap(); + std::fs::write( + c.proj.join("gradle/verification-metadata.xml"), + format!( + r#" + + + true + true + + + + + + +"#, + jvm_fixture_repo::KEY_FINGERPRINT + ), + ) + .unwrap(); + c.leaf_manifest(); + let (code, json) = c.socket(&["apply", "--offline"]); + assert_ne!(code, Some(0), "{json}"); + assert!(has(&json, "gradle_verification_metadata_present"), "{json}"); + assert_eq!(tree(&c.home.join("caches/modules-2/files-2.1")), before); + std::fs::remove_file(c.proj.join("gradle/verification-metadata.xml")).unwrap(); + c.assert_consumes_pristine(&[], "after the refusal"); +} + +/// The read-only cache (`GRADLE_RO_DEP_CACHE`) is never written: a copy +/// there fails the run (`gradle_ro_cache_shadows`). Records whether Gradle +/// resolves from it before the user home. +#[test] +#[ignore = "real Gradle (SOCKET_PATCH_GRADLE_E2E_*)"] +fn gradle_agent_ro_cache_shadows() { + let Some(mut c) = cell(CENTRAL, "") else { + return; + }; + c.assert_consumes_pristine(&[], "populate a cache to freeze"); + // Freeze it as the read-only cache, then start the user home over. + let ro = c.root.join("ro"); + copy_tree(&c.home.join("caches/modules-2"), &ro.join("modules-2")); + std::fs::remove_dir_all(c.home.join("caches")).unwrap(); + c.ro = Some(ro.clone()); + let (consumed, _) = c.consumed(&[], "build over the read-only cache"); + let from_ro = consumed.starts_with(&ro); + let ro_before = tree(&ro); + c.leaf_manifest(); + let (code, json) = c.socket(&["apply", "--offline"]); + assert_ne!(code, Some(0), "{json}"); + assert!(has(&json, "gradle_ro_cache_shadows"), "{json}"); + assert_eq!( + tree(&ro), + ro_before, + "the read-only cache must never be written" + ); + let (statements, _) = c.vex(&[]); + assert_eq!(statements, 0); + probe_report( + &c.cell_name("ro"), + &serde_json::json!({ + "gradle": c.gradle.version, "jvm": c.gradle.jvm, + "consumedFromReadOnly": from_ro, + "consumed": consumed, + }), + ); +} + +// ── global prefix ─────────────────────────────────────────────────────── + +/// `--global-prefix` naming the user home or its `caches/modules-2` +/// reaches the cache for apply, vex and rollback. +#[test] +#[ignore = "real Gradle (SOCKET_PATCH_GRADLE_E2E_*)"] +fn gradle_agent_global_prefix() { + let Some(c) = cell(CENTRAL, "") else { return }; + c.assert_consumes_pristine(&[], "first build"); + for prefix in [c.home.clone(), c.home.join("caches/modules-2")] { + // Each rollback drops the manifest record: write it per round. + c.leaf_manifest(); + let p = prefix.to_str().unwrap(); + c.socket_ok(&["apply", "--offline", "--global-prefix", p]); + c.assert_consumes_patched(&[], "after a global-prefix apply"); + let (statements, json) = c.vex(&["--global-prefix", p]); + assert!(statements > 0, "{json}"); + c.socket_ok(&["rollback", "--offline", "--global-prefix", p]); + c.assert_consumes_pristine(&[], "after a global-prefix rollback"); + } +} + +// ── refresh ───────────────────────────────────────────────────────────── + +/// `--refresh-dependencies` after an apply: whatever Gradle does to the +/// cache (overwrite in place, keep, or a new hash dir), VEX re-hashes the +/// disk and attests exactly when the consumed bytes are patched. +#[test] +#[ignore = "real Gradle (SOCKET_PATCH_GRADLE_E2E_*)"] +fn gradle_agent_refresh_vex_matches_disk() { + let Some(c) = cell(CENTRAL, "") else { return }; + c.assert_consumes_pristine(&[], "first build"); + c.leaf_manifest(); + c.socket_ok(&["apply", "--offline"]); + c.assert_consumes_patched(&[], "after apply"); + let dirs_before = c.hash_dirs(); + let (consumed, notice_bytes) = c.consumed(&["--refresh-dependencies"], "refresh"); + let consumed_patched = notice_bytes == patched_notice(); + let dirs_after = c.hash_dirs(); + let behaviour = if dirs_after.len() > dirs_before.len() { + "new_dir" + } else if consumed_patched { + "keep" + } else { + "overwrite" + }; + let every_copy_patched = dirs_after.iter().all(|d| { + jar_member(&std::fs::read(d.join(jar_leaf())).unwrap(), NOTICE).as_deref() + == Some(patched_notice().as_slice()) + }); + let (statements, json) = c.vex(&[]); + assert_eq!( + statements > 0, + every_copy_patched, + "VEX must attest exactly when every copy on disk is patched ({behaviour}): {json}" + ); + if !consumed_patched { + assert_eq!(statements, 0, "the build consumes an unpatched jar: {json}"); + } + probe_report( + &c.cell_name("refresh"), + &serde_json::json!({ + "gradle": c.gradle.version, "jvm": c.gradle.jvm, + "refresh": behaviour, + "consumedPatched": consumed_patched, + "consumed": consumed, + "hashDirsBefore": dirs_before.len(), + "hashDirsAfter": dirs_after.len(), + "vexStatements": statements, + }), + ); +} + +// ── Windows ───────────────────────────────────────────────────────────── + +/// Windows: a jar held open without delete sharing (what a Gradle daemon +/// does) refuses the write with `gradle_jar_locked_by_daemon`. +#[cfg(windows)] +#[test] +#[ignore = "real Gradle (SOCKET_PATCH_GRADLE_E2E_*)"] +fn gradle_agent_windows_daemon_lock() { + use std::os::windows::fs::OpenOptionsExt as _; + let Some(c) = cell(CENTRAL, "") else { return }; + c.assert_consumes_pristine(&[], "first build"); + c.leaf_manifest(); + let jar = c.hash_dirs()[0].join(jar_leaf()); + let held = std::fs::OpenOptions::new() + .read(true) + .share_mode(1) // FILE_SHARE_READ only: no rename / delete over it + .open(&jar) + .unwrap(); + let (code, json) = c.socket(&["apply", "--offline"]); + drop(held); + assert_ne!(code, Some(0), "{json}"); + assert!(has(&json, "gradle_jar_locked_by_daemon"), "{json}"); + c.socket_ok(&["apply", "--offline"]); + c.assert_consumes_patched(&[], "after the lock is released"); +} + +// ── canaries ──────────────────────────────────────────────────────────── + +/// How this Gradle major treats its cache, recorded in the probe report +/// (and asserted where agent mode depends on it): +/// +/// * (a) hash-dir naming — the victim jar's sha1 starts with `0`; +/// * (b) `--offline` reuses the patched cache without re-hashing it; +/// * (c) what `--refresh-dependencies` does (see the refresh test); +/// * (d) a daemon that ran before the apply, and after `--stop`; +/// * (e) a classifier jar gets its own hash dir and its own patch; +/// * (f) the buildscript classpath (`BuildLogic` prints `Victim.marker()`); +/// * (g) a `--build-cache` jar task picks up the patched member; +/// * (h) read-only cache precedence (see the read-only test). +#[test] +#[ignore = "real Gradle (SOCKET_PATCH_GRADLE_E2E_*)"] +fn gradle_agent_cache_semantics_canaries() { + let buildlogic = format!( + "{GROUP}:{}:{}", + jvm_fixture_repo::BUILDLOGIC, + jvm_fixture_repo::BUILDLOGIC_VERSION + ); + let extra = format!( + "dependencies {{ testImplementation '{}:tests' }}\n\ + tasks.register('printTestClasspath') {{\n \ + def cp = files(configurations.named('testRuntimeClasspath'))\n \ + doLast {{ cp.files.each {{ println('SOCKET-TCP ' + it.absolutePath) }} }}\n}}\n\ + tasks.register('fatJar', Jar) {{\n \ + archiveFileName = 'fat.jar'\n \ + def cp = files(configurations.named('runtimeClasspath'))\n \ + from({{ cp.collect {{ zipTree(it) }} }})\n \ + duplicatesStrategy = DuplicatesStrategy.EXCLUDE\n}}\n\ + tasks.register('buildLogicMarker') {{\n \ + doLast {{ {}.print() }}\n}}\n", + coordinate(), + jvm_fixture_repo::BUILDLOGIC_CLASS + ); + let Some(c) = cell(CENTRAL, &extra) else { + return; + }; + // Build logic: the buildlogic plugin (which depends on the victim) on the + // buildscript classpath. + let script = std::fs::read_to_string(c.proj.join("build.gradle")).unwrap(); + std::fs::write( + c.proj.join("build.gradle"), + format!( + "buildscript {{\n repositories {{ mavenCentral() }}\n \ + dependencies {{ classpath '{buildlogic}' }}\n}}\n{script}" + ), + ) + .unwrap(); + let mut report = serde_json::Map::new(); + report.insert("gradle".into(), c.gradle.version.clone().into()); + report.insert("jvm".into(), c.gradle.jvm.clone().into()); + + let m2_property = c.m2_property(); + let run = |args: &[&str]| -> Output { + let mut all: Vec<&str> = vec![&c.init[0], &c.init[1], &m2_property]; + all.extend_from_slice(args); + c.gradle.run(&c.proj, &c.home, &all) + }; + let marker = |out: &Output| -> Option { + String::from_utf8_lossy(&out.stdout) + .lines() + .find_map(|l| l.strip_prefix(jvm_fixture_repo::BUILDLOGIC_MARKER)) + .map(str::to_string) + }; + + // Populate: classpath, build logic, the fat jar into the build cache. + let first = run(&[ + "--build-cache", + "printRuntimeClasspath", + "printTestClasspath", + "buildLogicMarker", + "fatJar", + ]); + assert!(ok(&first), "first build:\n{}", dump(&first)); + assert_eq!( + marker(&first).as_deref(), + Some(victim_marker(VICTIM_VERSION, "pristine").as_str()) + ); + + // (a) hash-dir naming. + let dirs = c.hash_dirs(); + assert_eq!(dirs.len(), 1, "{dirs:?}"); + let name = dirs[0].file_name().unwrap().to_str().unwrap().to_string(); + let sha1 = sha1_hex(&pristine_jar()); + assert!(sha1.starts_with('0')); + assert!(socket_patch_core::crawlers::gradle_cache::hash_eq( + &name, &sha1 + )); + report.insert("a_hashDir".into(), name.clone().into()); + report.insert("a_leadingZeroDropped".into(), (name.len() < 40).into()); + + // Patch the main jar and the tests classifier together (two records). + let tests_path = repo_path(VICTIM, VICTIM_VERSION, Some("tests"), "jar"); + let tests_pristine = central_file(&tests_path); + let tests_leaf = tests_path.rsplit('/').next().unwrap().to_string(); + let tests_patched = patch_jar(&tests_pristine); + let main_files = [( + format!("package/{}", jar_leaf()), + pristine_jar(), + patched_jar(), + )]; + c.manifest(&purl(), &main_files); + let manifest_path = c.proj.join(".socket/manifest.json"); + let mut manifest: serde_json::Value = + serde_json::from_slice(&std::fs::read(&manifest_path).unwrap()).unwrap(); + let mut tests_record = manifest["patches"][purl()].clone(); + tests_record["uuid"] = "26400000-7b4e-4c1a-9f0e-2a3b4c5d6e80".into(); + tests_record["files"] = serde_json::json!({ format!("package/{tests_leaf}"): { + "beforeHash": git_sha256(&tests_pristine), "afterHash": git_sha256(&tests_patched), + } }); + for bytes in [&tests_pristine, &tests_patched] { + std::fs::write(c.proj.join(".socket/blobs").join(git_sha256(bytes)), bytes).unwrap(); + } + let main_record = manifest["patches"][purl()].clone(); + manifest["patches"] = serde_json::json!({ + format!("{}?ext=jar", purl()): main_record, + format!("{}?classifier=tests&ext=jar", purl()): tests_record, + }); + std::fs::write( + &manifest_path, + serde_json::to_vec_pretty(&manifest).unwrap(), + ) + .unwrap(); + + // (d) a daemon that resolved the pristine jar before the apply. Not on + // Windows: a live daemon holds the cached jars open there, which is + // `gradle_agent_windows_daemon_lock`'s subject. + let use_daemon = !cfg!(windows); + let daemon = |args: &[&str]| -> Output { + let mut cmd = Gradle::command(&c.gradle.program, Some(&c.home)); + cmd.current_dir(&c.proj) + .args(["--daemon", "--console=plain", &m2_property]) + .args(&c.init) + .args(args); + cmd.output().unwrap() + }; + if use_daemon { + let warm = daemon(&["buildLogicMarker"]); + report.insert("d_daemonStarted".into(), ok(&warm).into()); + } + + let (code, json) = c.socket(&["apply", "--offline"]); + let stale = has(&json, "gradle_transform_copy_stale"); + report.insert("f_applyReportsStaleTransforms".into(), stale.into()); + report.insert("applyExit".into(), code.into()); + report.insert("applyWarnings".into(), codes(&json).into()); + assert!( + code == Some(0) || stale, + "apply may only fail over derived copies it names: {json}" + ); + // The warm daemon is the measured hazard; `gradle_daemon_stale` is its + // only mitigation, so the apply must say it. + if use_daemon { + let daemon_stale = json["sidecars"].to_string().contains("gradle_daemon_stale"); + report.insert("d_applyReportsDaemonStale".into(), daemon_stale.into()); + assert!( + daemon_stale, + "(d) a daemon was running: apply must report gradle_daemon_stale: {json}" + ); + } + + // (d) the warm daemon, then after --stop. + if use_daemon { + let warm_after = daemon(&["buildLogicMarker", "printRuntimeClasspath"]); + report.insert( + "d_daemonBuildLogicMarker".into(), + marker(&warm_after).unwrap_or_default().into(), + ); + let warm_notice = gradle_build_common::gradle_classpath(&warm_after) + .iter() + .find(|p| p.file_name().is_some_and(|n| n == jar_leaf().as_str())) + .and_then(|p| jar_member(&std::fs::read(p).ok()?, NOTICE)); + report.insert( + "d_daemonClasspathPatched".into(), + (warm_notice.as_deref() == Some(patched_notice().as_slice())).into(), + ); + let _ = daemon(&["--stop"]); + } + + // (b) --offline reuse without a re-hash. + let offline = c.assert_consumes_patched(&["--offline"], "(b) --offline after apply"); + report.insert("b_offlineReuse".into(), true.into()); + report.insert( + "b_consumed".into(), + offline.to_string_lossy().into_owned().into(), + ); + + // (e) the classifier jar: its own hash dir, its own patch, consumed. + let tests_out = run(&["--offline", "printTestClasspath"]); + assert!(ok(&tests_out), "{}", dump(&tests_out)); + let consumed_tests = String::from_utf8_lossy(&tests_out.stdout) + .lines() + .filter_map(|l| l.strip_prefix("SOCKET-TCP ")) + .map(PathBuf::from) + .find(|p| p.file_name().is_some_and(|n| n == tests_leaf.as_str())) + .unwrap_or_else(|| { + panic!( + "no {tests_leaf} on the test classpath:\n{}", + dump(&tests_out) + ) + }); + assert_eq!(std::fs::read(&consumed_tests).unwrap(), tests_patched); + let tests_dirs: Vec = std::fs::read_dir(c.version_dir()) + .unwrap() + .flatten() + .map(|e| e.path()) + .filter(|p| p.join(&tests_leaf).is_file()) + .collect(); + assert_eq!(tests_dirs.len(), 1, "{tests_dirs:?}"); + assert_ne!(tests_dirs[0], dirs[0]); + let tests_now = std::fs::read(tests_dirs[0].join(&tests_leaf)).unwrap(); + assert_eq!( + tests_now, tests_patched, + "(e) the classifier jar must be patched" + ); + report.insert("e_classifierHashDir".into(), true.into()); + + // (f) build logic after the apply (a fresh, daemon-less build). + let logic = run(&["--offline", "buildLogicMarker"]); + let logic_marker = marker(&logic).unwrap_or_default(); + let logic_patched = logic_marker == victim_marker(VICTIM_VERSION, "patched"); + report.insert("f_buildLogicMarker".into(), logic_marker.clone().into()); + report.insert("f_buildLogicPatched".into(), logic_patched.into()); + assert!( + logic_patched || stale, + "(f) build logic still runs the pristine class but apply did not report the stale \ + derived copy: {logic_marker}" + ); + + // (g) the fat jar from the build cache carries the patched member. + let fat = run(&["--offline", "--build-cache", "fatJar"]); + assert!(ok(&fat), "{}", dump(&fat)); + let fat_jar = std::fs::read(c.proj.join("build/libs/fat.jar")).unwrap(); + let fat_notice = jar_member(&fat_jar, NOTICE).unwrap_or_default(); + report.insert( + "g_buildCacheFatJarPatched".into(), + (fat_notice == patched_notice()).into(), + ); + assert_eq!( + String::from_utf8_lossy(&fat_notice), + String::from_utf8_lossy(&patched_notice()), + "(g) the build-cache jar task must pick up the patched member" + ); + + // (f) VEX over the build-logic copies. Gradle instrumented the + // buildscript jar before the apply; that copy, left in its derived + // cache, may withhold the statement — but clearing the derived caches + // (what the warning says) and rebuilding must lead to one: Gradle + // instruments the patched jar anew. + let (statements, vex_json) = c.vex(&[]); + report.insert("f_vexStatementsBeforeClear".into(), statements.into()); + report.insert("f_vexWarningsBeforeClear".into(), codes(&vex_json).into()); + let caches = c.home.join("caches"); + for entry in std::fs::read_dir(&caches).unwrap().flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + let path = entry.path(); + if name.starts_with("jars-") || name.starts_with("transforms-") { + std::fs::remove_dir_all(&path).unwrap(); + } else if name.starts_with(|c: char| c.is_ascii_digit()) && path.join("transforms").is_dir() + { + std::fs::remove_dir_all(path.join("transforms")).unwrap(); + } + } + let rebuilt = run(&["--offline", "buildLogicMarker", "printRuntimeClasspath"]); + assert!(ok(&rebuilt), "{}", dump(&rebuilt)); + assert_eq!( + marker(&rebuilt).as_deref(), + Some(victim_marker(VICTIM_VERSION, "patched").as_str()) + ); + let (statements, vex_json) = c.vex(&[]); + report.insert("f_vexStatementsAfterClear".into(), statements.into()); + report.insert("f_vexWarningsAfterClear".into(), codes(&vex_json).into()); + assert!( + statements > 0 && !has(&vex_json, "vex_gradle_unpatched_copy"), + "(f) VEX must attest every record once the derived caches are rebuilt from the patched \ + jar: {vex_json}" + ); + + probe_report(&c.cell_name("canaries"), &serde_json::Value::Object(report)); +} + +/// `src` copied to `dst`, recursively. +fn copy_tree(src: &Path, dst: &Path) { + for file in walkdir(src) { + let target = dst.join(file.strip_prefix(src).unwrap()); + std::fs::create_dir_all(target.parent().unwrap()).unwrap(); + std::fs::copy(&file, &target).unwrap(); + } +} + +/// Pure checks of the fixture this suite builds on (run everywhere). +#[test] +fn gradle_agent_fixture_self_check() { + let pristine = pristine_jar(); + assert!(sha1_hex(&pristine).starts_with('0')); + let patched = patched_jar(); + assert_eq!(jar_member(&patched, NOTICE).unwrap(), patched_notice()); + // Only the two patched members differ. + for member in ["META-INF/MANIFEST.MF", jvm_fixture_repo::PAD_MEMBER] { + assert_eq!(jar_member(&patched, member), jar_member(&pristine, member)); + } +} diff --git a/crates/socket-patch-cli/tests/e2e_gradle_discovery_build.rs b/crates/socket-patch-cli/tests/e2e_gradle_discovery_build.rs new file mode 100644 index 000000000..46a90b34d --- /dev/null +++ b/crates/socket-patch-cli/tests/e2e_gradle_discovery_build.rs @@ -0,0 +1,592 @@ +//! Gradle discovery through the real `socket-patch scan --json`: packages +//! in a Gradle user home's `files-2.1` are found (#349), the Maven local +//! repository is left out of a Gradle-only build that never declares +//! `mavenLocal()` (#551) and the lock-membership annotation (`inLock`). +//! +//! The fabricated-cache tests run everywhere. The real-Gradle capstone +//! (`gradle_agent_349_scan_finds_gradle_cache`, `#[ignore]`) lets real +//! Gradle populate a fresh user home from the fake Central +//! (`jvm_fixture_repo`) and scans it; toolchain selection is +//! `gradle_build_common`'s `SOCKET_PATCH_GRADLE_E2E_*` knobs. + +#[path = "prebuilt_common/mod.rs"] +mod prebuilt_common; + +#[path = "gradle_build_common/mod.rs"] +mod gradle_build_common; + +#[path = "jvm_fixture_repo/mod.rs"] +mod jvm_fixture_repo; + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use gradle_build_common::{ + fixture_root, init_script, mirror_init_script, print_cp, print_cp_task, probe_report, + write_project, Dsl, Gradle, +}; +use prebuilt_common::fabricate_files21; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, Request, ResponseTemplate}; + +const SUITE: &str = "e2e_gradle_discovery_build"; +const ORG: &str = "test-org"; +const COMMONS_TEXT: &str = "pkg:maven/org.apache.commons/commons-text@1.10.0"; +const BUILD_PLUGIN: &str = "pkg:maven/com.example/build-plugin@1.0"; +const M2_ONLY: &str = "pkg:maven/com.example/m2-only@3.0"; + +fn binary() -> PathBuf { + env!("CARGO_BIN_EXE_socket-patch").into() +} + +/// A batch endpoint that answers every queried purl with one free patch, +/// so each crawled package shows up in `packages[]`. +async fn mock_batch_all(server: &MockServer) { + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(|req: &Request| { + let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap_or_default(); + let packages: Vec = body["components"] + .as_array() + .into_iter() + .flatten() + .filter_map(|c| c["purl"].as_str()) + .map(|purl| { + serde_json::json!({ + "purl": purl, + "patches": [{ + "uuid": "11111111-2222-4333-8444-555555555555", + "purl": purl, "tier": "free", "cveIds": [], "ghsaIds": [], + "severity": "high", "title": "gradle discovery fixture" + }] + }) + }) + .collect(); + ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": packages, "canAccessPaidPatches": false, + })) + }) + .mount(server) + .await; +} + +struct Scan { + env: serde_json::Value, + /// Every purl the batch requests named. + queried: Vec, + stderr: String, +} + +impl Scan { + fn warning(&self, code: &str) -> Option { + self.env["warnings"] + .as_array()? + .iter() + .find(|w| w["code"] == code) + .and_then(|w| w["detail"].as_str()) + .map(str::to_string) + } + + /// The `level` of the run-level warning `code`. + fn warning_level(&self, code: &str) -> Option { + self.env["warnings"] + .as_array()? + .iter() + .find(|w| w["code"] == code) + .and_then(|w| w["level"].as_str()) + .map(str::to_string) + } + + fn package(&self, purl: &str) -> Option<&serde_json::Value> { + self.env["packages"] + .as_array()? + .iter() + .find(|p| p["purl"] == purl) + } +} + +/// `socket-patch scan --json` in `cwd` with the Gradle user home `gradle_home` +/// (`None` = no `GRADLE_USER_HOME`) and the Maven local repository `m2`, +/// plus `extra` arguments, against a mock API; every other JVM cache +/// scrubbed (`prebuilt_common::prepare_command`). +fn scan(cwd: &Path, gradle_home: Option<&Path>, m2: &Path, extra: &[&str]) -> Scan { + let rt = tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .enable_all() + .build() + .unwrap(); + let server = rt.block_on(async { + let server = MockServer::start().await; + mock_batch_all(&server).await; + server + }); + let mut cmd = Command::new(binary()); + for (key, _) in std::env::vars_os() { + if key.to_string_lossy().starts_with("SOCKET_") { + cmd.env_remove(&key); + } + } + let uri = server.uri(); + let mut args = vec![ + "scan", + "--json", + "--api-url", + uri.as_str(), + "--api-token", + "fake-token-for-test", + "--org", + ORG, + ]; + args.extend_from_slice(extra); + let env: Vec<(&str, &str)> = gradle_home + .map(|h| ("GRADLE_USER_HOME", h.to_str().unwrap())) + .into_iter() + .collect(); + prebuilt_common::prepare_command(&mut cmd, cwd, &args, &env); + let out = cmd + .current_dir(cwd) + .env("SOCKET_TELEMETRY_DISABLED", "1") + .env("SOCKET_NO_CONFIG", "1") + .env("MAVEN_REPO_LOCAL", m2) + .env_remove("M2_HOME") + .env_remove("VIRTUAL_ENV") + .output() + .expect("run socket-patch"); + let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); + assert_eq!(out.status.code(), Some(0), "scan: {stdout}\n{stderr}"); + let env: serde_json::Value = serde_json::from_str(stdout.trim()) + .unwrap_or_else(|e| panic!("scan: not JSON ({e})\n{stdout}\n{stderr}")); + let mut queried: Vec = rt + .block_on(server.received_requests()) + .unwrap_or_default() + .iter() + .filter(|r| r.url.path().ends_with("/patches/batch")) + .flat_map(|r| { + let body: serde_json::Value = serde_json::from_slice(&r.body).unwrap_or_default(); + body["components"] + .as_array() + .into_iter() + .flatten() + .filter_map(|c| c["purl"].as_str().map(str::to_string)) + .collect::>() + }) + .collect(); + queried.sort(); + Scan { + env, + queried, + stderr, + } +} + +/// A fixture machine: a project dir, a Gradle user home and an m2. +struct Fixture { + _tmp: tempfile::TempDir, + project: PathBuf, + gradle_home: PathBuf, + m2: PathBuf, +} + +impl Fixture { + fn new() -> Self { + let tmp = tempfile::tempdir().unwrap(); + let root = fixture_root(&tmp); + let fx = Self { + project: root.join("project"), + gradle_home: root.join("gradle-home"), + m2: root.join("m2"), + _tmp: tmp, + }; + for dir in [&fx.project, &fx.gradle_home, &fx.m2] { + std::fs::create_dir_all(dir).unwrap(); + } + fx + } + + fn write(&self, rel: &str, text: &str) { + write_project(&self.project, &[(rel, text)]); + } + + fn scan(&self) -> Scan { + self.scan_with(&[]) + } + + fn scan_with(&self, extra: &[&str]) -> Scan { + scan(&self.project, Some(&self.gradle_home), &self.m2, extra) + } + + fn cache_commons_text(&self) -> PathBuf { + fabricate_files21( + &self.gradle_home, + "org.apache.commons:commons-text:1.10.0", + &[ + ("commons-text-1.10.0.jar", b"commons-text jar"), + ("commons-text-1.10.0.pom", b""), + ], + ) + } + + /// `/com/example/m2-only/3.0/m2-only-3.0.pom`. + fn m2_only(&self) { + let dir = self.m2.join("com/example/m2-only/3.0"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("m2-only-3.0.pom"), + "com.examplem2-only\ + 3.0", + ) + .unwrap(); + } +} + +const BUILD: &str = "plugins { id 'java' }\n\ + repositories { mavenCentral() }\n\ + dependencies { implementation 'org.apache.commons:commons-text:1.10.0' }\n"; + +/// `scan --json` on a fabricated GRADLE_USER_HOME with an empty m2 reports +/// the Gradle-cached package. +#[test] +fn scan_reports_gradle_user_home_packages_with_empty_m2() { + let fx = Fixture::new(); + fx.write("settings.gradle", "rootProject.name = 'p'\n"); + fx.write("build.gradle", BUILD); + let version_dir = fx.cache_commons_text(); + assert_eq!(crawled_path(&fx, COMMONS_TEXT), Some(version_dir)); + + let scan = fx.scan(); + assert_eq!(scan.env["scannedPackages"], 1, "{}", scan.env); + assert_eq!(scan.queried, vec![COMMONS_TEXT.to_string()]); + assert!(scan.package(COMMONS_TEXT).is_some(), "{}", scan.env); + assert_eq!( + scan.warning("gradle_build_ignores_m2"), + None, + "{}", + scan.env + ); +} + +/// Lock files only annotate: a cached build-logic module no lock names is +/// still scanned (`inLock: false`), the locked one is `inLock: true`. +#[test] +fn scan_annotates_lock_membership_without_filtering() { + let fx = Fixture::new(); + fx.write( + "build.gradle", + &format!( + "buildscript {{ dependencies {{ classpath 'com.example:build-plugin:1.0' }} }}\n\ + {BUILD}dependencyLocking {{ lockAllConfigurations() }}\n" + ), + ); + fx.write( + "gradle.lockfile", + "# This is a Gradle generated file for dependency locking.\n\ + org.apache.commons:commons-text:1.10.0=compileClasspath,runtimeClasspath\n\ + empty=annotationProcessor\n", + ); + fx.cache_commons_text(); + fabricate_files21( + &fx.gradle_home, + "com.example:build-plugin:1.0", + &[("build-plugin-1.0.jar", b"plugin jar")], + ); + + let scan = fx.scan(); + assert_eq!( + scan.queried, + vec![BUILD_PLUGIN.to_string(), COMMONS_TEXT.to_string()] + ); + assert_eq!( + scan.package(COMMONS_TEXT).unwrap()["inLock"], + true, + "{}", + scan.env + ); + assert_eq!( + scan.package(BUILD_PLUGIN).unwrap()["inLock"], + false, + "{}", + scan.env + ); + + // A global run in the same build reads its locks too: `inLock` is + // never a lock membership that was not computed. + let files21 = fx.gradle_home.join(prebuilt_common::GRADLE_FILES21); + for extra in [ + &["--global", "--ecosystems", "maven"][..], + &["--global-prefix", files21.to_str().unwrap()], + ] { + let scan = fx.scan_with(extra); + assert_eq!( + scan.package(COMMONS_TEXT).unwrap()["inLock"], + true, + "{extra:?}: {}", + scan.env + ); + assert_eq!( + scan.package(BUILD_PLUGIN).unwrap()["inLock"], + false, + "{extra:?}: {}", + scan.env + ); + } + + // Outside any Gradle build there are no locks to consult: no `inLock`. + let elsewhere = fx.project.parent().unwrap().join("elsewhere"); + std::fs::create_dir_all(&elsewhere).unwrap(); + let scan = crate::scan( + &elsewhere, + Some(&fx.gradle_home), + &fx.m2, + &["--global-prefix", files21.to_str().unwrap()], + ); + let pkg = scan.package(COMMONS_TEXT).unwrap(); + assert_eq!(pkg.get("inLock"), None, "{}", scan.env); +} + +/// With `--global-prefix` the cache is named outright, so the user home +/// Gradle would pick (here the passwd home, not the scrubbed `$HOME`) is +/// never mentioned. +#[test] +fn global_prefix_scan_says_nothing_about_the_user_home() { + let fx = Fixture::new(); + fx.write("build.gradle", BUILD); + let version_dir = fx.cache_commons_text(); + let files21 = version_dir.ancestors().nth(3).unwrap(); + let scan = scan( + &fx.project, + None, + &fx.m2, + &["--global-prefix", files21.to_str().unwrap()], + ); + assert_eq!(scan.queried, vec![COMMONS_TEXT.to_string()], "{}", scan.env); + assert_eq!( + scan.warning("gradle_user_home_differs"), + None, + "{}", + scan.env + ); +} + +/// #551: a Gradle-only build without mavenLocal() does not scan m2, and a +/// locked module found only there is named in `gradle_build_ignores_m2`. +/// Declaring mavenLocal() brings m2 back. +#[test] +fn gradle_only_build_ignores_m2_and_says_so() { + let fx = Fixture::new(); + fx.write("build.gradle", BUILD); + fx.write( + "gradle.lockfile", + "com.example:m2-only:3.0=runtimeClasspath\n\ + org.apache.commons:commons-text:1.10.0=runtimeClasspath\n", + ); + fx.cache_commons_text(); + fx.m2_only(); + + let scan = fx.scan(); + assert_eq!( + scan.queried, + vec![COMMONS_TEXT.to_string()], + "{}", + scan.stderr + ); + let detail = scan + .warning("gradle_build_ignores_m2") + .unwrap_or_else(|| panic!("no gradle_build_ignores_m2: {}", scan.env)); + assert!(detail.contains(M2_ONLY), "{detail}"); + assert!(!detail.contains(COMMONS_TEXT), "{detail}"); + assert_eq!( + scan.warning_level("gradle_build_ignores_m2").as_deref(), + Some("warn") + ); + + fx.write( + "build.gradle", + &BUILD.replace("mavenCentral()", "mavenLocal()\nmavenCentral()"), + ); + let scan = fx.scan(); + assert_eq!( + scan.queried, + vec![M2_ONLY.to_string(), COMMONS_TEXT.to_string()] + ); + assert_eq!(scan.warning("gradle_build_ignores_m2"), None); + assert_eq!(scan.package(M2_ONLY).unwrap().get("inLock"), None); +} + +/// #551: a script reference that cannot be followed keeps m2, with a note +/// saying why. +#[test] +fn undetermined_maven_local_keeps_m2_with_a_note() { + let fx = Fixture::new(); + fx.write( + "build.gradle", + &format!("{BUILD}apply from: rootProject.file(System.getenv('REPOS') ?: 'r.gradle')\n"), + ); + fx.m2_only(); + + let scan = fx.scan(); + assert_eq!(scan.queried, vec![M2_ONLY.to_string()]); + let detail = scan + .warning("gradle_maven_local_undetermined") + .unwrap_or_else(|| panic!("no gradle_maven_local_undetermined: {}", scan.env)); + assert!(detail.contains("mavenLocal()"), "{detail}"); + assert_eq!( + scan.warning_level("gradle_maven_local_undetermined") + .as_deref(), + Some("info") + ); +} + +// ── real Gradle ───────────────────────────────────────────────────────── + +/// The path the crawler reports for `purl` in `fx`'s project: the first +/// root, in scan order, of `get_jvm_cache_roots_with` (under the fixture's +/// `GRADLE_USER_HOME` and m2, never the process env) that resolves it. +fn crawled_path(fx: &Fixture, purl: &str) -> Option { + use socket_patch_core::crawlers::maven_crawler::JvmEnv; + use socket_patch_core::crawlers::types::CrawlerOptions; + use socket_patch_core::crawlers::MavenCrawler; + use socket_patch_core::gradle::Os; + + let env: std::collections::HashMap = [ + ("GRADLE_USER_HOME", &fx.gradle_home), + ("MAVEN_REPO_LOCAL", &fx.m2), + ] + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string_lossy().into_owned())) + .collect(); + let env = JvmEnv::resolve(&env, Os::current(), None); + let options = CrawlerOptions { + cwd: fx.project.clone(), + global: false, + global_prefix: None, + }; + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async { + for root in MavenCrawler.get_jvm_cache_roots_with(&options, &env).await { + let found = MavenCrawler + .find_by_purls(&root.path, &[purl.to_string()]) + .await + .unwrap_or_default(); + if let Some(pkg) = found.get(purl) { + return Some(pkg.path.clone()); + } + } + None + }) +} + +/// #349 on real Gradle: Gradle resolves the fixture into a fresh user home, +/// `scan` reports the module from it (with an empty m2), and the version +/// dir the crawler reports expands to exactly the hash dir whose jar Gradle +/// put on the classpath (`victim-1.10.0.jar`'s sha1 starts with `0`, the +/// case some releases spell without the leading zero). +#[test] +#[ignore = "real Gradle (SOCKET_PATCH_GRADLE_E2E_*)"] +fn gradle_agent_349_scan_finds_gradle_cache() { + use socket_patch_core::crawlers::gradle_cache; + use socket_patch_core::manifest::schema::PatchFileInfo; + + let fx = Fixture::new(); + let Some(gradle) = Gradle::detect(SUITE, &fx.gradle_home) else { + return; + }; + let central = jvm_fixture_repo::FakeCentral::start(); + let victim = jvm_fixture_repo::victim_purl(jvm_fixture_repo::VICTIM_VERSION); + let leaf = format!( + "{}-{}.jar", + jvm_fixture_repo::VICTIM, + jvm_fixture_repo::VICTIM_VERSION + ); + fx.write("settings.gradle", "rootProject.name = 'discovery'\n"); + fx.write( + "build.gradle", + &format!( + "plugins {{ id 'java' }}\n\ + repositories {{ mavenCentral() }}\n\ + dependencies {{ implementation '{}:{}:{}' }}\n{}", + jvm_fixture_repo::GROUP, + jvm_fixture_repo::VICTIM, + jvm_fixture_repo::VICTIM_VERSION, + print_cp_task(Dsl::Groovy, "runtimeClasspath") + ), + ); + let init = init_script( + &fx.project.parent().unwrap().join("init"), + "mirror.gradle", + &mirror_init_script(¢ral.uri(), None), + ); + let out = gradle.run( + &fx.project, + &fx.gradle_home, + &[&init[0], &init[1], "printRuntimeClasspath"], + ); + let cp = print_cp(&out, "printRuntimeClasspath"); + let consumed = cp + .iter() + .find(|p| p.file_name().is_some_and(|n| n == leaf.as_str())) + .unwrap_or_else(|| panic!("no {leaf} on the classpath: {cp:?}")) + .clone(); + + let scan = fx.scan(); + assert!( + scan.queried.contains(&victim), + "scan did not report {victim}: {:?}\n{}", + scan.queried, + scan.stderr + ); + assert!(scan.package(&victim).is_some(), "{}", scan.env); + assert_eq!(scan.warning("gradle_build_ignores_m2"), None); + + // The crawler, over the roots this build scans (the fixture's caches, + // in scan order), reports the version dir; installed_copies expands + // it to the hash dir Gradle wrote and the build consumed. + let files21 = fx.gradle_home.join(prebuilt_common::GRADLE_FILES21); + let expected_dir = files21 + .join(jvm_fixture_repo::GROUP) + .join(jvm_fixture_repo::VICTIM) + .join(jvm_fixture_repo::VICTIM_VERSION); + let version_dir = crawled_path(&fx, &victim) + .unwrap_or_else(|| panic!("the crawler does not resolve {victim}")); + assert_eq!(version_dir, expected_dir); + assert!(gradle_cache::is_gradle_version_dir(&version_dir)); + let files = std::collections::HashMap::from([( + leaf.clone(), + PatchFileInfo { + before_hash: "x".into(), + after_hash: "y".into(), + }, + )]); + let copies = gradle_cache::installed_copies_detailed(&version_dir, &files); + assert!(copies.missing.is_empty(), "{copies:?}"); + let consumed_dir = std::fs::canonicalize(consumed.parent().unwrap()).unwrap(); + let dirs: Vec = copies + .targets + .iter() + .map(|(d, _)| std::fs::canonicalize(d).unwrap()) + .collect(); + assert_eq!(dirs, vec![consumed_dir.clone()], "{copies:?}"); + let hash_dir = consumed_dir + .file_name() + .unwrap() + .to_str() + .unwrap() + .to_string(); + let jar = std::fs::read(&consumed).unwrap(); + assert!(gradle_cache::pristine(&hash_dir, &jar), "{hash_dir}"); + let sha1 = jvm_fixture_repo::sha1_hex(&jar); + probe_report( + &format!("{SUITE}-349-gradle-{}", gradle.version), + &serde_json::json!({ + "gradle": gradle.version, + "jvm": gradle.jvm, + "jar": leaf, + "sha1": sha1, + "hashDir": hash_dir, + "leadingZeroDropped": hash_dir.len() < 40, + }), + ); +} diff --git a/crates/socket-patch-cli/tests/e2e_maven.rs b/crates/socket-patch-cli/tests/e2e_maven.rs index 6f4474404..22d7e940d 100644 --- a/crates/socket-patch-cli/tests/e2e_maven.rs +++ b/crates/socket-patch-cli/tests/e2e_maven.rs @@ -251,10 +251,16 @@ async fn scan_discovers_gradle_project_artifacts() { ) .unwrap(); - // Create a build.gradle in the project directory (Gradle project) + // Create a build.gradle in the project directory (Gradle project). It + // declares mavenLocal(): a Gradle-only build without it never + // reads the Maven local repository, so scan leaves m2 out for it (#551). let project_dir = dir.path().join("project"); std::fs::create_dir_all(&project_dir).unwrap(); - std::fs::write(project_dir.join("build.gradle"), "plugins { id 'java' }\n").unwrap(); + std::fs::write( + project_dir.join("build.gradle"), + "plugins { id 'java' }\nrepositories { mavenLocal() }\n", + ) + .unwrap(); // --- JSON run: the `scannedPackages` count is the contract field ----- // A single artifact lives in the repo. We assert the *value* (1), not diff --git a/crates/socket-patch-cli/tests/e2e_redirect_gradle_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_gradle_build.rs new file mode 100644 index 000000000..813220f3d --- /dev/null +++ b/crates/socket-patch-cli/tests/e2e_redirect_gradle_build.rs @@ -0,0 +1,2295 @@ +//! Hosted mode (`scan --mode hosted`) against real Gradle: Gradle resolves +//! the deterministic fake Central (`jvm_fixture_repo`), `socket-patch` +//! wires the owned hosted settings script, its index and the lock files, +//! and every assertion is on the bytes Gradle then CONSUMES (the +//! `printRuntimeClasspath` marker task's jar), never only the CLI's report. +//! +//! The Socket repository is served by the same fake Central under the +//! production path (`/patch-registry/maven///maven2/…`): the +//! test-only mirror init script maps `https://patch.socket.dev/…` (what the +//! committed index names) onto it, and every other repository onto the +//! fake Central. The suffixed artifacts are the patched jar, the upstream +//! pom re-versioned to the suffixed version and — like the patch service +//! (depscan) — the upstream `.module` with its component version and jar +//! file entry rewritten to the suffixed, patched jar. +//! +//! Every test is `#[ignore]` and prefixed `gradle_hosted_` (the CI filter +//! contract, `scripts/ci-e2e-bundle.py`), and runs both DSLs unless the +//! case is DSL-specific; toolchain selection is `gradle_build_common`'s +//! `SOCKET_PATCH_GRADLE_E2E_*` knobs. + +#[path = "prebuilt_common/mod.rs"] +mod prebuilt_common; + +#[path = "gradle_build_common/mod.rs"] +mod gradle_build_common; + +#[path = "jvm_fixture_repo/mod.rs"] +mod jvm_fixture_repo; + +#[path = "hosted_maven_common/mod.rs"] +mod hosted_maven_common; + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use gradle_build_common::{ + configuration_reused, dump, fixture_root, for_each_dsl, gradle_classpath, init_script, + jar_member, lockfiles, mirror_init_script, ok, print_cp_task, probe_report, snapshot, Dsl, + Gradle, +}; +use hosted_maven_common::{Hosted, Server}; +use jvm_fixture_repo::{ + md5_hex, notice, repo_path, sha1_hex, sha256_hex, sha512_hex, victim_class, FakeCentral, + CONSUMER, CONSUMER_RANGE, CONSUMER_VERSION, GROUP, NOTICE, VICTIM, VICTIM_CLASS_MEMBER, + VICTIM_OLD, VICTIM_VERSION, +}; +use wiremock::matchers::{method, path, path_regex}; +use wiremock::{Mock, ResponseTemplate}; + +const SUITE: &str = "e2e_redirect_gradle_build"; +const ORG: &str = "test-org"; +const UUID: &str = "4d5e6f70-8192-4a3b-9c4d-5e6f708192a3"; +const HEX8: &str = "4d5e6f70"; +const TOKEN: &str = "22222222-3333-4444-8555-666666666666"; +const GHSA: &str = "GHSA-gr4d-h057-0347"; +const CVE: &str = "CVE-2026-0347"; +const PRODUCT: &str = "pkg:maven/com.example/app@1.0"; +const SCRIPT_REL: &str = ".socket/gradle/socket-patch.hosted.settings.gradle"; +const INDEX_REL: &str = ".socket/gradle/hosted-index.tsv"; + +const HOSTED: Hosted = Hosted { + org: ORG, + uuid: UUID, + hex8: HEX8, + token: TOKEN, + ghsa: GHSA, + cve: CVE, + group: GROUP, + artifact: VICTIM, + version: VICTIM_VERSION, + title: "gradle hosted e2e", +}; + +fn sfx() -> String { + HOSTED.suffixed() +} + +fn coordinate() -> String { + format!("{GROUP}:{VICTIM}:{VICTIM_VERSION}") +} + +fn purl() -> String { + jvm_fixture_repo::victim_purl(VICTIM_VERSION) +} + +fn git_sha256(bytes: &[u8]) -> String { + socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes(bytes) +} + +fn central_file(path: &str) -> Vec { + jvm_fixture_repo::repository() + .remove(path) + .unwrap_or_else(|| panic!("the fixture serves no {path}")) +} + +fn pristine_jar() -> Vec { + central_file(&repo_path(VICTIM, VICTIM_VERSION, None, "jar")) +} + +fn pristine_notice() -> Vec { + notice(&coordinate(), "pristine").into_bytes() +} + +fn patched_notice() -> Vec { + notice(&coordinate(), "patched").into_bytes() +} + +/// The pristine victim with NOTICE and `Victim.class` patched. +fn patched_jar() -> Vec { + use std::io::Read as _; + let jar = pristine_jar(); + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(jar)).unwrap(); + let mut members = Vec::new(); + for i in 0..archive.len() { + let mut entry = archive.by_index(i).unwrap(); + let mut buf = Vec::new(); + entry.read_to_end(&mut buf).unwrap(); + let name = entry.name().to_string(); + let buf = if name == NOTICE { + patched_notice() + } else if name == VICTIM_CLASS_MEMBER { + victim_class(VICTIM_VERSION, "patched") + } else { + buf + }; + members.push((name, buf)); + } + jvm_fixture_repo::jar(&members) +} + +/// The Socket repository path of the suffixed `ext` (no leading `/`). +fn socket_path(ext: &str) -> String { + HOSTED.served_path(ext).trim_start_matches('/').to_string() +} + +/// The upstream `.module` as the patch service serves it: the suffixed +/// component version, the jar entry renamed to the suffixed jar with its +/// size and digests, and the variants shipping any other file dropped. +fn served_module(jar: &[u8]) -> Vec { + let upstream = central_file(&repo_path(VICTIM, VICTIM_VERSION, None, "module")); + let mut module: serde_json::Value = serde_json::from_slice(&upstream).unwrap(); + module["component"]["version"] = sfx().into(); + let base_jar = format!("{VICTIM}-{VICTIM_VERSION}.jar"); + let sfx_jar = format!("{VICTIM}-{}.jar", sfx()); + let variants: Vec = module["variants"] + .as_array() + .unwrap() + .iter() + .filter(|v| { + v["files"] + .as_array() + .is_none_or(|fs| fs.iter().all(|f| f["url"] == base_jar.as_str())) + }) + .cloned() + .map(|mut v| { + for f in v["files"].as_array_mut().into_iter().flatten() { + f["name"] = sfx_jar.clone().into(); + f["url"] = sfx_jar.clone().into(); + f["size"] = jar.len().into(); + f["sha512"] = sha512_hex(jar).into(); + f["sha256"] = sha256_hex(jar).into(); + f["sha1"] = sha1_hex(jar).into(); + f["md5"] = md5_hex(jar).into(); + } + v + }) + .collect(); + module["variants"] = variants.into(); + (serde_json::to_string_pretty(&module).unwrap() + "\n").into_bytes() +} + +/// What the Socket repository serves for the patch. +struct Served { + jar: Vec, + pom: Vec, + /// `None`: the `.module` 404s (a service that predates serving it). + module: Option>, +} + +impl Served { + fn new(module: bool) -> Self { + let jar = patched_jar(); + let upstream_pom = central_file(&repo_path(VICTIM, VICTIM_VERSION, None, "pom")); + Served { + pom: HOSTED.served_pom(&upstream_pom), + module: module.then(|| served_module(&jar)), + jar, + } + } +} + +/// One real-Gradle cell: a project, its own Gradle user home, the fake +/// Central (which also serves the Socket repository) and the fake API. +struct Cell { + _tmp: tempfile::TempDir, + root: PathBuf, + proj: PathBuf, + home: PathBuf, + gradle: Gradle, + central: FakeCentral, + api: Server, + init: [String; 2], + dsl: Dsl, +} + +/// Start a cell (`None`: no Gradle, skipped) with the project `files`. +fn cell(dsl: Dsl, files: &[(String, String)]) -> Option { + let tmp = tempfile::tempdir().unwrap(); + let root = fixture_root(&tmp); + let home = root.join(".gradle"); + std::fs::create_dir_all(&home).unwrap(); + let gradle = Gradle::detect(SUITE, &home)?; + let central = FakeCentral::start(); + let init = init_script( + &root.join("init"), + "mirror.gradle", + &mirror_init_script(¢ral.uri(), Some(¢ral.uri())), + ); + let proj = root.join("proj"); + std::fs::create_dir_all(&proj).unwrap(); + let borrowed: Vec<(&str, &str)> = files + .iter() + .map(|(rel, body)| (rel.as_str(), body.as_str())) + .collect(); + gradle_build_common::write_project(&proj, &borrowed); + Some(Cell { + _tmp: tmp, + root, + proj, + home, + gradle, + central, + api: Server::start(), + init, + dsl, + }) +} + +/// A plain single-project build: `java`, Central, `deps` (dependency +/// notations, one per line, in the DSL's spelling) and the classpath task. +fn single(dsl: Dsl, deps: &[&str], extra: &str) -> Vec<(String, String)> { + let deps: String = deps.iter().map(|d| format!(" {d}\n")).collect(); + let (settings, build) = match dsl { + Dsl::Groovy => ( + "rootProject.name = 'app'\n".to_string(), + format!( + "plugins {{ id 'java' }}\nrepositories {{ mavenCentral() }}\ndependencies {{\n{deps}}}\n{extra}{}", + print_cp_task(dsl, "runtimeClasspath") + ), + ), + Dsl::Kotlin => ( + "rootProject.name = \"app\"\n".to_string(), + format!( + "plugins {{ java }}\nrepositories {{ mavenCentral() }}\ndependencies {{\n{deps}}}\n{extra}{}", + print_cp_task(dsl, "runtimeClasspath") + ), + ), + }; + vec![(dsl.settings_file(), settings), (dsl.build_file(), build)] +} + +/// `implementation ''` in the DSL's spelling. +fn implementation(dsl: Dsl, notation: &str) -> String { + match dsl { + Dsl::Groovy => format!("implementation '{notation}'"), + Dsl::Kotlin => format!("implementation(\"{notation}\")"), + } +} + +impl Cell { + /// Serve the patch: the Socket repository's suffixed files, the grant + /// (with `mavenModuleSha256` when the `.module` is served) and the + /// patch view (`files`: `(record key, after bytes)`). + fn serve(&self, served: &Served, files: &[(String, Vec)]) { + self.central.put(&socket_path("jar"), &served.jar); + self.central.put(&socket_path("pom"), &served.pom); + match &served.module { + Some(m) => self.central.put(&socket_path("module"), m), + None => self.central.remove(&socket_path("module")), + } + mount_api(&self.api, served, files); + } + + /// The leaf record: the whole jar. + fn serve_leaf(&self, served: &Served) { + let key = format!("{VICTIM}-{VICTIM_VERSION}.jar"); + self.serve(served, &[(key, served.jar.clone())]); + } + + /// `gradle ` with the mirror init script. + fn run(&self, args: &[&str]) -> Output { + self.run_in(&self.proj, args) + } + + fn run_in(&self, dir: &Path, args: &[&str]) -> Output { + let mut all: Vec<&str> = vec![&self.init[0], &self.init[1]]; + all.extend_from_slice(args); + self.gradle.run(dir, &self.home, &all) + } + + /// `printRuntimeClasspath` (plus `extra` first). + fn build(&self, extra: &[&str]) -> Output { + let mut args = extra.to_vec(); + args.push("printRuntimeClasspath"); + self.run(&args) + } + + /// The victim jar on the classpath of a successful run, and its NOTICE. + fn victim_on(&self, out: &Output, what: &str) -> (PathBuf, Vec) { + assert!(ok(out), "{what}:\n{}", dump(out)); + let cp = gradle_classpath(out); + let hits: Vec<&PathBuf> = cp + .iter() + .filter(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with(&format!("{VICTIM}-")) && n.ends_with(".jar")) + }) + .collect(); + assert_eq!( + hits.len(), + 1, + "{what}: one victim jar on {cp:?}\n{}", + dump(out) + ); + let bytes = std::fs::read(hits[0]).unwrap(); + (hits[0].clone(), jar_member(&bytes, NOTICE).unwrap()) + } + + /// The build consumes the suffixed, patched victim. + fn assert_patched(&self, out: &Output, what: &str) -> PathBuf { + let (jar, got) = self.victim_on(out, what); + assert_eq!( + String::from_utf8_lossy(&got), + String::from_utf8_lossy(&patched_notice()), + "{what}: {} is not the patched jar\n{}", + jar.display(), + dump(out) + ); + assert_eq!( + jar.file_name().unwrap().to_string_lossy(), + format!("{VICTIM}-{}.jar", sfx()), + "{what}: the suffixed version resolves" + ); + jar + } + + fn assert_pristine(&self, out: &Output, what: &str) { + let (jar, got) = self.victim_on(out, what); + assert_eq!( + String::from_utf8_lossy(&got), + String::from_utf8_lossy(&pristine_notice()), + "{what}: {} is not the pristine jar", + jar.display() + ); + } + + /// A failed run that never consumed the unpatched victim. + fn assert_fails_loud(&self, out: &Output, what: &str) { + assert!(!ok(out), "{what}: the build must fail:\n{}", dump(out)); + for p in gradle_classpath(out) { + let name = p.file_name().unwrap().to_string_lossy().into_owned(); + assert!( + !name.starts_with(&format!("{VICTIM}-1")) || name.contains("-socket."), + "{what}: an unpatched victim was consumed: {name}" + ); + } + } + + /// Resolve the project once so the base version is in the cache (the + /// installed GAV scan finds), consuming the pristine jar. + fn warm(&self) { + let out = self.build(&[]); + self.assert_pristine(&out, "warm (before the scan)"); + } + + /// `socket-patch --json --cwd ` under the cell's Gradle + /// home, with the fake API. + fn socket_in(&self, dir: &Path, args: &[&str]) -> (Option, serde_json::Value, String) { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_socket-patch")); + for (k, _) in std::env::vars_os() { + if k.to_string_lossy().starts_with("SOCKET_") { + cmd.env_remove(&k); + } + } + prebuilt_common::jvm_env::isolate_cli(&mut cmd); + cmd.args(args) + .args(["--json", "--cwd", dir.to_str().unwrap()]) + .env("SOCKET_TELEMETRY_DISABLED", "1") + .env("SOCKET_NO_CONFIG", "1") + .env("GRADLE_USER_HOME", &self.home) + .env_remove("M2_HOME"); + let out = cmd.output().expect("run socket-patch"); + let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); + let json = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { + panic!("{args:?}: not one JSON document ({e})\n{stdout}\n{stderr}") + }); + (out.status.code(), json, stderr) + } + + /// `socket-patch vendor …` (or `repair`) with the vendor fixture + /// server serving the Gradle cache (`prebuilt_common::prepare_command`). + /// `fixture` is the project the fixture server serves its records + /// from (the cell's own project when `None`). + fn vendor_cmd( + &self, + fixture: Option<&Path>, + extra: &[&str], + ) -> (Option, serde_json::Value) { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_socket-patch")); + for (k, _) in std::env::vars_os() { + if k.to_string_lossy().starts_with("SOCKET_") { + cmd.env_remove(&k); + } + } + let proj = self.proj.to_string_lossy().into_owned(); + let mut args: Vec<&str> = vec!["vendor"]; + args.extend_from_slice(extra); + args.extend(["--json", "--cwd", &proj]); + let _fixture = prebuilt_common::prepare_command( + &mut cmd, + fixture.unwrap_or(&self.proj), + &args, + &[("GRADLE_USER_HOME", self.home.to_str().unwrap())], + ); + let out = cmd + .current_dir(&self.proj) + .env("SOCKET_TELEMETRY_DISABLED", "1") + .env("SOCKET_NO_CONFIG", "1") + .env_remove("MAVEN_REPO_LOCAL") + .env_remove("M2_HOME") + .output() + .expect("run socket-patch"); + let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + let json = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { + panic!( + "vendor {extra:?}: not JSON ({e})\n{stdout}\n{}", + String::from_utf8_lossy(&out.stderr) + ) + }); + (out.status.code(), json) + } + + fn api_args(&self) -> Vec { + vec![ + "--api-url".into(), + self.api.uri(), + "--org".into(), + ORG.into(), + "--api-token".into(), + "fake".into(), + ] + } + + /// `scan --mode hosted --yes` (plus `extra`). + fn scan_in(&self, dir: &Path, extra: &[&str]) -> (Option, serde_json::Value) { + let api = self.api_args(); + let mut args: Vec<&str> = vec!["scan", "--mode", "hosted", "--yes"]; + args.extend(api.iter().map(String::as_str)); + args.extend_from_slice(extra); + let (code, json, stderr) = self.socket_in(dir, &args); + let _ = stderr; + (code, json) + } + + /// A successful hosted scan that redirected the victim. + fn scan_ok(&self) -> serde_json::Value { + let (code, json) = self.scan_in(&self.proj, &[]); + assert_eq!(code, Some(0), "scan --mode hosted: {json}"); + assert_eq!(json["redirect"]["mode"], "hosted", "{json}"); + assert_eq!(json["redirect"]["redirected"], 1, "{json}"); + json + } + + /// `vex` (org-scoped against the fake API): the statement count and + /// the envelope. + fn vex(&self, extra: &[&str]) -> (usize, serde_json::Value) { + let doc = self.root.join("vex.json"); + let _ = std::fs::remove_file(&doc); + let api = self.api_args(); + let mut args = vec!["vex", "-O", doc.to_str().unwrap(), "--product", PRODUCT]; + args.extend(api.iter().map(String::as_str)); + args.extend_from_slice(extra); + let (_, json, _) = self.socket_in(&self.proj, &args); + let statements = std::fs::read(&doc) + .ok() + .and_then(|b| serde_json::from_slice::(&b).ok()) + .and_then(|d| d["statements"].as_array().map(Vec::len)) + .unwrap_or(0); + (statements, json) + } + + /// Drop the cached victim (base and suffixed), so the next build must + /// download it. + fn purge_cache(&self) { + let dir = self + .home + .join(prebuilt_common::GRADLE_FILES21) + .join(GROUP) + .join(VICTIM); + let _ = std::fs::remove_dir_all(dir); + let _ = std::fs::remove_dir_all(self.home.join("caches/modules-2/metadata-2.97")); + for entry in std::fs::read_dir(self.home.join("caches/modules-2")) + .into_iter() + .flatten() + .flatten() + { + if entry.file_name().to_string_lossy().starts_with("metadata-") { + let _ = std::fs::remove_dir_all(entry.path()); + } + } + } + + fn file(&self, rel: &str) -> String { + std::fs::read_to_string(self.proj.join(rel)).unwrap_or_else(|e| panic!("{rel}: {e}")) + } + + fn cell_name(&self, test: &str) -> String { + format!( + "{SUITE}-{test}-{}-gradle-{}-{}", + self.dsl.name(), + self.gradle.version, + std::env::consts::OS + ) + } +} + +/// The grant, the patch view and the discovery routes `scan` drives. +fn mount_api(s: &Server, served: &Served, files: &[(String, Vec)]) { + mount_grants(s, &[(&HOSTED, served, files)]); +} + +/// One hosted patch for [`mount_grants`]: the patch, what the Socket +/// repository serves and the patch view's files. +type Grant<'a> = (&'a Hosted, &'a Served, &'a [(String, Vec)]); + +/// [`mount_api`] for several hosted patches at once. +fn mount_grants(s: &Server, grants: &[Grant<'_>]) { + let mut packages = Vec::new(); + let mut results = serde_json::Map::new(); + let mut by_package = Vec::new(); + let mut views = Vec::new(); + for (h, served, files) in grants { + let purl = h.purl(); + let uuid = h.uuid; + let artifact_url = format!( + "https://patch.socket.dev/patch/maven/{}/{}/{}/{TOKEN}/{uuid}/{}-{}.jar", + h.group, + h.artifact, + h.version, + h.artifact, + h.suffixed() + ); + let mut identifiers = serde_json::json!({ + "name": format!("{}/{}", h.group, h.artifact), + "version": h.version, + "mavenGroupId": h.group, + "mavenArtifactId": h.artifact, + "mavenSuffixedVersion": h.suffixed(), + "mavenPomSha256": sha256_hex(&served.pom), + }); + if let Some(m) = &served.module { + identifiers["mavenModuleSha256"] = sha256_hex(m).into(); + } + let view_files: serde_json::Map = files + .iter() + .map(|(key, after)| { + ( + key.clone(), + serde_json::json!({ "beforeHash": "a".repeat(64), "afterHash": git_sha256(after) }), + ) + }) + .collect(); + let vulnerabilities = serde_json::json!({ + GHSA: { "cves": [CVE], "summary": "s", "severity": "high", "description": "d" } + }); + let view = serde_json::json!({ + "uuid": uuid, + "purl": purl, + "publishedAt": "Fri, 27 Mar 2026 00:00:00 GMT", + "files": view_files, + "vulnerabilities": vulnerabilities.clone(), + "description": h.title, + "license": "MIT", + "tier": "free", + }); + packages.push(serde_json::json!({ "purl": purl, "patches": [{ + "uuid": uuid, "purl": purl, "tier": "free", "cveIds": [CVE], + "ghsaIds": [GHSA], "severity": "high", "title": h.title + }] })); + results.insert( + uuid.to_string(), + serde_json::json!({ + "status": "granted", + "url": artifact_url, + "purl": purl, + "artifacts": [{ + "kind": "tarball", + "url": artifact_url, + "integrity": { "sha1": sha1_hex(&served.jar), "sha256": sha256_hex(&served.jar) } + }], + "registryOverride": { + "kind": "maven2", + "indexUrl": h.prod_index_url(), + "identifiers": identifiers, + } + }), + ); + // One patch answers every by-package lookup (as before); several + // answer by artifact. + let route = if grants.len() == 1 { + format!("^/v0/orgs/{ORG}/patches/by-package/.+$") + } else { + format!("^/v0/orgs/{ORG}/patches/by-package/.*{}.*$", h.artifact) + }; + by_package.push(( + route, + serde_json::json!({ + "patches": [{ + "uuid": uuid, "purl": purl, "publishedAt": "2026-01-01T00:00:00Z", + "description": "d", "license": "MIT", "tier": "free", + "vulnerabilities": vulnerabilities + }], + "canAccessPaidPatches": false, + }), + )); + views.push((uuid, view)); + } + s.rt.block_on(async { + s.server.reset().await; + let mut mocks = vec![ + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": packages, + "canAccessPaidPatches": false, + }))), + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/package"))) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({ "results": results })), + ), + ]; + for (route, body) in by_package { + mocks.push( + Mock::given(method("GET")) + .and(path_regex(route)) + .respond_with(ResponseTemplate::new(200).set_body_json(body)), + ); + } + for (uuid, view) in views { + mocks.push( + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{uuid}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(view)), + ); + } + for m in mocks { + m.mount(&s.server).await; + } + }); +} + +/// The warning codes of an envelope's `redirect` block and top level. +fn codes(json: &serde_json::Value) -> Vec { + let mut out = Vec::new(); + for list in [&json["warnings"], &json["redirect"]["warnings"]] { + for w in list.as_array().into_iter().flatten() { + if let Some(c) = w["code"].as_str() { + out.push(c.to_string()); + } + } + } + out +} + +fn has(json: &serde_json::Value, code: &str) -> bool { + codes(json).iter().any(|c| c == code) +} + +/// The common run of a hosted cell: warm, serve, scan, and the build +/// consumes the patched jar. Returns the scan envelope. +fn wire_and_build(c: &Cell, served: &Served, what: &str) -> serde_json::Value { + c.warm(); + c.serve_leaf(served); + let json = c.scan_ok(); + let out = c.build(&[]); + c.assert_patched(&out, what); + json +} + +// ── the tests ─────────────────────────────────────────────────────────── + +/// A direct dependency: the owned files are written, the build resolves +/// the suffixed patched jar (its `.module` served), and a second scan +/// changes nothing and still confirms. +#[test] +#[ignore = "real Gradle; run with --ignored"] +fn gradle_hosted_direct() { + for_each_dsl(|dsl| { + let files = single(dsl, &[&implementation(dsl, &coordinate())], ""); + let Some(c) = cell(dsl, &files) else { return }; + let json = wire_and_build(&c, &Served::new(true), "direct"); + assert!( + !has(&json, "redirect_gradle_module_metadata_unavailable"), + "{json}" + ); + assert_eq!( + c.file(SCRIPT_REL), + socket_patch_core::patch::redirect::gradle::HOSTED_SCRIPT + ); + assert!(c.file(INDEX_REL).contains(&format!("\t{UUID}\n"))); + let settings = c.file(&dsl.settings_file()); + assert!(settings.contains("// socket-patch-hosted "), "{settings}"); + let before = snapshot(&c.proj); + let (code, again) = c.scan_in(&c.proj, &[]); + assert_eq!(code, Some(0), "{again}"); + assert_eq!( + again["redirect"]["redirected"], 1, + "rescan still confirms: {again}" + ); + assert_eq!(snapshot(&c.proj), before, "a rescan writes nothing"); + }); +} + +/// A service that does not serve the suffixed `.module` yet: the scan +/// warns and Gradle falls back to the pom. +#[test] +#[ignore = "real Gradle; run with --ignored"] +fn gradle_hosted_module_metadata_unavailable_falls_back_to_the_pom() { + let dsl = Dsl::Groovy; + let files = single(dsl, &[&implementation(dsl, &coordinate())], ""); + let Some(c) = cell(dsl, &files) else { return }; + let json = wire_and_build(&c, &Served::new(false), "pom fallback"); + assert!( + has(&json, "redirect_gradle_module_metadata_unavailable"), + "{json}" + ); +} + +/// The Groovy selector port in the hosted script agrees with the golden +/// tables (themselves checked against Gradle's own comparator by +/// [`gradle_hosted_selector_golden_tables_match_real_gradle`]) on this +/// Gradle major. +#[test] +#[ignore = "real Gradle; run with --ignored"] +fn gradle_hosted_selector_port_matches_golden_tables() { + use socket_patch_core::gradle::selector::{ + GOLDEN_ADMITS, GOLDEN_ADMITS_BY_MAJOR, GOLDEN_ORDERING, GOLDEN_ORDERING_BY_MAJOR, + }; + use std::cmp::Ordering; + let script = socket_patch_core::patch::redirect::gradle::HOSTED_SCRIPT; + let begin = script.find("// ---- socket-patch selector begin").unwrap(); + let end = script + .find("// ---- socket-patch selector end ----") + .unwrap(); + let port = &script[begin..end]; + let groovy = |s: &str| format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'")); + let mut probe = String::from(port); + probe.push_str("def socketRows = [\n"); + let mut want: Vec = Vec::new(); + let Some(c) = cell(Dsl::Groovy, &[("settings.gradle".into(), String::new())]) else { + return; + }; + let major = c.gradle.major(); + let ord = |o: Ordering| match o { + Ordering::Less => -1, + Ordering::Equal => 0, + Ordering::Greater => 1, + }; + for (a, b, o) in GOLDEN_ORDERING { + probe.push_str(&format!(" ['ORD', {}, {}],\n", groovy(a), groovy(b))); + want.push(format!("ORD\t{a}\t{b}\t{}", ord(*o))); + } + for (a, b, six, later) in GOLDEN_ORDERING_BY_MAJOR { + probe.push_str(&format!(" ['ORD', {}, {}],\n", groovy(a), groovy(b))); + let o = if major < 7 { six } else { later }; + want.push(format!("ORD\t{a}\t{b}\t{}", ord(*o))); + } + let adm = |o: &Option| o.map_or("null".to_string(), |b| b.to_string()); + for (s, v, o) in GOLDEN_ADMITS { + probe.push_str(&format!(" ['ADM', {}, {}],\n", groovy(s), groovy(v))); + want.push(format!("ADM\t{s}\t{v}\t{}", adm(o))); + } + for (s, v, six, later) in GOLDEN_ADMITS_BY_MAJOR { + probe.push_str(&format!(" ['ADM', {}, {}],\n", groovy(s), groovy(v))); + let o = if major < 7 { six } else { later }; + want.push(format!("ADM\t{s}\t{v}\t{}", adm(o))); + } + probe.push_str("]\nsocketRows.each { r ->\n if (r[0] == 'ORD') { println \"ORD\\t${r[1]}\\t${r[2]}\\t${socketCmp(r[1], r[2])}\" }\n else { println \"ADM\\t${r[1]}\\t${r[2]}\\t${socketAdmits(r[1], r[2])}\" }\n}\n"); + std::fs::write(c.proj.join("settings.gradle"), probe).unwrap(); + let out = c.run(&["help", "-q"]); + assert!(ok(&out), "{}", dump(&out)); + let stdout = String::from_utf8_lossy(&out.stdout); + let got: Vec<&str> = stdout + .lines() + .filter(|l| l.starts_with("ORD\t") || l.starts_with("ADM\t")) + .collect(); + let mismatches: Vec = want + .iter() + .zip(&got) + .filter(|(w, g)| w.as_str() != **g) + .map(|(w, g)| format!("want {w:?}, got {g:?}")) + .collect(); + assert_eq!(got.len(), want.len(), "{}", dump(&out)); + assert!( + mismatches.is_empty(), + "Gradle {}: {mismatches:#?}", + c.gradle.version + ); +} + +/// The `gradle::selector` golden tables, and the Rust port itself, agree +/// with real Gradle's own version comparator and selector scheme (its +/// internal `VersionParser`, `DefaultVersionComparator` and +/// `DefaultVersionSelectorScheme`, stable from 6.9 through 9.x). Both +/// selector ports (Rust and the hosted script's Groovy) are checked against +/// these tables, so this is the check that keeps the tables themselves +/// honest; it runs in every hosted cell. +#[test] +#[ignore = "real Gradle; run with --ignored"] +fn gradle_hosted_selector_golden_tables_match_real_gradle() { + use socket_patch_core::gradle::selector::{ + admits_for, gradle_version_cmp_for, parse_selector, GOLDEN_ADMITS, GOLDEN_ADMITS_BY_MAJOR, + GOLDEN_ORDERING, GOLDEN_ORDERING_BY_MAJOR, + }; + let groovy = |s: &str| format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'")); + let mut ords: Vec<(&str, &str)> = GOLDEN_ORDERING.iter().map(|(a, b, _)| (*a, *b)).collect(); + ords.extend(GOLDEN_ORDERING_BY_MAJOR.iter().map(|(a, b, _, _)| (*a, *b))); + let mut adm: Vec<(&str, &str)> = GOLDEN_ADMITS.iter().map(|(s, v, _)| (*s, *v)).collect(); + adm.extend(GOLDEN_ADMITS_BY_MAJOR.iter().map(|(s, v, _, _)| (*s, *v))); + let list = |rows: &[(&str, &str)]| { + rows.iter() + .map(|(a, b)| format!(" [{}, {}],", groovy(a), groovy(b))) + .collect::>() + .join("\n") + }; + let probe = format!( + r#"import org.gradle.api.internal.artifacts.ivyservice.ivyresolve.strategy.* +def parser = new VersionParser() +def cmp = new DefaultVersionComparator() +def scheme = new DefaultVersionSelectorScheme(cmp, parser) +def ords = [ +{} +] +def adm = [ +{} +] +for (r in ords) {{ + int c = Integer.signum(cmp.asVersionComparator().compare(parser.transform(r[0]), parser.transform(r[1]))) + println "ORD\t${{r[0]}}\t${{r[1]}}\t${{c}}" +}} +for (r in adm) {{ + def sel = scheme.parseSelector(r[0]) + def got = sel.requiresMetadata() ? 'None' : String.valueOf(sel.accept(r[1])) + println "ADM\t${{r[0]}}\t${{r[1]}}\t${{got}}" +}} +"#, + list(&ords), + list(&adm) + ); + let Some(c) = cell( + Dsl::Groovy, + &[ + ( + "settings.gradle".into(), + "rootProject.name = 'probe'\n".into(), + ), + ("build.gradle".into(), probe), + ], + ) else { + return; + }; + let major = c.gradle.major(); + let out = c.run(&["help", "-q"]); + assert!(ok(&out), "{}", dump(&out)); + let stdout = String::from_utf8_lossy(&out.stdout); + + let mut mismatches = Vec::new(); + let mut ord_rows = 0; + let mut adm_rows = 0; + for line in stdout.lines() { + let cols: Vec<&str> = line.split('\t').collect(); + match cols.as_slice() { + ["ORD", a, b, n] => { + ord_rows += 1; + let gradle = n.parse::().expect("signum").cmp(&0); + let table = GOLDEN_ORDERING + .iter() + .find(|(x, y, _)| x == a && y == b) + .map(|(_, _, o)| *o) + .or_else(|| { + GOLDEN_ORDERING_BY_MAJOR + .iter() + .find(|(x, y, _, _)| x == a && y == b) + .map(|(_, _, six, later)| if major < 7 { *six } else { *later }) + }) + .expect("row"); + let port = gradle_version_cmp_for(a, b, major); + if gradle != table || gradle != port { + mismatches.push(format!( + "order {a} vs {b}: gradle {gradle:?}, table {table:?}, port {port:?}" + )); + } + } + ["ADM", sel, v, got] => { + adm_rows += 1; + let gradle = match *got { + "None" => None, + "true" => Some(true), + _ => Some(false), + }; + let table = GOLDEN_ADMITS + .iter() + .find(|(s, x, _)| s == sel && x == v) + .map(|(_, _, w)| *w) + .or_else(|| { + GOLDEN_ADMITS_BY_MAJOR + .iter() + .find(|(s, x, _, _)| s == sel && x == v) + .map(|(_, _, six, later)| if major < 7 { *six } else { *later }) + }) + .expect("row"); + let port = admits_for(&parse_selector(sel), v, major); + if gradle != table || gradle != port { + mismatches.push(format!( + "{sel:?} admits {v}: gradle {gradle:?}, table {table:?}, port {port:?}" + )); + } + } + _ => {} + } + } + assert_eq!(ord_rows, ords.len(), "{}", dump(&out)); + assert_eq!(adm_rows, adm.len(), "{}", dump(&out)); + assert!( + mismatches.is_empty(), + "Gradle {} disagrees:\n{}", + c.gradle.version, + mismatches.join("\n") + ); +} + +// ── more project shapes ──────────────────────────────────────────────── + +/// A `print` task printing [`gradle_build_common::CP_MARKER`] lines +/// for the files of `files_expr` (a `FileCollection` expression evaluated +/// at configuration time, captured task-locally: configuration-cache safe). +fn print_task(dsl: Dsl, name: &str, files_expr: &str) -> String { + let marker = gradle_build_common::CP_MARKER; + match dsl { + Dsl::Groovy => format!( + "tasks.register('{name}') {{\n def socketFiles = {files_expr}\n \ + doLast {{ socketFiles.files.each {{ println('{marker}' + it.absolutePath) }} }}\n}}\n" + ), + Dsl::Kotlin => format!( + "tasks.register(\"{name}\") {{\n val socketFiles: FileCollection = {files_expr}\n \ + doLast {{ socketFiles.files.forEach {{ println(\"{marker}\" + it.absolutePath) }} }}\n}}\n" + ), + } +} + +/// `dependencyLocking { lockAllConfigurations(); lockMode = }`. +fn locking(dsl: Dsl, mode: Option<&str>) -> String { + let mode = mode + .map(|m| format!(" lockMode.set(LockMode.{m})\n")) + .unwrap_or_default(); + match dsl { + Dsl::Groovy | Dsl::Kotlin => { + format!("dependencyLocking {{\n lockAllConfigurations()\n{mode}}}\n") + } + } +} + +/// The lock files of the project as text. +fn lock_texts(proj: &Path) -> BTreeMap { + lockfiles(proj) + .into_iter() + .map(|(k, v)| (k, String::from_utf8(v).unwrap())) + .collect() +} + +/// Every lock file holding the victim locks it at the suffixed version. +fn assert_locks_suffixed(proj: &Path, what: &str) { + let locks = lock_texts(proj); + let holding: Vec<(&String, &String)> = locks + .iter() + .filter(|(_, t)| t.contains(&format!("{GROUP}:{VICTIM}:"))) + .collect(); + assert!( + !holding.is_empty(), + "{what}: no lock holds the victim: {locks:?}" + ); + for (rel, text) in holding { + assert!( + text.contains(&format!("{GROUP}:{VICTIM}:{}", sfx())) + && !text.contains(&format!("{GROUP}:{VICTIM}:{VICTIM_VERSION}=")), + "{what}: {rel} is not pinned:\n{text}" + ); + } +} + +/// A transitive request of the base (`consumer:2.0` → `victim:1.10.0`) +/// next to a direct `victim:1.9` (#347): the base request is pinned to +/// the suffixed version, which wins conflict resolution over 1.9. +#[test] +#[ignore = "real Gradle; run with --ignored"] +fn gradle_hosted_347_transitive_base() { + for_each_dsl(|dsl| { + let deps = [ + implementation(dsl, &format!("{GROUP}:{VICTIM}:{VICTIM_OLD}")), + implementation(dsl, &format!("{GROUP}:{CONSUMER}:{CONSUMER_VERSION}")), + ]; + let deps: Vec<&str> = deps.iter().map(String::as_str).collect(); + let Some(c) = cell(dsl, &single(dsl, &deps, "")) else { + return; + }; + wire_and_build( + &c, + &Served::new(true), + "#347 transitive base over a direct 1.9", + ); + }); +} + +#[test] +#[ignore = "real Gradle; run with --ignored"] +fn gradle_hosted_transitive_only() { + for_each_dsl(|dsl| { + let dep = implementation(dsl, &format!("{GROUP}:{CONSUMER}:{CONSUMER_VERSION}")); + let Some(c) = cell(dsl, &single(dsl, &[&dep], "")) else { + return; + }; + wire_and_build(&c, &Served::new(true), "transitive only"); + }); +} + +/// Dependency locking (#396): the lock entry moves to the suffixed +/// version and the locked build consumes the patched jar. +fn locked_case(mode: Option<&str>, what: &str) { + for_each_dsl(|dsl| { + let dep = implementation(dsl, &coordinate()); + let Some(c) = cell(dsl, &single(dsl, &[&dep], &locking(dsl, mode))) else { + return; + }; + let out = c.build(&["--write-locks"]); + c.assert_pristine(&out, "write the locks"); + c.serve_leaf(&Served::new(true)); + c.scan_ok(); + assert_locks_suffixed(&c.proj, what); + let out = c.build(&[]); + c.assert_patched(&out, what); + }); +} + +#[test] +#[ignore = "real Gradle; run with --ignored"] +fn gradle_hosted_396_lock_default() { + locked_case(None, "#396 default lock mode"); +} + +#[test] +#[ignore = "real Gradle; run with --ignored"] +fn gradle_hosted_396_lock_strict() { + locked_case(Some("STRICT"), "#396 strict lock mode"); +} + +#[test] +#[ignore = "real Gradle; run with --ignored"] +fn gradle_hosted_396_lock_lenient() { + locked_case(Some("LENIENT"), "#396 lenient lock mode"); +} + +/// Gradle 6's default per-configuration lock files +/// (`gradle/dependency-locks/.lockfile`). +#[test] +#[ignore = "real Gradle; run with --ignored"] +fn gradle_hosted_396_legacy_locks_6x() { + for_each_dsl(|dsl| { + let dep = implementation(dsl, &coordinate()); + let Some(c) = cell(dsl, &single(dsl, &[&dep], &locking(dsl, None))) else { + return; + }; + if c.gradle.major() != 6 { + println!("SKIP: legacy lock files are Gradle 6's default only"); + return; + } + let out = c.build(&["--write-locks"]); + c.assert_pristine(&out, "write the legacy locks"); + assert!( + lock_texts(&c.proj) + .keys() + .any(|k| k.starts_with("gradle/dependency-locks/")), + "Gradle 6 writes legacy locks: {:?}", + lock_texts(&c.proj) + ); + c.serve_leaf(&Served::new(true)); + c.scan_ok(); + assert_locks_suffixed(&c.proj, "legacy locks"); + let out = c.build(&[]); + c.assert_patched(&out, "legacy locks"); + }); +} + +/// #348: a Kotlin DSL build is wired with the Kotlin apply line. +#[test] +#[ignore = "real Gradle; run with --ignored"] +fn gradle_hosted_348_kotlin() { + let dsl = Dsl::Kotlin; + let Some(c) = cell( + dsl, + &single(dsl, &[&implementation(dsl, &coordinate())], ""), + ) else { + return; + }; + wire_and_build(&c, &Served::new(true), "#348 Kotlin DSL"); + let settings = c.file("settings.gradle.kts"); + assert!( + settings.contains(&format!( + "apply(from = \"{SCRIPT_REL}\") // socket-patch-hosted " + )), + "{settings}" + ); +} + +/// A version-catalog declaration (Gradle 7.4+: stable catalogs). +#[test] +#[ignore = "real Gradle; run with --ignored"] +fn gradle_hosted_catalog() { + for_each_dsl(|dsl| { + let dep = match dsl { + Dsl::Groovy => "implementation libs.victim", + Dsl::Kotlin => "implementation(libs.victim)", + }; + let mut files = single(dsl, &[dep], ""); + files.push(( + "gradle/libs.versions.toml".into(), + format!("[libraries]\nvictim = \"{}\"\n", coordinate()), + )); + let Some(c) = cell(dsl, &files) else { return }; + if !c.gradle.at_least(7, 4) { + println!("SKIP: version catalogs are stable from Gradle 7.4"); + return; + } + wire_and_build(&c, &Served::new(true), "catalog"); + }); +} + +/// A project buildscript classpath is pinned (the hook reaches +/// `buildscript.configurations`). +#[test] +#[ignore = "real Gradle; run with --ignored"] +fn gradle_hosted_buildscript_classpath() { + for_each_dsl(|dsl| { + let (settings, build) = match dsl { + Dsl::Groovy => ( + "rootProject.name = 'app'\n".to_string(), + format!( + "buildscript {{\n repositories {{ mavenCentral() }}\n dependencies {{ classpath '{}' }}\n}}\n{}", + coordinate(), + print_task(dsl, "printRuntimeClasspath", "files(buildscript.configurations.named('classpath'))") + ), + ), + Dsl::Kotlin => ( + "rootProject.name = \"app\"\n".to_string(), + format!( + "buildscript {{\n repositories {{ mavenCentral() }}\n dependencies {{ classpath(\"{}\") }}\n}}\n{}", + coordinate(), + print_task(dsl, "printRuntimeClasspath", "files(buildscript.configurations.named(\"classpath\"))") + ), + ), + }; + let files = vec![(dsl.settings_file(), settings), (dsl.build_file(), build)]; + let Some(c) = cell(dsl, &files) else { return }; + wire_and_build(&c, &Served::new(true), "buildscript classpath"); + }); +} + +/// Settings with `pluginManagement` and a `plugins {}` block: the hosted +/// script never touches the settings classpath, so nothing throws. +#[test] +#[ignore = "real Gradle; run with --ignored"] +fn gradle_hosted_settings_plugins_block_no_throw() { + for_each_dsl(|dsl| { + let mut files = single(dsl, &[&implementation(dsl, &coordinate())], ""); + files[0].1 = format!( + "pluginManagement {{ repositories {{ mavenCentral() }} }}\nplugins {{ }}\n{}", + files[0].1 + ); + let Some(c) = cell(dsl, &files) else { return }; + wire_and_build(&c, &Served::new(true), "settings plugins block"); + }); +} + +/// `dependencyResolutionManagement` repositories in the given mode. +fn repos_mode_case(mode: &str, project_repos: bool) { + for_each_dsl(|dsl| { + let mut files = single(dsl, &[&implementation(dsl, &coordinate())], ""); + let drm = format!( + "dependencyResolutionManagement {{\n repositoriesMode.set(RepositoriesMode.{mode})\n repositories {{ mavenCentral() }}\n}}\n" + ); + files[0].1 = format!("{}{drm}", files[0].1); + if !project_repos { + files[1].1 = files[1].1.replace("repositories { mavenCentral() }\n", ""); + } + let Some(c) = cell(dsl, &files) else { return }; + wire_and_build(&c, &Served::new(true), mode); + }); +} + +#[test] +#[ignore = "real Gradle; run with --ignored"] +fn gradle_hosted_repos_mode_fail_on_project() { + repos_mode_case("FAIL_ON_PROJECT_REPOS", false); +} + +#[test] +#[ignore = "real Gradle; run with --ignored"] +fn gradle_hosted_repos_mode_prefer_settings() { + repos_mode_case("PREFER_SETTINGS", true); +} + +/// A multi-project build with `buildSrc` and an included build that locks +/// its dependencies: every build is wired, every lock moves, and the +/// subproject, the included build and `buildSrc` all consume the patch. +#[test] +#[ignore = "real Gradle; run with --ignored"] +fn gradle_hosted_multiproject_buildsrc_includebuild() { + for_each_dsl(|dsl| { + let (q, plugins) = match dsl { + Dsl::Groovy => ("'", "plugins { id 'java' }"), + Dsl::Kotlin => ("\"", "plugins { java }"), + }; + let include = match dsl { + Dsl::Groovy => "include 'app'\nincludeBuild 'tools'\n".to_string(), + Dsl::Kotlin => "include(\"app\")\nincludeBuild(\"tools\")\n".to_string(), + }; + let dep = implementation(dsl, &coordinate()); + let app = format!( + "{plugins}\nrepositories {{ mavenCentral() }}\ndependencies {{ {dep} }}\n{}", + print_cp_task(dsl, "runtimeClasspath") + ); + let tools = format!( + "{plugins}\nrepositories {{ mavenCentral() }}\ndependencies {{ {dep} }}\n{}{}", + locking(dsl, None), + print_cp_task(dsl, "runtimeClasspath") + ); + let bsrc_print = match dsl { + Dsl::Groovy => "configurations.runtimeClasspath.files.each { println('SOCKET-BSRC ' + it.name) }\n", + Dsl::Kotlin => "configurations.getByName(\"runtimeClasspath\").files.forEach { println(\"SOCKET-BSRC \" + it.name) }\n", + }; + let bsrc = format!( + "{plugins}\nrepositories {{ mavenCentral() }}\ndependencies {{ {dep} }}\n{bsrc_print}" + ); + let files = vec![ + ( + dsl.settings_file(), + format!("rootProject.name = {q}root{q}\n{include}"), + ), + (dsl.build_file(), String::new()), + (format!("app/{}", dsl.build_file()), app), + ( + format!("tools/{}", dsl.settings_file()), + format!("rootProject.name = {q}tools{q}\n"), + ), + (format!("tools/{}", dsl.build_file()), tools), + ]; + let Some(c) = cell(dsl, &files) else { return }; + // Gradle before 8.0 applies no init script to buildSrc, so the test + // mirror cannot reach it: there buildSrc only proves its settings + // are wired (and still evaluate). + let bsrc_resolves = c.gradle.at_least(8, 0); + let bsrc = if bsrc_resolves { + bsrc + } else { + format!("{plugins}\n") + }; + std::fs::create_dir_all(c.proj.join("buildSrc")).unwrap(); + std::fs::write(c.proj.join("buildSrc").join(dsl.build_file()), bsrc).unwrap(); + let out = c.run(&[":app:printRuntimeClasspath"]); + c.assert_pristine(&out, "warm app"); + let out = c.run_in( + &c.proj.join("tools"), + &["printRuntimeClasspath", "--write-locks"], + ); + c.assert_pristine(&out, "warm and lock tools"); + c.serve_leaf(&Served::new(true)); + c.scan_ok(); + for rel in [ + dsl.settings_file(), + format!("tools/{}", dsl.settings_file()), + format!("buildSrc/{}", dsl.settings_file()), + ] { + assert!( + c.file(&rel).contains("// socket-patch-hosted "), + "{rel} wired" + ); + } + assert_locks_suffixed(&c.proj.join("tools"), "the included build's locks"); + let out = c.run(&[":app:printRuntimeClasspath"]); + c.assert_patched(&out, "subproject"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + !bsrc_resolves || stdout.contains(&format!("SOCKET-BSRC {VICTIM}-{}.jar", sfx())), + "buildSrc consumes the patch:\n{}", + dump(&out) + ); + let out = c.run(&[":tools:printRuntimeClasspath"]); + c.assert_patched(&out, "included build"); + }); +} + +/// An existing `gradle/verification-metadata.xml` gets the suffixed +/// component, and Gradle verifies the patched jar, pom and module with it. +#[test] +#[ignore = "real Gradle; run with --ignored"] +fn gradle_hosted_verification_metadata() { + for_each_dsl(|dsl| { + let Some(c) = cell( + dsl, + &single(dsl, &[&implementation(dsl, &coordinate())], ""), + ) else { + return; + }; + let out = c.build(&["--write-verification-metadata", "sha256"]); + c.assert_pristine(&out, "write verification metadata"); + c.serve_leaf(&Served::new(true)); + c.scan_ok(); + let vm = c.file("gradle/verification-metadata.xml"); + assert!(vm.contains(&format!("version=\"{}\"", sfx())), "{vm}"); + let out = c.build(&[]); + c.assert_patched(&out, "verified"); + }); +} + +/// A Gradle platform whose variants `require` the base: patched. +#[test] +#[ignore = "real Gradle; run with --ignored"] +fn gradle_hosted_platform_require_patched() { + platform_case("fixture-platform", false); +} + +/// A non-enforced Maven BOM import: patched. +#[test] +#[ignore = "real Gradle; run with --ignored"] +fn gradle_hosted_bom_import_patched() { + platform_case("fixture-bom", false); +} + +/// An ENFORCED BOM (a `strictly` constraint on the base): the build may +/// fail loudly, but never consumes the unpatched base. +#[test] +#[ignore = "real Gradle; run with --ignored"] +fn gradle_hosted_bom_strict_fails_loud() { + platform_case("fixture-bom", true); +} + +fn platform_case(platform: &str, enforced: bool) { + for_each_dsl(|dsl| { + let kind = if enforced { + "enforcedPlatform" + } else { + "platform" + }; + let notation = format!("{GROUP}:{platform}:1.0"); + let deps = match dsl { + Dsl::Groovy => [ + format!("implementation {kind}('{notation}')"), + format!("implementation '{GROUP}:{VICTIM}'"), + ], + Dsl::Kotlin => [ + format!("implementation({kind}(\"{notation}\"))"), + format!("implementation(\"{GROUP}:{VICTIM}\")"), + ], + }; + let deps: Vec<&str> = deps.iter().map(String::as_str).collect(); + let Some(c) = cell(dsl, &single(dsl, &deps, "")) else { + return; + }; + c.warm(); + c.serve_leaf(&Served::new(true)); + c.scan_ok(); + let out = c.build(&[]); + if enforced && !ok(&out) { + c.assert_fails_loud(&out, "enforced BOM"); + return; + } + c.assert_patched(&out, &format!("{kind} {platform}")); + }); +} + +/// #511: a dynamic prefix admitting the base. +#[test] +#[ignore = "real Gradle; run with --ignored"] +fn gradle_hosted_511_dynamic_prefix() { + selector_case("1.+"); +} + +/// #511: an open range admitting the base. +#[test] +#[ignore = "real Gradle; run with --ignored"] +fn gradle_hosted_511_open_range() { + selector_case("[1.9,)"); +} + +fn selector_case(selector: &str) { + for_each_dsl(|dsl| { + let dep = implementation(dsl, &format!("{GROUP}:{VICTIM}:{selector}")); + let Some(c) = cell(dsl, &single(dsl, &[&dep], "")) else { + return; + }; + wire_and_build(&c, &Served::new(true), selector); + }); +} + +/// #511: `consumer-range:2.0`'s pom requests `[1.9,1.11)`: the patched +/// jar, never 1.9. +#[test] +#[ignore = "real Gradle; run with --ignored"] +fn gradle_hosted_511_pom_transitive_range() { + for_each_dsl(|dsl| { + let dep = implementation(dsl, &format!("{GROUP}:{CONSUMER_RANGE}:{CONSUMER_VERSION}")); + let Some(c) = cell(dsl, &single(dsl, &[&dep], "")) else { + return; + }; + wire_and_build(&c, &Served::new(true), "pom transitive range"); + }); +} + +/// The Socket repository goes away: a fresh cache fails the build +/// instead of falling back to the unpatched base. +#[test] +#[ignore = "real Gradle; run with --ignored"] +fn gradle_hosted_outage_fails_build() { + for_each_dsl(|dsl| { + let Some(c) = cell( + dsl, + &single(dsl, &[&implementation(dsl, &coordinate())], ""), + ) else { + return; + }; + wire_and_build(&c, &Served::new(true), "before the outage"); + for ext in ["jar", "pom", "module"] { + c.central.remove(&socket_path(ext)); + } + c.purge_cache(); + let out = c.build(&["--refresh-dependencies"]); + c.assert_fails_loud(&out, "outage"); + }); +} + +/// The Socket repository serves other bytes than the pinned jar: the +/// script's tripwire fails the build. +#[test] +#[ignore = "real Gradle; run with --ignored"] +fn gradle_hosted_tamper_fails() { + for_each_dsl(|dsl| { + let Some(c) = cell( + dsl, + &single(dsl, &[&implementation(dsl, &coordinate())], ""), + ) else { + return; + }; + wire_and_build(&c, &Served::new(true), "before the tamper"); + let mut tampered = patched_jar(); + tampered.extend_from_slice(b"TAMPER"); + c.central.put(&socket_path("jar"), &tampered); + c.purge_cache(); + let out = c.build(&["--refresh-dependencies"]); + assert!( + !ok(&out), + "a tampered jar must fail the build:\n{}", + dump(&out) + ); + assert!( + String::from_utf8_lossy(&out.stderr).contains("socket-patch:") + || String::from_utf8_lossy(&out.stdout).contains("socket-patch:"), + "the failure names socket-patch:\n{}", + dump(&out) + ); + }); +} + +/// A lock rolled back to the base after the scan: the locked build fails +/// rather than consuming the unpatched base, and discovery stops naming +/// the pin. A build after the scan installs the suffixed jar first, so the +/// attestation is withheld by discovery's lock check, not by missing +/// installed evidence. +#[test] +#[ignore = "real Gradle; run with --ignored"] +fn gradle_hosted_stale_lock_fails() { + for_each_dsl(|dsl| { + let dep = implementation(dsl, &coordinate()); + let Some(c) = cell(dsl, &single(dsl, &[&dep], &locking(dsl, None))) else { + return; + }; + let out = c.build(&["--write-locks"]); + c.assert_pristine(&out, "write the locks"); + let pristine_locks = lock_texts(&c.proj); + c.serve_leaf(&Served::new(true)); + c.scan_ok(); + let out = c.build(&[]); + c.assert_patched(&out, "the scanned locks"); + let (statements, json) = c.vex(&[]); + assert_eq!(statements, 1, "attested while the locks pin: {json}"); + for (rel, text) in &pristine_locks { + std::fs::write(c.proj.join(rel), text).unwrap(); + } + let out = c.build(&[]); + c.assert_fails_loud(&out, "stale lock"); + let (statements, json) = c.vex(&[]); + assert_eq!(statements, 0, "a stale lock is not attested: {json}"); + assert!( + json.to_string().contains("patched_ref_invalid") + && json + .to_string() + .contains(&format!("locks {GROUP}:{VICTIM} at {VICTIM_VERSION}")), + "discovery names the stale lock: {json}" + ); + }); +} + +/// The second hosted patch: `consumer:2.0` (which requests the victim +/// base transitively). +const UUID2: &str = "0abcdef1-2345-4678-9abc-def012345678"; +const HOSTED2: Hosted = Hosted { + org: ORG, + uuid: UUID2, + hex8: "0abcdef1", + token: TOKEN, + ghsa: GHSA, + cve: CVE, + group: GROUP, + artifact: CONSUMER, + version: CONSUMER_VERSION, + title: "gradle hosted e2e consumer", +}; +/// The member the patched consumer jar adds. +const CONSUMER_PATCHED_MEMBER: &str = "META-INF/socket-consumer-patched.txt"; + +/// The consumer as its Socket repository serves it: the upstream jar plus +/// a marker member, the upstream pom re-versioned, no `.module`. +fn served_consumer() -> Served { + use std::io::Read as _; + let upstream = central_file(&repo_path(CONSUMER, CONSUMER_VERSION, None, "jar")); + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(upstream)).unwrap(); + let mut members = Vec::new(); + for i in 0..archive.len() { + let mut entry = archive.by_index(i).unwrap(); + let mut buf = Vec::new(); + entry.read_to_end(&mut buf).unwrap(); + members.push((entry.name().to_string(), buf)); + } + members.push((CONSUMER_PATCHED_MEMBER.to_string(), b"patched\n".to_vec())); + let upstream_pom = central_file(&repo_path(CONSUMER, CONSUMER_VERSION, None, "pom")); + Served { + jar: jvm_fixture_repo::jar(&members), + pom: HOSTED2.served_pom(&upstream_pom), + module: None, + } +} + +/// The configuration cache: a stored entry with one hosted row is +/// invalidated when a second row lands (the digest on the apply line +/// changes), and both GAs then resolve their suffixed, patched jars from +/// their own Socket repositories; the restore invalidates it again. +#[test] +#[ignore = "real Gradle; run with --ignored"] +fn gradle_hosted_config_cache_second_row() { + for_each_dsl(|dsl| { + let deps = [ + implementation(dsl, &coordinate()), + implementation(dsl, &format!("{GROUP}:{CONSUMER}:{CONSUMER_VERSION}")), + ]; + let deps: Vec<&str> = deps.iter().map(String::as_str).collect(); + let Some(c) = cell(dsl, &single(dsl, &deps, "")) else { + return; + }; + if !c.gradle.at_least(8, 1) { + println!("SKIP: the configuration cache is stable from Gradle 8.1"); + return; + } + let cc = "--configuration-cache"; + c.warm(); + // Row one: the victim. + let victim = Served::new(true); + c.serve_leaf(&victim); + c.scan_ok(); + let out = c.build(&[cc]); + c.assert_patched(&out, "one row: store"); + let out = c.build(&[cc]); + assert!( + configuration_reused(&out), + "one row: reused\n{}", + dump(&out) + ); + c.assert_patched(&out, "one row: reused"); + + // Row two: the consumer, served from its own Socket repository. + let consumer = served_consumer(); + c.central.put( + HOSTED2.served_path("jar").trim_start_matches('/'), + &consumer.jar, + ); + c.central.put( + HOSTED2.served_path("pom").trim_start_matches('/'), + &consumer.pom, + ); + let victim_files = [(format!("{VICTIM}-{VICTIM_VERSION}.jar"), victim.jar.clone())]; + let consumer_files = [( + format!("{CONSUMER}-{CONSUMER_VERSION}.jar"), + consumer.jar.clone(), + )]; + mount_grants( + &c.api, + &[ + (&HOSTED, &victim, &victim_files), + (&HOSTED2, &consumer, &consumer_files), + ], + ); + let (code, json) = c.scan_in(&c.proj, &[]); + assert_eq!(code, Some(0), "second scan: {json}"); + let index = c.file(INDEX_REL); + assert_eq!(index.lines().count(), 3, "two rows: {index}"); + let out = c.build(&[cc]); + assert!( + !configuration_reused(&out), + "the second row invalidates the entry\n{}", + dump(&out) + ); + c.assert_patched(&out, "two rows"); + let consumer_jar = format!("{CONSUMER}-{}.jar", HOSTED2.suffixed()); + let hit = gradle_classpath(&out) + .into_iter() + .find(|p| { + p.file_name() + .is_some_and(|n| n.to_string_lossy() == consumer_jar) + }) + .unwrap_or_else(|| panic!("{consumer_jar} resolves:\n{}", dump(&out))); + assert_eq!( + jar_member(&std::fs::read(&hit).unwrap(), CONSUMER_PATCHED_MEMBER).as_deref(), + Some(&b"patched\n"[..]), + "the patched consumer resolves" + ); + let out = c.build(&[cc]); + assert!( + configuration_reused(&out), + "two rows: reused\n{}", + dump(&out) + ); + + let (code, json, _) = c.socket_in(&c.proj, &["rollback", "--yes"]); + assert_eq!(code, Some(0), "rollback: {json}"); + let out = c.build(&[cc]); + assert!( + !configuration_reused(&out), + "the restore invalidates the entry" + ); + c.assert_pristine(&out, "after the restore"); + }); +} + +/// Detached configurations are outside the pin (documented); this records +/// what each Gradle major resolves for one. +#[test] +#[ignore = "real Gradle; run with --ignored"] +fn gradle_hosted_detached_config_records_behaviour() { + for_each_dsl(|dsl| { + let detached = match dsl { + Dsl::Groovy => format!( + "files(configurations.detachedConfiguration(dependencies.create('{}')))", + coordinate() + ), + Dsl::Kotlin => format!( + "files(configurations.detachedConfiguration(dependencies.create(\"{}\")))", + coordinate() + ), + }; + let extra = print_task(dsl, "printDetached", &detached); + let Some(c) = cell( + dsl, + &single(dsl, &[&implementation(dsl, &coordinate())], &extra), + ) else { + return; + }; + wire_and_build(&c, &Served::new(true), "the project configuration"); + let out = c.run(&["printDetached"]); + let resolved: Vec = gradle_classpath(&out) + .iter() + .map(|p| p.file_name().unwrap().to_string_lossy().into_owned()) + .collect(); + println!( + "detached configuration resolved {resolved:?} (ok: {})", + ok(&out) + ); + probe_report( + &c.cell_name("detached"), + &serde_json::json!({ + "gradle": c.gradle.version, + "jvm": c.gradle.jvm, + "dsl": dsl.name(), + "detachedBuildOk": ok(&out), + "detachedResolved": resolved, + }), + ); + }); +} + +/// A second scan changes nothing and still confirms. +#[test] +#[ignore = "real Gradle; run with --ignored"] +fn gradle_hosted_rescan_idempotent() { + for_each_dsl(|dsl| { + let dep = implementation(dsl, &coordinate()); + let Some(c) = cell(dsl, &single(dsl, &[&dep], &locking(dsl, None))) else { + return; + }; + let out = c.build(&["--write-locks"]); + c.assert_pristine(&out, "write the locks"); + c.serve_leaf(&Served::new(true)); + c.scan_ok(); + let before = snapshot(&c.proj); + let again = c.scan_ok(); + assert_eq!( + snapshot(&c.proj), + before, + "a rescan writes nothing: {again}" + ); + }); +} + +/// `rollback` restores every file byte for byte (locks, settings, the +/// verification file) and removes the owned files. +#[test] +#[ignore = "real Gradle; run with --ignored"] +fn gradle_hosted_restore_byte_exact() { + for_each_dsl(|dsl| { + let dep = implementation(dsl, &coordinate()); + let Some(c) = cell(dsl, &single(dsl, &[&dep], &locking(dsl, None))) else { + return; + }; + let out = c.build(&["--write-locks", "--write-verification-metadata", "sha256"]); + c.assert_pristine(&out, "write the locks and verification metadata"); + let before = snapshot(&c.proj); + c.serve_leaf(&Served::new(true)); + c.scan_ok(); + assert_ne!(snapshot(&c.proj), before); + let (code, json, _) = c.socket_in(&c.proj, &["rollback", "--yes", "--offline"]); + assert_eq!(code, Some(0), "rollback: {json}"); + assert_eq!(snapshot(&c.proj), before, "byte-exact restore: {json}"); + let out = c.build(&[]); + c.assert_pristine(&out, "after the restore"); + }); +} + +/// `remove ` restores the lock and the settings. +#[test] +#[ignore = "real Gradle; run with --ignored"] +fn gradle_hosted_remove_restores_lock_and_settings() { + for_each_dsl(|dsl| { + let dep = implementation(dsl, &coordinate()); + let Some(c) = cell(dsl, &single(dsl, &[&dep], &locking(dsl, None))) else { + return; + }; + let out = c.build(&["--write-locks"]); + c.assert_pristine(&out, "write the locks"); + let before = snapshot(&c.proj); + c.serve_leaf(&Served::new(true)); + c.scan_ok(); + let (code, json, _) = c.socket_in(&c.proj, &["remove", &purl(), "--yes", "--offline"]); + assert_eq!(code, Some(0), "remove: {json}"); + assert_eq!(snapshot(&c.proj), before, "remove restores: {json}"); + }); +} + +/// #429: the wired project committed and cloned with `core.autocrlf` +/// (CRLF settings and locks; the owned files stay LF through their +/// `.gitattributes`) builds patched, and a rescan of the clone changes +/// nothing. +#[test] +#[ignore = "real Gradle; run with --ignored"] +fn gradle_hosted_429_autocrlf_clone() { + for_each_dsl(|dsl| { + let dep = implementation(dsl, &coordinate()); + let Some(c) = cell(dsl, &single(dsl, &[&dep], &locking(dsl, None))) else { + return; + }; + let out = c.build(&["--write-locks"]); + c.assert_pristine(&out, "write the locks"); + c.serve_leaf(&Served::new(true)); + c.scan_ok(); + let clone = gradle_build_common::git_autocrlf_clone(&c.proj, &c.root.join("clone")); + let settings = std::fs::read(clone.join(dsl.settings_file())).unwrap(); + assert!(settings.windows(2).any(|w| w == b"\r\n"), "CRLF settings"); + let script = std::fs::read(clone.join(SCRIPT_REL)).unwrap(); + assert!(!script.contains(&b'\r'), "the owned script stays LF"); + let out = c.run_in(&clone, &["printRuntimeClasspath"]); + c.assert_patched(&out, "autocrlf clone"); + let before = snapshot(&clone); + let (code, json) = c.scan_in(&clone, &[]); + assert_eq!(code, Some(0), "{json}"); + assert_eq!(json["redirect"]["redirected"], 1, "{json}"); + assert_eq!( + snapshot(&clone), + before, + "a rescan of the clone writes nothing" + ); + }); +} + +/// #646 review: a settings.gradle that is not UTF-8 (a Latin-1 comment) +/// is not absent. The scan refuses the build +/// (`redirect_gradle_build_file_unreadable`) and leaves the settings file +/// byte-identical, instead of replacing it with a one-line apply file. +/// Gradle still builds the untouched project. +#[test] +#[ignore = "real Gradle; run with --ignored"] +fn gradle_hosted_non_utf8_settings_refused_untouched() { + let dsl = Dsl::Groovy; + let Some(c) = cell( + dsl, + &single(dsl, &[&implementation(dsl, &coordinate())], ""), + ) else { + return; + }; + let settings = c.proj.join("settings.gradle"); + let latin1: &[u8] = b"rootProject.name = 'app'\n// Auteur: Andr\xe9\n"; + std::fs::write(&settings, latin1).unwrap(); + c.warm(); + c.serve_leaf(&Served::new(true)); + let (code, json) = c.scan_in(&c.proj, &[]); + assert_eq!(code, Some(0), "{json}"); + assert_eq!(json["redirect"]["redirected"], 0, "{json}"); + assert!( + has(&json, "redirect_gradle_build_file_unreadable"), + "{json}" + ); + assert_eq!(std::fs::read(&settings).unwrap(), latin1, "{json}"); + let out = c.build(&[]); + c.assert_pristine(&out, "refused build"); +} + +/// A build the planner refuses (a declared classifier) with the fallback +/// snippet pasted in: the build consumes the patch, but nothing attests +/// it. +#[test] +#[ignore = "real Gradle; run with --ignored"] +fn gradle_hosted_pasted_snippet_not_attested() { + let dsl = Dsl::Groovy; + let tests = format!("testImplementation '{}:tests'", coordinate()); + let Some(c) = cell( + dsl, + &single(dsl, &[&implementation(dsl, &coordinate()), &tests], ""), + ) else { + return; + }; + c.warm(); + c.serve_leaf(&Served::new(true)); + let (code, json) = c.scan_in(&c.proj, &[]); + assert_eq!(code, Some(0), "{json}"); + assert_eq!(json["redirect"]["redirected"], 0, "{json}"); + assert!(has(&json, "redirect_gradle_classifier_declared"), "{json}"); + let snippet = snippet_code(&json); + let build = c.proj.join("build.gradle"); + let text = std::fs::read_to_string(&build).unwrap(); + std::fs::write(&build, format!("{text}\n{snippet}\n")).unwrap(); + let out = c.build(&[]); + c.assert_patched(&out, "pasted snippet"); + let (statements, _) = c.vex(&[]); + assert_eq!(statements, 0, "a pasted snippet is never attested"); +} + +/// The code of the fallback snippet a refusal printed. +fn snippet_code(json: &serde_json::Value) -> String { + let detail = json["redirect"]["warnings"] + .as_array() + .into_iter() + .flatten() + .chain(json["warnings"].as_array().into_iter().flatten()) + .find(|w| w["code"] == "redirect_gradle_manual_snippet") + .and_then(|w| w["detail"].as_str()) + .unwrap_or_else(|| panic!("no fallback snippet: {json}")) + .to_string(); + let code = detail + .split_once(":\n") + .expect("the snippet follows its intro") + .1; + code.split("\nThen re-lock").next().unwrap().to_string() +} + +/// A member-keyed record (`NOTICE` and `Victim.class`) attests once the +/// build installed the suffixed copy. +#[test] +#[ignore = "real Gradle; run with --ignored"] +fn gradle_hosted_vex_member_record_attests() { + for_each_dsl(|dsl| { + let Some(c) = cell( + dsl, + &single(dsl, &[&implementation(dsl, &coordinate())], ""), + ) else { + return; + }; + c.warm(); + let served = Served::new(true); + c.serve( + &served, + &[ + (NOTICE.to_string(), patched_notice()), + ( + VICTIM_CLASS_MEMBER.to_string(), + victim_class(VICTIM_VERSION, "patched"), + ), + ], + ); + c.scan_ok(); + let out = c.build(&[]); + c.assert_patched(&out, "member record"); + let (statements, json) = c.vex(&[]); + assert_eq!(statements, 1, "attested: {json}"); + }); +} + +/// Before any build fetched the suffixed jar, VEX has no evidence: no +/// statement (a Gradle pin never takes the lockfile basis). +#[test] +#[ignore = "real Gradle; run with --ignored"] +fn gradle_hosted_vex_before_build_no_statement() { + for_each_dsl(|dsl| { + let Some(c) = cell( + dsl, + &single(dsl, &[&implementation(dsl, &coordinate())], ""), + ) else { + return; + }; + c.warm(); + c.serve_leaf(&Served::new(true)); + c.scan_ok(); + let (statements, json) = c.vex(&[]); + assert_eq!(statements, 0, "no installed suffixed copy yet: {json}"); + let out = c.build(&[]); + c.assert_patched(&out, "build"); + let (statements, json) = c.vex(&[]); + assert_eq!(statements, 1, "attested after the build: {json}"); + }); +} + +/// Whether a failed run failed in a pasted script rather than in +/// dependency resolution: a Groovy or Kotlin compile error, or an +/// evaluation error in the build script. +fn script_error(out: &Output) -> bool { + let text = format!( + "{}\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + text.contains("Script compilation error") + || text.contains("Could not compile build file") + || text.lines().any(|l| l.starts_with("e: ")) + || text.contains("A problem occurred evaluating root project") +} + +/// The request a [`fallback_case`] pastes the snippet over. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Request { + Direct, + Transitive, + /// A rich version with only `prefer` (#646 review): its + /// `requested.version` is empty, so only the snippet's dependency + /// substitution on the version constraint rewrites it. + PreferOnly, +} + +/// The fallback snippet compiles in each DSL and pins the patch. A direct +/// (or `prefer`-only) request must resolve the suffixed, patched jar; a +/// transitive-only one (`consumer:2.0` → the base) resolves it or fails in +/// resolution, never in the pasted script, and never resolves the +/// unpatched base. +fn fallback_case(dsl: Dsl, kind: Request) { + let what = match kind { + Request::Direct => "direct", + Request::Transitive => "transitive", + Request::PreferOnly => "prefer-only", + }; + let direct = kind != Request::Transitive; + let tests = match dsl { + Dsl::Groovy => format!("testImplementation '{}:tests'", coordinate()), + Dsl::Kotlin => format!("testImplementation(\"{}:tests\")", coordinate()), + }; + let request = match (kind, dsl) { + (Request::Direct, _) => implementation(dsl, &coordinate()), + (Request::Transitive, _) => { + implementation(dsl, &format!("{GROUP}:{CONSUMER}:{CONSUMER_VERSION}")) + } + (Request::PreferOnly, Dsl::Groovy) => format!( + "implementation('{GROUP}:{VICTIM}') {{ version {{ prefer '{VICTIM_VERSION}' }} }}" + ), + (Request::PreferOnly, Dsl::Kotlin) => format!( + "implementation(\"{GROUP}:{VICTIM}\") {{ version {{ prefer(\"{VICTIM_VERSION}\") }} }}" + ), + }; + let deps = [request, tests]; + let deps: Vec<&str> = deps.iter().map(String::as_str).collect(); + let Some(c) = cell(dsl, &single(dsl, &deps, "")) else { + return; + }; + c.warm(); + c.serve_leaf(&Served::new(true)); + let (code, json) = c.scan_in(&c.proj, &[]); + assert_eq!(code, Some(0), "{json}"); + assert!(has(&json, "redirect_gradle_classifier_declared"), "{json}"); + let snippet = snippet_code(&json); + let build = c.proj.join(dsl.build_file()); + // The classifier request the snippet's pin cannot serve goes; the + // request of the base stays. + let text = std::fs::read_to_string(&build) + .unwrap() + .replace(deps[1], ""); + std::fs::write(&build, format!("{text}\n{snippet}\n")).unwrap(); + let out = c.build(&[]); + assert!( + !script_error(&out), + "{what}: the pasted snippet does not compile:\n{snippet}\n{}", + dump(&out) + ); + if direct || ok(&out) { + c.assert_patched(&out, &format!("fallback snippet ({what})")); + } else { + c.assert_fails_loud(&out, &format!("fallback snippet ({what})")); + } +} + +#[test] +#[ignore = "real Gradle; run with --ignored"] +fn gradle_hosted_fallback_snippet_compiles_groovy() { + fallback_case(Dsl::Groovy, Request::Direct); + fallback_case(Dsl::Groovy, Request::Transitive); + fallback_case(Dsl::Groovy, Request::PreferOnly); +} + +#[test] +#[ignore = "real Gradle; run with --ignored"] +fn gradle_hosted_fallback_snippet_compiles_kotlin() { + fallback_case(Dsl::Kotlin, Request::Direct); + fallback_case(Dsl::Kotlin, Request::Transitive); + fallback_case(Dsl::Kotlin, Request::PreferOnly); +} + +/// #646 review: `latest.release` is refused (`redirect_gradle_latest_selector`) +/// with nothing written, and the build keeps resolving as before. Pinning +/// it would break it: with every upstream version at or below the base +/// rejected and none listed by the Socket repository, no version matches. +#[test] +#[ignore = "real Gradle; run with --ignored"] +fn gradle_hosted_latest_selector_refused() { + for_each_dsl(|dsl| { + let dep = implementation(dsl, &format!("{GROUP}:{VICTIM}:latest.release")); + let Some(c) = cell(dsl, &single(dsl, &[&dep], "")) else { + return; + }; + c.warm(); + c.serve_leaf(&Served::new(true)); + let before = snapshot(&c.proj); + let (code, json) = c.scan_in(&c.proj, &[]); + assert_eq!(code, Some(0), "{json}"); + assert!(has(&json, "redirect_gradle_latest_selector"), "{json}"); + assert!( + !has(&json, "redirect_gradle_dynamic_selector_pinned"), + "{json}" + ); + assert!(has(&json, "redirect_gradle_manual_snippet"), "{json}"); + assert_eq!(snapshot(&c.proj), before, "a refusal writes nothing"); + let out = c.build(&[]); + c.assert_pristine(&out, "latest.release, refused"); + }); +} + +/// The agent-mode record `vendor` builds from: `Victim.class` patched. +fn stage_manifest(proj: &Path) { + stage_manifest_as(proj, "patched"); +} + +/// [`stage_manifest`] with `Victim.class` in `state`. +fn stage_manifest_as(proj: &Path, state: &str) { + let before = victim_class(VICTIM_VERSION, "pristine"); + let after = victim_class(VICTIM_VERSION, state); + std::fs::create_dir_all(proj.join(".socket/blobs")).unwrap(); + std::fs::write(proj.join(".socket/blobs").join(git_sha256(&after)), &after).unwrap(); + let manifest = serde_json::json!({ "patches": { purl(): { + "uuid": UUID, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { VICTIM_CLASS_MEMBER: { + "beforeHash": git_sha256(&before), + "afterHash": git_sha256(&after), + } }, + "vulnerabilities": { GHSA: { + "cves": [CVE], "summary": "s", "severity": "high", "description": "d" + } }, + "description": "gradle hosted takeover", + "license": "MIT", + "tier": "free", + } } }); + std::fs::write( + proj.join(".socket/manifest.json"), + serde_json::to_string_pretty(&manifest).unwrap(), + ) + .unwrap(); +} + +/// A vendored Gradle build switched to hosted (the vendored wiring is +/// reverted first, then the hosted wiring lands), and ejected back. +#[test] +#[ignore = "real Gradle; run with --ignored"] +fn gradle_hosted_vendored_takeover_and_eject() { + for_each_dsl(|dsl| { + let Some(c) = cell( + dsl, + &single(dsl, &[&implementation(dsl, &coordinate())], ""), + ) else { + return; + }; + c.warm(); + stage_manifest(&c.proj); + let (code, json) = c.vendor_cmd(None, &[]); + assert_eq!(code, Some(0), "vendor: {json}"); + assert!(c.proj.join(".socket/vendor/gradle-index.tsv").is_file()); + // The vendored checkout: no manifest, no blobs. + std::fs::remove_file(c.proj.join(".socket/manifest.json")).unwrap(); + std::fs::remove_dir_all(c.proj.join(".socket/blobs")).unwrap(); + let out = c.build(&[]); + let (_, notice) = c.victim_on(&out, "vendored"); + assert_eq!( + notice, + pristine_notice(), + "the vendored record patches the class only" + ); + + // The hosted record matches the vendored one (`Victim.class`), so the + // eject below can vendor it from the same prebuilt. + let served = Served::new(true); + c.serve( + &served, + &[( + VICTIM_CLASS_MEMBER.to_string(), + victim_class(VICTIM_VERSION, "patched"), + )], + ); + let json = c.scan_ok(); + assert!(has(&json, "redirect_takeover_reverted_vendored"), "{json}"); + assert!(!c.proj.join(".socket/vendor/gradle-index.tsv").exists()); + let out = c.build(&[]); + c.assert_patched(&out, "after the takeover"); + + // The eject: the patch service (the vendor fixture server) builds + // the prebuilt from the same record. + let records = c.root.join("records"); + std::fs::create_dir_all(&records).unwrap(); + stage_manifest(&records); + let api = c.api_args(); + let mut args: Vec<&str> = api.iter().map(String::as_str).collect(); + args.push("--yes"); + let (code, json) = c.vendor_cmd(Some(&records), &args); + assert_eq!(code, Some(0), "eject: {json}"); + assert!(!c.proj.join(INDEX_REL).exists(), "the hosted index is gone"); + assert!(c.proj.join(".socket/vendor/gradle-index.tsv").is_file()); + let out = c.build(&[]); + let (jar, _) = c.victim_on(&out, "ejected"); + assert!( + jar.to_string_lossy() + .replace('\\', "/") + .contains(".socket/vendor/gradle/"), + "the vendored jar: {}", + jar.display() + ); + let class = jar_member(&std::fs::read(&jar).unwrap(), VICTIM_CLASS_MEMBER).unwrap(); + assert_eq!(class, victim_class(VICTIM_VERSION, "patched")); + }); +} + +/// A vendored Gradle build the hosted planner would refuse (a custom +/// `lockFile`): `scan --mode hosted` refuses the takeover BEFORE reverting +/// anything, so the vendored patch keeps working. +#[test] +#[ignore = "real Gradle; run with --ignored"] +fn gradle_hosted_takeover_refusal_keeps_vendored() { + // `lockFile = file(..)` is an assignment only the Groovy DSL takes on + // every supported Gradle. + let dsl = Dsl::Groovy; + let custom = "dependencyLocking { lockFile = file('locks/custom.lockfile') }\n"; + let Some(c) = cell( + dsl, + &single(dsl, &[&implementation(dsl, &coordinate())], custom), + ) else { + return; + }; + c.warm(); + stage_manifest(&c.proj); + let (code, json) = c.vendor_cmd(None, &[]); + assert_eq!(code, Some(0), "vendor: {json}"); + std::fs::remove_file(c.proj.join(".socket/manifest.json")).unwrap(); + std::fs::remove_dir_all(c.proj.join(".socket/blobs")).unwrap(); + let before = snapshot(&c.proj); + c.serve( + &Served::new(true), + &[( + VICTIM_CLASS_MEMBER.to_string(), + victim_class(VICTIM_VERSION, "patched"), + )], + ); + let (_, json) = c.scan_in(&c.proj, &[]); + assert!( + has(&json, "redirect_gradle_lock_location_unknown"), + "{json}" + ); + assert!(!has(&json, "redirect_takeover_reverted_vendored"), "{json}"); + assert_eq!(json["redirect"]["redirected"], 0, "{json}"); + assert_eq!(snapshot(&c.proj), before, "nothing was reverted or written"); + let out = c.build(&[]); + let (jar, _) = c.victim_on(&out, "still vendored"); + assert!( + jar.to_string_lossy() + .replace('\\', "/") + .contains(".socket/vendor/gradle/"), + "the vendored jar still resolves: {}", + jar.display() + ); +} + +/// An eject whose vendor step fails after the upstream restore rolls the +/// whole multi-build project back: the included build's and buildSrc's +/// settings (buildSrc's created by the planner) and the included build's +/// lock are byte-identical to the hosted checkout. +#[test] +#[ignore = "real Gradle; run with --ignored"] +fn gradle_hosted_eject_rollback_multi_build() { + for_each_dsl(|dsl| { + let q = match dsl { + Dsl::Groovy => "'", + Dsl::Kotlin => "\"", + }; + let mut files = single(dsl, &[&implementation(dsl, &coordinate())], ""); + files[0].1.push_str(&match dsl { + Dsl::Groovy => "includeBuild 'tools'\n".to_string(), + Dsl::Kotlin => "includeBuild(\"tools\")\n".to_string(), + }); + files.push(( + format!("tools/{}", dsl.settings_file()), + format!("rootProject.name = {q}tools{q}\n"), + )); + files.push((format!("tools/{}", dsl.build_file()), String::new())); + files.push(( + "tools/gradle.lockfile".to_string(), + format!("{GROUP}:{VICTIM}:{VICTIM_VERSION}=runtimeClasspath\nempty=\n"), + )); + files.push((format!("buildSrc/{}", dsl.build_file()), String::new())); + let Some(c) = cell(dsl, &files) else { return }; + c.warm(); + let served = Served::new(true); + c.serve( + &served, + &[( + VICTIM_CLASS_MEMBER.to_string(), + victim_class(VICTIM_VERSION, "patched"), + )], + ); + c.scan_ok(); + let bsrc_settings = format!("buildSrc/{}", dsl.settings_file()); + assert!(c.file(&bsrc_settings).contains(" created"), "created"); + assert!(c.file("tools/gradle.lockfile").contains(&sfx())); + let hosted = snapshot(&c.proj); + // The prebuilt the fixture serves is not the record's patch, so + // the vendor step fails after the restore. + let records = c.root.join("records"); + std::fs::create_dir_all(&records).unwrap(); + stage_manifest_as(&records, "tampered"); + let api = c.api_args(); + let mut args: Vec<&str> = api.iter().map(String::as_str).collect(); + args.push("--yes"); + let (code, json) = c.vendor_cmd(Some(&records), &args); + assert_ne!(code, Some(0), "the eject fails: {json}"); + assert!(has(&json, "eject_rolled_back"), "{json}"); + let after = snapshot(&c.proj); + let changed: Vec<&String> = hosted + .keys() + .chain(after.keys()) + .filter(|k| hosted.get(*k) != after.get(*k)) + .collect(); + assert!( + changed.is_empty(), + "the rollback is byte-exact: {changed:?}" + ); + }); +} diff --git a/crates/socket-patch-cli/tests/e2e_redirect_maven_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_maven_build.rs index e30f3c04d..153f4d3f1 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_maven_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_maven_build.rs @@ -52,6 +52,8 @@ //! `SOCKET_PATCH_MAVEN_E2E_{MVN,VERSION,REQUIRED}` gates in //! `maven_build_common`. +#[path = "hosted_maven_common/mod.rs"] +mod hosted_maven_common; #[path = "maven_build_common/mod.rs"] mod maven_build_common; #[path = "vex_e2e_common/mod.rs"] @@ -60,10 +62,9 @@ mod vex_e2e_common; use std::path::{Path, PathBuf}; use std::process::Command; +use hosted_maven_common::{Hosted, Server}; use maven_build_common::*; use vex_e2e_common::*; -use wiremock::matchers::{method, path, path_regex}; -use wiremock::{Mock, MockServer, ResponseTemplate}; const SUITE: &str = "e2e_redirect_maven_build"; const ORG: &str = "test-org"; @@ -76,26 +77,35 @@ const GHSA: &str = "GHSA-redirect-maven-real"; const CVE: &str = "CVE-2026-7201"; const PRODUCT: &str = "pkg:maven/com.example/app@1.0.0"; +const HOSTED: Hosted = Hosted { + org: ORG, + uuid: UUID, + hex8: HEX8, + token: TOKEN, + ghsa: GHSA, + cve: CVE, + group: GROUP, + artifact: ARTIFACT, + version: VERSION, + title: "maven hosted capstone", +}; + fn suffixed() -> String { - format!("{VERSION}-socket.{HEX8}") + HOSTED.suffixed() } /// The Socket repository path (mirror target and index url path). fn repo_path() -> String { - format!("/patch-registry/maven/{TOKEN}/{UUID}/maven2") + HOSTED.repo_path() } fn prod_index_url() -> String { - format!("https://patch.socket.dev{}", repo_path()) + HOSTED.prod_index_url() } /// `////-.` under the Socket repository. fn served_path(ext: &str) -> String { - let sfx = suffixed(); - format!( - "{}/{GROUP_PATH}/{ARTIFACT}/{sfx}/{ARTIFACT}-{sfx}.{ext}", - repo_path() - ) + HOSTED.served_path(ext) } /// The record's file key: the version-dir jar name (the consumed copy is @@ -108,141 +118,16 @@ fn jar_key() -> String { /// own `` right after ``; the parent and the /// dependencies — the transitive — are untouched). fn served_pom(upstream: &[u8]) -> Vec { - let text = String::from_utf8(upstream.to_vec()).expect("utf-8 pom"); - let after_parent = text.find("").expect("commons-text has a parent") + 9; - let needle = format!("{VERSION}"); - let at = after_parent + text[after_parent..].find(&needle).expect("project version"); - let mut out = text.clone(); - out.replace_range( - at..at + needle.len(), - &format!("{}", suffixed()), + let out = HOSTED.served_pom(upstream); + assert!( + String::from_utf8_lossy(&out).contains("commons-lang3"), + "transitive kept" ); - assert!(out.contains("commons-lang3"), "transitive kept"); - out.into_bytes() -} - -/// A wiremock server with its own runtime (the CLI and Maven run as -/// blocking child processes on the test thread). -struct Server { - server: MockServer, - rt: tokio::runtime::Runtime, + out } -impl Server { - fn start() -> Self { - let rt = tokio::runtime::Builder::new_multi_thread() - .worker_threads(1) - .enable_all() - .build() - .unwrap(); - let server = rt.block_on(MockServer::start()); - Server { server, rt } - } - - fn uri(&self) -> String { - self.server.uri() - } - - fn get(&self, route: &str, status: u16, body: Vec) { - self.rt.block_on( - Mock::given(method("GET")) - .and(path(route.to_string())) - .respond_with(ResponseTemplate::new(status).set_body_bytes(body)) - .mount(&self.server), - ); - } - - /// Serve `jar` + `pom` (and `.sha1` sidecars: `jar_sha1` overrides the - /// jar's) as the Socket repository's suffixed GAV. - fn serve_repo(&self, jar: &[u8], pom: &[u8], jar_sha1: Option) { - self.get(&served_path("jar"), 200, jar.to_vec()); - self.get( - &format!("{}.sha1", served_path("jar")), - 200, - jar_sha1.unwrap_or_else(|| sha1_hex(jar)).into_bytes(), - ); - self.get(&served_path("pom"), 200, pom.to_vec()); - self.get( - &format!("{}.sha1", served_path("pom")), - 200, - sha1_hex(pom).into_bytes(), - ); - } - - fn paths(&self) -> Vec { - self.rt - .block_on(self.server.received_requests()) - .unwrap_or_default() - .iter() - .map(|r| r.url.path().to_string()) - .collect() - } -} - -/// The API `scan --mode hosted` drives: batch discovery, the by-package -/// listing, the reference grant (maven2 override, production-shaped urls) -/// and the patch view. fn mount_api(s: &Server, jar: &[u8], pom: &[u8], view: &serde_json::Value) { - let purl = purl(); - let artifact_url = format!( - "https://patch.socket.dev/patch/maven/{GROUP}/{ARTIFACT}/{VERSION}/{TOKEN}/{UUID}/{ARTIFACT}-{}.jar", - suffixed() - ); - let mounts = [ - Mock::given(method("POST")) - .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "packages": [{ "purl": purl, "patches": [{ - "uuid": UUID, "purl": purl, "tier": "free", "cveIds": [CVE], - "ghsaIds": [GHSA], "severity": "high", "title": "maven hosted capstone" - }] }], - "canAccessPaidPatches": false, - }))), - Mock::given(method("GET")) - .and(path_regex(format!( - "^/v0/orgs/{ORG}/patches/by-package/.+$" - ))) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "patches": [{ - "uuid": UUID, "purl": purl, "publishedAt": "2026-01-01T00:00:00Z", - "description": "d", "license": "MIT", "tier": "free", - "vulnerabilities": view["vulnerabilities"].clone() - }], - "canAccessPaidPatches": false, - }))), - Mock::given(method("POST")) - .and(path(format!("/v0/orgs/{ORG}/patches/package"))) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "results": { UUID: { - "status": "granted", - "url": artifact_url, - "purl": purl, - "artifacts": [{ - "kind": "tarball", - "url": artifact_url, - "integrity": { "sha1": sha1_hex(jar), "sha256": sha256_hex(jar) } - }], - "registryOverride": { - "kind": "maven2", - "indexUrl": prod_index_url(), - "identifiers": { - "name": format!("{GROUP}/{ARTIFACT}"), - "version": VERSION, - "mavenGroupId": GROUP, - "mavenArtifactId": ARTIFACT, - "mavenSuffixedVersion": suffixed(), - "mavenPomSha256": sha256_hex(pom), - } - } - } } - }))), - Mock::given(method("GET")) - .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(view.clone())), - ]; - for m in mounts { - s.rt.block_on(m.mount(&s.server)); - } + hosted_maven_common::mount_api(s, &HOSTED, jar, pom, view); } /// `socket-patch ` with ambient `SOCKET_*` scrubbed and the per-test @@ -336,7 +221,7 @@ fn maven_scan_hosted_fresh_checkout_install_and_manifestless_vex() { ); let server = Server::start(); mount_api(&server, &patched_jar, &sfx_pom, &view); - server.serve_repo(&patched_jar, &sfx_pom, None); + server.serve_repo(&HOSTED, &patched_jar, &sfx_pom, None); // 3. The real writer: three-file rewrite + in-run VEX, no ledger. let (code, env, stderr) = socket( @@ -415,7 +300,7 @@ fn maven_scan_hosted_fresh_checkout_install_and_manifestless_vex() { let bad = Server::start(); let mut tampered = patched_jar.clone(); tampered.extend_from_slice(b"TAMPER"); - bad.serve_repo(&tampered, &sfx_pom, Some(sha1_hex(&patched_jar))); + bad.serve_repo(&HOSTED, &tampered, &sfx_pom, Some(sha1_hex(&patched_jar))); let bad_settings = root.join("settings-bad.xml"); let bad_url = format!("{}{}", bad.uri(), repo_path()); write_settings( @@ -435,7 +320,7 @@ fn maven_scan_hosted_fresh_checkout_install_and_manifestless_vex() { // MATCHING `.sha1` passes transport validation; only the committed // sha256 summary can catch it. let resigned = Server::start(); - resigned.serve_repo(&tampered, &sfx_pom, None); + resigned.serve_repo(&HOSTED, &tampered, &sfx_pom, None); let resigned_settings = root.join("settings-resigned.xml"); let resigned_url = format!("{}{}", resigned.uri(), repo_path()); write_settings( diff --git a/crates/socket-patch-cli/tests/e2e_vendor_gradle_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_gradle_build.rs new file mode 100644 index 000000000..5dea7e10a --- /dev/null +++ b/crates/socket-patch-cli/tests/e2e_vendor_gradle_build.rs @@ -0,0 +1,1324 @@ +//! Real-Gradle capstones for the vendored Gradle fixes of the v5 JVM +//! backend (`docs/design/maven-vendoring.md`): #395 #428 #429 #461 #487 +//! #511 #533, offline sourcing from the Gradle cache, the configuration +//! cache, and ports of the bug-hunt scenarios. +//! +//! Every test resolves the deterministic fake Central +//! (`jvm_fixture_repo`) through the mirror init script, so no network is +//! needed (only `gradle_vendor_395_mixed_root` downloads Maven's own +//! plugins). A pre-vendor build fills the per-test Gradle user home, the +//! CLI vendors from it (`GRADLE_USER_HOME`, no Maven repository at all), +//! and a fresh checkout without the manifest or blobs is built again. The +//! assertion is always on the bytes Gradle consumed: the classpath entry's +//! `Victim.class` must be the patched one. +//! +//! Gated like the other real-Gradle suites: `#[ignore]`, the launcher from +//! `SOCKET_PATCH_GRADLE_E2E_GRADLE` (default `gradle` on `PATH`), the +//! version it must report from `SOCKET_PATCH_GRADLE_E2E_VERSION`, no SKIP +//! with `SOCKET_PATCH_GRADLE_E2E_REQUIRED` (`gradle_build_common`). Maven +//! for #395 via `SOCKET_PATCH_MAVEN_E2E_{MVN,VERSION,REQUIRED}`. + +#[path = "prebuilt_common/mod.rs"] +mod prebuilt_common; + +#[path = "gradle_build_common/mod.rs"] +mod gradle_build_common; + +#[path = "jvm_fixture_repo/mod.rs"] +mod jvm_fixture_repo; + +#[path = "maven_build_common/mod.rs"] +mod maven_build_common; + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use gradle_build_common::{ + configuration_reused, dump, fixture_root, gradle_classpath, init_script, jar_member, + mirror_init_script, ok, print_cp_task, snapshot, write_project, Dsl, Gradle, CP_MARKER, +}; +use jvm_fixture_repo::{ + generate, public_keyring_gpg, repo_path, sha256_hex, victim_class, victim_purl, FakeCentral, + CONSUMER, CONSUMER_VERSION, GROUP, GROUP_PATH, KEY_FINGERPRINT, NOTICE, PARENT, PARENT_VERSION, + VICTIM, VICTIM_CLASS_MEMBER, VICTIM_VERSION, +}; + +const UUID: &str = "1d3c1fd2-7b4e-4c1a-9f0e-2a3b4c5d6e7f"; +const UUID_2: &str = "9a8b7c6d-7b4e-4c1a-9f0e-2a3b4c5d6e7f"; +const DEP: &str = "com.socketfixture:victim:1.10.0"; + +fn binary() -> PathBuf { + env!("CARGO_BIN_EXE_socket-patch").into() +} + +fn git_sha256(bytes: &[u8]) -> String { + socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes(bytes) +} + +/// `Victim.class` in `state` (`pristine` upstream, `patched` vendored). +fn victim_member(state: &str) -> Vec { + victim_class(VICTIM_VERSION, state) +} + +/// The vendored tree of `artifact:version`, project-relative. +fn tree_rel(artifact: &str, version: &str) -> String { + format!(".socket/vendor/gradle/{GROUP_PATH}/{artifact}/{version}") +} + +/// One per-test world: a Gradle user home, the fake Central and the mirror +/// init script routing every repository to it. +struct World { + root: PathBuf, + home: PathBuf, + gradle: Gradle, + central: Option, + init: [String; 2], + _tmp: tempfile::TempDir, +} + +impl World { + /// A repositories block for a buildSrc build: Gradle before 8.0 does not + /// apply init scripts to buildSrc, so it names the fake Central itself. + fn buildsrc_repositories(&self) -> String { + let uri = self.central.as_ref().unwrap().uri(); + format!("repositories {{ maven {{ url '{uri}'; allowInsecureProtocol = true }} }}\n") + } + + fn new(suite: &str) -> Option { + let tmp = tempfile::tempdir().unwrap(); + let root = fixture_root(&tmp); + let home = root.join("gradle-home"); + let gradle = Gradle::detect(suite, &home)?; + let central = FakeCentral::start(); + let init = init_script( + &root.join("init"), + "mirror.gradle", + &mirror_init_script(¢ral.uri(), None), + ); + Some(World { + root, + home, + gradle, + central: Some(central), + init, + _tmp: tmp, + }) + } + + /// `gradle ` in `dir` through the mirror. + fn build(&self, dir: &Path, args: &[&str]) -> Output { + let mut all: Vec<&str> = vec![&self.init[0], &self.init[1]]; + all.extend(args); + self.gradle.run(dir, &self.home, &all) + } + + /// `socket-patch --json --cwd ` with the Gradle user home + /// as the only JVM cache: `(exit, envelope)`. + fn socket(&self, cwd: &Path, args: &[&str]) -> (Option, serde_json::Value) { + let mut cmd = Command::new(binary()); + for (k, _) in std::env::vars_os() { + if k.to_string_lossy().starts_with("SOCKET_") { + cmd.env_remove(&k); + } + } + let mut all: Vec<&str> = args.to_vec(); + let cwd_text = cwd.to_string_lossy().into_owned(); + all.extend(["--json", "--cwd", &cwd_text]); + let _fixture = prebuilt_common::prepare_command( + &mut cmd, + cwd, + &all, + &[("GRADLE_USER_HOME", self.home.to_str().unwrap())], + ); + let out = cmd + .current_dir(cwd) + .env("SOCKET_TELEMETRY_DISABLED", "1") + .env("SOCKET_NO_CONFIG", "1") + .env_remove("MAVEN_REPO_LOCAL") + .env_remove("M2_HOME") + .output() + .expect("run socket-patch"); + let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + let env = serde_json::from_str(&stdout).unwrap_or_else(|e| { + panic!( + "{args:?}: not JSON ({e})\n{stdout}\n{}", + String::from_utf8_lossy(&out.stderr) + ) + }); + (out.status.code(), env) + } + + fn socket_ok(&self, cwd: &Path, args: &[&str]) -> serde_json::Value { + let (code, env) = self.socket(cwd, args); + assert_eq!(code, Some(0), "{args:?}: {env}"); + let failed = env["summary"].get("failed").unwrap_or(&env["failed"]); + assert_eq!(failed, 0, "{args:?}: {env}"); + env + } + + fn vendor(&self, proj: &Path) -> serde_json::Value { + let env = self.socket_ok(proj, &["vendor"]); + println!("vendor: {env}"); + env + } +} + +/// What `get` saves for an agent-mode patch of `purl`: the manifest record +/// (`member` from `before` to `after`) and the after blob. Several calls +/// accumulate records. +fn stage_patch(proj: &Path, purl: &str, uuid: &str, member: &str, before: &[u8], after: &[u8]) { + let manifest_path = proj.join(".socket/manifest.json"); + let mut manifest: serde_json::Value = std::fs::read(&manifest_path) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()) + .unwrap_or_else(|| serde_json::json!({ "patches": {} })); + manifest["patches"][purl] = serde_json::json!({ + "uuid": uuid, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { member: { + "beforeHash": git_sha256(before), + "afterHash": git_sha256(after), + } }, + "vulnerabilities": { "GHSA-vend-grad-e2e1": { + "cves": ["CVE-2026-7533"], "summary": "s", "severity": "high", "description": "d" + } }, + "description": "vendored Gradle capstone", + "license": "MIT", + "tier": "free", + }); + std::fs::create_dir_all(proj.join(".socket/blobs")).unwrap(); + std::fs::write( + &manifest_path, + serde_json::to_string_pretty(&manifest).unwrap(), + ) + .unwrap(); + std::fs::write(proj.join(".socket/blobs").join(git_sha256(after)), after).unwrap(); +} + +/// The victim patch: `Victim.marker()` returns the patched marker. +fn stage_victim(proj: &Path) { + stage_patch( + proj, + &victim_purl(VICTIM_VERSION), + UUID, + VICTIM_CLASS_MEMBER, + &victim_member("pristine"), + &victim_member("patched"), + ); +} + +/// A checkout of `proj` in `dst`: every committable file, minus the +/// manifest, the blobs and build output (a vendored checkout builds +/// without them). +fn fresh_checkout(proj: &Path, dst: &Path) -> PathBuf { + for (rel, bytes) in snapshot(proj) { + if rel == ".socket/manifest.json" || rel.starts_with(".socket/blobs/") { + continue; + } + let path = dst.join(&rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, bytes).unwrap(); + } + dst.to_path_buf() +} + +/// The classpath entries of `artifact` (`-<…>.jar`). +fn entries(out: &Output, artifact: &str, what: &str) -> Vec { + assert!(ok(out), "{what}:\n{}", dump(out)); + gradle_classpath(out) + .into_iter() + .filter(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with(&format!("{artifact}-")) && n.ends_with(".jar")) + }) + .collect() +} + +/// The one main victim jar on the classpath carries `Victim.class` in +/// `state`; when `vendored_in` is given it is that checkout's vendored +/// jar. Returns the consumed path. +fn assert_victim(out: &Output, state: &str, vendored_in: Option<&Path>, what: &str) -> PathBuf { + let main = format!("{VICTIM}-{VICTIM_VERSION}.jar"); + let hits: Vec = entries(out, VICTIM, what) + .into_iter() + .filter(|p| p.file_name().is_some_and(|n| n == main.as_str())) + .collect(); + assert_eq!( + hits.len(), + 1, + "{what}: one {main} on the classpath:\n{}", + dump(out) + ); + let jar = std::fs::read(&hits[0]).unwrap(); + let got = jar_member(&jar, VICTIM_CLASS_MEMBER) + .unwrap_or_else(|| panic!("{what}: {} has no Victim.class", hits[0].display())); + assert!( + got == victim_member(state), + "{what}: {} does not carry the {state} Victim.class", + hits[0].display() + ); + if let Some(checkout) = vendored_in { + let want = checkout.join(format!("{}/{main}", tree_rel(VICTIM, VICTIM_VERSION))); + assert_eq!( + hits[0].canonicalize().unwrap(), + want.canonicalize().unwrap(), + "{what}: Gradle consumed the vendored jar" + ); + } + println!("{what}: consumed {}", hits[0].display()); + hits[0].clone() +} + +fn groovy_app(deps: &str) -> String { + format!( + "plugins {{ id 'java' }}\nrepositories {{ mavenCentral() }}\ndependencies {{\n{deps}}}\n{}", + print_cp_task(Dsl::Groovy, "runtimeClasspath") + ) +} + +/// The standard single-project Groovy build depending on the victim. +fn simple_project(proj: &Path) { + write_project( + proj, + &[ + ("settings.gradle", "rootProject.name = 'app'\n"), + ( + "build.gradle", + &groovy_app(&format!(" implementation '{DEP}'\n")), + ), + ], + ); +} + +/// Pre-vendor build (fills the Gradle cache with the pristine victim), +/// then stage the victim patch. +fn prepare(world: &World, proj: &Path, task: &str) { + let out = world.build(proj, &[task]); + assert!( + ok(&out), + "pre-vendor build:\n{}\n{}", + dump(&out), + verification_report(proj) + ); + assert_victim(&out, "pristine", None, "pre-vendor build"); + stage_victim(proj); +} + +/// The text of Gradle's dependency-verification reports under `proj` +/// (markup stripped), for a failure message. +fn verification_report(proj: &Path) -> String { + let reports = proj.join("build/reports/dependency-verification"); + let markup = regex::Regex::new(r"<[^>]*>").unwrap(); + let mut out = String::new(); + for dir in std::fs::read_dir(&reports).into_iter().flatten().flatten() { + for file in std::fs::read_dir(dir.path()) + .into_iter() + .flatten() + .flatten() + { + let text = std::fs::read_to_string(file.path()).unwrap_or_default(); + let stripped = markup + .replace_all(&text, " ") + .split_whitespace() + .collect::>() + .join(" "); + out.push_str(&stripped.chars().take(4000).collect::()); + } + } + out +} + +fn refusal(env: &serde_json::Value) -> (String, String) { + let event = env["events"] + .as_array() + .unwrap() + .iter() + .find(|e| e["action"] == "failed") + .unwrap_or_else(|| panic!("no failed event: {env}")); + ( + event["errorCode"].as_str().unwrap_or_default().to_string(), + event["error"].as_str().unwrap_or_default().to_string(), + ) +} + +/// `vendor` refuses with `reason` (naming `named`) and writes nothing. +fn assert_vendor_refused(world: &World, proj: &Path, reason: &str, named: &str) { + let before = snapshot(proj); + let (code, env) = world.socket(proj, &["vendor"]); + assert_ne!(code, Some(0), "{env}"); + let (_, error) = refusal(&env); + assert!( + error.starts_with(&format!("reason: {reason}: ")) && error.contains(named), + "{env}" + ); + assert_eq!(snapshot(proj), before, "a refused vendor writes nothing"); +} + +// ── git ───────────────────────────────────────────────────────────────── + +/// `git ` in `cwd`, ambient git environment and config scrubbed. +fn git(cwd: &Path, args: &[&str]) -> Output { + let mut cmd = Command::new("git"); + for (key, _) in std::env::vars_os() { + if key.to_string_lossy().starts_with("GIT_") { + cmd.env_remove(&key); + } + } + let global = cwd.join("..").join("e2e-empty.gitconfig"); + if !global.is_file() { + let _ = std::fs::write(&global, ""); + } + let out = cmd + .current_dir(cwd) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", &global) + .args(["-c", "user.name=socket-patch-e2e"]) + .args(["-c", "user.email=e2e@socket.invalid"]) + .args(["-c", "init.defaultBranch=main"]) + .args(args) + .output() + .expect("spawn git"); + assert!(ok(&out), "git {args:?}:\n{}", dump(&out)); + out +} + +/// Commit everything in `dir` (initializing the repository first). +fn commit(dir: &Path, message: &str) -> String { + if !dir.join(".git").exists() { + git(dir, &["init", "-q"]); + } + git(dir, &["add", "-A"]); + git(dir, &["commit", "-q", "--allow-empty", "-m", message]); + String::from_utf8(git(dir, &["rev-parse", "HEAD"]).stdout) + .unwrap() + .trim() + .to_string() +} + +/// Clone `src` to `dst`, with `core.autocrlf=true` when `autocrlf`. +fn clone(src: &Path, dst: &Path, autocrlf: bool) -> PathBuf { + let flag = format!("core.autocrlf={autocrlf}"); + git( + dst.parent().unwrap(), + &[ + "-c", + &flag, + "clone", + "-q", + "--config", + &flag, + &src.to_string_lossy(), + &dst.to_string_lossy(), + ], + ); + dst.to_path_buf() +} + +/// The working tree of `dir` differs from commit `rev` in nothing (line +/// endings normalized the way the checkout's config does) and holds no +/// untracked file. +fn assert_tree_is(dir: &Path, rev: &str, what: &str) { + let changed = git(dir, &["diff", "--name-status", rev]); + let untracked = git(dir, &["ls-files", "--others", "--exclude-standard"]); + assert!( + changed.stdout.is_empty() && untracked.stdout.is_empty(), + "{what}: the tree is not back at {rev}:\n{}{}", + String::from_utf8_lossy(&changed.stdout), + String::from_utf8_lossy(&untracked.stdout) + ); +} + +const GITIGNORE: &str = ".gradle/\nbuild/\n"; + +/// Stands for [`World::buildsrc_repositories`] in a scenario's files. +const BUILDSRC_REPOS: &str = "@BUILDSRC_REPOSITORIES@\n"; + +// ── #395 ──────────────────────────────────────────────────────────────── + +/// #395: a `pom.xml` beside the Gradle build. Both builds of the fresh +/// checkout consume the patched jar: Gradle from the vendored Gradle tree, +/// Maven the suffixed version from the vendored maven2 tree. +#[test] +#[ignore = "real Gradle + Maven (Maven plugins from Central); run with --ignored"] +fn gradle_vendor_395_mixed_root() { + const SUITE: &str = "e2e_vendor_gradle_build::395"; + let Some(world) = World::new(SUITE) else { + return; + }; + let Some(mvn) = maven_build_common::Mvn::detect(SUITE) else { + return; + }; + let proj = world.root.join("proj"); + simple_project(&proj); + write_project( + &proj, + &[( + "pom.xml", + &format!( + "\n 4.0.0\n \ + com.example\n app\n 1.0.0\n \ + \n \n {GROUP}\n \ + {VICTIM}\n {VICTIM_VERSION}\n \ + \n \n\n" + ), + )], + ); + prepare(&world, &proj, "printRuntimeClasspath"); + let pristine = snapshot(&proj); + let env = world.vendor(&proj); + assert_eq!(env["summary"]["applied"], 1, "{env}"); + let sv = format!("{VICTIM_VERSION}-socket.1d3c1fd2"); + let maven_jar = format!(".socket/vendor/maven2/{GROUP_PATH}/{VICTIM}/{sv}/{VICTIM}-{sv}.jar"); + let vendored = snapshot(&proj); + assert!( + vendored.contains_key(&maven_jar), + "the Maven tree is vendored" + ); + assert!(String::from_utf8_lossy(&vendored["pom.xml"]).contains(&sv)); + world.socket_ok(&proj, &["vendor", "--check"]); + + let fresh = fresh_checkout(&proj, &world.root.join("fresh")); + let out = world.build(&fresh, &["printRuntimeClasspath"]); + assert_victim(&out, "patched", Some(&fresh), "Gradle half"); + + // Maven: the fake Central for the fixture, Central for Maven's plugins. + let settings = world.root.join("settings.xml"); + let central = world.central.as_ref().unwrap().uri(); + std::fs::write( + &settings, + format!( + "\n \n \n \ + fixture\n \n \n fixture\n \ + {central}\n \n \n \n \n \ + \n fixture\n \n\n" + ), + ) + .unwrap(); + let m2 = world.root.join("m2"); + let out = mvn.run( + &fresh, + &m2, + &settings, + &[ + "org.apache.maven.plugins:maven-dependency-plugin:3.5.0:build-classpath", + "-Dmdep.outputFile=cp.txt", + ], + ); + assert!(ok(&out), "Maven half:\n{}", dump(&out)); + let cp = std::fs::read_to_string(fresh.join("cp.txt")).unwrap(); + let hit = std::env::split_paths(&cp) + .find(|p| { + p.file_name() + .is_some_and(|n| n.to_string_lossy().starts_with(&format!("{VICTIM}-"))) + }) + .unwrap_or_else(|| panic!("Maven classpath has no victim: {cp}")); + assert!( + hit.to_string_lossy().contains(&sv), + "Maven resolves the suffixed version: {}", + hit.display() + ); + let jar = std::fs::read(&hit).unwrap(); + assert_eq!( + jar_member(&jar, VICTIM_CLASS_MEMBER).unwrap(), + victim_member("patched"), + "Maven half consumes the patched jar" + ); + let _ = std::fs::remove_file(fresh.join("cp.txt")); + + world.socket_ok(&proj, &["vendor", "--revert"]); + let mut after = snapshot(&proj); + after.retain(|rel, _| !rel.starts_with("build/")); + assert_eq!(after, pristine, "byte-exact revert of both builds"); +} + +// ── #428 ──────────────────────────────────────────────────────────────── + +/// #428: vendoring from a subproject refuses with `not_build_root`; no +/// nested settings file appears and both invocations still build. +#[test] +#[ignore = "real Gradle (the fake Central); run with --ignored"] +fn gradle_vendor_428_subproject_refuses() { + const SUITE: &str = "e2e_vendor_gradle_build::428"; + let Some(world) = World::new(SUITE) else { + return; + }; + let proj = world.root.join("proj"); + write_project( + &proj, + &[ + ( + "settings.gradle", + "rootProject.name = 'root'\ninclude 'app'\n", + ), + ( + "build.gradle", + "allprojects { repositories { mavenCentral() } }\n", + ), + ( + "app/build.gradle", + &format!( + "plugins {{ id 'java' }}\ndependencies {{ implementation '{DEP}' }}\n{}", + print_cp_task(Dsl::Groovy, "runtimeClasspath") + ), + ), + ], + ); + std::fs::create_dir_all(proj.join(".git")).unwrap(); + let app = proj.join("app"); + let out = world.build(&proj, &[":app:printRuntimeClasspath"]); + assert_victim(&out, "pristine", None, "pre-vendor"); + stage_victim(&app); + assert_vendor_refused( + &world, + &app, + "not_build_root", + "run vendor from Gradle root", + ); + assert!(!app.join("settings.gradle").exists()); + let out = world.build(&app, &["printRuntimeClasspath"]); + assert_victim(&out, "pristine", None, "`cd app && gradle` still builds"); +} + +// ── #429 ──────────────────────────────────────────────────────────────── + +/// #429: a `core.autocrlf=true` clone of a vendored checkout (buildSrc +/// included) passes `vendor --check`, builds the patched jar, and +/// `vendor --revert`, `remove` and `rollback` leave it at the pre-vendor +/// commit. +#[test] +#[ignore = "real Gradle (the fake Central) + git; run with --ignored"] +fn gradle_vendor_429_autocrlf_clone() { + const SUITE: &str = "e2e_vendor_gradle_build::429"; + let Some(world) = World::new(SUITE) else { + return; + }; + let proj = world.root.join("proj"); + simple_project(&proj); + write_project( + &proj, + &[ + (".gitignore", GITIGNORE), + ( + "buildSrc/build.gradle", + &format!( + "plugins {{ id 'java' }}\n{}dependencies {{ implementation '{DEP}' }}\n", + world.buildsrc_repositories() + ), + ), + ], + ); + prepare(&world, &proj, "printRuntimeClasspath"); + let pristine = commit(&proj, "pristine"); + world.vendor(&proj); + commit(&proj, "vendored"); + + for command in [ + &["vendor", "--revert"][..], + &["remove", &victim_purl(VICTIM_VERSION)], + &["rollback"], + ] { + let name = command.join("-").replace(['/', ':', '@'], "_"); + let win = clone(&proj, &world.root.join(format!("win-{name}")), true); + let settings = std::fs::read(win.join("settings.gradle")).unwrap(); + assert!( + settings.windows(2).any(|w| w == b"\r\n"), + "the clone checks text files out with CRLF" + ); + let env = world.socket_ok(&win, &["vendor", "--check"]); + assert_eq!(env["summary"]["verified"], 1, "{env}"); + let out = world.build(&win, &["printRuntimeClasspath"]); + assert_victim(&out, "patched", Some(&win), "autocrlf clone"); + world.socket_ok(&win, command); + if command[0] != "vendor" { + // `remove` and `rollback` drop the patch and its blob from the + // manifest by design; everything else must be back. + git( + &win, + &[ + "checkout", + "-q", + &pristine, + "--", + ".socket/manifest.json", + ".socket/blobs", + ], + ); + } + assert_tree_is(&win, &pristine, &format!("{command:?}")); + } +} + +// ── #461 ──────────────────────────────────────────────────────────────── + +const EXCLUSIVE: &str = "repositories {\n exclusiveContent {\n forRepository { mavenCentral() }\n filter { includeGroup 'com.socketfixture' }\n }\n mavenCentral()\n}\n"; + +/// The build resolves through a user `exclusiveContent` rule for the +/// group in `files`; `vendor` refuses naming `named`, and the build still +/// works. +fn exclusive_case(suite: &str, files: &[(&str, String)], task: &str, named: &str) { + let Some(world) = World::new(suite) else { + return; + }; + let proj = world.root.join("proj"); + let borrowed: Vec<(&str, &str)> = files.iter().map(|(r, b)| (*r, b.as_str())).collect(); + write_project(&proj, &borrowed); + let out = world.build(&proj, &[task]); + assert_victim(&out, "pristine", None, "pre-vendor"); + stage_victim(&proj); + assert_vendor_refused(&world, &proj, "gradle_exclusive_content_conflict", named); + let out = world.build(&proj, &[task]); + assert_victim(&out, "pristine", None, "after the refusal"); +} + +#[test] +#[ignore = "real Gradle (the fake Central); run with --ignored"] +fn gradle_vendor_461_sub() { + exclusive_case( + "e2e_vendor_gradle_build::461_sub", + &[ + ("settings.gradle", "rootProject.name = 'root'\ninclude 'app'\n".into()), + ( + "app/build.gradle", + format!( + "plugins {{ id 'java' }}\n{EXCLUSIVE}dependencies {{ implementation '{DEP}' }}\n{}", + print_cp_task(Dsl::Groovy, "runtimeClasspath") + ), + ), + ], + ":app:printRuntimeClasspath", + "app/build.gradle", + ); +} + +#[test] +#[ignore = "real Gradle (the fake Central); run with --ignored"] +fn gradle_vendor_461_conv() { + exclusive_case( + "e2e_vendor_gradle_build::461_conv", + &[ + ("settings.gradle", "rootProject.name = 'root'\ninclude 'app'\n".into()), + ("buildSrc/build.gradle", "plugins { id 'groovy-gradle-plugin' }\nrepositories { gradlePluginPortal() }\n".into()), + ("buildSrc/src/main/groovy/bh.repos.gradle", EXCLUSIVE.into()), + ( + "app/build.gradle", + format!( + "plugins {{ id 'java'; id 'bh.repos' }}\ndependencies {{ implementation '{DEP}' }}\n{}", + print_cp_task(Dsl::Groovy, "runtimeClasspath") + ), + ), + ], + ":app:printRuntimeClasspath", + "buildSrc/src/main/groovy/bh.repos.gradle", + ); +} + +#[test] +#[ignore = "real Gradle (the fake Central); run with --ignored"] +fn gradle_vendor_461_applyfrom() { + exclusive_case( + "e2e_vendor_gradle_build::461_applyfrom", + &[ + ("settings.gradle", "rootProject.name = 'root'\n".into()), + ("gradle/repos.gradle", EXCLUSIVE.into()), + ( + "build.gradle", + format!( + "plugins {{ id 'java' }}\napply from: 'gradle/repos.gradle'\ndependencies {{ implementation '{DEP}' }}\n{}", + print_cp_task(Dsl::Groovy, "runtimeClasspath") + ), + ), + ], + "printRuntimeClasspath", + "gradle/repos.gradle", + ); +} + +// ── #487 ──────────────────────────────────────────────────────────────── + +/// One ``: the fixture key's signature (`pgp`) or the upstream +/// sha256. +fn vm_artifact(name: &str, pgp: bool) -> String { + let body = if pgp { + // Gradle before 8 compares key ids as written: the lowercase form. + format!("", KEY_FINGERPRINT.to_lowercase()) + } else { + let path = generate() + .into_keys() + .find(|p| p.ends_with(&format!("/{name}"))) + .unwrap(); + format!( + "", + sha256_hex(&generate()[&path]) + ) + }; + format!(" \n {body}\n \n") +} + +/// A verification file with signature and metadata verification on: the +/// victim's entries and the parent's are pgp-only (`*_pgp`) or checksums, +/// and the fixture key is trusted for the other side. +fn verification(victim_pgp: bool, parent_pgp: bool) -> String { + let key = KEY_FINGERPRINT.to_lowercase(); + let trusted = match (victim_pgp, parent_pgp) { + (true, false) => format!(""), + _ => format!(""), + }; + let v = VICTIM_VERSION; + format!( + "\n\n \ + \n true\n true\n \ + \n {trusted}\n \n \n \n \ + \n{} \n \ + \n{}{}{} \n \ + \n\n", + vm_artifact(&format!("{PARENT}-{PARENT_VERSION}.pom"), parent_pgp), + vm_artifact(&format!("{VICTIM}-{v}.jar"), victim_pgp), + vm_artifact(&format!("{VICTIM}-{v}.module"), victim_pgp), + vm_artifact(&format!("{VICTIM}-{v}.pom"), victim_pgp), + ) +} + +/// #487: with signature verification on, pgp-only entries of the vendored +/// metadata (`victim_pgp`: the victim's own pom and module; `parent_pgp`: +/// its parent pom) get a checksum, so the fresh checkout builds the +/// patched jar; the revert restores the file byte for byte. +fn pgp_case(suite: &str, victim_pgp: bool, parent_pgp: bool) { + let Some(world) = World::new(suite) else { + return; + }; + let proj = world.root.join("proj"); + simple_project(&proj); + let original = verification(victim_pgp, parent_pgp); + write_project(&proj, &[("gradle/verification-metadata.xml", &original)]); + std::fs::copy( + public_keyring_gpg(), + proj.join("gradle/verification-keyring.gpg"), + ) + .unwrap(); + prepare(&world, &proj, "printRuntimeClasspath"); + world.vendor(&proj); + let text = std::fs::read_to_string(proj.join("gradle/verification-metadata.xml")).unwrap(); + let pgp_left: Vec<&str> = text + .split(", min: (u32, u32)) { + let Some(world) = World::new(suite) else { + return; + }; + if !world.gradle.at_least(min.0, min.1) { + println!( + "{suite}: Gradle {} predates this declaration", + world.gradle.version + ); + return; + } + let proj = world.root.join("proj"); + let borrowed: Vec<(&str, &str)> = files.iter().map(|(r, b)| (*r, b.as_str())).collect(); + write_project(&proj, &borrowed); + prepare(&world, &proj, "printRuntimeClasspath"); + world.vendor(&proj); + let metadata = std::fs::read_to_string(proj.join(format!( + ".socket/vendor/gradle/{GROUP_PATH}/{VICTIM}/maven-metadata.xml" + ))) + .unwrap(); + assert!( + metadata.contains(&format!("{VICTIM_VERSION}")), + "{metadata}" + ); + let fresh = fresh_checkout(&proj, &world.root.join("fresh")); + let out = world.build(&fresh, &["printRuntimeClasspath"]); + assert_victim(&out, "patched", Some(&fresh), "range after vendoring"); + world.socket_ok(&proj, &["vendor", "--check"]); +} + +#[test] +#[ignore = "real Gradle (the fake Central); run with --ignored"] +fn gradle_vendor_511_range() { + range_case( + "e2e_vendor_gradle_build::511_range", + vec![ + ("settings.gradle", "rootProject.name = 'app'\n".into()), + ( + "build.gradle", + groovy_app(" implementation 'com.socketfixture:victim:[1.9,1.10.0]'\n"), + ), + ], + (6, 0), + ); +} + +#[test] +#[ignore = "real Gradle (the fake Central); run with --ignored"] +fn gradle_vendor_511_rich() { + range_case( + "e2e_vendor_gradle_build::511_rich", + vec![ + ("settings.gradle", "rootProject.name = 'app'\n".into()), + ( + "build.gradle", + groovy_app(" implementation('com.socketfixture:victim') { version { strictly '[1.9,1.11)'; prefer '1.10.0' } }\n"), + ), + ], + (6, 0), + ); +} + +#[test] +#[ignore = "real Gradle (the fake Central); run with --ignored"] +fn gradle_vendor_511_catalog() { + range_case( + "e2e_vendor_gradle_build::511_catalog", + vec![ + ("settings.gradle.kts", "rootProject.name = \"app\"\n".into()), + ( + "gradle/libs.versions.toml", + "[versions]\nvictim = { strictly = \"[1.9,1.11)\", prefer = \"1.10.0\" }\n\n[libraries]\nvictim = { module = \"com.socketfixture:victim\", version.ref = \"victim\" }\n".into(), + ), + ( + "build.gradle.kts", + format!( + "plugins {{ java }}\nrepositories {{ mavenCentral() }}\ndependencies {{ implementation(libs.victim) }}\n{}", + print_cp_task(Dsl::Kotlin, "runtimeClasspath") + ), + ), + ], + // Version catalogs are stable from 7.4. + (7, 4), + ); +} + +// ── #533 ──────────────────────────────────────────────────────────────── + +/// A task printing the sources jar an IDE sync resolves for each module of +/// `runtimeClasspath` (`ArtifactResolutionQuery`). +/// +/// The query runs in a `providers.provider` the task captures, never in the +/// task action: the configuration-cache cells reject an action that reaches +/// `configurations` / `dependencies` (the project) at execution time, while a +/// provider is evaluated (and its value stored) when the cache entry is +/// written. +fn sources_task(dsl: Dsl) -> &'static str { + match dsl { + Dsl::Groovy => "def socketSources = providers.provider {\n def ids = configurations.runtimeClasspath.incoming.resolutionResult.allComponents.collect { it.id }.findAll { it instanceof ModuleComponentIdentifier }\n def r = dependencies.createArtifactResolutionQuery().forComponents(ids).withArtifacts(JvmLibrary, SourcesArtifact).execute()\n def paths = []\n r.resolvedComponents.each { c -> c.getArtifacts(SourcesArtifact).each { a -> if (a instanceof ResolvedArtifactResult) { paths << a.file.absolutePath } } }\n paths\n}\ntasks.register('printSources') {\n def srcs = socketSources\n doLast { srcs.get().each { println('SOCKET-SRC ' + it) } }\n}\n", + Dsl::Kotlin => "val socketSources = providers.provider {\n val ids = configurations.getByName(\"runtimeClasspath\").incoming.resolutionResult.allComponents.map { it.id }.filterIsInstance()\n val r = dependencies.createArtifactResolutionQuery().forComponents(ids).withArtifacts(JvmLibrary::class.java, SourcesArtifact::class.java).execute()\n r.resolvedComponents.flatMap { c -> c.getArtifacts(SourcesArtifact::class.java).filterIsInstance().map { it.file.absolutePath } }\n}\ntasks.register(\"printSources\") {\n val srcs = socketSources\n doLast { srcs.get().forEach { println(\"SOCKET-SRC \" + it) } }\n}\n", + } +} + +/// #533: a declared `tests` classifier and the IDE sources of the patched +/// module still resolve after vendoring, from the vendored tree. +fn classifier_case(suite: &str, dsl: Dsl) { + let Some(world) = World::new(suite) else { + return; + }; + let proj = world.root.join("proj"); + let (settings, build) = match dsl { + Dsl::Groovy => ( + "rootProject.name = 'app'\n".to_string(), + format!( + "plugins {{ id 'java' }}\nrepositories {{ mavenCentral() }}\ndependencies {{\n implementation '{DEP}'\n testImplementation '{DEP}:tests'\n}}\n" + ), + ), + Dsl::Kotlin => ( + "rootProject.name = \"app\"\n".to_string(), + "plugins { java }\nrepositories { mavenCentral() }\ndependencies {\n implementation(\"com.socketfixture:victim:1.10.0\")\n testImplementation(group = \"com.socketfixture\", name = \"victim\", version = \"1.10.0\", classifier = \"tests\")\n}\n".to_string(), + ), + }; + let build = format!( + "{build}{}{}", + print_cp_task(dsl, "testRuntimeClasspath"), + sources_task(dsl) + ); + write_project( + &proj, + &[ + (dsl.settings_file().as_str(), &settings), + (dsl.build_file().as_str(), &build), + ], + ); + let out = world.build(&proj, &["printRuntimeClasspath", "printSources"]); + assert_victim(&out, "pristine", None, "pre-vendor"); + stage_victim(&proj); + let env = world.vendor(&proj); + assert!( + !env.to_string().contains("ide_sources_unavailable"), + "the cached sources jar is vendored: {env}" + ); + let tree = proj.join(tree_rel(VICTIM, VICTIM_VERSION)); + for name in ["victim-1.10.0-tests.jar", "victim-1.10.0-sources.jar"] { + assert_eq!( + std::fs::read(tree.join(name)).unwrap(), + generate()[&format!("{GROUP_PATH}/{VICTIM}/{VICTIM_VERSION}/{name}")], + "{name} is vendored verbatim" + ); + } + let fresh = fresh_checkout(&proj, &world.root.join("fresh")); + let out = world.build(&fresh, &["printRuntimeClasspath", "printSources"]); + assert_victim(&out, "patched", Some(&fresh), "main jar"); + let tests: Vec = entries(&out, VICTIM, "tests jar") + .into_iter() + .filter(|p| p.to_string_lossy().ends_with("victim-1.10.0-tests.jar")) + .collect(); + assert_eq!(tests.len(), 1, "{}", dump(&out)); + assert_eq!( + tests[0].canonicalize().unwrap(), + fresh + .join(tree_rel(VICTIM, VICTIM_VERSION)) + .join("victim-1.10.0-tests.jar") + .canonicalize() + .unwrap() + ); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout + .lines() + .filter_map(|l| l.strip_prefix("SOCKET-SRC ")) + .any(|p| p.ends_with("victim-1.10.0-sources.jar")), + "IDE sources still resolve:\n{}", + dump(&out) + ); + world.socket_ok(&proj, &["vendor", "--check"]); +} + +#[test] +#[ignore = "real Gradle (the fake Central); run with --ignored"] +fn gradle_vendor_533_classifier_groovy_and_sources() { + classifier_case("e2e_vendor_gradle_build::533_groovy", Dsl::Groovy); +} + +#[test] +#[ignore = "real Gradle (the fake Central); run with --ignored"] +fn gradle_vendor_533_classifier_kotlin_and_sources() { + classifier_case("e2e_vendor_gradle_build::533_kotlin", Dsl::Kotlin); +} + +// ── offline sourcing ──────────────────────────────────────────────────── + +/// Vendoring needs no Maven repository: the pom and module come from the +/// Gradle cache's hash directories, and the fresh checkout then builds +/// `--offline` with the fake Central gone. +#[test] +#[ignore = "real Gradle (the fake Central); run with --ignored"] +fn gradle_vendor_offline_no_m2_seed() { + const SUITE: &str = "e2e_vendor_gradle_build::offline_no_m2_seed"; + let Some(mut world) = World::new(SUITE) else { + return; + }; + let proj = world.root.join("proj"); + simple_project(&proj); + prepare(&world, &proj, "printRuntimeClasspath"); + assert!(!world.root.join("m2").exists()); + world.vendor(&proj); + let tree = proj.join(tree_rel(VICTIM, VICTIM_VERSION)); + for ext in ["pom", "module"] { + assert_eq!( + std::fs::read(tree.join(format!("{VICTIM}-{VICTIM_VERSION}.{ext}"))).unwrap(), + generate()[&repo_path(VICTIM, VICTIM_VERSION, None, ext)], + "the upstream {ext} verbatim" + ); + } + world.central = None; + let fresh = fresh_checkout(&proj, &world.root.join("fresh")); + let out = world.build(&fresh, &["--offline", "printRuntimeClasspath"]); + assert_victim(&out, "patched", Some(&fresh), "--offline"); +} + +// ── configuration cache ───────────────────────────────────────────────── + +/// Vendoring a second patch into a build whose configuration is cached +/// invalidates the entry (the index is a configuration input): the +/// rebuild consumes the second patched jar too. +#[test] +#[ignore = "real Gradle 8.1+ (the fake Central); run with --ignored"] +fn gradle_vendor_config_cache_second_patch() { + const SUITE: &str = "e2e_vendor_gradle_build::config_cache"; + let Some(world) = World::new(SUITE) else { + return; + }; + if !world.gradle.at_least(8, 1) { + println!( + "{SUITE}: Gradle {} predates the stable configuration cache", + world.gradle.version + ); + return; + } + let proj = world.root.join("proj"); + write_project( + &proj, + &[ + ("settings.gradle", "rootProject.name = 'app'\n"), + ( + "build.gradle", + &groovy_app(&format!( + " implementation '{DEP}'\n implementation 'com.socketfixture:consumer:2.0'\n" + )), + ), + ], + ); + let cc = "--configuration-cache"; + prepare(&world, &proj, "printRuntimeClasspath"); + world.vendor(&proj); + let out = world.build(&proj, &[cc, "printRuntimeClasspath"]); + assert_victim(&out, "patched", Some(&proj), "first patch, cache stored"); + let out = world.build(&proj, &[cc, "printRuntimeClasspath"]); + assert!( + configuration_reused(&out), + "the entry is reused:\n{}", + dump(&out) + ); + + let consumer_jar = generate()[&repo_path(CONSUMER, CONSUMER_VERSION, None, "jar")].clone(); + let before = jar_member(&consumer_jar, NOTICE).unwrap(); + let after = [before.as_slice(), b"SOCKET-PATCH-SECOND\n"].concat(); + stage_patch( + &proj, + &format!("pkg:maven/{GROUP}/{CONSUMER}@{CONSUMER_VERSION}"), + UUID_2, + NOTICE, + &before, + &after, + ); + let env = world.vendor(&proj); + assert_eq!(env["summary"]["applied"], 1, "{env}"); + let out = world.build(&proj, &[cc, "printRuntimeClasspath"]); + assert!( + !configuration_reused(&out), + "a changed index must invalidate the entry:\n{}", + dump(&out) + ); + assert_victim(&out, "patched", Some(&proj), "first patch kept"); + let consumer: Vec = entries(&out, CONSUMER, "consumer"); + assert_eq!(consumer.len(), 1, "{}", dump(&out)); + assert_eq!( + jar_member(&std::fs::read(&consumer[0]).unwrap(), NOTICE).unwrap(), + after, + "the second patch is consumed: {}", + consumer[0].display() + ); +} + +// ── bug-hunt scenarios ────────────────────────────────────────────────── + +/// A bug-hunt scenario end to end: vendor, commit, a clean clone without +/// the manifest passes `vendor --check` and builds the patched jar (with +/// `task`, from `cwd` below the clone), then `vendor --revert` leaves the +/// source checkout at its pre-vendor commit. +fn bughunt(suite: &str, files: &[(&str, String)], task: &str) { + bughunt_with(suite, files, task, assert_victim); +} + +/// What a scenario's task proves: `(output, state, vendored checkout, +/// what)`. +type Check = fn(&Output, &str, Option<&Path>, &str) -> PathBuf; + +/// [`bughunt`] with its own consumption `check`. +fn bughunt_with(suite: &str, files: &[(&str, String)], task: &str, check: Check) { + let Some(world) = World::new(suite) else { + return; + }; + let proj = world.root.join("proj"); + let files: Vec<(&str, String)> = files + .iter() + .map(|(r, b)| { + ( + *r, + b.replace(BUILDSRC_REPOS, &world.buildsrc_repositories()), + ) + }) + .collect(); + let mut borrowed: Vec<(&str, &str)> = files.iter().map(|(r, b)| (*r, b.as_str())).collect(); + borrowed.push((".gitignore", GITIGNORE)); + write_project(&proj, &borrowed); + let out = world.build(&proj, &[task]); + check(&out, "pristine", None, "pre-vendor build"); + stage_victim(&proj); + let pristine = commit(&proj, "pristine"); + world.vendor(&proj); + commit(&proj, "vendored"); + let fresh = clone(&proj, &world.root.join("fresh"), false); + std::fs::remove_file(fresh.join(".socket/manifest.json")).unwrap(); + let _ = std::fs::remove_dir_all(fresh.join(".socket/blobs")); + world.socket_ok(&fresh, &["vendor", "--check"]); + let out = world.build(&fresh, &[task]); + check(&out, "patched", Some(&fresh), "fresh clone"); + world.socket_ok(&proj, &["vendor", "--revert"]); + assert_tree_is(&proj, &pristine, "revert"); +} + +fn print_task_groovy() -> String { + print_cp_task(Dsl::Groovy, "runtimeClasspath") +} + +/// s1: a plain single-project Groovy build. +#[test] +#[ignore = "real Gradle (the fake Central) + git; run with --ignored"] +fn gradle_vendor_bughunt_s1() { + bughunt( + "e2e_vendor_gradle_build::bughunt_s1", + &[ + ("settings.gradle", "rootProject.name = 's1'\n".into()), + ( + "build.gradle", + groovy_app(&format!(" implementation '{DEP}'\n")), + ), + ], + "printRuntimeClasspath", + ); +} + +/// s2: no settings file at all (vendor creates it). +#[test] +#[ignore = "real Gradle (the fake Central) + git; run with --ignored"] +fn gradle_vendor_bughunt_s2() { + bughunt( + "e2e_vendor_gradle_build::bughunt_s2", + &[( + "build.gradle", + groovy_app(&format!(" implementation '{DEP}'\n")), + )], + "printRuntimeClasspath", + ); +} + +/// s6: `pluginManagement` repositories and a last line without a newline. +#[test] +#[ignore = "real Gradle (the fake Central) + git; run with --ignored"] +fn gradle_vendor_bughunt_s6() { + bughunt( + "e2e_vendor_gradle_build::bughunt_s6", + &[ + ( + "settings.gradle", + "pluginManagement {\n repositories { gradlePluginPortal() }\n}\nrootProject.name = 's6' // last line no newline".into(), + ), + ("build.gradle", groovy_app(&format!(" implementation '{DEP}'\n"))), + ], + "printRuntimeClasspath", + ); +} + +/// s9: a multi-project build with `allprojects` repositories. +#[test] +#[ignore = "real Gradle (the fake Central) + git; run with --ignored"] +fn gradle_vendor_bughunt_s9() { + bughunt( + "e2e_vendor_gradle_build::bughunt_s9", + &[ + ( + "settings.gradle", + "rootProject.name='s9'\ninclude 'app'\n".into(), + ), + ( + "build.gradle", + "allprojects { repositories { mavenCentral() } }\n".into(), + ), + ( + "app/build.gradle", + format!( + "plugins {{ id 'java' }}\ndependencies {{ implementation '{DEP}' }}\n{}", + print_task_groovy() + ), + ), + ], + ":app:printRuntimeClasspath", + ); +} + +/// The build-logic marker: `Victim.marker()` run from buildSrc code (the +/// class Gradle loads is an instrumented copy of the jar, so its bytes are +/// not compared). +fn assert_build_logic_marker(out: &Output, state: &str, _: Option<&Path>, what: &str) -> PathBuf { + assert!(ok(out), "{what}:\n{}", dump(out)); + let want = format!( + "SOCKET-MARKER {}", + jvm_fixture_repo::victim_marker(VICTIM_VERSION, state) + ); + assert!( + String::from_utf8_lossy(&out.stdout) + .lines() + .any(|l| l.trim() == want), + "{what}: build logic prints `{want}`:\n{}", + dump(out) + ); + PathBuf::new() +} + +/// s12: the patched library on the buildSrc classpath, loaded by build +/// logic: buildSrc gets its own wiring. +#[test] +#[ignore = "real Gradle (the fake Central) + git; run with --ignored"] +fn gradle_vendor_bughunt_s12() { + bughunt_with( + "e2e_vendor_gradle_build::bughunt_s12", + &[ + ("settings.gradle", "rootProject.name='s12'\n".into()), + ( + "build.gradle", + "tasks.register('printMarker') { doLast { println 'SOCKET-MARKER ' + Loc.marker() } }\n".into(), + ), + ( + "buildSrc/build.gradle", + format!("plugins {{ id 'groovy-gradle-plugin' }}\n{BUILDSRC_REPOS}dependencies {{ implementation '{DEP}' }}\n"), + ), + ( + "buildSrc/src/main/groovy/Loc.groovy", + "class Loc { static String marker() { com.socketfixture.victim.Victim.marker() } }\n".into(), + ), + ], + "printMarker", + assert_build_logic_marker, + ); +} + +/// s14: the classpath captured at configuration time. +#[test] +#[ignore = "real Gradle (the fake Central) + git; run with --ignored"] +fn gradle_vendor_bughunt_s14() { + bughunt( + "e2e_vendor_gradle_build::bughunt_s14", + &[ + ("settings.gradle", "rootProject.name = 's14'\n".into()), + ( + "build.gradle", + format!( + "plugins {{ id 'java' }}\nrepositories {{ mavenCentral() }}\ndependencies {{ implementation '{DEP}' }}\n\ + tasks.register('printRuntimeClasspath') {{ def cp = configurations.runtimeClasspath; doLast {{ cp.files.each {{ println '{CP_MARKER}' + it }} }} }}\n" + ), + ), + ], + "printRuntimeClasspath", + ); +} diff --git a/crates/socket-patch-cli/tests/e2e_vendor_jvm_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_jvm_build.rs index d4e7f1963..ef49cba43 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_jvm_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_jvm_build.rs @@ -20,14 +20,15 @@ //! vendoring the lockfiles are byte-unchanged, `:app` runtimeClasspath //! resolves the vendored jar online and `--offline`, a tampered vendored //! jar fails the build with the socket-patch message, and -//! `vendor --revert` is byte-exact. +//! `vendor --revert` is byte-exact. It needs no Maven: the CLI reads the +//! registry bytes from the Gradle cache the build fills. //! //! Gated like the other real-toolchain capstones: `#[ignore]` (network to //! Maven Central), Maven via `SOCKET_PATCH_MAVEN_E2E_{MVN,VERSION,REQUIRED}` //! (`maven_build_common`), Gradle via `SOCKET_PATCH_GRADLE_E2E_GRADLE` (the //! launcher; default `gradle` on `PATH`), `SOCKET_PATCH_GRADLE_E2E_VERSION` //! (the version it must report) and `SOCKET_PATCH_GRADLE_E2E_REQUIRED` (no -//! SKIP). Scratch trees go under `TMPDIR`. +//! SKIP) (`gradle_build_common`). Scratch trees go under `TMPDIR`. #[path = "maven_build_common/mod.rs"] mod maven_build_common; @@ -35,67 +36,59 @@ mod maven_build_common; #[path = "prebuilt_common/mod.rs"] mod prebuilt_common; +#[path = "gradle_build_common/mod.rs"] +mod gradle_build_common; + +#[path = "jvm_fixture_repo/mod.rs"] +mod jvm_fixture_repo; + use std::collections::BTreeMap; -use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; +use gradle_build_common::{ + assert_patched, configuration_reused, fixture_root, gradle_classpath, gradle_skip, init_script, + lockfiles, mirror_init_script, print_cp_task, probe_report, snapshot, write_both_dsls, + write_project, Dsl, Gradle, +}; use maven_build_common::*; const UUID: &str = "1d3c1fd2-7b4e-4c1a-9f0e-2a3b4c5d6e7f"; const SV: &str = "1.10.0-socket.1d3c1fd2"; -const GRADLE_ENV: &str = "SOCKET_PATCH_GRADLE_E2E_GRADLE"; -const GRADLE_VERSION_ENV: &str = "SOCKET_PATCH_GRADLE_E2E_VERSION"; -const GRADLE_REQUIRED_ENV: &str = "SOCKET_PATCH_GRADLE_E2E_REQUIRED"; - /// The classpath probe. Not [`DEPENDENCY_PLUGIN`]: 3.6.x itself depends on /// `commons-text:1.10.0` (3.5.0 on 1.3), so purging the fixture version from /// the local repository would break the plugin realm, not the project. const CLASSPATH_PLUGIN: &str = "org.apache.maven.plugins:maven-dependency-plugin:3.5.0"; -/// Directories a build writes that a checkout never carries. -const BUILD_OUTPUT_DIRS: &[&str] = &["target", "build", ".gradle", ".kotlin"]; - fn binary() -> PathBuf { env!("CARGO_BIN_EXE_socket-patch").into() } -/// Java rejects the extended Windows paths returned by canonicalize. -/// Keep a canonical root for symlinked macOS temp directories, but use the -/// ordinary drive/UNC spelling when handing paths to Maven and Gradle. -fn fixture_root(tmp: &tempfile::TempDir) -> PathBuf { - let root = tmp.path().canonicalize().unwrap(); - #[cfg(windows)] - if let Some(path) = root.to_str() { - if let Some(rest) = path.strip_prefix(r"\\?\UNC\") { - return format!(r"\\{rest}").into(); - } - if let Some(rest) = path.strip_prefix(r"\\?\") { - return rest.into(); - } - } - root -} - fn git_sha256(bytes: &[u8]) -> String { socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes(bytes) } -/// `socket-patch ` with ambient `SOCKET_*` scrubbed and `m2` as the Maven repo. -fn socket(cwd: &Path, m2: &Path, args: &[&str]) -> (Option, serde_json::Value, String) { +/// `socket-patch ` with ambient `SOCKET_*` scrubbed, `m2` as the Maven +/// repo and, when given, `gradle_home` as the `GRADLE_USER_HOME` the CLI +/// crawls (and the fixture registry serves). +fn socket_in( + cwd: &Path, + m2: &Path, + gradle_home: Option<&Path>, + args: &[&str], +) -> (Option, serde_json::Value, String) { let mut cmd = Command::new(binary()); for (k, _) in std::env::vars_os() { if k.to_string_lossy().starts_with("SOCKET_") { cmd.env_remove(&k); } } - let _fixture = prebuilt_common::prepare_command( - &mut cmd, - cwd, - args, - &[("MAVEN_REPO_LOCAL", m2.to_str().unwrap())], - ); + let mut caches = vec![("MAVEN_REPO_LOCAL", m2.to_str().unwrap())]; + if let Some(home) = gradle_home { + caches.push(("GRADLE_USER_HOME", home.to_str().unwrap())); + } + let _fixture = prebuilt_common::prepare_command(&mut cmd, cwd, args, &caches); let out = cmd .current_dir(cwd) .env("SOCKET_TELEMETRY_DISABLED", "1") @@ -113,9 +106,14 @@ fn socket(cwd: &Path, m2: &Path, args: &[&str]) -> (Option, serde_json::Val } fn vendor(proj: &Path, m2: &Path) -> serde_json::Value { - let (code, env, stderr) = socket( + vendor_in(proj, m2, None) +} + +fn vendor_in(proj: &Path, m2: &Path, gradle_home: Option<&Path>) -> serde_json::Value { + let (code, env, stderr) = socket_in( proj, m2, + gradle_home, &[ "vendor", "--json", @@ -130,9 +128,14 @@ fn vendor(proj: &Path, m2: &Path) -> serde_json::Value { } fn revert(proj: &Path, m2: &Path) { - let (code, env, stderr) = socket( + revert_in(proj, m2, None) +} + +fn revert_in(proj: &Path, m2: &Path, gradle_home: Option<&Path>) { + let (code, env, stderr) = socket_in( proj, m2, + gradle_home, &[ "vendor", "--revert", @@ -176,33 +179,6 @@ fn stage_manifest(proj: &Path, member_before: &[u8], member_after: &[u8]) { .unwrap(); } -/// Every committable file under `root` (build output skipped), keyed by its -/// forward-slash relative path. -fn snapshot(root: &Path) -> BTreeMap> { - fn walk(root: &Path, dir: &Path, out: &mut BTreeMap>) { - for entry in std::fs::read_dir(dir).unwrap() { - let entry = entry.unwrap(); - let name = entry.file_name().to_string_lossy().into_owned(); - let path = entry.path(); - if entry.file_type().unwrap().is_dir() { - if !BUILD_OUTPUT_DIRS.contains(&name.as_str()) { - walk(root, &path, out); - } - continue; - } - let rel = path - .strip_prefix(root) - .unwrap() - .to_string_lossy() - .replace('\\', "/"); - out.insert(rel, std::fs::read(&path).unwrap()); - } - } - let mut out = BTreeMap::new(); - walk(root, root, &mut out); - out -} - /// `(changed, added, removed)` paths from `before` to `after`. fn diff( before: &BTreeMap>, @@ -578,109 +554,6 @@ fn maven_reactor_vendor_fresh_checkout_offline_build_and_byte_exact_revert() { // ── P2: Gradle multi-project with dependency locking ──────────────────── -fn gradle_flag(name: &str) -> bool { - std::env::var_os(name).is_some_and(|v| !v.is_empty()) -} - -fn gradle_skip(suite: &str, why: &str) { - assert!( - !gradle_flag(GRADLE_REQUIRED_ENV), - "{suite}: {GRADLE_REQUIRED_ENV} is set but the Gradle capstone cannot run: {why}" - ); - println!("SKIP {suite}: {why}"); -} - -/// The selected Gradle launcher, run hermetically: a per-test -/// `GRADLE_USER_HOME`, no daemon, plain console, ambient options scrubbed. -struct Gradle { - program: OsString, - version: String, -} - -impl Gradle { - fn command(program: &OsString, home: Option<&Path>) -> Command { - let mut cmd = Command::new(program); - for key in ["GRADLE_OPTS", "JAVA_OPTS", "GRADLE_USER_HOME"] { - cmd.env_remove(key); - } - for key in CI_DETECTOR_ENV { - cmd.env_remove(key); - } - if let Some(home) = home { - cmd.env("GRADLE_USER_HOME", home); - } - cmd - } - - fn detect(suite: &str, home: &Path) -> Option { - let program: OsString = std::env::var_os(GRADLE_ENV) - .filter(|v| !v.is_empty()) - .unwrap_or_else(|| { - if cfg!(windows) { - "gradle.bat" - } else { - "gradle" - } - .into() - }); - let out = match Self::command(&program, Some(home)) - .args(["--version", "--no-daemon"]) - .output() - { - Ok(out) => out, - Err(e) => { - gradle_skip( - suite, - &format!("`{}` did not run: {e}", program.to_string_lossy()), - ); - return None; - } - }; - let banner = String::from_utf8_lossy(&out.stdout).into_owned(); - let Some(version) = banner.lines().find_map(|l| { - l.trim() - .strip_prefix("Gradle ") - .map(|v| v.trim().to_string()) - }) else { - gradle_skip( - suite, - &format!( - "`{} --version` printed no `Gradle ` banner:\n{}{}", - program.to_string_lossy(), - banner, - String::from_utf8_lossy(&out.stderr) - ), - ); - return None; - }; - if let Some(pin) = std::env::var(GRADLE_VERSION_ENV) - .ok() - .filter(|v| !v.is_empty()) - { - assert_eq!( - version, - pin, - "{GRADLE_VERSION_ENV} pins Gradle {pin} but `{}` is Gradle {version}", - program.to_string_lossy() - ); - } - println!( - "{suite}: driving Gradle {version} ({})", - program.to_string_lossy() - ); - Some(Gradle { program, version }) - } - - fn run(&self, cwd: &Path, home: &Path, args: &[&str]) -> Output { - Self::command(&self.program, Some(home)) - .current_dir(cwd) - .args(["--no-daemon", "--console=plain", "--stacktrace"]) - .args(args) - .output() - .expect("spawn gradle") - } -} - const GRADLE_SETTINGS: &str = r#"buildscript { repositories { mavenCentral() } dependencies { classpath("org.apache.commons:commons-text:1.10.0") } @@ -726,9 +599,9 @@ dependencyLocking { } tasks.register("printRuntimeClasspath") { - val runtime = configurations.named("runtimeClasspath") + val runtime: FileCollection = files(configurations.named("runtimeClasspath")) doLast { - runtime.get().files.forEach { println("SOCKET-CP " + it.absolutePath) } + runtime.files.forEach { println("SOCKET-CP " + it.absolutePath) } } } "#; @@ -737,33 +610,32 @@ const APPLY_LINE: &str = r#"apply(from = ".socket/gradle/socket-patch.settings.gradle") // socket-patch"#; fn write_gradle_project(proj: &Path) { - for (rel, body) in [ - ("settings.gradle.kts", GRADLE_SETTINGS), - ("lib/build.gradle.kts", GRADLE_LIB), - ("app/build.gradle.kts", GRADLE_APP), - ] { - let path = proj.join(rel); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(path, body).unwrap(); - } -} - -/// The `SOCKET-CP` lines `:app:printRuntimeClasspath` printed. -fn gradle_classpath(out: &Output) -> Vec { - String::from_utf8_lossy(&out.stdout) - .lines() - .filter_map(|l| l.strip_prefix("SOCKET-CP ")) - .map(PathBuf::from) - .collect() + write_project( + proj, + &[ + ("settings.gradle.kts", GRADLE_SETTINGS), + ("lib/build.gradle.kts", GRADLE_LIB), + ("app/build.gradle.kts", GRADLE_APP), + ], + ); } -/// Every Gradle lockfile under `root`: `/gradle.lockfile` on 7+, -/// `/gradle/dependency-locks/.lockfile` on 6.x. -fn lockfiles(root: &Path) -> BTreeMap> { - snapshot(root) - .into_iter() - .filter(|(rel, _)| rel.ends_with(".lockfile")) - .collect() +/// The registry jar of the fixture GAV as the build cached it in +/// `gradle_home`'s `files-2.1` (the multi-project capstone's only copy). +fn gradle_cached_jar(gradle_home: &Path) -> Vec { + let version_dir = gradle_home + .join(prebuilt_common::GRADLE_FILES21) + .join(GROUP) + .join(ARTIFACT) + .join(VERSION); + let leaf = format!("{ARTIFACT}-{VERSION}.jar"); + let jars: Vec = std::fs::read_dir(&version_dir) + .unwrap_or_else(|e| panic!("{}: {e}", version_dir.display())) + .map(|e| e.unwrap().path().join(&leaf)) + .filter(|p| p.is_file()) + .collect(); + assert_eq!(jars.len(), 1, "one cached {leaf}: {jars:?}"); + std::fs::read(&jars[0]).unwrap() } fn gradle_tree_rel() -> String { @@ -772,8 +644,11 @@ fn gradle_tree_rel() -> String { fn assert_gradle_vendored(out: &Output, checkout: &Path, patched: &[u8], what: &str) { assert!(ok(out), "{what}:\n{}", dump(out)); + // A configuration-cache reuse (the CI `configuration-cache` rows) skips + // the settings script; the run that stored the entry asserted it. assert!( - String::from_utf8_lossy(&out.stdout).contains("SOCKET-SETTINGS-PATCHED true"), + configuration_reused(out) + || String::from_utf8_lossy(&out.stdout).contains("SOCKET-SETTINGS-PATCHED true"), "{what}: settings buildscript must load the patched jar:\n{}", dump(out) ); @@ -798,7 +673,7 @@ fn assert_gradle_vendored(out: &Output, checkout: &Path, patched: &[u8], what: & } #[test] -#[ignore = "real Gradle + Maven + Maven Central (fixture); run with --ignored"] +#[ignore = "real Gradle + Maven Central (fixture); run with --ignored"] fn gradle_multi_project_vendor_locked_offline_tamper_and_byte_exact_revert() { const SUITE: &str = "e2e_vendor_jvm_build::gradle"; let tmp = tempfile::tempdir().unwrap(); @@ -807,50 +682,11 @@ fn gradle_multi_project_vendor_locked_offline_tamper_and_byte_exact_revert() { let Some(gradle) = Gradle::detect(SUITE, &gradle_home) else { return; }; - // The crawler reads a maven repository: seed it with the registry bytes. - let Some(mvn) = Mvn::detect(SUITE) else { - return; - }; + // No Maven seed: the crawler reads the Gradle cache the build fills, and + // the vendor plan sources parent poms and BOM metadata from it offline. + // `m2` stays empty so the user's own ~/.m2 is never consulted. let m2 = root.join("m2"); - let settings = root.join("settings.xml"); - write_settings(&settings, &[]); - std::fs::create_dir_all(root.join("seed")).unwrap(); - let out = mvn.run( - &root.join("seed"), - &m2, - &settings, - &[ - &format!("{DEPENDENCY_PLUGIN}:get"), - &format!("-Dartifact={GROUP}:{ARTIFACT}:{VERSION}"), - ], - ); - if !ok(&out) { - skip( - SUITE, - &format!( - "seeding the maven repo from Central failed:\n{}", - dump(&out) - ), - ); - return; - } - // Gradle verifies the standalone parent's import as well as the child's - // effective import. Maven does not fetch the former or their module metadata. - for version in ["5.9.0", "5.9.1"] { - let out = mvn.run( - &root.join("seed"), - &m2, - &settings, - &[ - &format!("{DEPENDENCY_PLUGIN}:get"), - &format!("-Dartifact=org.junit:junit-bom:{version}:module"), - "-Dtransitive=false", - ], - ); - assert!(ok(&out), "seeding imported BOM metadata:\n{}", dump(&out)); - } - let jar = - std::fs::read(repo_dir(&m2, VERSION).join(format!("{ARTIFACT}-{VERSION}.jar"))).unwrap(); + std::fs::create_dir_all(&m2).unwrap(); let proj = root.join("proj"); write_gradle_project(&proj); @@ -869,6 +705,7 @@ fn gradle_multi_project_vendor_locked_offline_tamper_and_byte_exact_revert() { ); return; } + let jar = gradle_cached_jar(&gradle_home); let locked = lockfiles(&proj); for project in ["app", "lib"] { assert!( @@ -911,7 +748,7 @@ fn gradle_multi_project_vendor_locked_offline_tamper_and_byte_exact_revert() { stage_manifest(&proj, &orig, &patched); let before = snapshot(&proj); - let env = vendor(&proj, &m2); + let env = vendor_in(&proj, &m2, Some(&gradle_home)); assert_eq!(env["summary"]["applied"], 1, "{env}"); println!("vendor envelope: {env}"); let vendored = snapshot(&proj); @@ -921,6 +758,9 @@ fn gradle_multi_project_vendor_locked_offline_tamper_and_byte_exact_revert() { socket_patch_core::vendor::jvm::gradle::SCRIPT_REL.to_string(), socket_patch_core::vendor::jvm::gradle::INDEX_REL.to_string(), ".socket/vendor/gradle/.gitattributes".to_string(), + ".socket/gradle/.gitattributes".to_string(), + ".socket/vendor/.gitattributes".to_string(), + socket_patch_core::vendor::jvm::gradle::derived_metadata_rel(GROUP, ARTIFACT), format!("{tree}/{ARTIFACT}-{VERSION}.jar"), format!("{tree}/{ARTIFACT}-{VERSION}.pom"), format!("{tree}/socket-patch.vendor.json"), @@ -955,7 +795,7 @@ fn gradle_multi_project_vendor_locked_offline_tamper_and_byte_exact_revert() { ); // Idempotent: a second vendor run changes no project file. - vendor(&proj, &m2); + vendor_in(&proj, &m2, Some(&gradle_home)); let (changed, added, removed) = diff(&vendored, &snapshot(&proj)); assert!( changed.iter().all(|p| p == ".socket/vendor/state.json") @@ -1035,7 +875,7 @@ fn gradle_multi_project_vendor_locked_offline_tamper_and_byte_exact_revert() { println!("{SUITE}: Gradle {} green", gradle.version); // Byte-exact revert of the source project. - revert(&proj, &m2); + revert_in(&proj, &m2, Some(&gradle_home)); assert_restored(&proj, &before); assert!( !proj.join(".socket/vendor").exists(), @@ -1046,3 +886,214 @@ fn gradle_multi_project_vendor_locked_offline_tamper_and_byte_exact_revert() { ".socket/gradle residue" ); } + +// ── P3: the fake Central through the mirror init script ───────────────── + +/// Settings with a buildscript-classpath library whose class prints a +/// marker from build logic, and `FAIL_ON_PROJECT_REPOS` + `mavenCentral()`. +fn smoke_settings(dsl: Dsl) -> String { + let (classpath, mode) = match dsl { + Dsl::Groovy => ( + "classpath 'com.socketfixture:buildlogic-plugin:1.0'", + "repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)", + ), + Dsl::Kotlin => ( + "classpath(\"com.socketfixture:buildlogic-plugin:1.0\")", + "repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)", + ), + }; + let (name, include) = match dsl { + Dsl::Groovy => ("rootProject.name = 'smoke'", "include 'app'"), + Dsl::Kotlin => ("rootProject.name = \"smoke\"", "include(\"app\")"), + }; + format!( + "buildscript {{\n repositories {{ mavenCentral() }}\n dependencies {{ {classpath} }}\n}}\n\ + com.socketfixture.buildlogic.BuildLogic.print()\n\ + dependencyResolutionManagement {{\n {mode}\n repositories {{ mavenCentral() }}\n}}\n\ + {name}\n{include}\n" + ) +} + +/// `:app` reaches the victim only through `consumer-range`'s pom range +/// `[1.9,1.11)`, so Gradle lists versions from the artifact-level +/// `maven-metadata.xml` and must pick 1.10.0 (the settings classpath +/// requests it literally, through `buildlogic-plugin`). +fn smoke_app(dsl: Dsl) -> String { + let body = match dsl { + Dsl::Groovy => { + "plugins { id 'java' }\n\ndependencies {\n \ + implementation 'com.socketfixture:consumer-range:2.0'\n}\n" + } + Dsl::Kotlin => { + "plugins { java }\n\ndependencies {\n \ + implementation(\"com.socketfixture:consumer-range:2.0\")\n}\n" + } + }; + format!("{body}\n{}", print_cp_task(dsl, "runtimeClasspath")) +} + +#[test] +#[ignore = "real Gradle (the fake Central, no network); run with --ignored"] +fn gradle_multi_project_fake_central_mirror_smoke_both_dsls() { + use jvm_fixture_repo::*; + const SUITE: &str = "e2e_vendor_jvm_build::fake_central"; + let tmp = tempfile::tempdir().unwrap(); + let root = fixture_root(&tmp); + let home = root.join("gradle-home"); + let Some(gradle) = Gradle::detect(SUITE, &home) else { + return; + }; + let central = FakeCentral::start(); + let init = init_script( + &root.join("init"), + "mirror.gradle", + &mirror_init_script(¢ral.uri(), None), + ); + let projects = write_both_dsls(&root.join("proj"), |dsl| { + vec![ + (dsl.settings_file(), smoke_settings(dsl)), + (format!("app/{}", dsl.build_file()), smoke_app(dsl)), + ] + }); + let pristine = notice(&format!("{GROUP}:{VICTIM}:{VICTIM_VERSION}"), "pristine"); + let jar = generate()[&repo_path(VICTIM, VICTIM_VERSION, None, "jar")].clone(); + for (dsl, proj) in projects { + let what = format!("Gradle {} {} DSL", gradle.version, dsl.name()); + let out = gradle.run( + &proj, + &home, + &[&init[0], &init[1], ":app:printRuntimeClasspath"], + ); + let consumed = assert_patched(&out, VICTIM, NOTICE, pristine.as_bytes(), &what); + assert!( + String::from_utf8_lossy(&out.stdout).contains(&format!( + "{BUILDLOGIC_MARKER}{}", + victim_marker(VICTIM_VERSION, "pristine") + )), + "{what}: the settings buildscript class prints the victim marker:\n{}", + gradle_build_common::dump(&out) + ); + let files21 = home.join("caches/modules-2/files-2.1"); + assert!( + consumed.starts_with(&files21), + "{what}: resolved into the per-test Gradle cache: {}", + consumed.display() + ); + assert_eq!( + std::fs::read(&consumed).unwrap(), + jar, + "{what}: the fixture bytes" + ); + let hash_dir = consumed + .parent() + .and_then(|p| p.file_name()) + .unwrap() + .to_string_lossy() + .into_owned(); + let sha1 = sha1_hex(&jar); + assert_eq!( + format!("{hash_dir:0>40}"), + sha1, + "{what}: the hash dir names the jar's sha1 (leading zeros may be dropped)" + ); + probe_report( + &format!("fake-central-smoke-{}-{}", gradle.version, dsl.name()), + &serde_json::json!({ + "gradle": gradle.version, + "jvm": gradle.jvm, + "dsl": dsl.name(), + "resolved": consumed.to_string_lossy(), + "sha256": sha256_hex(&jar), + "sha1": sha1, + "hashDir": hash_dir, + "leadingZeroKept": hash_dir.len() == 40, + }), + ); + } + let requests = central.requests(); + for leaf in [ + repo_path(VICTIM, VICTIM_VERSION, None, "jar"), + repo_path(BUILDLOGIC, BUILDLOGIC_VERSION, None, "jar"), + format!("{GROUP_PATH}/{VICTIM}/maven-metadata.xml"), + ] { + assert!( + requests.contains(&format!("/{leaf}")), + "the fake Central served {leaf}: {requests:?}" + ); + } + println!("{SUITE}: Gradle {} green", gradle.version); +} + +/// [`print_cp_task`] is configuration-cache safe in both DSLs: the store run +/// and the reuse run each print the classpath. This is what the +/// gradle-compatibility.yml `configuration-cache` rows rely on for every +/// suite's printRuntimeClasspath assertion. +#[test] +#[ignore = "real Gradle (the fake Central, no network); run with --ignored"] +fn gradle_multi_project_print_cp_configuration_cache_both_dsls() { + use jvm_fixture_repo::*; + const SUITE: &str = "e2e_vendor_jvm_build::print_cp_configuration_cache"; + let tmp = tempfile::tempdir().unwrap(); + let root = fixture_root(&tmp); + let home = root.join("gradle-home"); + let Some(gradle) = Gradle::detect(SUITE, &home) else { + return; + }; + // Stable (and warning-free for the `java` plugin) from 8.1. + if !gradle.at_least(8, 1) { + println!( + "{SUITE}: Gradle {} predates the stable configuration cache; nothing to check", + gradle.version + ); + return; + } + let central = FakeCentral::start(); + let init = init_script( + &root.join("init"), + "mirror.gradle", + &mirror_init_script(¢ral.uri(), None), + ); + let projects = write_both_dsls(&root.join("proj"), |dsl| { + vec![ + (dsl.settings_file(), smoke_settings(dsl)), + (format!("app/{}", dsl.build_file()), smoke_app(dsl)), + ] + }); + let pristine = notice(&format!("{GROUP}:{VICTIM}:{VICTIM_VERSION}"), "pristine"); + for (dsl, proj) in projects { + for run in ["store", "reuse"] { + let what = format!( + "Gradle {} {} DSL configuration cache {run}", + gradle.version, + dsl.name() + ); + let out = gradle.run( + &proj, + &home, + &[ + &init[0], + &init[1], + "--configuration-cache", + ":app:printRuntimeClasspath", + ], + ); + assert_patched(&out, VICTIM, NOTICE, pristine.as_bytes(), &what); + let log = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + let want = if run == "store" { + "Configuration cache entry stored" + } else { + "Configuration cache entry reused" + }; + assert!( + log.contains(want), + "{what}: expected `{want}`:\n{}", + gradle_build_common::dump(&out) + ); + } + } + println!("{SUITE}: Gradle {} green", gradle.version); +} diff --git a/crates/socket-patch-cli/tests/gradle_agent_cli.rs b/crates/socket-patch-cli/tests/gradle_agent_cli.rs new file mode 100644 index 000000000..5981241c9 --- /dev/null +++ b/crates/socket-patch-cli/tests/gradle_agent_cli.rs @@ -0,0 +1,1381 @@ +//! Agent mode over Gradle caches through the real CLI, hermetic: a +//! temp-dir `GRADLE_USER_HOME` (and `~/.m2`, read-only cache) fabricated in +//! Gradle's `files-2.1` layout, the JVM environment scrubbed +//! (`common/jvm_env.rs`), no network but the in-test patch service. +//! +//! Pins the agent-mode contract for Maven purls (#551, #264): every copy a +//! build consumes is patched (each hash dir of each Gradle cache, `~/.m2` +//! only when the build reads it), member-keyed records swap the whole jar +//! and roll back byte for byte, and each Gradle hazard refuses or degrades +//! under its own code. +//! +//! Designated #551 regression test: [`m2_only_gradle_project_refuses`]. + +#[path = "prebuilt_common/mod.rs"] +mod prebuilt_common; + +use std::collections::{BTreeMap, HashMap}; +use std::io::Write as _; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use sha1::Digest as _; + +const GROUP: &str = "com.example"; +const ARTIFACT: &str = "victim"; +const VERSION: &str = "1.0"; +const PURL: &str = "pkg:maven/com.example/victim@1.0"; +const UUID: &str = "5a1e0c2d-7b4e-4c1a-9f0e-2a3b4c5d6e7f"; +const JAR: &str = "victim-1.0.jar"; +const POM: &str = "victim-1.0.pom"; +const NOTICE: &str = "META-INF/NOTICE.txt"; +const GAV: &str = "com.example:victim:1.0"; + +fn git_sha256(bytes: &[u8]) -> String { + socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes(bytes) +} + +fn sha1_hex(bytes: &[u8]) -> String { + hex::encode(sha1::Sha1::digest(bytes)) +} + +/// A stored jar of `members`, deterministic. +fn jar(members: &[(&str, &[u8])]) -> Vec { + let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let opts = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Stored) + .last_modified_time(zip::DateTime::default()); + for (name, bytes) in members { + zw.start_file(*name, opts).unwrap(); + zw.write_all(bytes).unwrap(); + } + zw.finish().unwrap().into_inner() +} + +/// The pristine jar: its sha1 starts with `0`, so Gradle releases that +/// drop leading zeros name its hash dir differently (both spellings are +/// fabricated where a test needs two copies of the same bytes). +fn pristine_jar() -> Vec { + for n in 0u32.. { + let pad = format!("pad {n}\n"); + let bytes = jar(&[ + ("META-INF/MANIFEST.MF", b"Manifest-Version: 1.0\r\n\r\n"), + (NOTICE, b"pristine\n"), + ("pad.txt", pad.as_bytes()), + ]); + if sha1_hex(&bytes).starts_with('0') { + return bytes; + } + } + unreachable!() +} + +/// The pristine jar with `NOTICE` patched: what the patch service builds +/// for the member-keyed record (every other member unchanged). +fn service_jar() -> Vec { + let pristine = pristine_jar(); + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(&pristine)).unwrap(); + let mut members = Vec::new(); + for i in 0..archive.len() { + use std::io::Read as _; + let mut entry = archive.by_index(i).unwrap(); + let mut buf = Vec::new(); + entry.read_to_end(&mut buf).unwrap(); + let name = entry.name().to_string(); + let buf = if name == NOTICE { + b"patched\n".to_vec() + } else { + buf + }; + members.push((name, buf)); + } + let refs: Vec<(&str, &[u8])> = members + .iter() + .map(|(n, b)| (n.as_str(), b.as_slice())) + .collect(); + jar(&refs) +} + +/// The whole-file patched jar of the leaf record. +fn patched_jar() -> Vec { + jar(&[ + ("META-INF/MANIFEST.MF", b"Manifest-Version: 1.0\r\n\r\n"), + (NOTICE, b"patched by socket\n"), + ]) +} + +const PRISTINE_POM: &[u8] = b"com.examplevictim1.0\n"; +const PATCHED_POM: &[u8] = b"com.examplevictim1.0\n"; + +/// One test's world: a Gradle project, a Gradle user home, an `~/.m2`. +struct Fx { + _tmp: tempfile::TempDir, + root: PathBuf, + proj: PathBuf, + /// The Gradle user home (named `.gradle` so `--global-prefix` takes it). + home: PathBuf, + m2: PathBuf, + ro: PathBuf, + use_ro: bool, +} + +/// A Gradle project whose build script's `repositories` block is `repos`. +fn fx(repos: &str) -> Fx { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().canonicalize().unwrap(); + let proj = root.join("proj"); + std::fs::create_dir_all(proj.join(".socket/blobs")).unwrap(); + std::fs::write(proj.join("settings.gradle"), "rootProject.name = 'app'\n").unwrap(); + std::fs::write( + proj.join("build.gradle"), + format!( + "plugins {{ id 'java' }}\nrepositories {{\n{repos}}}\ndependencies {{\n \ + implementation '{GAV}'\n}}\n" + ), + ) + .unwrap(); + let home = root.join(".gradle"); + std::fs::create_dir_all(&home).unwrap(); + let m2 = root.join("m2"); + std::fs::create_dir_all(&m2).unwrap(); + Fx { + _tmp: tmp, + proj, + home, + m2, + ro: root.join("ro"), + use_ro: false, + root, + } +} + +impl Fx { + /// Lay `files` out in the user home's `files-2.1`; returns the version dir. + fn gradle(&self, files: &[(&str, &[u8])]) -> PathBuf { + prebuilt_common::fabricate_files21(&self.home, GAV, files) + } + + /// The same, with the hash dirs named without leading zeros. + fn gradle_unpadded(&self, files: &[(&str, &[u8])]) -> PathBuf { + prebuilt_common::fabricate_files21_unpadded(&self.home, GAV, files) + } + + /// Lay `files` out in the read-only cache (`GRADLE_RO_DEP_CACHE`). + fn read_only(&mut self, files: &[(&str, &[u8])]) -> PathBuf { + self.use_ro = true; + let dir = self + .ro + .join(prebuilt_common::RO_FILES21) + .join(GROUP) + .join(ARTIFACT) + .join(VERSION); + for (leaf, bytes) in files { + let hash = dir.join(sha1_hex(bytes)); + std::fs::create_dir_all(&hash).unwrap(); + std::fs::write(hash.join(leaf), bytes).unwrap(); + } + dir + } + + /// Lay `files` out in `~/.m2`; returns the version dir. + fn m2(&self, files: &[(&str, &[u8])]) -> PathBuf { + let dir = self.m2.join("com/example/victim/1.0"); + std::fs::create_dir_all(&dir).unwrap(); + for (leaf, bytes) in files { + std::fs::write(dir.join(leaf), bytes).unwrap(); + } + dir + } + + /// Write the manifest: one record per `(purl, files)`, each file + /// `(key, before, after)`, with every blob committed. + fn manifest(&self, records: &[(&str, &[(&str, &[u8], &[u8])])]) { + let mut patches = serde_json::Map::new(); + for (purl, files) in records { + let mut entries = serde_json::Map::new(); + for (key, before, after) in *files { + // Both sides committed: apply reads the afterHash blob, + // an offline rollback the beforeHash one. + for bytes in [before, after] { + std::fs::write( + self.proj.join(".socket/blobs").join(git_sha256(bytes)), + bytes, + ) + .unwrap(); + } + entries.insert( + key.to_string(), + serde_json::json!({ + "beforeHash": git_sha256(before), + "afterHash": git_sha256(after), + }), + ); + } + patches.insert( + purl.to_string(), + serde_json::json!({ + "uuid": UUID, + "exportedAt": "2026-01-01T00:00:00Z", + "files": entries, + "vulnerabilities": { "GHSA-gr4d-1e55-0001": { + "cves": ["CVE-2026-0551"], "summary": "s", + "severity": "high", "description": "d" + } }, + "description": "gradle agent fixture", + "license": "MIT", + "tier": "free", + }), + ); + } + std::fs::write( + self.proj.join(".socket/manifest.json"), + serde_json::to_string_pretty(&serde_json::json!({ "patches": patches })).unwrap(), + ) + .unwrap(); + } + + /// The jar + pom leaf record (whole-file jar and pom patches). + fn leaf_manifest(&self) { + let pristine = pristine_jar(); + let patched = patched_jar(); + self.manifest(&[( + PURL, + &[ + (&format!("package/{JAR}"), &pristine, &patched), + (&format!("package/{POM}"), PRISTINE_POM, PATCHED_POM), + ], + )]); + } + + /// The member-keyed record (#264). + fn member_manifest(&self) { + self.manifest(&[(PURL, &[(NOTICE, b"pristine\n", b"patched\n")])]); + } + + /// `socket-patch --json --cwd ` under the fixture's caches. + fn run(&self, args: &[&str]) -> Out { + self.run_env(args, &[]) + } + + fn run_env(&self, args: &[&str], env: &[(&str, &str)]) -> Out { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_socket-patch")); + for (k, _) in std::env::vars_os() { + if k.to_string_lossy().starts_with("SOCKET_") { + cmd.env_remove(&k); + } + } + prebuilt_common::jvm_env::isolate_cli(&mut cmd); + cmd.args(args) + .args(["--json", "--cwd", self.proj.to_str().unwrap()]) + .env("SOCKET_TELEMETRY_DISABLED", "1") + .env("SOCKET_NO_CONFIG", "1") + .env("GRADLE_USER_HOME", &self.home) + .env("MAVEN_REPO_LOCAL", &self.m2) + .env_remove("M2_HOME"); + if self.use_ro { + cmd.env("GRADLE_RO_DEP_CACHE", &self.ro); + } + for (k, v) in env { + cmd.env(k, v); + } + let out = cmd.output().expect("run socket-patch"); + let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); + let json = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { + panic!("{args:?}: stdout is not one JSON document ({e})\n{stdout}\n{stderr}") + }); + Out { + code: out.status.code(), + json, + stderr, + } + } + + /// Every file under the caches and the project (`.socket/manifest.json` + /// and blobs, and the `vex` output, excluded) by path relative to the + /// fixture root, with its sha1. + fn snapshot(&self) -> BTreeMap { + fn walk(base: &Path, dir: &Path, out: &mut BTreeMap) { + for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() { + let path = entry.path(); + let rel = path + .strip_prefix(base) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + if rel.ends_with(".socket/manifest.json") + || rel.ends_with(".socket/blobs") + || rel == "vex.json" + { + continue; + } + if path.is_dir() { + walk(base, &path, out); + } else { + out.insert(rel, sha1_hex(&std::fs::read(&path).unwrap())); + } + } + } + let mut out = BTreeMap::new(); + walk(&self.root, &self.root, &mut out); + out + } + + /// `vex -O `: the envelope and the statement count (0 when the + /// command wrote no document). + fn vex(&self, extra: &[&str]) -> (Out, usize) { + let doc = self.root.join("vex.json"); + let _ = std::fs::remove_file(&doc); + let mut args = vec![ + "vex", + "-O", + doc.to_str().unwrap(), + "--product", + "pkg:maven/com.example/app@1.0", + ]; + args.extend_from_slice(extra); + let out = self.run(&args); + let statements = std::fs::read(&doc) + .ok() + .and_then(|b| serde_json::from_slice::(&b).ok()) + .and_then(|d| d["statements"].as_array().map(Vec::len)) + .unwrap_or(0); + (out, statements) + } +} + +struct Out { + code: Option, + json: serde_json::Value, + stderr: String, +} + +impl Out { + fn warning_codes(&self) -> Vec { + let mut codes: Vec = self.json["warnings"] + .as_array() + .into_iter() + .flatten() + .filter_map(|w| w["code"].as_str().map(str::to_string)) + .collect(); + codes.extend( + self.json["vex"]["warnings"] + .as_array() + .into_iter() + .flatten() + .filter_map(|w| w["code"].as_str().map(str::to_string)), + ); + codes + } + + fn ok(&self) -> &Self { + assert_eq!(self.code, Some(0), "{}\n{}", self.json, self.stderr); + self + } + + fn failed(&self) -> &Self { + assert_ne!(self.code, Some(0), "{}\n{}", self.json, self.stderr); + self + } + + fn has(&self, code: &str) -> &Self { + assert!( + self.warning_codes().iter().any(|c| c == code), + "missing warning {code}: {}\n{}", + self.json, + self.stderr + ); + self + } +} + +/// The file `leaf` in every hash dir of a version dir. +fn hash_copies(version_dir: &Path, leaf: &str) -> Vec<(String, Vec)> { + let mut out: Vec<(String, Vec)> = std::fs::read_dir(version_dir) + .unwrap() + .flatten() + .filter(|e| e.path().join(leaf).is_file()) + .map(|e| { + ( + e.file_name().to_string_lossy().into_owned(), + std::fs::read(e.path().join(leaf)).unwrap(), + ) + }) + .collect(); + out.sort(); + out +} + +fn pristine_files() -> Vec<(&'static str, Vec)> { + vec![(JAR, pristine_jar()), (POM, PRISTINE_POM.to_vec())] +} + +fn as_refs<'a>(files: &'a [(&'static str, Vec)]) -> Vec<(&'static str, &'a [u8])> { + files.iter().map(|(l, b)| (*l, b.as_slice())).collect() +} + +const CENTRAL: &str = " mavenCentral()\n"; + +// ── fan-out ───────────────────────────────────────────────────────────── + +/// Every hash dir holding the jar is patched — here the same pristine jar +/// under its padded and its zero-dropped sha1 — and the pom in its own +/// hash dir; rollback puts every file back to the bytes its hash dir +/// names. +#[test] +fn every_hash_copy_is_patched_and_rollback_restores_hash_eq() { + let f = fx(CENTRAL); + let files = pristine_files(); + let version = f.gradle(&as_refs(&files)); + f.gradle_unpadded(&[(JAR, &pristine_jar())]); + f.leaf_manifest(); + + let out = f.run(&["apply", "--offline"]); + out.ok(); + let jars = hash_copies(&version, JAR); + assert_eq!(jars.len(), 2, "{jars:?}"); + for (_, bytes) in &jars { + assert_eq!(bytes, &patched_jar()); + } + assert_eq!(hash_copies(&version, POM)[0].1, PATCHED_POM); + assert!(out.json["sidecars"] + .to_string() + .contains("gradle_refresh_reverts")); + + f.run(&["rollback", "--offline"]).ok(); + for leaf in [JAR, POM] { + for (dir, bytes) in hash_copies(&version, leaf) { + assert!( + socket_patch_core::crawlers::gradle_cache::pristine(&dir, &bytes), + "{leaf} in {dir} does not hash to its dir after rollback" + ); + } + } +} + +/// #551 regression: a Gradle-only build that never declares mavenLocal() +/// does not read `~/.m2`. A GAV installed only there is not patched and the +/// run fails (`gradle_build_ignores_m2`); `vex` attests nothing. +#[test] +fn m2_only_gradle_project_refuses() { + let f = fx(CENTRAL); + let m2 = f.m2(&as_refs(&pristine_files())); + f.leaf_manifest(); + let before = f.snapshot(); + + f.run(&["apply", "--offline"]) + .failed() + .has("gradle_build_ignores_m2"); + assert_eq!(f.snapshot(), before, "nothing may be written"); + assert_eq!(std::fs::read(m2.join(JAR)).unwrap(), pristine_jar()); + + let (out, statements) = f.vex(&[]); + assert_eq!(statements, 0, "{}", out.json); +} + +/// The #551 tree: the GAV in both `~/.m2` and the Gradle cache of a build +/// without mavenLocal(): only the Gradle copy is patched. +#[test] +fn tree_551_patches_only_gradle() { + let f = fx(CENTRAL); + let files = pristine_files(); + let m2 = f.m2(&as_refs(&files)); + let version = f.gradle(&as_refs(&files)); + f.leaf_manifest(); + + f.run(&["apply", "--offline"]).ok(); + assert_eq!(hash_copies(&version, JAR)[0].1, patched_jar()); + assert_eq!(std::fs::read(m2.join(JAR)).unwrap(), pristine_jar()); + let (out, statements) = f.vex(&[]); + out.ok(); + assert!(statements > 0); +} + +/// mavenLocal() declared — before or after mavenCentral() — patches the +/// `~/.m2` copy too, and its `.sha1` (which described the pristine jar) +/// follows the patched bytes; rollback restores it exactly. +#[test] +fn maven_local_declared_patches_m2_and_gradle_in_both_orders() { + for repos in [ + " mavenLocal()\n mavenCentral()\n", + " mavenCentral()\n mavenLocal()\n", + ] { + let f = fx(repos); + let files = pristine_files(); + let m2 = f.m2(&as_refs(&files)); + let sha1_text = format!("{} {JAR}\n", sha1_hex(&pristine_jar())); + std::fs::write(m2.join(format!("{JAR}.sha1")), &sha1_text).unwrap(); + let version = f.gradle(&as_refs(&files)); + f.leaf_manifest(); + let before = f.snapshot(); + + f.run(&["apply", "--offline"]).ok(); + assert_eq!( + std::fs::read(m2.join(JAR)).unwrap(), + patched_jar(), + "{repos}" + ); + assert_eq!(hash_copies(&version, JAR)[0].1, patched_jar(), "{repos}"); + assert_eq!( + std::fs::read_to_string(m2.join(format!("{JAR}.sha1"))).unwrap(), + format!("{} {JAR}\n", sha1_hex(&patched_jar())) + ); + let (out, statements) = f.vex(&[]); + out.ok(); + assert!(statements > 0); + + f.run(&["rollback", "--offline"]).ok(); + assert_eq!(f.snapshot(), before, "{repos}: rollback must be byte-exact"); + } +} + +/// #646 review: mavenLocal() declared after mavenCentral() and no Gradle +/// cache copy yet: the m2 copy is patched (a module only mavenLocal() +/// has is read from there, never cached in files-2.1), but the run says +/// the build may instead download the pristine jar from the earlier +/// repository (`gradle_m2_may_be_unconsumed`). +#[test] +fn m2_only_copy_with_maven_local_declared_warns_it_may_be_unconsumed() { + let f = fx(" mavenCentral()\n mavenLocal()\n"); + let m2 = f.m2(&as_refs(&pristine_files())); + f.leaf_manifest(); + f.run(&["apply", "--offline"]) + .ok() + .has("gradle_m2_may_be_unconsumed"); + assert_eq!(std::fs::read(m2.join(JAR)).unwrap(), patched_jar()); + // With a Gradle cache copy patched too there is nothing to warn about. + let g = fx(" mavenCentral()\n mavenLocal()\n"); + g.m2(&as_refs(&pristine_files())); + g.gradle(&as_refs(&pristine_files())); + g.leaf_manifest(); + let out = g.run(&["apply", "--offline"]); + out.ok(); + assert!( + !out.warning_codes() + .iter() + .any(|c| c == "gradle_m2_may_be_unconsumed"), + "{}", + out.json + ); +} + +/// mavenLocal() declared only in a user-home init script still makes the +/// build read `~/.m2`: the m2 copy is patched. +#[test] +fn maven_local_in_init_d_patches_m2() { + let f = fx(CENTRAL); + std::fs::create_dir_all(f.home.join("init.d")).unwrap(); + std::fs::write( + f.home.join("init.d/local.gradle"), + "allprojects {\n repositories {\n mavenLocal()\n }\n}\n", + ) + .unwrap(); + let m2 = f.m2(&as_refs(&pristine_files())); + f.leaf_manifest(); + f.run(&["apply", "--offline"]).ok(); + assert_eq!(std::fs::read(m2.join(JAR)).unwrap(), patched_jar()); +} + +/// Dependency verification (key trust only — no component entry for the +/// GAV) still refuses agent mode for the build, with nothing written. +#[test] +fn verification_metadata_refuses_and_writes_nothing() { + let f = fx(" mavenLocal()\n mavenCentral()\n"); + let files = pristine_files(); + f.m2(&as_refs(&files)); + f.gradle(&as_refs(&files)); + std::fs::create_dir_all(f.proj.join("gradle")).unwrap(); + std::fs::write( + f.proj.join("gradle/verification-metadata.xml"), + r#" + + + true + true + + + + + + +"#, + ) + .unwrap(); + f.leaf_manifest(); + let before = f.snapshot(); + f.run(&["apply", "--offline"]) + .failed() + .has("gradle_verification_metadata_present"); + assert_eq!(f.snapshot(), before); +} + +/// A copy in the read-only cache is never written and fails the run +/// (`gradle_ro_cache_shadows`); the writable copy is still patched. +#[test] +fn read_only_copy_shadows() { + let mut f = fx(CENTRAL); + let files = pristine_files(); + let rw = f.gradle(&as_refs(&files)); + let ro = f.read_only(&as_refs(&files)); + f.leaf_manifest(); + f.run(&["apply", "--offline"]) + .failed() + .has("gradle_ro_cache_shadows"); + assert_eq!(hash_copies(&rw, JAR)[0].1, patched_jar()); + assert_eq!(hash_copies(&ro, JAR)[0].1, pristine_jar()); + let (_, statements) = f.vex(&[]); + assert_eq!( + statements, 0, + "a pristine read-only copy withholds the statement" + ); +} + +/// A copy Gradle derived from the pristine jar outside `files-2.1` +/// (`caches/transforms-*`) keeps serving the old bytes: the apply fails +/// with `gradle_transform_copy_stale` and VEX withholds, naming the copy. +#[test] +fn stale_transform_copy_fails_apply_and_vex_withholds() { + let f = fx(CENTRAL); + f.gradle(&as_refs(&pristine_files())); + let stale = f + .home + .join("caches/transforms-4/0f1e2d3c/transformed") + .join(JAR); + std::fs::create_dir_all(stale.parent().unwrap()).unwrap(); + std::fs::write(&stale, pristine_jar()).unwrap(); + f.leaf_manifest(); + + f.run(&["apply", "--offline"]) + .failed() + .has("gradle_transform_copy_stale"); + let (out, statements) = f.vex(&[]); + assert_eq!(statements, 0); + out.has("vex_gradle_unpatched_copy"); + assert!( + out.json.to_string().contains("transforms-4"), + "{}", + out.json + ); + + // Cleared: the next apply is clean and VEX attests. + std::fs::remove_dir_all(f.home.join("caches/transforms-4")).unwrap(); + f.run(&["apply", "--offline"]).ok(); + let (out, statements) = f.vex(&[]); + out.ok(); + assert!(statements > 0); +} + +/// A hash dir whose file is the pristine download of OTHER bytes than the +/// record expects is left alone (`gradle_copy_unexpected_bytes`) and fails +/// the run: the build may load it, unpatched. The other hash dir is still +/// patched. +#[test] +fn unexpected_pristine_bytes_are_left_alone() { + let f = fx(CENTRAL); + let version = f.gradle(&as_refs(&pristine_files())); + let other = jar(&[(NOTICE, b"some other build\n")]); + f.gradle(&[(JAR, &other)]); + f.leaf_manifest(); + f.run(&["apply", "--offline"]) + .failed() + .has("gradle_copy_unexpected_bytes"); + let jars = hash_copies(&version, JAR); + assert!(jars.iter().any(|(_, b)| *b == other)); + assert!(jars.iter().any(|(_, b)| *b == patched_jar())); +} + +/// The only hash dir holds the genuine download of other bytes: the copy is +/// installed and consumed, so the run fails with the code instead of +/// calling the patch `package_not_installed`. +#[test] +fn unexpected_bytes_in_the_only_copy_fail_the_run() { + let f = fx(CENTRAL); + let other = jar(&[(NOTICE, b"some other build\n")]); + let version = f.gradle(&[(JAR, &other), (POM, PRISTINE_POM)]); + f.leaf_manifest(); + let out = f.run(&["apply", "--offline"]); + out.failed().has("gradle_copy_unexpected_bytes"); + assert!( + !out.json.to_string().contains("package_not_installed"), + "{}", + out.json + ); + assert_eq!(hash_copies(&version, JAR)[0].1, other); +} + +/// A qualified manifest key (`?ext=jar`) turns on the release-variant gate +/// even for one variant: a hash dir holding the jar with bytes the variant +/// was not made for is still a consumed, unpatched copy — the run fails +/// (`gradle_copy_unexpected_bytes`), it is not `package_not_installed`. +#[test] +fn qualified_variant_mismatch_fails_the_run() { + let f = fx(CENTRAL); + let other = jar(&[(NOTICE, b"some other build\n")]); + let version = f.gradle(&[(JAR, &other)]); + let pristine = pristine_jar(); + let patched = patched_jar(); + f.manifest(&[( + &format!("{PURL}?ext=jar"), + &[(&format!("package/{JAR}"), &pristine, &patched)], + )]); + let out = f.run(&["apply", "--offline"]); + out.failed().has("gradle_copy_unexpected_bytes"); + assert!( + !out.json.to_string().contains("package_not_installed"), + "{}", + out.json + ); + assert_eq!(hash_copies(&version, JAR)[0].1, other); +} + +/// A Gradle version dir holding none of a record's files (here only the +/// pom of a jar patch) is not an install of it: that patch is +/// `package_not_installed`, the other one applies, and the run succeeds. +#[test] +fn gradle_copy_without_the_patched_file_is_not_installed() { + let f = fx(CENTRAL); + let version = f.gradle(&as_refs(&pristine_files())); + let other_pom: &[u8] = b"other\n"; + prebuilt_common::fabricate_files21( + &f.home, + "com.example:other:1.0", + &[("other-1.0.pom", other_pom)], + ); + let pristine = pristine_jar(); + let patched = patched_jar(); + f.manifest(&[ + (PURL, &[(&format!("package/{JAR}"), &pristine, &patched)]), + ( + "pkg:maven/com.example/other@1.0", + &[("package/other-1.0.jar", b"other jar", b"patched other jar")], + ), + ]); + let out = f.run(&["apply", "--offline"]); + out.ok(); + assert_eq!(hash_copies(&version, JAR)[0].1, patched_jar()); + assert!( + out.json.to_string().contains("package_not_installed"), + "{}", + out.json + ); + // VEX agrees: the pom-only dir is no copy of the patch. + let (out, statements) = f.vex(&[]); + assert!(statements > 0, "{}", out.json); + assert!( + !out.warning_codes() + .iter() + .any(|c| c == "vex_gradle_unpatched_copy"), + "{}", + out.json + ); +} + +/// #646 review: a Gradle copy holding only SOME of a record's files (the +/// jar, but not the pom a `metadataSources { artifact() }` build never +/// downloads) is still an install: the held jar is patched, the missing +/// pom fails the run as it would on `~/.m2`, and `vex` withholds instead +/// of dropping the copy and attesting the `~/.m2` one. +#[test] +fn gradle_copy_missing_some_record_files_fails_and_vex_withholds() { + let f = fx(" mavenLocal()\n mavenCentral()\n"); + let m2 = f.m2(&as_refs(&pristine_files())); + let version = f.gradle(&[(JAR, &pristine_jar())]); + f.leaf_manifest(); + f.run(&["apply", "--offline"]).failed(); + assert_eq!(hash_copies(&version, JAR)[0].1, patched_jar()); + assert_eq!(std::fs::read(m2.join(JAR)).unwrap(), patched_jar()); + let (out, statements) = f.vex(&[]); + assert_eq!(statements, 0, "{}", out.json); +} + +// ── derived copies ────────────────────────────────────────────────────── + +/// Set `path`'s mtime `secs` seconds into the past. +fn age(path: &Path, secs: u64) { + let file = std::fs::OpenOptions::new().write(true).open(path).unwrap(); + file.set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(secs)) + .unwrap(); +} + +/// An instrumented build-logic copy (never byte-equal to its input) left +/// from BEFORE the apply withholds the statement; one Gradle made AFTER +/// the apply was made from the patched jar and does not — so clearing the +/// old one and rebuilding leads to a statement. +#[test] +fn instrumented_copy_made_after_the_apply_does_not_withhold() { + let f = fx(CENTRAL); + f.gradle(&as_refs(&pristine_files())); + let old = f + .home + .join("caches/jars-9/0a1b2c3d4e5f60718293a4b5c6d7e8f9") + .join(JAR); + std::fs::create_dir_all(old.parent().unwrap()).unwrap(); + std::fs::write(&old, b"instrumented from the pristine jar").unwrap(); + age(&old, 3600); + f.leaf_manifest(); + + f.run(&["apply", "--offline"]) + .ok() + .has("gradle_transform_copy_unverified"); + let (out, statements) = f.vex(&[]); + assert_eq!(statements, 0, "{}", out.json); + out.has("vex_gradle_unpatched_copy"); + + // Cleared and rebuilt: Gradle instruments the patched jar anew. + std::fs::remove_file(&old).unwrap(); + let new = f + .home + .join("caches/8.14.3/transforms/fedcba98765432100123456789abcdef/transformed") + .join(format!("instrumented-{JAR}")); + std::fs::create_dir_all(new.parent().unwrap()).unwrap(); + std::fs::write(&new, b"instrumented from the patched jar").unwrap(); + let (out, statements) = f.vex(&[]); + out.ok(); + assert!(statements > 0, "{}", out.json); +} + +// ── rollback scope and checks ─────────────────────────────────────────── + +/// A Gradle-only build's rollback also restores a `~/.m2` copy it no +/// longer reads: an earlier apply (before the `mavenLocal()` gate, or +/// while the script declared it) patched it, and leaving it would strand +/// the shared jar patched once `remove` drops the record. +#[test] +fn rollback_restores_an_m2_copy_the_build_no_longer_reads() { + let f = fx(CENTRAL); + let m2 = f.m2(&[(JAR, &patched_jar()), (POM, PATCHED_POM)]); + let version = f.gradle(&as_refs(&pristine_files())); + f.leaf_manifest(); + f.run(&["apply", "--offline"]).ok(); + assert_eq!(hash_copies(&version, JAR)[0].1, patched_jar()); + f.run(&["rollback", "--offline"]).ok(); + assert_eq!(hash_copies(&version, JAR)[0].1, pristine_jar()); + assert_eq!(std::fs::read(m2.join(JAR)).unwrap(), pristine_jar()); + assert_eq!(std::fs::read(m2.join(POM)).unwrap(), PRISTINE_POM); +} + +/// #646 review: the patched `~/.m2` copy is the only one (the build never +/// cached the artifact). `remove` restores it before it drops the record, +/// instead of reporting success with the shared jar still patched. +#[test] +fn remove_restores_an_m2_only_copy_the_build_no_longer_reads() { + let f = fx(CENTRAL); + let m2 = f.m2(&[(JAR, &patched_jar()), (POM, PATCHED_POM)]); + f.leaf_manifest(); + f.run(&["remove", PURL, "--offline"]).ok(); + assert_eq!(std::fs::read(m2.join(JAR)).unwrap(), pristine_jar()); + assert_eq!(std::fs::read(m2.join(POM)).unwrap(), PRISTINE_POM); +} + +/// #646 review: a `~/.m2` copy this Gradle-only build never reads, holding +/// bytes that are neither side of the record (another build applied a +/// different patch there, or `mvn install` rebuilt it), must not fail this +/// project's rollback or `remove`: the Gradle copy is restored, the m2 copy +/// is left as it is with `gradle_m2_copy_not_restored`, and `remove` drops +/// the record. +#[test] +fn unconsumed_m2_copy_with_foreign_bytes_does_not_fail_rollback_or_remove() { + let foreign = jar(&[(NOTICE, b"another build's patch\n")]); + for cmd in [ + &["rollback", "--offline"][..], + &["remove", PURL, "--offline"], + ] { + let f = fx(CENTRAL); + let m2 = f.m2(&[(JAR, &foreign), (POM, PRISTINE_POM)]); + let version = f.gradle(&as_refs(&pristine_files())); + f.leaf_manifest(); + f.run(&["apply", "--offline"]).ok(); + assert_eq!(hash_copies(&version, JAR)[0].1, patched_jar()); + f.run(cmd).ok().has("gradle_m2_copy_not_restored"); + assert_eq!(hash_copies(&version, JAR)[0].1, pristine_jar(), "{cmd:?}"); + assert_eq!(std::fs::read(m2.join(JAR)).unwrap(), foreign, "{cmd:?}"); + let manifest = std::fs::read_to_string(f.proj.join(".socket/manifest.json")).unwrap(); + if cmd[0] == "remove" { + assert!(!manifest.contains(PURL), "{manifest}"); + } + } + // A Gradle copy in the same state still fails: the build reads it. + let f = fx(CENTRAL); + f.gradle(&[(JAR, &foreign), (POM, PRISTINE_POM)]); + f.leaf_manifest(); + let out = f.run(&["rollback", "--offline"]); + out.failed(); + assert!( + !out.warning_codes() + .iter() + .any(|c| c == "gradle_m2_copy_not_restored"), + "{}", + out.json + ); +} + +/// #646 review: an unconsumed `~/.m2` copy this project patched whose jar +/// cannot be read (a `sudo mvn install` left it root-owned, mode 600) may +/// still hold the patched bytes. It is not "foreign or absent": `remove` +/// fails and keeps the record and its before-blobs, so a later rollback +/// can still restore the shared jar. +#[cfg(unix)] +#[test] +fn unreadable_unconsumed_m2_copy_fails_remove_and_keeps_the_record() { + use std::os::unix::fs::PermissionsExt; + let f = fx(CENTRAL); + let m2 = f.m2(&[(JAR, &patched_jar()), (POM, PATCHED_POM)]); + f.gradle(&as_refs(&pristine_files())); + f.leaf_manifest(); + f.run(&["apply", "--offline"]).ok(); + let jar_path = m2.join(JAR); + std::fs::set_permissions(&jar_path, std::fs::Permissions::from_mode(0o000)).unwrap(); + if std::fs::read(&jar_path).is_ok() { + // Running as root: mode 000 does not stop the read. + std::fs::set_permissions(&jar_path, std::fs::Permissions::from_mode(0o644)).unwrap(); + return; + } + let out = f.run(&["remove", PURL, "--offline"]); + std::fs::set_permissions(&jar_path, std::fs::Permissions::from_mode(0o644)).unwrap(); + out.failed(); + assert!( + !out.warning_codes() + .iter() + .any(|c| c == "gradle_m2_copy_not_restored"), + "{}", + out.json + ); + let manifest = std::fs::read_to_string(f.proj.join(".socket/manifest.json")).unwrap(); + assert!(manifest.contains(PURL), "{manifest}"); + // Readable again, the copy still holds the patched bytes and rolls back. + assert_eq!(std::fs::read(&jar_path).unwrap(), patched_jar()); + f.run(&["rollback", "--offline"]).ok(); + assert_eq!(std::fs::read(&jar_path).unwrap(), pristine_jar()); +} + +/// #646 review: a member-keyed record whose unconsumed `~/.m2` jar was +/// swapped by another project (its original backed up there, not under +/// this project's `.socket/jvm-originals/`) cannot be restored here; the +/// rollback restores the Gradle copy and leaves the m2 jar with a warning +/// instead of failing on `jvm_jar_backup_missing`. +#[test] +fn unconsumed_m2_jar_without_a_backup_here_does_not_fail_rollback() { + let other = jar(&[ + ("META-INF/MANIFEST.MF", b"Manifest-Version: 1.0\r\n\r\n"), + (NOTICE, b"patched\n"), + ("other.txt", b"swapped by another project\n"), + ]); + let f = fx(CENTRAL); + let m2 = f.m2(&[(JAR, &other), (POM, PRISTINE_POM)]); + let version = f.gradle(&as_refs(&pristine_files())); + f.member_manifest(); + let (_rt, server) = service(&service_jar()); + f.run(&["apply", "--vendor-url", &server.uri()]).ok(); + assert_eq!(hash_copies(&version, JAR)[0].1, service_jar()); + f.run(&["rollback", "--offline"]) + .ok() + .has("gradle_m2_copy_not_restored"); + assert_eq!(hash_copies(&version, JAR)[0].1, pristine_jar()); + assert_eq!(std::fs::read(m2.join(JAR)).unwrap(), other); +} + +/// A before-blob that does not hash to the Gradle hash dir it is restored +/// into fails the rollback (`gradle_rollback_hash_mismatch`) before anything +/// is written: the patched file stays, and a second run refuses the same way. +#[test] +fn rollback_before_blob_not_matching_the_hash_dir_fails() { + let f = fx(CENTRAL); + let downloaded = pristine_jar(); + let before = jar(&[(NOTICE, b"what the record calls pristine\n")]); + let patched = patched_jar(); + let dir = f + .home + .join(prebuilt_common::GRADLE_FILES21) + .join(GROUP) + .join(ARTIFACT) + .join(VERSION) + .join(sha1_hex(&downloaded)); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(JAR), &patched).unwrap(); + f.manifest(&[(PURL, &[(&format!("package/{JAR}"), &before, &patched)])]); + let dry = f.run(&["rollback", "--offline", "--dry-run"]); + dry.failed(); + assert!( + dry.json + .to_string() + .contains("gradle_rollback_hash_mismatch"), + "a dry run predicts the refusal: {}", + dry.json + ); + let out = f.run(&["rollback", "--offline"]); + out.failed(); + assert!( + out.json + .to_string() + .contains("gradle_rollback_hash_mismatch"), + "{}", + out.json + ); + assert_eq!(std::fs::read(dir.join(JAR)).unwrap(), patched); + let again = f.run(&["rollback", "--offline"]); + again.failed(); + assert!( + again + .json + .to_string() + .contains("gradle_rollback_hash_mismatch"), + "{}", + again.json + ); + assert_eq!(std::fs::read(dir.join(JAR)).unwrap(), patched); +} + +// ── member-keyed records (#264) ───────────────────────────────────────── + +/// Offline, a member-keyed record cannot get the service-built jar: +/// refused with nothing written anywhere. +#[test] +fn offline_member_record_writes_nothing() { + let f = fx(CENTRAL); + f.gradle(&[(JAR, &pristine_jar())]); + f.member_manifest(); + let before = f.snapshot(); + f.run(&["apply", "--offline"]) + .failed() + .has("jvm_agent_service_required"); + assert_eq!(f.snapshot(), before); + assert!(!f.proj.join(".socket/jvm-originals").exists()); +} + +/// A service jar carrying a member the installed jar does not have is not +/// the installed jar plus the patch: refused, nothing written, no backup. +#[test] +fn tampered_service_jar_writes_nothing() { + let f = fx(" mavenLocal()\n mavenCentral()\n"); + f.m2(&as_refs(&pristine_files())); + f.gradle(&as_refs(&pristine_files())); + f.member_manifest(); + let before = f.snapshot(); + let mut tampered = zip::ZipArchive::new(std::io::Cursor::new(service_jar())).unwrap(); + let mut members = Vec::new(); + for i in 0..tampered.len() { + use std::io::Read as _; + let mut entry = tampered.by_index(i).unwrap(); + let mut buf = Vec::new(); + entry.read_to_end(&mut buf).unwrap(); + members.push((entry.name().to_string(), buf)); + } + members.push(("com/example/Backdoor.class".to_string(), b"evil".to_vec())); + let refs: Vec<(&str, &[u8])> = members + .iter() + .map(|(n, b)| (n.as_str(), b.as_slice())) + .collect(); + let (_rt, server) = service(&jar(&refs)); + f.run(&["apply", "--vendor-url", &server.uri()]).failed(); + assert_eq!(f.snapshot(), before); + assert!(!f.proj.join(".socket/jvm-originals").exists()); +} + +/// A swap is one transaction across every consumed copy: when the write of +/// one copy fails, the copies already swapped are put back — `~/.m2` and +/// the Gradle cache alike — whichever copy is written first. +#[cfg(target_os = "macos")] +#[test] +fn failed_swap_restores_every_copy() { + for locked_m2 in [false, true] { + let f = fx(" mavenLocal()\n mavenCentral()\n"); + let m2 = f.m2(&as_refs(&pristine_files())); + let version = f.gradle(&as_refs(&pristine_files())); + f.member_manifest(); + let before = f.snapshot(); + let (_rt, server) = service(&service_jar()); + // A user-immutable jar: the rename over it fails with EPERM. + let locked = if locked_m2 { + m2.join(JAR) + } else { + version.join(&hash_copies(&version, JAR)[0].0).join(JAR) + }; + let flag = |f: &str| { + assert!(Command::new("chflags") + .args([f, locked.to_str().unwrap()]) + .status() + .unwrap() + .success()) + }; + flag("uchg"); + let out = f.run(&["apply", "--vendor-url", &server.uri()]); + flag("nouchg"); + out.failed(); + let after: BTreeMap = f + .snapshot() + .into_iter() + .filter(|(k, _)| !k.contains(".socket/jvm-originals")) + .collect(); + assert_eq!(after, before, "locked_m2={locked_m2}: every copy restored"); + } +} + +/// Windows: a hash-dir jar held open without delete sharing (how a Gradle +/// daemon's `JarFile` holds it) refuses the write with +/// `gradle_jar_locked_by_daemon` — the rename fails with +/// `ERROR_ACCESS_DENIED`, not a sharing violation. +#[cfg(windows)] +#[test] +fn windows_held_jar_reports_daemon_lock() { + use std::os::windows::fs::OpenOptionsExt as _; + let f = fx(CENTRAL); + let version = f.gradle(&as_refs(&pristine_files())); + f.leaf_manifest(); + let jar_path = version.join(&hash_copies(&version, JAR)[0].0).join(JAR); + let held = std::fs::OpenOptions::new() + .read(true) + .share_mode(1) // FILE_SHARE_READ only + .open(&jar_path) + .unwrap(); + let out = f.run(&["apply", "--offline"]); + drop(held); + out.failed().has("gradle_jar_locked_by_daemon"); + f.run(&["apply", "--offline"]).ok(); + assert_eq!(hash_copies(&version, JAR)[0].1, patched_jar()); +} + +/// A patch service answering the vendoring route with `jar`. +fn service(jar: &[u8]) -> (tokio::runtime::Runtime, wiremock::MockServer) { + let rt = tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .enable_all() + .build() + .unwrap(); + let server = rt.block_on(async { + let server = wiremock::MockServer::start().await; + prebuilt_common::mount_download(&server, PURL, UUID, JAR, jar).await; + server + }); + (rt, server) +} + +/// apply swaps the service jar into every copy (m2 and Gradle, mavenLocal +/// declared) and keeps the original under `.socket/jvm-originals/`; +/// `repair` (blob GC) leaves it; rollback restores every copy byte for +/// byte; VEX attests in between. +#[test] +fn member_record_apply_repair_rollback_is_byte_exact() { + let f = fx(" mavenLocal()\n mavenCentral()\n"); + let m2 = f.m2(&as_refs(&pristine_files())); + let version = f.gradle(&as_refs(&pristine_files())); + f.member_manifest(); + let before = f.snapshot(); + let (_rt, server) = service(&service_jar()); + + f.run(&["apply", "--vendor-url", &server.uri()]).ok(); + assert_eq!(std::fs::read(m2.join(JAR)).unwrap(), service_jar()); + assert_eq!(hash_copies(&version, JAR)[0].1, service_jar()); + let backup = f.proj.join(".socket/jvm-originals").join(format!( + "{}.jar", + hex::encode(::digest(pristine_jar())) + )); + assert_eq!(std::fs::read(&backup).unwrap(), pristine_jar()); + let (out, statements) = f.vex(&[]); + out.ok(); + assert!(statements > 0); + + f.run(&["repair", "--offline"]).ok(); + assert!(backup.is_file(), "repair must keep the jar originals"); + + f.run(&["rollback", "--offline"]).ok(); + let after: BTreeMap = f + .snapshot() + .into_iter() + .filter(|(k, _)| !k.contains(".socket/jvm-originals")) + .collect(); + assert_eq!( + after, before, + "rollback must restore every copy byte for byte" + ); +} + +// ── global prefix ─────────────────────────────────────────────────────── + +/// `--global-prefix` naming the Gradle user home (`.gradle`) or its +/// `caches/modules-2` reaches the `files-2.1` copies for apply, vex and +/// rollback alike. +#[test] +fn global_prefix_apply_vex_rollback() { + for prefix in ["", "caches/modules-2"] { + let f = fx(CENTRAL); + let version = f.gradle(&as_refs(&pristine_files())); + f.leaf_manifest(); + let p = if prefix.is_empty() { + f.home.clone() + } else { + f.home.join(prefix) + }; + let p = p.to_str().unwrap(); + f.run(&["apply", "--offline", "--global-prefix", p]).ok(); + assert_eq!(hash_copies(&version, JAR)[0].1, patched_jar(), "{prefix}"); + let (out, statements) = f.vex(&["--global-prefix", p]); + out.ok(); + assert!(statements > 0, "{prefix}"); + f.run(&["rollback", "--offline", "--global-prefix", p]).ok(); + assert_eq!(hash_copies(&version, JAR)[0].1, pristine_jar(), "{prefix}"); + } +} + +// ── remove ────────────────────────────────────────────────────────────── + +/// `remove` (rollback + drop) restores every copy it patched: `~/.m2` and +/// each Gradle hash dir. +#[test] +fn gradle_agent_remove_restores_all_copies() { + let f = fx(" mavenLocal()\n mavenCentral()\n"); + let files = pristine_files(); + f.m2(&as_refs(&files)); + f.gradle(&as_refs(&files)); + f.gradle_unpadded(&[(JAR, &pristine_jar())]); + f.leaf_manifest(); + let before = f.snapshot(); + f.run(&["apply", "--offline"]).ok(); + assert_ne!(f.snapshot(), before); + f.run(&["remove", PURL, "--offline"]).ok(); + assert_eq!(f.snapshot(), before); +} + +// ── get narrowing ─────────────────────────────────────────────────────── + +/// `get` keeps the release variant whose classifier jar sits in the Gradle +/// cache (`files-2.1` hash dirs are expanded) and drops the other. +#[test] +fn get_narrowing_picks_the_classifier_in_files21() { + let f = fx(CENTRAL); + let linux = jar(&[(NOTICE, b"linux\n")]); + let osx = jar(&[(NOTICE, b"osx\n")]); + f.gradle(&[("victim-1.0-linux.jar", &linux), (POM, PRISTINE_POM)]); + let variants = [ + ("11111111-1111-4111-8111-111111111111", "linux", &linux), + ("22222222-2222-4222-8222-222222222222", "osx", &osx), + ]; + let rt = tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .enable_all() + .build() + .unwrap(); + let server = rt.block_on(async { + use wiremock::matchers::{method, path, path_regex}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + let server = MockServer::start().await; + let listed: Vec = variants + .iter() + .map(|(uuid, c, _)| { + serde_json::json!({ + "uuid": uuid, "purl": format!("{PURL}?classifier={c}&ext=jar"), + "publishedAt": "2024-01-01T00:00:00Z", "description": "x", + "license": "MIT", "tier": "free", "vulnerabilities": {} + }) + }) + .collect(); + Mock::given(method("GET")) + .and(path_regex("^/v0/orgs/test-org/patches/by-package/.+$")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "patches": listed, "canAccessPaidPatches": false, + }))) + .mount(&server) + .await; + for (uuid, c, before) in &variants { + let after = [before.as_slice(), b"patched"].concat(); + use base64::Engine as _; + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/test-org/patches/view/{uuid}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "uuid": uuid, "purl": format!("{PURL}?classifier={c}&ext=jar"), + "publishedAt": "2024-01-01T00:00:00Z", + "files": { format!("package/victim-1.0-{c}.jar"): { + "beforeHash": git_sha256(before), + "afterHash": git_sha256(&after), + "blobContent": base64::engine::general_purpose::STANDARD.encode(&after), + } }, + "vulnerabilities": {}, "description": "x", "license": "MIT", "tier": "free", + }))) + .mount(&server) + .await; + } + server + }); + let out = f.run(&[ + "get", + PURL, + "--save-only", + "--yes", + "--api-url", + &server.uri(), + "--api-token", + "fake-token-for-tests", + "--org", + "test-org", + ]); + out.ok(); + let manifest: serde_json::Value = + serde_json::from_slice(&std::fs::read(f.proj.join(".socket/manifest.json")).unwrap()) + .unwrap(); + let keys: Vec<&String> = manifest["patches"].as_object().unwrap().keys().collect(); + assert_eq!( + keys, + [&format!("{PURL}?classifier=linux&ext=jar")], + "{}", + out.json + ); +} + +// ── vendored ──────────────────────────────────────────────────────────── + +/// A vendored Gradle entry's pristine `files-2.1` copy is a sibling the +/// vendored build never reads: `vex` attests from the committed tree and +/// does not flag it `vendored_tree_out_of_sync`. +#[test] +fn vendored_gradle_entry_is_not_out_of_sync() { + let f = fx(CENTRAL); + let files = pristine_files(); + f.m2(&as_refs(&files)); + f.gradle(&as_refs(&files)); + f.member_manifest(); + let mut cmd = Command::new(env!("CARGO_BIN_EXE_socket-patch")); + for (k, _) in std::env::vars_os() { + if k.to_string_lossy().starts_with("SOCKET_") { + cmd.env_remove(&k); + } + } + let home = f.home.to_string_lossy().into_owned(); + let m2 = f.m2.to_string_lossy().into_owned(); + let _server = prebuilt_common::prepare_command( + &mut cmd, + &f.proj, + &["vendor"], + &[("GRADLE_USER_HOME", &home), ("MAVEN_REPO_LOCAL", &m2)], + ); + let out = cmd + .args(["--json", "--cwd", f.proj.to_str().unwrap()]) + .env("SOCKET_TELEMETRY_DISABLED", "1") + .env("SOCKET_NO_CONFIG", "1") + .env("MAVEN_REPO_LOCAL", &f.m2) + .output() + .unwrap(); + assert_eq!( + out.status.code(), + Some(0), + "{}\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + let (out, statements) = f.vex(&[]); + out.ok(); + assert!(statements > 0, "{}", out.json); + assert!( + !out.warning_codes() + .iter() + .any(|c| c == "vendored_tree_out_of_sync"), + "{}", + out.json + ); +} + +/// The shapes these tests rely on. +#[test] +fn fixture_self_check() { + assert!(sha1_hex(&pristine_jar()).starts_with('0')); + let services: HashMap<&str, Vec> = HashMap::from([("service", service_jar())]); + assert_ne!(services["service"], pristine_jar()); +} diff --git a/crates/socket-patch-cli/tests/gradle_build_common/mod.rs b/crates/socket-patch-cli/tests/gradle_build_common/mod.rs new file mode 100644 index 000000000..fffb8aa1a --- /dev/null +++ b/crates/socket-patch-cli/tests/gradle_build_common/mod.rs @@ -0,0 +1,791 @@ +//! Real-Gradle plumbing shared by the Gradle capstones +//! (`e2e_vendor_jvm_build`'s multi-project leg and the `e2e_gradle_*` / +//! `e2e_*_gradle_build` suites). +//! +//! Toolchain selection (the version-matrix lever, mirroring +//! `maven_build_common`'s `SOCKET_PATCH_MAVEN_E2E_*` trio): +//! +//! * `SOCKET_PATCH_GRADLE_E2E_GRADLE` — the launcher to drive (an unpacked +//! `gradle-/bin/gradle`); unset/empty = `gradle` on `PATH`. +//! * `SOCKET_PATCH_GRADLE_E2E_VERSION` — when set and non-empty, the +//! launcher's `--version` banner MUST report exactly this version. +//! * `SOCKET_PATCH_GRADLE_E2E_REQUIRED` — when set and non-empty, a missing +//! toolchain or an unreachable origin is a hard failure, not a SKIP. +//! * `SOCKET_PATCH_GRADLE_E2E_PROBE_DIR` — where [`probe_report`] writes the +//! per-cell JSON (default `/gradle-probe`). +//! * `SOCKET_PATCH_GRADLE_E2E_ARGS` — extra whitespace-separated arguments +//! for every Gradle run (the CI grid's `--configuration-cache` and +//! Isolated Projects cells). +//! * `SOCKET_PATCH_GRADLE_E2E_REAL_CENTRAL` — when set and non-empty +//! ([`real_central`]), tests that have a real-Central variant resolve +//! from Maven Central instead of the fake (gradle-compatibility.yml's +//! `real-central` row). +//! +//! Every Gradle run is hermetic: a per-test `GRADLE_USER_HOME`, no daemon, +//! plain console, and the ambient JVM / Gradle options ([`AMBIENT_ENV`]) and +//! CI markers ([`CI_DETECTOR_ENV`]) scrubbed. Repositories are redirected to +//! the fake origins by a test-only init script ([`mirror_init_script`]); +//! production code has no test override. + +#![allow(dead_code)] + +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::io::Read as _; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +pub const GRADLE_ENV: &str = "SOCKET_PATCH_GRADLE_E2E_GRADLE"; +pub const GRADLE_VERSION_ENV: &str = "SOCKET_PATCH_GRADLE_E2E_VERSION"; +pub const GRADLE_REQUIRED_ENV: &str = "SOCKET_PATCH_GRADLE_E2E_REQUIRED"; +pub const GRADLE_PROBE_DIR_ENV: &str = "SOCKET_PATCH_GRADLE_E2E_PROBE_DIR"; +pub const GRADLE_ARGS_ENV: &str = "SOCKET_PATCH_GRADLE_E2E_ARGS"; +pub const GRADLE_REAL_CENTRAL_ENV: &str = "SOCKET_PATCH_GRADLE_E2E_REAL_CENTRAL"; + +/// The real Maven Central, for [`real_central`] runs. +pub const MAVEN_CENTRAL: &str = "https://repo.maven.apache.org/maven2"; + +/// Ambient settings that would change what a Gradle child resolves or where +/// it caches: JVM options (a `-Dgradle.user.home` there beats the per-test +/// home), the user home itself, the read-only dependency cache and an +/// installation's `init.d`. +pub const AMBIENT_ENV: &[&str] = &[ + "GRADLE_OPTS", + "JAVA_OPTS", + "GRADLE_USER_HOME", + "GRADLE_RO_DEP_CACHE", + "GRADLE_HOME", +]; + +/// The CI markers `maven_build_common` scrubs, scrubbed here too so a leg +/// logs what a developer's terminal run logs. +pub const CI_DETECTOR_ENV: &[&str] = &[ + "CI", + "GITHUB_ACTIONS", + "CIRCLECI", + "WORKSPACE", + "TEAMCITY_VERSION", + "TRAVIS", +]; + +/// Directories a build writes that a checkout never carries. +pub const BUILD_OUTPUT_DIRS: &[&str] = &["target", "build", ".gradle", ".kotlin"]; + +/// The line prefix [`print_cp_task`] prints before each classpath entry. +pub const CP_MARKER: &str = "SOCKET-CP "; + +pub fn gradle_flag(name: &str) -> bool { + std::env::var_os(name).is_some_and(|v| !v.is_empty()) +} + +/// CI legs set `SOCKET_PATCH_GRADLE_E2E_REQUIRED`: never skip there. +pub fn gradle_required() -> bool { + gradle_flag(GRADLE_REQUIRED_ENV) +} + +/// The leg targets the real Maven Central ([`GRADLE_REAL_CENTRAL_ENV`]): +/// a test with a real-Central variant mirrors to [`MAVEN_CENTRAL`] instead +/// of its fake origin; the others ignore it. +pub fn real_central() -> bool { + gradle_flag(GRADLE_REAL_CENTRAL_ENV) +} + +/// Skip (println) locally; fail when the leg is required. +pub fn gradle_skip(suite: &str, why: &str) { + assert!( + !gradle_required(), + "{suite}: {GRADLE_REQUIRED_ENV} is set but the Gradle capstone cannot run: {why}" + ); + println!("SKIP {suite}: {why}"); +} + +/// Java rejects the extended Windows paths returned by canonicalize. +/// Keep a canonical root for symlinked macOS temp directories, but use the +/// ordinary drive/UNC spelling when handing paths to Maven and Gradle. +pub fn fixture_root(tmp: &tempfile::TempDir) -> PathBuf { + strip_verbatim(tmp.path().canonicalize().unwrap()) +} + +/// `\\?\C:\x` → `C:\x` and `\\?\UNC\h\s` → `\\h\s`; identity elsewhere. +pub fn strip_verbatim(path: PathBuf) -> PathBuf { + #[cfg(windows)] + if let Some(text) = path.to_str() { + if let Some(rest) = text.strip_prefix(r"\\?\UNC\") { + return format!(r"\\{rest}").into(); + } + if let Some(rest) = text.strip_prefix(r"\\?\") { + return rest.into(); + } + } + path +} + +pub fn ok(out: &Output) -> bool { + out.status.success() +} + +pub fn dump(out: &Output) -> String { + format!( + "exit {:?}\n--- stdout\n{}\n--- stderr\n{}", + out.status.code(), + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ) +} + +// ── the launcher ──────────────────────────────────────────────────────── + +/// The selected Gradle launcher, run hermetically: a per-test +/// `GRADLE_USER_HOME`, no daemon, plain console, ambient options scrubbed. +pub struct Gradle { + pub program: OsString, + /// What the `Gradle ` banner line reports, e.g. `8.14.3`. + pub version: String, + /// What the `JVM:` banner line reports, e.g. `21.0.8 (Eclipse Adoptium 21.0.8+9-LTS)`. + pub jvm: String, + /// Arguments every [`Gradle::run`] passes first (see + /// [`Gradle::with_isolated_projects`]). + pub extra_args: Vec, +} + +impl Gradle { + pub fn command(program: &OsString, home: Option<&Path>) -> Command { + let mut cmd = Command::new(program); + for key in AMBIENT_ENV.iter().chain(CI_DETECTOR_ENV) { + cmd.env_remove(key); + } + if let Some(home) = home { + cmd.env("GRADLE_USER_HOME", home); + } + cmd + } + + /// Probe the launcher (`gradle --version`). `None` = skipped (message + /// printed; a hard failure on required legs). + pub fn detect(suite: &str, home: &Path) -> Option { + let program: OsString = std::env::var_os(GRADLE_ENV) + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| { + if cfg!(windows) { + "gradle.bat" + } else { + "gradle" + } + .into() + }); + let out = match Self::command(&program, Some(home)) + .args(["--version", "--no-daemon"]) + .output() + { + Ok(out) => out, + Err(e) => { + gradle_skip( + suite, + &format!("`{}` did not run: {e}", program.to_string_lossy()), + ); + return None; + } + }; + let banner = String::from_utf8_lossy(&out.stdout).into_owned(); + let Some(version) = banner.lines().find_map(|l| { + l.trim() + .strip_prefix("Gradle ") + .map(|v| v.trim().to_string()) + }) else { + gradle_skip( + suite, + &format!( + "`{} --version` printed no `Gradle ` banner:\n{}{}", + program.to_string_lossy(), + banner, + String::from_utf8_lossy(&out.stderr) + ), + ); + return None; + }; + if let Some(pin) = std::env::var(GRADLE_VERSION_ENV) + .ok() + .filter(|v| !v.is_empty()) + { + assert_eq!( + version, + pin, + "{GRADLE_VERSION_ENV} pins Gradle {pin} but `{}` is Gradle {version}", + program.to_string_lossy() + ); + } + let jvm = jvm_banner(&banner).unwrap_or_default(); + let extra_args: Vec = std::env::var(GRADLE_ARGS_ENV) + .unwrap_or_default() + .split_whitespace() + .map(str::to_string) + .collect(); + println!( + "{suite}: driving Gradle {version} on JVM {jvm} ({}) {extra_args:?}", + program.to_string_lossy() + ); + Some(Gradle { + program, + version, + jvm, + extra_args, + }) + } + + /// The major version (`6` for `6.9.4`). + pub fn major(&self) -> u32 { + version_part(&self.version, 0) + } + + pub fn minor(&self) -> u32 { + version_part(&self.version, 1) + } + + /// `self.version >= major.minor`. + pub fn at_least(&self, major: u32, minor: u32) -> bool { + (self.major(), self.minor()) >= (major, minor) + } + + /// The JVM's feature release (`21` for `21.0.8`, `8` for `1.8.0_412`). + pub fn jdk_major(&self) -> Option { + jdk_feature(&self.jvm) + } + + /// Every later [`Gradle::run`] enables Isolated Projects. Meaningful on + /// 9.x (and late 8.x); older releases ignore the unknown property. + pub fn with_isolated_projects(mut self) -> Self { + self.extra_args + .push("-Dorg.gradle.unsafe.isolated-projects=true".into()); + self + } + + pub fn run(&self, cwd: &Path, home: &Path, args: &[&str]) -> Output { + self.run_env(cwd, home, args, &[]) + } + + /// [`Gradle::run`] plus child-only environment (applied last). + pub fn run_env( + &self, + cwd: &Path, + home: &Path, + args: &[&str], + env: &[(&str, &std::ffi::OsStr)], + ) -> Output { + // The single-use daemon a `--no-daemon` build forks sometimes dies + // before it answers on Windows runners ("The first result from the + // daemon was empty", `DaemonInitialConnectException`): the build + // never reported anything, so it is retried (twice at most). Any + // other failure is the build's own and is returned as is. + let mut attempt = 0; + loop { + let mut cmd = Self::command(&self.program, Some(home)); + cmd.current_dir(cwd) + .args(["--no-daemon", "--console=plain", "--stacktrace"]) + .args(&self.extra_args) + .args(args); + for (k, v) in env { + cmd.env(k, v); + } + let out = cmd.output().expect("spawn gradle"); + attempt += 1; + if attempt > 2 || out.status.success() || !daemon_died_unanswered(&out) { + return out; + } + eprintln!( + "gradle: the single-use daemon died before answering; retrying ({attempt}/2)" + ); + } + } +} + +/// Whether a failed Gradle run is the launcher reporting that its forked +/// daemon died before returning any result (see [`Gradle::run_env`]). +fn daemon_died_unanswered(out: &Output) -> bool { + let stderr = String::from_utf8_lossy(&out.stderr); + stderr.contains("DaemonInitialConnectException") + && stderr.contains("The first result from the daemon was empty") +} + +fn version_part(version: &str, index: usize) -> u32 { + version + .split(['.', '-']) + .nth(index) + .and_then(|p| p.parse().ok()) + .unwrap_or(0) +} + +/// The `JVM:` line of a `gradle --version` banner (`Launcher JVM:` on +/// Gradle >= 9, which also prints a `Daemon JVM:` line). +pub fn jvm_banner(banner: &str) -> Option { + banner.lines().find_map(|l| { + let l = l.trim(); + l.strip_prefix("JVM:") + .or_else(|| l.strip_prefix("Launcher JVM:")) + .map(|v| v.trim().to_string()) + }) +} + +/// `21.0.8 (…)` → 21; `1.8.0_412 (…)` → 8; `11 (…)` → 11. +pub fn jdk_feature(jvm: &str) -> Option { + let version = jvm.split_whitespace().next()?; + let mut parts = version.split(['.', '_', '+', '-']); + let first: u32 = parts.next()?.parse().ok()?; + if first == 1 { + parts.next()?.parse().ok() + } else { + Some(first) + } +} + +// ── build scripts ─────────────────────────────────────────────────────── + +/// The two build-script dialects. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Dsl { + Groovy, + Kotlin, +} + +impl Dsl { + pub const ALL: [Dsl; 2] = [Dsl::Groovy, Dsl::Kotlin]; + + pub fn name(self) -> &'static str { + match self { + Dsl::Groovy => "groovy", + Dsl::Kotlin => "kotlin", + } + } + + /// `.gradle` / `.gradle.kts`. + pub fn ext(self) -> &'static str { + match self { + Dsl::Groovy => ".gradle", + Dsl::Kotlin => ".gradle.kts", + } + } + + pub fn settings_file(self) -> String { + format!("settings{}", self.ext()) + } + + pub fn build_file(self) -> String { + format!("build{}", self.ext()) + } +} + +/// Run `f` once per DSL, labelling the output so a failure names its DSL. +pub fn for_each_dsl(mut f: impl FnMut(Dsl)) { + for dsl in Dsl::ALL { + println!("── {} DSL ──", dsl.name()); + f(dsl); + } +} + +/// Write `files` (`(relative path, body)`) under `proj`. +pub fn write_project(proj: &Path, files: &[(&str, &str)]) { + for (rel, body) in files { + let path = proj.join(rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, body).unwrap(); + } +} + +/// The same project in both DSLs, under `root/groovy` and `root/kotlin`. +/// `files(dsl)` returns that DSL's `(relative path, body)` list. +pub fn write_both_dsls( + root: &Path, + files: impl Fn(Dsl) -> Vec<(String, String)>, +) -> [(Dsl, PathBuf); 2] { + Dsl::ALL.map(|dsl| { + let proj = root.join(dsl.name()); + let owned = files(dsl); + let borrowed: Vec<(&str, &str)> = owned + .iter() + .map(|(rel, body)| (rel.as_str(), body.as_str())) + .collect(); + write_project(&proj, &borrowed); + (dsl, proj) + }) +} + +/// A `printRuntimeClasspath` task printing one [`CP_MARKER`] line per +/// resolved file of `configuration`. Configuration-cache safe: the action +/// captures a task-local file collection, never a script-level property (a +/// Kotlin script `val` drags the unserializable script object along) nor the +/// configuration provider itself. +pub fn print_cp_task(dsl: Dsl, configuration: &str) -> String { + match dsl { + Dsl::Groovy => format!( + "tasks.register('printRuntimeClasspath') {{\n \ + def socketCp = files(configurations.named('{configuration}'))\n \ + doLast {{ socketCp.files.each {{ println('{CP_MARKER}' + it.absolutePath) }} }}\n\ + }}\n" + ), + Dsl::Kotlin => format!( + "tasks.register(\"printRuntimeClasspath\") {{\n \ + val socketCp: FileCollection = files(configurations.named(\"{configuration}\"))\n \ + doLast {{ socketCp.files.forEach {{ println(\"{CP_MARKER}\" + it.absolutePath) }} }}\n\ + }}\n" + ), + } +} + +/// The [`CP_MARKER`] lines a `printRuntimeClasspath` run printed. +pub fn gradle_classpath(out: &Output) -> Vec { + String::from_utf8_lossy(&out.stdout) + .lines() + .filter_map(|l| l.strip_prefix(CP_MARKER)) + .map(PathBuf::from) + .collect() +} + +/// [`gradle_classpath`] with the build's own success asserted. +pub fn print_cp(out: &Output, what: &str) -> Vec { + assert!(ok(out), "{what}:\n{}", dump(out)); + gradle_classpath(out) +} + +/// The one classpath entry named `-…`; its `member` must be +/// exactly `want`. Returns the entry: what Gradle actually consumed. +pub fn assert_patched( + out: &Output, + artifact: &str, + member: &str, + want: &[u8], + what: &str, +) -> PathBuf { + let cp = print_cp(out, what); + let prefix = format!("{artifact}-"); + let hits: Vec<&PathBuf> = cp + .iter() + .filter(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with(&prefix) && n.ends_with(".jar")) + }) + .collect(); + assert_eq!( + hits.len(), + 1, + "{what}: exactly one `{artifact}` jar on the classpath:\n{cp:?}" + ); + let jar = std::fs::read(hits[0]).unwrap(); + let got = jar_member(&jar, member) + .unwrap_or_else(|| panic!("{what}: {} has no {member}", hits[0].display())); + assert_eq!( + String::from_utf8_lossy(&got), + String::from_utf8_lossy(want), + "{what}: {member} of the consumed {} is not the expected bytes", + hits[0].display() + ); + hits[0].clone() +} + +/// The run reused a configuration-cache entry: settings and build scripts +/// were not evaluated, so nothing they print at configuration time appears +/// (the entry's store run printed it; reuse proves its inputs are unchanged). +pub fn configuration_reused(out: &Output) -> bool { + String::from_utf8_lossy(&out.stdout).contains("Reusing configuration cache.") +} + +/// One member's bytes from a jar. +pub fn jar_member(jar: &[u8], name: &str) -> Option> { + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(jar)).ok()?; + let mut entry = archive.by_name(name).ok()?; + let mut buf = Vec::new(); + entry.read_to_end(&mut buf).ok()?; + Some(buf) +} + +// ── init scripts ──────────────────────────────────────────────────────── + +/// Write an init script into `dir` and return the `--init-script` args. +pub fn init_script(dir: &Path, name: &str, body: &str) -> [String; 2] { + std::fs::create_dir_all(dir).unwrap(); + let path = dir.join(name); + std::fs::write(&path, body).unwrap(); + ["--init-script".into(), path.to_string_lossy().into_owned()] +} + +/// A Groovy init script that points every Maven repository of the build — +/// settings `pluginManagement` / `buildscript` / `dependencyResolutionManagement` +/// and each project's (+ buildscript) repositories, including ones declared +/// later — at the fake origins: `https://patch.socket.dev/` at +/// `hosted` + `` when given, everything else except `file:` at +/// `central`. Plain-http origins need `allowInsecureProtocol`. Projects are +/// reached through `gradle.lifecycle.beforeProject` on Gradle ≥ 8.8 +/// (Isolated Projects compatible), `gradle.beforeProject` below that. +pub fn mirror_init_script(central: &str, hosted: Option<&str>) -> String { + let central = central.trim_end_matches('/'); + let hosted = hosted.unwrap_or("").trim_end_matches('/'); + format!( + r#"// socket-patch e2e harness: route every repository to the fake origins. +def socketCentral = '{central}/' +def socketHosted = '{hosted}' +def socketMirror = {{ repos -> + repos.configureEach {{ repo -> + if (repo instanceof org.gradle.api.artifacts.repositories.MavenArtifactRepository) {{ + def url = repo.url.toString() + if (url.startsWith('file:')) return + if (socketHosted && url.startsWith('https://patch.socket.dev/')) {{ + repo.url = socketHosted + url.substring('https://patch.socket.dev'.length()) + }} else {{ + repo.url = socketCentral + }} + repo.allowInsecureProtocol = true + }} + }} +}} +gradle.beforeSettings {{ settings -> + socketMirror(settings.pluginManagement.repositories) + socketMirror(settings.buildscript.repositories) + socketMirror(settings.dependencyResolutionManagement.repositories) +}} +def socketProject = {{ project -> + socketMirror(project.buildscript.repositories) + socketMirror(project.repositories) +}} +if (org.gradle.util.GradleVersion.current() >= org.gradle.util.GradleVersion.version('8.8')) {{ + gradle.lifecycle.beforeProject(socketProject) +}} else {{ + gradle.beforeProject(socketProject) +}} +"# + ) +} + +// ── files ─────────────────────────────────────────────────────────────── + +/// Every committable file under `root` (build output skipped), keyed by its +/// forward-slash relative path. +pub fn snapshot(root: &Path) -> BTreeMap> { + fn walk(root: &Path, dir: &Path, out: &mut BTreeMap>) { + for entry in std::fs::read_dir(dir).unwrap() { + let entry = entry.unwrap(); + let name = entry.file_name().to_string_lossy().into_owned(); + let path = entry.path(); + if entry.file_type().unwrap().is_dir() { + if !BUILD_OUTPUT_DIRS.contains(&name.as_str()) && name != ".git" { + walk(root, &path, out); + } + continue; + } + let rel = path + .strip_prefix(root) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + out.insert(rel, std::fs::read(&path).unwrap()); + } + } + let mut out = BTreeMap::new(); + walk(root, root, &mut out); + out +} + +/// Every Gradle lockfile under `root`: `/gradle.lockfile` on 7+, +/// `/gradle/dependency-locks/.lockfile` on 6.x. +pub fn lockfiles(root: &Path) -> BTreeMap> { + snapshot(root) + .into_iter() + .filter(|(rel, _)| rel.ends_with(".lockfile")) + .collect() +} + +/// `git` with the ambient repository / config environment scrubbed. +fn git(cwd: &Path) -> Command { + let mut cmd = Command::new("git"); + for (key, _) in std::env::vars_os() { + if key.to_string_lossy().starts_with("GIT_") { + cmd.env_remove(&key); + } + } + // No system or global config (a developer's commit signing, hooks or + // autocrlf must not change the fixture): an empty global file. + let global = std::env::temp_dir().join("socket-patch-e2e-empty.gitconfig"); + if !global.is_file() { + let _ = std::fs::write(&global, ""); + } + cmd.current_dir(cwd) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", &global) + .args(["-c", "user.name=socket-patch-e2e"]) + .args(["-c", "user.email=e2e@socket.invalid"]) + .args(["-c", "init.defaultBranch=main"]); + cmd +} + +fn git_ok(cmd: &mut Command, what: &str) { + let out = cmd.output().unwrap_or_else(|e| panic!("{what}: git: {e}")); + assert!(ok(&out), "{what}:\n{}", dump(&out)); +} + +/// Commit `src`'s tree (LF, as written) and clone it to `dst` with +/// `core.autocrlf=true`: the working tree a Windows developer checks out, +/// on every OS. Text files arrive with CRLF unless `.gitattributes` says +/// otherwise. Returns `dst`. +pub fn git_autocrlf_clone(src: &Path, dst: &Path) -> PathBuf { + if !src.join(".git").exists() { + git_ok(git(src).args(["init", "-q"]), "git init"); + } + git_ok( + git(src).args(["-c", "core.autocrlf=false", "add", "-A"]), + "git add", + ); + git_ok( + git(src).args([ + "-c", + "core.autocrlf=false", + "commit", + "-q", + "--allow-empty", + "-m", + "fixture", + ]), + "git commit", + ); + let parent = dst.parent().unwrap(); + std::fs::create_dir_all(parent).unwrap(); + git_ok( + git(parent).args([ + "-c", + "core.autocrlf=true", + "clone", + "-q", + "--config", + "core.autocrlf=true", + &src.to_string_lossy(), + &dst.to_string_lossy(), + ]), + "git clone", + ); + dst.to_path_buf() +} + +// ── probe reports ─────────────────────────────────────────────────────── + +/// Where [`probe_report`] writes. +pub fn probe_dir() -> PathBuf { + std::env::var_os(GRADLE_PROBE_DIR_ENV) + .filter(|v| !v.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("gradle-probe")) +} + +/// Write one cell's JSON probe report (`/.json`, the cell +/// name reduced to `[A-Za-z0-9._-]`) and return its path. The CI grid +/// uploads the directory, so a cell's measured Gradle behaviour survives +/// the run. +pub fn probe_report(cell: &str, json: &serde_json::Value) -> PathBuf { + let dir = probe_dir(); + std::fs::create_dir_all(&dir).unwrap(); + let name: String = cell + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') { + c + } else { + '_' + } + }) + .collect(); + let path = dir.join(format!("{name}.json")); + std::fs::write(&path, serde_json::to_vec_pretty(json).unwrap()).unwrap(); + println!("probe report: {}", path.display()); + path +} + +// ── self-tests (pure; integration crates get no cfg(test)) ────────────── + +mod gradle_build_common_selftests { + use super::*; + + #[test] + fn jdk_feature_reads_both_version_schemes() { + assert_eq!( + jdk_feature("21.0.8 (Eclipse Adoptium 21.0.8+9-LTS)"), + Some(21) + ); + assert_eq!(jdk_feature("11.0.24 (Homebrew 11.0.24+0)"), Some(11)); + assert_eq!(jdk_feature("1.8.0_412 (Temurin 25.412-b08)"), Some(8)); + assert_eq!(jdk_feature("17 (x)"), Some(17)); + assert_eq!(jdk_feature(""), None); + let banner = "\n------\nGradle 8.14.3\n------\n\nKotlin: 2.0.21\nJVM: 21.0.8 (Eclipse Adoptium 21.0.8+9-LTS)\nOS: Linux\n"; + assert_eq!( + jvm_banner(banner).as_deref(), + Some("21.0.8 (Eclipse Adoptium 21.0.8+9-LTS)") + ); + let nine = "Gradle 9.8.0\nLauncher JVM: 21.0.12.1 (Homebrew 21.0.12.1)\n\ + Daemon JVM: /x (no Daemon JVM specified)\n"; + assert_eq!( + jvm_banner(nine).as_deref(), + Some("21.0.12.1 (Homebrew 21.0.12.1)") + ); + assert_eq!(jdk_feature(&jvm_banner(nine).unwrap()), Some(21)); + } + + #[test] + fn version_parts_and_ordering() { + let g = |v: &str| Gradle { + program: "gradle".into(), + version: v.into(), + jvm: String::new(), + extra_args: Vec::new(), + }; + assert_eq!((g("6.9.4").major(), g("6.9.4").minor()), (6, 9)); + assert_eq!((g("9.0-rc-1").major(), g("9.0-rc-1").minor()), (9, 0)); + assert!(g("8.14.3").at_least(8, 8) && !g("8.7").at_least(8, 8)); + assert!(g("9.8.0").at_least(8, 8) && !g("7.6.6").at_least(8, 0)); + let ip = g("9.8.0").with_isolated_projects(); + assert_eq!( + ip.extra_args, + vec!["-Dorg.gradle.unsafe.isolated-projects=true".to_string()] + ); + } + + #[test] + fn mirror_init_script_redirects_both_origins() { + let script = mirror_init_script("http://127.0.0.1:1/", Some("http://127.0.0.1:2")); + assert!(script.contains("def socketCentral = 'http://127.0.0.1:1/'")); + assert!(script.contains("def socketHosted = 'http://127.0.0.1:2'")); + assert!(script.contains("gradle.beforeSettings")); + assert!(script.contains("gradle.lifecycle.beforeProject")); + assert!(script.contains("repo.allowInsecureProtocol = true")); + assert!(mirror_init_script("http://h", None).contains("def socketHosted = ''")); + } + + /// LF as committed, CRLF in the autocrlf clone; `-text` files stay LF. + #[test] + fn git_autocrlf_clone_checks_out_crlf() { + let tmp = tempfile::tempdir().unwrap(); + let src = tmp.path().join("src"); + write_project( + &src, + &[ + ("settings.gradle", "include 'app'\nrootProject.name = 'x'\n"), + (".gitattributes", "*.bin -text\n"), + ("data.bin", "a\nb\n"), + ], + ); + let dst = git_autocrlf_clone(&src, &tmp.path().join("clone")); + assert_eq!( + std::fs::read(dst.join("settings.gradle")).unwrap(), + b"include 'app'\r\nrootProject.name = 'x'\r\n" + ); + assert_eq!(std::fs::read(dst.join("data.bin")).unwrap(), b"a\nb\n"); + assert_eq!( + std::fs::read(src.join("settings.gradle")).unwrap(), + b"include 'app'\nrootProject.name = 'x'\n" + ); + } + + #[test] + fn print_cp_task_per_dsl() { + assert!(print_cp_task(Dsl::Groovy, "runtimeClasspath") + .contains("files(configurations.named('runtimeClasspath'))")); + assert!(print_cp_task(Dsl::Kotlin, "compileClasspath") + .contains("files(configurations.named(\"compileClasspath\"))")); + assert_eq!(Dsl::Kotlin.settings_file(), "settings.gradle.kts"); + assert_eq!(Dsl::Groovy.build_file(), "build.gradle"); + } +} diff --git a/crates/socket-patch-cli/tests/hosted_maven_common/mod.rs b/crates/socket-patch-cli/tests/hosted_maven_common/mod.rs new file mode 100644 index 000000000..c5456a2ed --- /dev/null +++ b/crates/socket-patch-cli/tests/hosted_maven_common/mod.rs @@ -0,0 +1,229 @@ +//! The hosted (`scan --mode hosted`) Socket API + `maven2` repository fake +//! shared by the real-build hosted capstones (`e2e_redirect_maven_build` +//! and the Gradle hosted suites). +//! +//! A [`Hosted`] names one patched GAV and its grant coordinates; the +//! served repository is the production-shaped +//! `…/patch-registry/maven///maven2` path carrying the +//! SUFFIXED version (`-socket.`). + +#![allow(dead_code)] + +use wiremock::matchers::{method, path, path_regex}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +/// One hosted patch: the GAV it patches and the grant around it. +pub struct Hosted { + pub org: &'static str, + /// Canonical lowercase patch uuid; its first 8 hex are the suffix. + pub uuid: &'static str, + pub hex8: &'static str, + /// Grant-token path level of the hosted urls (uuid-shaped, like prod). + pub token: &'static str, + pub ghsa: &'static str, + pub cve: &'static str, + pub group: &'static str, + pub artifact: &'static str, + pub version: &'static str, + /// The batch listing's `title`. + pub title: &'static str, +} + +impl Hosted { + pub fn purl(&self) -> String { + format!( + "pkg:maven/{}/{}@{}", + self.group, self.artifact, self.version + ) + } + + pub fn group_path(&self) -> String { + self.group.replace('.', "/") + } + + pub fn suffixed(&self) -> String { + format!("{}-socket.{}", self.version, self.hex8) + } + + /// The Socket repository path (mirror target and index url path). + pub fn repo_path(&self) -> String { + format!("/patch-registry/maven/{}/{}/maven2", self.token, self.uuid) + } + + pub fn prod_index_url(&self) -> String { + format!("https://patch.socket.dev{}", self.repo_path()) + } + + /// `////-.` under the Socket repository. + pub fn served_path(&self, ext: &str) -> String { + let sfx = self.suffixed(); + format!( + "{}/{}/{}/{sfx}/{}-{sfx}.{ext}", + self.repo_path(), + self.group_path(), + self.artifact, + self.artifact + ) + } + + /// The upstream pom re-versioned to the suffixed version (the project's + /// own `` right after ``; the parent and the + /// dependencies — the transitive — are untouched). + pub fn served_pom(&self, upstream: &[u8]) -> Vec { + let text = String::from_utf8(upstream.to_vec()).expect("utf-8 pom"); + let after_parent = text + .find("") + .expect("the fixture pom has a parent") + + 9; + let needle = format!("{}", self.version); + let at = after_parent + text[after_parent..].find(&needle).expect("project version"); + let mut out = text.clone(); + out.replace_range( + at..at + needle.len(), + &format!("{}", self.suffixed()), + ); + out.into_bytes() + } +} + +pub fn sha1_hex(bytes: &[u8]) -> String { + use sha1::{Digest, Sha1}; + hex::encode(Sha1::digest(bytes)) +} + +pub fn sha256_hex(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + hex::encode(Sha256::digest(bytes)) +} + +/// A wiremock server with its own runtime (the CLI and the build tool run +/// as blocking child processes on the test thread). +pub struct Server { + pub server: MockServer, + pub rt: tokio::runtime::Runtime, +} + +impl Server { + pub fn start() -> Self { + let rt = tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .enable_all() + .build() + .unwrap(); + let server = rt.block_on(MockServer::start()); + Server { server, rt } + } + + pub fn uri(&self) -> String { + self.server.uri() + } + + pub fn get(&self, route: &str, status: u16, body: Vec) { + self.rt.block_on( + Mock::given(method("GET")) + .and(path(route.to_string())) + .respond_with(ResponseTemplate::new(status).set_body_bytes(body)) + .mount(&self.server), + ); + } + + /// Serve `jar` + `pom` (and `.sha1` sidecars: `jar_sha1` overrides the + /// jar's) as the Socket repository's suffixed GAV. + pub fn serve_repo(&self, h: &Hosted, jar: &[u8], pom: &[u8], jar_sha1: Option) { + self.get(&h.served_path("jar"), 200, jar.to_vec()); + self.get( + &format!("{}.sha1", h.served_path("jar")), + 200, + jar_sha1.unwrap_or_else(|| sha1_hex(jar)).into_bytes(), + ); + self.get(&h.served_path("pom"), 200, pom.to_vec()); + self.get( + &format!("{}.sha1", h.served_path("pom")), + 200, + sha1_hex(pom).into_bytes(), + ); + } + + /// Every request path the server has seen, in order. + pub fn paths(&self) -> Vec { + self.rt + .block_on(self.server.received_requests()) + .unwrap_or_default() + .iter() + .map(|r| r.url.path().to_string()) + .collect() + } +} + +/// The API `scan --mode hosted` drives: batch discovery, the by-package +/// listing, the reference grant (maven2 override, production-shaped urls) +/// and the patch view. +pub fn mount_api(s: &Server, h: &Hosted, jar: &[u8], pom: &[u8], view: &serde_json::Value) { + let purl = h.purl(); + let org = h.org; + let uuid = h.uuid; + let artifact_url = format!( + "https://patch.socket.dev/patch/maven/{}/{}/{}/{}/{uuid}/{}-{}.jar", + h.group, + h.artifact, + h.version, + h.token, + h.artifact, + h.suffixed() + ); + let mounts = [ + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{org}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [{ "purl": purl, "patches": [{ + "uuid": uuid, "purl": purl, "tier": "free", "cveIds": [h.cve], + "ghsaIds": [h.ghsa], "severity": "high", "title": h.title + }] }], + "canAccessPaidPatches": false, + }))), + Mock::given(method("GET")) + .and(path_regex(format!( + "^/v0/orgs/{org}/patches/by-package/.+$" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "patches": [{ + "uuid": uuid, "purl": purl, "publishedAt": "2026-01-01T00:00:00Z", + "description": "d", "license": "MIT", "tier": "free", + "vulnerabilities": view["vulnerabilities"].clone() + }], + "canAccessPaidPatches": false, + }))), + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{org}/patches/package"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "results": { uuid: { + "status": "granted", + "url": artifact_url, + "purl": purl, + "artifacts": [{ + "kind": "tarball", + "url": artifact_url, + "integrity": { "sha1": sha1_hex(jar), "sha256": sha256_hex(jar) } + }], + "registryOverride": { + "kind": "maven2", + "indexUrl": h.prod_index_url(), + "identifiers": { + "name": format!("{}/{}", h.group, h.artifact), + "version": h.version, + "mavenGroupId": h.group, + "mavenArtifactId": h.artifact, + "mavenSuffixedVersion": h.suffixed(), + "mavenPomSha256": sha256_hex(pom), + } + } + } } + }))), + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{org}/patches/view/{uuid}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(view.clone())), + ]; + for m in mounts { + s.rt.block_on(m.mount(&s.server)); + } +} diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs index 7ae650809..a08cb43ea 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect.rs @@ -2251,8 +2251,9 @@ packages: /// The rewriters' own warnings must reach HUMAN mode too, not just the /// `--json` envelope: they carry the load-bearing "why nothing happened / -/// what you must do" guidance (`redirect_npm_no_lockfile`, -/// `redirect_gradle_manual_snippet`, the missing-integrity family). +/// what you must do" guidance (`redirect_npm_no_lockfile`, the hosted +/// Gradle planner's refusal codes and its `redirect_gradle_manual_snippet` +/// fallback, the missing-integrity family). /// Regression guard: the human branch printed skipped/record/rush warnings /// but dropped `rewrite.warnings` entirely, so a default-mode /// `scan --mode hosted` in a lockfile-less project reported "Redirected 0 diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/.gitattributes b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/.gitattributes new file mode 100644 index 000000000..deaf383bf --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/.gitattributes @@ -0,0 +1,2 @@ +# Committed fixture bytes (signatures, keys, digests): never normalize line endings. +* -text diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/SHA256SUMS b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/SHA256SUMS new file mode 100644 index 000000000..6c5cb74f2 --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/SHA256SUMS @@ -0,0 +1,27 @@ +1d3cd6eb4911c1a24da3ff995abe9ffa5be379de21823e0e780d0822cc01ef0c com/socketfixture/buildlogic-plugin/1.0/buildlogic-plugin-1.0.jar +da8a9981f80fea0607658ec3b49edd1397b53880e010b060173a0f41bd72066b com/socketfixture/buildlogic-plugin/1.0/buildlogic-plugin-1.0.pom +da678ffc1ac59ca1454fdc07286bc4cefb45e27575d9ce8a3916b9cba7a05b96 com/socketfixture/buildlogic-plugin/maven-metadata.xml +3f18c444dd52acddf8adf54f65ed5e4cf94734636124a4ec7c6efefc4c2db41f com/socketfixture/consumer-range/2.0/consumer-range-2.0.jar +e88974c9f8e7b74190ae04bf220fa05fc5b70ba69942476ec81cb1c0a0a28721 com/socketfixture/consumer-range/2.0/consumer-range-2.0.pom +2cb80c25272aa716b077f08b9c940929d59727671a35c7793903c041c6b7fcb6 com/socketfixture/consumer-range/maven-metadata.xml +32d26eface0ffe31c107b67a168a4dc73f57f8b8536944bae122346c8e472375 com/socketfixture/consumer/2.0/consumer-2.0.jar +05d26bb3fc762cb725cc4e62885e34c9a455fbe27366d8f8ad889e7197f509f3 com/socketfixture/consumer/2.0/consumer-2.0.pom +28237bc2c0c14b0973afdfc6a392c96aea84ed2002cd46c81d0201c63596b029 com/socketfixture/consumer/maven-metadata.xml +c524afda369ca674b93df5b2867185ee50f050ebf716afc0a11edf0c6a60f789 com/socketfixture/fixture-bom/1.0/fixture-bom-1.0.pom +15d66d0526299aa847bb4993b0e3b7286c750af0123486b765a2c013075f4d9b com/socketfixture/fixture-bom/maven-metadata.xml +bfc0dc2e4f7ab548249e0a1f168cf51e75c99c4a64fbe4cff27abea88221fbad com/socketfixture/fixture-parent/1/fixture-parent-1.pom +8f89f457e68f626e7757f913e411121e469fe8420e534dca06eb0549779a8657 com/socketfixture/fixture-parent/maven-metadata.xml +340279be700918ea28e184769c11821e67ef579860f4c9798b12d859ec4bc526 com/socketfixture/fixture-platform/1.0/fixture-platform-1.0.module +c78cda57d24c31b98e79c226150da95d69effe3828a6b76684adbb804da16ffe com/socketfixture/fixture-platform/1.0/fixture-platform-1.0.pom +059d99814bb4468a444e565ad948c1e900f6794fd9ce2936c2b8c99d6126bcd3 com/socketfixture/fixture-platform/maven-metadata.xml +615a906296640fea47a4c66cf9d7a40341f409d7ac3dfdf9342ad311d5369fd0 com/socketfixture/victim/1.10.0/victim-1.10.0-sources.jar +802bde18abd579c2e30bc9f7c19e9d4b078ed71d5d28f9981dab91cb1f0f6631 com/socketfixture/victim/1.10.0/victim-1.10.0-tests.jar +fa0c4b3aa359b5a05019167a5332f44b6d19f5c018a1913dcaa73ffd957bb82f com/socketfixture/victim/1.10.0/victim-1.10.0.jar +5be8c832ab26543967dec0aaca94d26d96b32c594a269242adf3740d2f3df31c com/socketfixture/victim/1.10.0/victim-1.10.0.module +0b797534b1bbc437ff8d074738876f3dd7a8a25f93dc61187ad9a36b0ae5096b com/socketfixture/victim/1.10.0/victim-1.10.0.pom +afac36e0a4ebc44116d2c76c3bf46280272ce575c9a0029ff980506eb35a8d93 com/socketfixture/victim/1.9/victim-1.9-sources.jar +be395d671dec8af8d03d9e2f72774425c9e0c48b79ffcbb67a19a83d65e5e1c2 com/socketfixture/victim/1.9/victim-1.9-tests.jar +51260df91559493fed4750f57dc6823a654e8f43cc234c49865f8b0b32b03424 com/socketfixture/victim/1.9/victim-1.9.jar +4144231dcc05292b55c72aded46f1d23858cca0b8278998de9b7d3b0dca15674 com/socketfixture/victim/1.9/victim-1.9.module +ada82bc38fb0d14097cc303bcb7f9dd05d39749ba0104092f7fa4c0c8502c011 com/socketfixture/victim/1.9/victim-1.9.pom +418935f7b77c8bf002f76eef48e4b315cacc40e9e11d97ae83344b9576b74ef5 com/socketfixture/victim/maven-metadata.xml diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/keys/signing-key.public.asc b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/keys/signing-key.public.asc new file mode 100644 index 000000000..da2dfd785 --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/keys/signing-key.public.asc @@ -0,0 +1,20 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQENBGlVuQABCAC3xRji5S7VSgkNFqgrpa5m3+nH3bxt8J/EX8lJp5kuAhfuixC4 +MBstPUEG1xctFbN4Vpc0YHDchXJkcFQqJC04LGKoBcIhUsS5Q4HPXMbi8dfKj6tt +MnGRAqifVV7Bf4Jnt5Jbckk5OogxmTvy66E/yfLEBJ2+SpxTGgCK33POxI6unmPe +wi2I0YTHctvYtnvqZn6sbr3iKdnLwYMwasE9L7SbZlmPFM+9SKrH/UScJxhKHlXB +PvXbIyhYASkB61He04dJ2+PAopLXQ5b6zaLW1sR6QnHTHzdWnXf4vTgQZ5kNe52g +BYTiA5XMfg5PNzEMVnScAqI1EDyJBhP7SWbNABEBAAG0VHNvY2tldC1wYXRjaCB0 +ZXN0IGZpeHR1cmUgKFRIUk9XQVdBWSwgZG8gbm90IHRydXN0KSA8Zml4dHVyZS1z +aWduaW5nQHNvY2tldC5pbnZhbGlkPokBbQQTAQgAVxYhBN0M3dK0g47JVye5S0x3 +qckR1GoZBQJpVbkAGxSAAAAAAAQADm1hbnUyLDIuNSsxLjEyLDAsMwIbAwULCQgH +AgIiAgYVCgkICwIEFgIDAQIeBwIXgAAKCRBMd6nJEdRqGUbzCACY3D/qd5oXnujp +GEtXtV6iPsAbjN2f1ceJFwVrp6u0OlFPDbwU655E9amZwWnEaFVPf+9ujOTLehmd +Rj5J2Ld9L7CYxvNlXTJ6tUUhA/taLScrGbl8jmdP91fKpypOYImq7SccV7Is0VVw +9X7aNGkc6gCZqCbP7M+lOqxTlKwQ/QPi+dvSyMaPPkXZMg4YRL5EJw+iOkCiElHf +DO5oF8qWKzbUrsifnS3aWqTKSryGrgxgwqss46wPnb7uk2oPNgtaBiONCUDJbRhk +rfS6OBK7UgIxq7fTg6lZXqhtiZCI4IFbayR1myxZTKp02GL+ru4AgfeluakdWezM +Dq427Qdj +=9kjM +-----END PGP PUBLIC KEY BLOCK----- diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/keys/signing-key.secret.asc b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/keys/signing-key.secret.asc new file mode 100644 index 000000000..ffbf748a6 --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/keys/signing-key.secret.asc @@ -0,0 +1,33 @@ +-----BEGIN PGP PRIVATE KEY BLOCK----- + +lQOYBGlVuQABCAC3xRji5S7VSgkNFqgrpa5m3+nH3bxt8J/EX8lJp5kuAhfuixC4 +MBstPUEG1xctFbN4Vpc0YHDchXJkcFQqJC04LGKoBcIhUsS5Q4HPXMbi8dfKj6tt +MnGRAqifVV7Bf4Jnt5Jbckk5OogxmTvy66E/yfLEBJ2+SpxTGgCK33POxI6unmPe +wi2I0YTHctvYtnvqZn6sbr3iKdnLwYMwasE9L7SbZlmPFM+9SKrH/UScJxhKHlXB +PvXbIyhYASkB61He04dJ2+PAopLXQ5b6zaLW1sR6QnHTHzdWnXf4vTgQZ5kNe52g +BYTiA5XMfg5PNzEMVnScAqI1EDyJBhP7SWbNABEBAAEAB/oC96eKeGVsc8TYJYLD +K6p4hkqV296ATveheeN78T0fuVBuHWhAdSRnM1tCs0PwSi6q9Yj9A0anRO9fMFIn +mQxy4QKZ0Hf9RkMfc7fbo5WhkgKoRnAf9AXR799NrVj5mme+aYAvQlXs2uVama0W +Y9gneckWAbYTXcyO6fdSxr9tugdCTthUUDJzqg0JTDLjB7NkH/tY6R26cI2SDQmk +CVUIvHf9DN4kDBAfM2CPIJLuNjBhg5gjGCV/cZvjY1dge+V+7RoYGj3iuSRZ1+1x +wbDg0hclw5ZqIBgHZkNNVlrCkKCTrjaM0mW6EgHqtrR8GsMeJFK8EHP+lOycqCiV +n7j5BADD9Hpuv5vWzK8dzvIbjnl7BBi74qhgEeZKak6xg1eyKMS0TWYzIBt5/g/Q +r7DXfhdWb/Az2AKM6DQY9Zygfzqcj2uGGfpDlTx2320sH8ZY7D5O/VWVEb1GZtNh +EitsZrZw0rh6pTjC/cZgnrcPCg+KVxxjkFDt38LGKV7/3aDiFQQA8BTFRwetn2bM +5kBafCMmGgoyjYimVyXFZvTtHjjnf40CQsO5WHf/ChYwQwWUMt6IFZ/i3L1xPqdP +vkj/SnFT4XDjEiG1euyObfqNWg8RDa2kYXHcjP60VZKGAPvR0T8JmdorpVwuS5sg +PgP1RlECV+3og0D9WOIc+SqMeIAKd9kD/2IvpmQFVLBcy2E8ZPcJ7r8QORlOCmEe +F1zazjMr0nTvv5Ax1qqoB8pIe+vwqAWeKdJ6Qupivd6wxxZiTgDbw9InIY4G4lRK +28AtqiQGZM+l+ECJKKUvqIxzAiGkFcRPmlsdFK9tvlK+aDMxUMFMd21xxh2yHPvH +Rs00aeeVUhxONEK0VHNvY2tldC1wYXRjaCB0ZXN0IGZpeHR1cmUgKFRIUk9XQVdB +WSwgZG8gbm90IHRydXN0KSA8Zml4dHVyZS1zaWduaW5nQHNvY2tldC5pbnZhbGlk +PokBbQQTAQgAVxYhBN0M3dK0g47JVye5S0x3qckR1GoZBQJpVbkAGxSAAAAAAAQA +Dm1hbnUyLDIuNSsxLjEyLDAsMwIbAwULCQgHAgIiAgYVCgkICwIEFgIDAQIeBwIX +gAAKCRBMd6nJEdRqGUbzCACY3D/qd5oXnujpGEtXtV6iPsAbjN2f1ceJFwVrp6u0 +OlFPDbwU655E9amZwWnEaFVPf+9ujOTLehmdRj5J2Ld9L7CYxvNlXTJ6tUUhA/ta +LScrGbl8jmdP91fKpypOYImq7SccV7Is0VVw9X7aNGkc6gCZqCbP7M+lOqxTlKwQ +/QPi+dvSyMaPPkXZMg4YRL5EJw+iOkCiElHfDO5oF8qWKzbUrsifnS3aWqTKSryG +rgxgwqss46wPnb7uk2oPNgtaBiONCUDJbRhkrfS6OBK7UgIxq7fTg6lZXqhtiZCI +4IFbayR1myxZTKp02GL+ru4AgfeluakdWezMDq427Qdj +=goEO +-----END PGP PRIVATE KEY BLOCK----- diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/keys/verification-keyring.gpg b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/keys/verification-keyring.gpg new file mode 100644 index 000000000..24fedd2ab Binary files /dev/null and b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/keys/verification-keyring.gpg differ diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/buildlogic-plugin/1.0/buildlogic-plugin-1.0.jar.asc b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/buildlogic-plugin/1.0/buildlogic-plugin-1.0.jar.asc new file mode 100644 index 000000000..c9bfd13e0 --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/buildlogic-plugin/1.0/buildlogic-plugin-1.0.jar.asc @@ -0,0 +1,12 @@ +-----BEGIN PGP SIGNATURE----- + +iQFPBAABCAA5FiEE3Qzd0rSDjslXJ7lLTHepyRHUahkFAmlVuQAbFIAAAAAABAAO +bWFudTIsMi41KzEuMTIsMCwzAAoJEEx3qckR1GoZzl4H/1CVQGIAUvRqW8ZDNxjE +6a7OuDbllElDRtDKCwk9O6BCYydEjcbCUpMPz0AuwTCWNPM1ESr5wD1XFkY9idH7 +AsE1+gav/6rqP4S1ZRyxqI9wxYJm7fX7mA0RTPyKbJDSGnqL97XBVXypOcSVj9F+ +Nw4PLk4XJ8BQHUHX/CjQRqKCG4nlB8J3qmuh7dZW8DtiKVRVZXVV+7IR/EIp6Qgp +j0aMQYhkHhWM3qEpC5JqfBxhe73+AnNEkmykV8OJgNiB8nkqIdBRf2EonZw/Z2kd +CaTLeGE8t9w//hR4nxiBxzeoEhc/HnZbqA2tHGQ/PaLcmO3WZzFiNOA4d6kTtBBX +A/U= +=fmNF +-----END PGP SIGNATURE----- diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/buildlogic-plugin/1.0/buildlogic-plugin-1.0.pom.asc b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/buildlogic-plugin/1.0/buildlogic-plugin-1.0.pom.asc new file mode 100644 index 000000000..5e3b34094 --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/buildlogic-plugin/1.0/buildlogic-plugin-1.0.pom.asc @@ -0,0 +1,12 @@ +-----BEGIN PGP SIGNATURE----- + +iQFOBAABCAA5FiEE3Qzd0rSDjslXJ7lLTHepyRHUahkFAmlVuQAbFIAAAAAABAAO +bWFudTIsMi41KzEuMTIsMCwzAAoJEEx3qckR1GoZ8+sH8gKi8WWdC9sRJ9TiNN5B +kg3QcfmKawh+S+qAYViMzOCdwY8q8vsAGWmam0lg2Gc7IRehCFYWsAFWyG7IbqAs +vEH47qh56hiZ/le73SznyJ8E23v+Cj41w3OYcC2AsG2SJkl0ZE+l62uvlVGus3SM +qhIx1cmL4lmNua4TTJAjLOAtxT+Xb1Rt3RDsBMxcx08M4DP3xPHiTNgZHljx+Ckz +aWZgsX1P3Y/rif/pwc+U5xIsVCSiCQQSVRoXP5LciAaIy8pJKiNmInAN1osfcCbJ +/gbgn/uzt/2mM8FKXjHdq7dtWPGqpOeakwmejnOzgDFQH+uzD9QYcC2GcIDMFEnE +KA== +=ynCw +-----END PGP SIGNATURE----- diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/consumer-range/2.0/consumer-range-2.0.jar.asc b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/consumer-range/2.0/consumer-range-2.0.jar.asc new file mode 100644 index 000000000..97918e66f --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/consumer-range/2.0/consumer-range-2.0.jar.asc @@ -0,0 +1,12 @@ +-----BEGIN PGP SIGNATURE----- + +iQFPBAABCAA5FiEE3Qzd0rSDjslXJ7lLTHepyRHUahkFAmlVuQAbFIAAAAAABAAO +bWFudTIsMi41KzEuMTIsMCwzAAoJEEx3qckR1GoZ8ugH+wXj/1+b3K49imiYYpTx +/sT45U42NVB6nv92rnOSmMSVM/XSVHHUT6mSfNhNbOiHx9ZOMG40AG0lpneSo5+D +jqrXQHTz4wX422ATG13ZG0+ODdjn4LnWnRmNUE+KOt39Epmegj9DdcCCDOasM2Az +5ITNnoIc3Lu4PtZwG6zcvCDVHeEOz68LzVTQXklWVMQULGS3j5LdBxQhE0OF5Po/ ++jvwzEc3o9DxNO7HMmpKKFAqRY2j0ilWRlDiRDxk0bC0gUKT+reNA2HWhdNjLit7 +ls1xz/FSZTs42Qv+6LJnYcN9TrUSoCM7pNqv/5QcQ9194+JJ+0y9Ys3A54oCS0Bj +RWU= +=KTKX +-----END PGP SIGNATURE----- diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/consumer-range/2.0/consumer-range-2.0.pom.asc b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/consumer-range/2.0/consumer-range-2.0.pom.asc new file mode 100644 index 000000000..73be089d5 --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/consumer-range/2.0/consumer-range-2.0.pom.asc @@ -0,0 +1,12 @@ +-----BEGIN PGP SIGNATURE----- + +iQFPBAABCAA5FiEE3Qzd0rSDjslXJ7lLTHepyRHUahkFAmlVuQAbFIAAAAAABAAO +bWFudTIsMi41KzEuMTIsMCwzAAoJEEx3qckR1GoZN5wIAJcUb5shJFdRPw29QAOG +nm6QfCE8nuBew6x8LDJdmH8Rtfjob86bD7Sf+QwnhzwlUEr+psBs9emI6EgvXet8 +iBsaQlT8D/4V6EEuuG8X+IyqJbwxhcoYfd7BlTFucswCn+0vX2NLr8WFJ93iouJe +RCRVm0ckPwHpU34VlqymU0P9EDVqS8s+O2AFZMkwkQfG/0Ayu7FDUIMZHtY/+Pgb +VJL1e2xxgw6wKCHMwWxKj/W5eZnOOec11k0P5rFwn3w4lPhGJQog/OMgVir/bgdY +1jF3ymwf64YWHn5Ck+1Rr5fVcf0focXqerhS/l8IJoc39VATq5f3j6T9PkJxsSF0 +gww= +=Qp6k +-----END PGP SIGNATURE----- diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/consumer/2.0/consumer-2.0.jar.asc b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/consumer/2.0/consumer-2.0.jar.asc new file mode 100644 index 000000000..9fcecf3b6 --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/consumer/2.0/consumer-2.0.jar.asc @@ -0,0 +1,12 @@ +-----BEGIN PGP SIGNATURE----- + +iQFPBAABCAA5FiEE3Qzd0rSDjslXJ7lLTHepyRHUahkFAmlVuQAbFIAAAAAABAAO +bWFudTIsMi41KzEuMTIsMCwzAAoJEEx3qckR1GoZea8H/jZz9/TUaZOOW4pLz4dn +wXO+j0q+M2EruBrnrMkVz3Muvjyma/BeFrtfb8dl8Qb7nCM3VPowjjT1Q8b+w2bg +jBnmIQpxSAlfBx1hD8JgWU6LAN3bbb1tLNoiHljj6aLHtGPaU04waWvyc3WhuM70 +1TmWy1Nj/3Cimm4Xas5aozbwY9frugd16cAPbUerY4SyOwsSoqJqXNji3mNRlsDC +VGYKfpXTGbM9z7LRBaNYmQL3WdF5hzpkmM553vDV9KFKotjR7HJrmSulwmWTZB7E +m5TsWFE7w25qAUWOq7l1wDBP55EMO6MEVzg1sZ2Snr0SIZTOId6sB37CJDkPr67O +dew= +=SkFu +-----END PGP SIGNATURE----- diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/consumer/2.0/consumer-2.0.pom.asc b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/consumer/2.0/consumer-2.0.pom.asc new file mode 100644 index 000000000..af4e3f6ae --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/consumer/2.0/consumer-2.0.pom.asc @@ -0,0 +1,12 @@ +-----BEGIN PGP SIGNATURE----- + +iQFPBAABCAA5FiEE3Qzd0rSDjslXJ7lLTHepyRHUahkFAmlVuQAbFIAAAAAABAAO +bWFudTIsMi41KzEuMTIsMCwzAAoJEEx3qckR1GoZXjgIAKhMDiUDw2OHxjgsAJec +dqudIWUmKOPkFjX1LhB+qHV5SzwltOpfaQ2/JnHuS0PMIMjAPrnqw0Y+YFADop1V +L5En+23FVlrHItj0cHVSm4QbiNuL5Ym150zxT0UhjsX6RMjOgW5OZwJjsIowV0V2 +BDcsY09gpPTlbcnN79NC57gItVxFGbYEb1iLsS17I3XLA6pNDiM0lPkFcjRg/czu +t3e30JQ1l3Tei9gwWwHJcGvcRVgOoXiSU3XZjB6y3dNx7tyNxmzMAh8e8VG8qL32 +fhV2rGMPgJP0XmiT/b8Zloj3akcJXhTvJcZo0r0BQMo3TuZPUKB+PkwG/zu15bT1 +esQ= +=0iv/ +-----END PGP SIGNATURE----- diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/fixture-bom/1.0/fixture-bom-1.0.pom.asc b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/fixture-bom/1.0/fixture-bom-1.0.pom.asc new file mode 100644 index 000000000..d4fa3b989 --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/fixture-bom/1.0/fixture-bom-1.0.pom.asc @@ -0,0 +1,12 @@ +-----BEGIN PGP SIGNATURE----- + +iQFPBAABCAA5FiEE3Qzd0rSDjslXJ7lLTHepyRHUahkFAmlVuQAbFIAAAAAABAAO +bWFudTIsMi41KzEuMTIsMCwzAAoJEEx3qckR1GoZ01oH/3TxZ/uemDCy7NKQogXK +2h/ZYw+r7gtCPTo3wwc1H3CMJxh/YN3asr2nwTRTRpuYpnnVwq2sE1eZEhC5zBSe +fTcL8vEsF/hLU9b6AqVtaGtnIQWFXcm/grUlJngD3TS5/lS5w7zeUt930h4+UINu +krAdTKpLp37qpAnp5N04xN0ZOF4mmSSSP9tHMJyZJlPcwScRgUJunY9D6ic8CZdC +rNQnesPdZHjzszGlyANkkDUAiD9/ofC17aMVV+dInvE3Eyc7gthS+//N2ifk03v+ +MWkWqGB/uUiKZTgpMRgmbuRXVSx6/YoOVfRG0hQ7p7UmM/6BU3imEOMhZuKvU4Jw +lyw= +=E7CJ +-----END PGP SIGNATURE----- diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/fixture-parent/1/fixture-parent-1.pom.asc b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/fixture-parent/1/fixture-parent-1.pom.asc new file mode 100644 index 000000000..3ba378763 --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/fixture-parent/1/fixture-parent-1.pom.asc @@ -0,0 +1,12 @@ +-----BEGIN PGP SIGNATURE----- + +iQFPBAABCAA5FiEE3Qzd0rSDjslXJ7lLTHepyRHUahkFAmlVuQAbFIAAAAAABAAO +bWFudTIsMi41KzEuMTIsMCwzAAoJEEx3qckR1GoZmOYIAJt6NNjc5pTgQ3OJwda0 +09bulpoTl75qKPZwheML2RDwHxr4ergnNb6QOeivf7swRaJ3wOvr1nnF1GGMj/L/ +qNMkwMLCAB4Sx9NhEPkG8Ll3Wvl08/kugPgEiEcUoWybyuMGv5hUgxnrNkgdYQJW +BstCZe0pM0feNAbCflyNFwtGj7tdYkGQXQS/3yshFHZte78O7NHpFm0AaUgXWzia +8IjIufaIdkv7OoEsQLJz+rBjrzkZktZ8xuan6LbUKVY8QM1akJaPGN80iPvDNib1 +nqow6f1AEcaizRs2bCyUfKRW3DiYz0p/7hWJL/B8XdTRLxHjoaIWhKlEc9XQxuN7 +vhc= +=sbzH +-----END PGP SIGNATURE----- diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/fixture-platform/1.0/fixture-platform-1.0.module.asc b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/fixture-platform/1.0/fixture-platform-1.0.module.asc new file mode 100644 index 000000000..cacc5ee91 --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/fixture-platform/1.0/fixture-platform-1.0.module.asc @@ -0,0 +1,12 @@ +-----BEGIN PGP SIGNATURE----- + +iQFPBAABCAA5FiEE3Qzd0rSDjslXJ7lLTHepyRHUahkFAmlVuQAbFIAAAAAABAAO +bWFudTIsMi41KzEuMTIsMCwzAAoJEEx3qckR1GoZVvwH/1o9jPMUtKS3nY6VwDgZ +I7fh7q0Qy547tE9HIS0tAugF7eAK/TSRfixeaC0fvBnrSKbHIV3F1fTm4Aj3HT5v +4k22jhd3ZZPU1qSoTQFPoQCmzFNurOV7CNobt/AAQ9uzbxi0yTOnF33b1aWKbr/r +Ylmzu0UggZq2I2eQynkLJ+O04Dk1Gk5DIwUSS4WHNd0x9AQyXMmyrHr71zd86ILE +peuju2Huqi9c+BjfnWHqT81X+PXb4OA2qfaOfO+kCY9Qo8hqwzLnJx4jk68hDFIe +NWkW/XAQZ7o4L0YdGKFUXhKssmBjtuq4VHjh9eXpgA2rcZhGs8Kaw8lrXic4X6bk +krI= +=JnFR +-----END PGP SIGNATURE----- diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/fixture-platform/1.0/fixture-platform-1.0.pom.asc b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/fixture-platform/1.0/fixture-platform-1.0.pom.asc new file mode 100644 index 000000000..ac76dafa8 --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/fixture-platform/1.0/fixture-platform-1.0.pom.asc @@ -0,0 +1,12 @@ +-----BEGIN PGP SIGNATURE----- + +iQFPBAABCAA5FiEE3Qzd0rSDjslXJ7lLTHepyRHUahkFAmlVuQAbFIAAAAAABAAO +bWFudTIsMi41KzEuMTIsMCwzAAoJEEx3qckR1GoZRG8H/iyXUh1NH/jX07Qi7FWd +PJR7UObXxu4a8YWqx3iV5+yscDD1WeiUGXZAd74d3Xs6PdvtS0fRaInzsKTq8YK+ +tVIxALrni/m5mVCIAI1P0/ivA+EYNczAd9HwFYfcZ0IV1/wPQYOpcTi0PkvbOFxQ +b98UB1xTRA7LsheHSyUqLbjRXyCeKn+jiq2PxOnmNBgttbxCPw/KWdc+Ko1rkaCf +riMne+KIsmFSYc5H1rMCVNhxs63iPNx/o3b0elSBGmAj+PNLrogL+S4veBeiNuH5 +bbs6JJuw0R+CsDWqyyO8CrOS4wgpXT+wikHATV0zTeNDtI5pKEqAi6rjs4U8tNEy +8r8= +=RgMk +-----END PGP SIGNATURE----- diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.10.0/victim-1.10.0-sources.jar.asc b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.10.0/victim-1.10.0-sources.jar.asc new file mode 100644 index 000000000..ff7ba036b --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.10.0/victim-1.10.0-sources.jar.asc @@ -0,0 +1,12 @@ +-----BEGIN PGP SIGNATURE----- + +iQFPBAABCAA5FiEE3Qzd0rSDjslXJ7lLTHepyRHUahkFAmlVuQAbFIAAAAAABAAO +bWFudTIsMi41KzEuMTIsMCwzAAoJEEx3qckR1GoZw/IH/2gWbB9TmsmG8bu9EqX2 +eSNaeCcg+pnaACu3F7W575Z0p0GTTFpf73kF7xZcm0A01YLJsEYJqK5ZH9xF4c2c +ZQqJZsPJ1bjAOU74hYSG7VElWSAkeisf//lUj7hDFbEA36I0qLb+jnlQXfZOSLt1 +tX7nc6X92pIEZewOqxeUYxScb8j2k+vYMvNf/SPx65NQL6zXj16NLkyWscu5w6/S +m9x4uDm9UZB/16Lv790XVAGtO3uxejjF0RTKihuCDg4v0NGVupIHnaYmXc3ynQZq +oeL+0pbskd5VpO9SftKI0h9avIsDexvYUGwyp/syvUf3ekijzZRv3BaT7uFIbiqP +i88= +=2ltE +-----END PGP SIGNATURE----- diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.10.0/victim-1.10.0-tests.jar.asc b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.10.0/victim-1.10.0-tests.jar.asc new file mode 100644 index 000000000..d24a8754c --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.10.0/victim-1.10.0-tests.jar.asc @@ -0,0 +1,12 @@ +-----BEGIN PGP SIGNATURE----- + +iQFPBAABCAA5FiEE3Qzd0rSDjslXJ7lLTHepyRHUahkFAmlVuQAbFIAAAAAABAAO +bWFudTIsMi41KzEuMTIsMCwzAAoJEEx3qckR1GoZS10H/3muC/bR/uwowHyDx6J8 +ItQpCFRXR3/vNF/0Ddz2bQQMe0v3U6NroHeH7rkfH52UWMPH21z/p1qUCJMvnGRY +pb34p9KEs1ErVRXIm5vtQ2IkR6fF+B0QtH2StlOEIztOYRGEClBvQ/l/IMiBlLz6 +ObSbo7nPk+fVdfPS52nYWjf+NLyMNYjWz/qRn4rEyERAtC+MlRfjvUjvPAAbub7+ +Hn6XrFM5S+290YctjA0eKNp4v8JY7zqMskmTgJRJ4Pu5jaSirZskHa92DSJed86h +RmSavUoCpri6jwE/kdNILub+zWT6ksZGzTnwswOXCQhqkGeBwa6+4LpYPIIIiTBl +lHQ= +=FVUs +-----END PGP SIGNATURE----- diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.10.0/victim-1.10.0.jar.asc b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.10.0/victim-1.10.0.jar.asc new file mode 100644 index 000000000..43c75c95b --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.10.0/victim-1.10.0.jar.asc @@ -0,0 +1,12 @@ +-----BEGIN PGP SIGNATURE----- + +iQFPBAABCAA5FiEE3Qzd0rSDjslXJ7lLTHepyRHUahkFAmlVuQAbFIAAAAAABAAO +bWFudTIsMi41KzEuMTIsMCwzAAoJEEx3qckR1GoZBGYIAIf+VKl3xGM3lnnWBMA/ +zQkJGkoP7OvB//NI2XbYZv0nSRmRmJaqP312IXgBBlloeyyV97+VAtsA/PD6QIqu +Rd82uPWRMdC4L0kKdJ1Lrn9Da/2NmCNX2i1EeEvm37xlowRY88hXndb3cPZVK3Jq +xn4a2q7S22jbNIKQ8RyRrJoiDDq7K3aky+Ss46MMH+LZIEWHX9OXjd3Tvc1InVnZ +84fIfNmFTMqniPg0GaaJBARhimP2SjXFCciSsGV5XimuuGslFqA/2a63HNo5RC8Y +miVxB+Z8ndIEU6+RrXMy2KEstk0eckmAvc+PnbQPrLw5p8xGMtUx1BdOUfnM3qSb +k1Y= +=CUem +-----END PGP SIGNATURE----- diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.10.0/victim-1.10.0.module.asc b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.10.0/victim-1.10.0.module.asc new file mode 100644 index 000000000..0857f407c --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.10.0/victim-1.10.0.module.asc @@ -0,0 +1,12 @@ +-----BEGIN PGP SIGNATURE----- + +iQFPBAABCAA5FiEE3Qzd0rSDjslXJ7lLTHepyRHUahkFAmlVuQAbFIAAAAAABAAO +bWFudTIsMi41KzEuMTIsMCwzAAoJEEx3qckR1GoZq1AH/RN/TnwQUwhsFmReSsY1 +qcvn1RbFtTRbkiyXqbEORORDYpXYJFLzad0wVfPOjUBaNbeyrkMtRcIvVgb4A53x +GJUIt/8z+iF3FMpAkaq5X6OUHmmjMH0ApGiXYiJX9Gz2vEGkwambi3BIjjitSZeV +7AZwd+iFtpP2ZWA4os9wBmVdwnMgZGRuObqLk9glMvVPFbIE6zaMoUxjR4QyMn2Y +P0Ibp/ZLJKUsdSRUniFQKeeKcgF1GjaQFvLg8OTcbNcpXxWWOxcC2PTdLdUUSMZW +ofrTv42dHgvhHkLl/XMMn66W99VZlm2IhnkadU7UcQk2WAzcBBhVwU4UkLaaVutf +cEc= +=/hSi +-----END PGP SIGNATURE----- diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.10.0/victim-1.10.0.pom.asc b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.10.0/victim-1.10.0.pom.asc new file mode 100644 index 000000000..2e59940b0 --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.10.0/victim-1.10.0.pom.asc @@ -0,0 +1,12 @@ +-----BEGIN PGP SIGNATURE----- + +iQFPBAABCAA5FiEE3Qzd0rSDjslXJ7lLTHepyRHUahkFAmlVuQAbFIAAAAAABAAO +bWFudTIsMi41KzEuMTIsMCwzAAoJEEx3qckR1GoZgTgH/0aoIgcTI/xw4+/ZZEFm +uMyKPrMhrBmJSofoRCahvfYktFr+tBTph4UGTsAW++3sHhm5KVWi8UaqXNLjSTF9 +hTIdZ9syVH/sgbaWSyGBmB7fGrQPp/9r0Hxtr0zw68feZPRMqwXlhSOGDbJU/r6s +fqpCrEU63DPu421Bu7gLYGFMhcK6amZ2pwd6PEPzJbyK8LE3Nf6rx10YpNQNpWtt +xzB8hoB1WGX/Zyc5nMNs8JyJcNikC/fQK8Rw+kChuhaQ01vgGjF/MHG9/m1KtpgV +lduBur4OQif19td0yu1j5/b2AoKsKrIGqpbtFI+tFVraQQLopvrVxvJaF03cRuPY +NU4= +=vNsP +-----END PGP SIGNATURE----- diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.9/victim-1.9-sources.jar.asc b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.9/victim-1.9-sources.jar.asc new file mode 100644 index 000000000..f3182f4fe --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.9/victim-1.9-sources.jar.asc @@ -0,0 +1,12 @@ +-----BEGIN PGP SIGNATURE----- + +iQFPBAABCAA5FiEE3Qzd0rSDjslXJ7lLTHepyRHUahkFAmlVuQAbFIAAAAAABAAO +bWFudTIsMi41KzEuMTIsMCwzAAoJEEx3qckR1GoZGb0H/RmpWJyN3ZixgggRdKzQ +4T1lZ3pe/FXzs0Te2/1lXQ0aCeaAbQO31fHYOIrsK9UtMtnrlTIBFWYOSQrFnVRe ++qxlVKSVZOUQVJ56biC/jSNFiFeHOz8kCCVll5kdyfi4FbxOr5uZj2GKB/aEyPqi +FRcFVCeVNfOeknZ/c7/3WuxVZfvBfdi7/oph56XDVJ9NlzoDwjUpMuDMpUvjjpFU +mpSMEcOHdBhdu4SkaoVsTtih4QGQI+//HfIaF1U15khM0B/ah5o47SXstJ/L2AzD +uDOAPrefJygoBtwl4yeYrjWswvRrGr00FK31JDZPh0GcbDRfrrfivhzLsdppBUcc +vIg= +=+oXQ +-----END PGP SIGNATURE----- diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.9/victim-1.9-tests.jar.asc b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.9/victim-1.9-tests.jar.asc new file mode 100644 index 000000000..d103c4793 --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.9/victim-1.9-tests.jar.asc @@ -0,0 +1,12 @@ +-----BEGIN PGP SIGNATURE----- + +iQFPBAABCAA5FiEE3Qzd0rSDjslXJ7lLTHepyRHUahkFAmlVuQAbFIAAAAAABAAO +bWFudTIsMi41KzEuMTIsMCwzAAoJEEx3qckR1GoZJVsH/34vBHZj1k0mA6Cqpql/ +ivVUedLfM8A2tAMl+Vizxc44DLBfIDfPFPnzMu5M593jPaVPWWI75qqbN6K35svR +d00wohXG2m14hYtR9WsGFSlzwBeR6BAXrIcl8cFn09WqVDQ6JC4o9GyomMfquVJ3 +H5n7EH6kBGdZiMkC9DWtRN8daS0mvNY1VYTIUzx5lx6NTSJVF6CMn6Ly+kpNOToS +26/qF6dNHf2ZLB5HZRm8yypmymVlQTz5OLSiPZfiZ0jyM/pkRq3z4T4nGplvzNXQ +5PAjYdeZyzNVXN1Es3AHjNESLMlvsXhiC1yILPP9KLSwF+5u8S9BqY9/9/QWNNcB +6L8= +=GneL +-----END PGP SIGNATURE----- diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.9/victim-1.9.jar.asc b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.9/victim-1.9.jar.asc new file mode 100644 index 000000000..c8b721728 --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.9/victim-1.9.jar.asc @@ -0,0 +1,12 @@ +-----BEGIN PGP SIGNATURE----- + +iQFPBAABCAA5FiEE3Qzd0rSDjslXJ7lLTHepyRHUahkFAmlVuQAbFIAAAAAABAAO +bWFudTIsMi41KzEuMTIsMCwzAAoJEEx3qckR1GoZuoQH/iNi4ZeXqbra+As8fxnl +CCLkf9LG+yUUEzkvQn+G+lv8vPPpkSFcEHCKtcgcWWWDlggVX6k2w1RL1eEaGRMt +iNltEpqMK1oT20D/FEdDA2FVGI9w9NFn7XFyQGtEaphIOKZKweuol1T+Q+w1HDF7 +7G9MVo3HFSKrrv8LEbCNIEr5uORUo8gv3QK7B1dvWmNKMByo89crcsesPW4C9og0 +5uJElWw1OdvkWFuGuTTYf0a+lEmgNoUgBIBHuj/xPRXCL6cm4KOSvutJYXsNmzr5 +M5wiQ8SThNC+JPg/CPmmxytVpTxevy0J9A6VQ2CYw84gbzAgpfNQDKTy8pilLsfP +hgQ= +=ZnYI +-----END PGP SIGNATURE----- diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.9/victim-1.9.module.asc b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.9/victim-1.9.module.asc new file mode 100644 index 000000000..aa7fa932a --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.9/victim-1.9.module.asc @@ -0,0 +1,12 @@ +-----BEGIN PGP SIGNATURE----- + +iQFPBAABCAA5FiEE3Qzd0rSDjslXJ7lLTHepyRHUahkFAmlVuQAbFIAAAAAABAAO +bWFudTIsMi41KzEuMTIsMCwzAAoJEEx3qckR1GoZpwYH/jxL+BD1z0nDh3OWa8Eq +S8SBYNw2CJSmd9fpeHDOTpuhI95atnG4fLtO/iovMmpDSto5P22drEIX72Qs2/Zt +E8HZ4XuPBLF9G85pUkFGHHlhkrFu66GRLQ8MuXuLkoJCsy216GcSRk/JfRCX5shy +FRhHjtGA5KtNC0R9/fsTyVfIV9hwwNJXM57Q6VUofJe2YnsoF7jSDpJ5wnHhIBAa +0WayVYsy9DZOSHZNvdqln8KcoIG9Gnr7yCRdv8UzSOpCAMF7s2ivTSmEbmlqHXVq +38N+ztxbeB84kV/G0Mto5/Y3Oh4sfv7vkPzIZUwBS/rfNFNNcHPEOAIqMIXGwRG8 +fR4= +=wMcW +-----END PGP SIGNATURE----- diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.9/victim-1.9.pom.asc b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.9/victim-1.9.pom.asc new file mode 100644 index 000000000..0b783dd02 --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.9/victim-1.9.pom.asc @@ -0,0 +1,12 @@ +-----BEGIN PGP SIGNATURE----- + +iQFPBAABCAA5FiEE3Qzd0rSDjslXJ7lLTHepyRHUahkFAmlVuQAbFIAAAAAABAAO +bWFudTIsMi41KzEuMTIsMCwzAAoJEEx3qckR1GoZVLIIAJHV0PXAQFNiZ9Zlatso +m3puyZ7Ld6yI1g3Iz4srbFnjxjpgRabwTukPhfm/SlOtpBeYnDkEfhGYUrx4n58t +1KhEezIPbcu3SeMS+meZo+KeinG/LNkbjF9YDwnDGXj2w4rlaYKFw/jnnDTCOu63 ++JZr4kLeBle/17iZ6SYz8QuhNU+sXuqSJoBrq4M+GykCXl/VNGn1ikOyNCmK4/4m +riUH/BsuWLSbMzSR/KvI4Ay0m4OrdEQHR5Xj2huKtdUg3kjxNJo62zNBnNWkaRmH +igSyYV/yxfN0ywzIRVP5pOoCgyNFnEov27zNketYVU/GdZpt7S/mWeWxomTx2zqg +Q2M= +=j2I/ +-----END PGP SIGNATURE----- diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/mod.rs b/crates/socket-patch-cli/tests/jvm_fixture_repo/mod.rs new file mode 100644 index 000000000..b83cbeb0c --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/mod.rs @@ -0,0 +1,1070 @@ +//! A deterministic fake Maven Central for the JVM build capstones (Gradle, +//! and sbt/Coursier, which resolve from the same maven2 layout). +//! +//! Everything under `com.socketfixture` is GENERATED here, byte-for-byte +//! reproducibly (stored zip entries, fixed timestamps and permissions, +//! hand-assembled class files, fixed-order JSON/XML): +//! +//! * `victim:{1.9,1.10.0}` — jar (a `Victim.marker()` class + a +//! `META-INF/NOTICE.txt` marker), pom (parent `fixture-parent:1`, the +//! `published-with-gradle-metadata` hint), `.module` (api / runtime / +//! sources variants with size + md5/sha1/sha256/sha512), and +//! `-tests` / `-sources` classifier jars. `victim-1.10.0.jar` carries a +//! padding member brute-forced so its sha1 starts with `0` (Gradle may +//! drop the leading zero from the `files-2.1` hash dir). +//! * `consumer:2.0` → `victim:1.10.0`; `consumer-range:2.0` → `victim:[1.9,1.11)`. +//! * `fixture-parent:1` (parent pom), `fixture-bom:1.0` (a Maven BOM, for a +//! non-enforced `platform()` import), `fixture-platform:1.0` (a Gradle +//! platform `.module` whose variants `require` `victim:1.10.0`). +//! * `buildlogic-plugin:1.0` — a buildscript-classpath library depending on +//! `victim:1.10.0`; `BuildLogic.print()` prints [`BUILDLOGIC_MARKER`] + +//! `Victim.marker()` from build logic. +//! * artifact-level `maven-metadata.xml`, and `.md5` / `.sha1` / `.sha256` / +//! `.sha512` sidecars for every file (computed when served). +//! +//! COMMITTED under `fixtures/`: `.asc` signatures of every jar / pom / +//! `.module` (`fixtures/signatures/.asc`) by a THROWAWAY key +//! (`fixtures/keys/`, secret included on purpose — never trust it), and +//! `fixtures/SHA256SUMS`, the digest of every generated file. The +//! stability self-test regenerates the repository and compares it with +//! `SHA256SUMS` on every OS. To change the fixture, edit the generator and +//! run that test with `SOCKET_PATCH_JVM_FIXTURES_REGENERATE=1` (needs +//! `gpg`): it rewrites `SHA256SUMS` and re-signs with the committed key. +//! +//! [`FakeCentral`] serves the repository over plain http (wiremock), plus +//! any overlay a test adds (e.g. the service-built patched jar the +//! member-keyed swap downloads, [`FakeCentral::serve_patched_jar`]). + +#![allow(dead_code)] + +use std::collections::BTreeMap; +use std::io::Write as _; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use wiremock::matchers::{method, path_regex}; +use wiremock::{Mock, MockServer, Request, ResponseTemplate}; + +pub const GROUP: &str = "com.socketfixture"; +pub const GROUP_PATH: &str = "com/socketfixture"; +pub const VICTIM: &str = "victim"; +/// The base version patches target, and the older one a downgrade lands on. +pub const VICTIM_VERSION: &str = "1.10.0"; +pub const VICTIM_OLD: &str = "1.9"; +pub const VICTIM_VERSIONS: [&str; 2] = [VICTIM_OLD, VICTIM_VERSION]; +pub const CONSUMER: &str = "consumer"; +pub const CONSUMER_RANGE: &str = "consumer-range"; +pub const CONSUMER_VERSION: &str = "2.0"; +/// What `consumer-range:2.0`'s pom requests. +pub const VICTIM_RANGE: &str = "[1.9,1.11)"; +pub const PARENT: &str = "fixture-parent"; +pub const PARENT_VERSION: &str = "1"; +pub const BOM: &str = "fixture-bom"; +pub const PLATFORM: &str = "fixture-platform"; +pub const PLATFORM_VERSION: &str = "1.0"; +pub const BUILDLOGIC: &str = "buildlogic-plugin"; +pub const BUILDLOGIC_VERSION: &str = "1.0"; +pub const BUILDLOGIC_CLASS: &str = "com.socketfixture.buildlogic.BuildLogic"; +pub const VICTIM_CLASS: &str = "com.socketfixture.victim.Victim"; +/// The jar member a text patch rewrites. +pub const NOTICE: &str = "META-INF/NOTICE.txt"; +/// The class member `Victim.marker()` lives in. +pub const VICTIM_CLASS_MEMBER: &str = "com/socketfixture/victim/Victim.class"; +/// The leading-zero padding member of `victim-1.10.0.jar`. +pub const PAD_MEMBER: &str = "META-INF/socket-fixture-pad.txt"; +/// What `BuildLogic.print()` prints before `Victim.marker()`. +pub const BUILDLOGIC_MARKER: &str = "SOCKET-FIXTURE-BUILDLOGIC "; +/// The `lastUpdated` of every `maven-metadata.xml`. +pub const LAST_UPDATED: &str = "20260101000000"; +/// The throwaway signing key's fingerprint. +pub const KEY_FINGERPRINT: &str = "DD0CDDD2B4838EC95727B94B4C77A9C911D46A19"; +pub const REGENERATE_ENV: &str = "SOCKET_PATCH_JVM_FIXTURES_REGENERATE"; + +/// The committed part of the fixture. +pub fn fixtures_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/jvm_fixture_repo/fixtures") +} + +/// The throwaway key as an armored public keyring (Gradle accepts it as +/// `gradle/verification-keyring.keys`). +pub fn public_key_armored() -> PathBuf { + fixtures_dir().join("keys/signing-key.public.asc") +} + +/// The same keyring in binary form (`gradle/verification-keyring.gpg`). +pub fn public_keyring_gpg() -> PathBuf { + fixtures_dir().join("keys/verification-keyring.gpg") +} + +pub fn victim_purl(version: &str) -> String { + format!("pkg:maven/{GROUP}/{VICTIM}@{version}") +} + +/// `///-[-].`. +pub fn repo_path(artifact: &str, version: &str, classifier: Option<&str>, ext: &str) -> String { + let classifier = classifier.map(|c| format!("-{c}")).unwrap_or_default(); + format!("{GROUP_PATH}/{artifact}/{version}/{artifact}-{version}{classifier}.{ext}") +} + +// ── member content ────────────────────────────────────────────────────── + +/// `Victim.marker()`'s return value. +pub fn victim_marker(version: &str, state: &str) -> String { + format!("SOCKET-FIXTURE-VICTIM {version} {state}") +} + +/// A jar's `META-INF/NOTICE.txt`; `state` is `pristine` upstream. +pub fn notice(coordinate: &str, state: &str) -> String { + format!("Socket fixture: {coordinate}\nSOCKET-FIXTURE-NOTICE {state}\n") +} + +/// `com.socketfixture.victim.Victim` whose `marker()` returns +/// [`victim_marker`]`(version, state)`: build a patched class member with a +/// different `state`. +pub fn victim_class(version: &str, state: &str) -> Vec { + let mut c = ClassFile::new("com/socketfixture/victim/Victim"); + let text = c.string(&victim_marker(version, state)); + let mut code = ldc(text); + code.push(0xb0); // areturn + c.method("marker", "()Ljava/lang/String;", 1, code); + c.finish() +} + +/// `com.socketfixture.buildlogic.BuildLogic`: `marker()` returns +/// [`BUILDLOGIC_MARKER`] + `Victim.marker()`, `print()` prints it. +pub fn buildlogic_class() -> Vec { + let mut c = ClassFile::new("com/socketfixture/buildlogic/BuildLogic"); + let prefix = c.string(BUILDLOGIC_MARKER); + let victim = c.methodref( + "com/socketfixture/victim/Victim", + "marker", + "()Ljava/lang/String;", + ); + let concat = c.methodref( + "java/lang/String", + "concat", + "(Ljava/lang/String;)Ljava/lang/String;", + ); + let out = c.fieldref("java/lang/System", "out", "Ljava/io/PrintStream;"); + let println = c.methodref("java/io/PrintStream", "println", "(Ljava/lang/String;)V"); + let this_marker = c.methodref( + "com/socketfixture/buildlogic/BuildLogic", + "marker", + "()Ljava/lang/String;", + ); + // marker(): ldc prefix; invokestatic Victim.marker; invokevirtual concat; areturn + let mut code = ldc(prefix); + code.extend(op_u2(0xb8, victim)); + code.extend(op_u2(0xb6, concat)); + code.push(0xb0); + c.method("marker", "()Ljava/lang/String;", 2, code); + // print(): getstatic System.out; invokestatic marker; invokevirtual println; return + let mut code = op_u2(0xb2, out); + code.extend(op_u2(0xb8, this_marker)); + code.extend(op_u2(0xb6, println)); + code.push(0xb1); + c.method("print", "()V", 2, code); + c.finish() +} + +fn manifest_mf() -> Vec { + b"Manifest-Version: 1.0\r\nCreated-By: socket-patch test fixture\r\n\r\n".to_vec() +} + +fn victim_jar(version: &str) -> Vec { + let coordinate = format!("{GROUP}:{VICTIM}:{version}"); + let mut members = vec![ + ("META-INF/MANIFEST.MF".to_string(), manifest_mf()), + ( + NOTICE.to_string(), + notice(&coordinate, "pristine").into_bytes(), + ), + ( + VICTIM_CLASS_MEMBER.to_string(), + victim_class(version, "pristine"), + ), + ]; + if version != VICTIM_VERSION { + return jar(&members); + } + // Brute-force the padding so the jar's sha1 starts with `0`. + members.push((PAD_MEMBER.to_string(), Vec::new())); + for n in 0u32.. { + members.last_mut().unwrap().1 = format!("pad {n}\n").into_bytes(); + let bytes = jar(&members); + if sha1_hex(&bytes).starts_with('0') { + return bytes; + } + } + unreachable!() +} + +fn classifier_jar(artifact: &str, version: &str, classifier: &str) -> Vec { + let coordinate = format!("{GROUP}:{artifact}:{version}:{classifier}"); + let mut members = vec![ + ("META-INF/MANIFEST.MF".to_string(), manifest_mf()), + ( + NOTICE.to_string(), + notice(&coordinate, "pristine").into_bytes(), + ), + ]; + if classifier == "sources" { + members.push(( + "com/socketfixture/victim/Victim.java".to_string(), + format!( + "package com.socketfixture.victim;\n\npublic final class Victim {{\n \ + public static String marker() {{ return \"{}\"; }}\n}}\n", + victim_marker(version, "pristine") + ) + .into_bytes(), + )); + } + jar(&members) +} + +fn notice_jar(artifact: &str, version: &str, extra: Vec<(String, Vec)>) -> Vec { + let coordinate = format!("{GROUP}:{artifact}:{version}"); + let mut members = vec![ + ("META-INF/MANIFEST.MF".to_string(), manifest_mf()), + ( + NOTICE.to_string(), + notice(&coordinate, "pristine").into_bytes(), + ), + ]; + members.extend(extra); + jar(&members) +} + +/// A deterministic jar: stored entries, a fixed 2026-01-01 timestamp and +/// 0644 Unix permissions, in the given order. +pub fn jar(members: &[(String, Vec)]) -> Vec { + let mut out = std::io::Cursor::new(Vec::new()); + { + let mut writer = zip::ZipWriter::new(&mut out); + let opts = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Stored) + .last_modified_time(zip::DateTime::from_date_and_time(2026, 1, 1, 0, 0, 0).unwrap()) + .system(zip::System::Unix) + .unix_permissions(0o644); + for (name, bytes) in members { + writer.start_file(name.as_str(), opts).unwrap(); + writer.write_all(bytes).unwrap(); + } + writer.finish().unwrap(); + } + out.into_inner() +} + +// ── metadata ──────────────────────────────────────────────────────────── + +const POM_HEAD: &str = "\n\ +\n"; + +/// Gradle reads the `.module` when a pom carries this comment. +const GRADLE_METADATA_HINT: &str = " \n"; + +fn parent_block() -> String { + format!( + " \n {GROUP}\n {PARENT}\n \ + {PARENT_VERSION}\n \n" + ) +} + +fn dependency(artifact: &str, version: &str) -> String { + format!( + " \n {GROUP}\n {artifact}\n \ + {version}\n \n" + ) +} + +fn parent_pom() -> String { + format!( + "{POM_HEAD} 4.0.0\n {GROUP}\n \ + {PARENT}\n {PARENT_VERSION}\n \ + pom\n socket-patch fixture parent\n \n \ + \n MIT\n \n \n\n" + ) +} + +/// A jar pom under `fixture-parent` (the project's own `` follows +/// ``, the shape `hosted_maven_common::Hosted::served_pom` rewrites). +fn jar_pom(artifact: &str, version: &str, gradle_metadata: bool, deps: &[(&str, &str)]) -> String { + let mut pom = POM_HEAD.to_string(); + if gradle_metadata { + pom.push_str(GRADLE_METADATA_HINT); + } + pom.push_str(&format!( + " 4.0.0\n{} {artifact}\n \ + {version}\n jar\n", + parent_block() + )); + if !deps.is_empty() { + pom.push_str(" \n"); + for (a, v) in deps { + pom.push_str(&dependency(a, v)); + } + pom.push_str(" \n"); + } + pom.push_str("\n"); + pom +} + +/// A `pom`-packaged pom managing `victim:1.10.0` (the BOM, and the +/// platform's Maven face). +fn managing_pom(artifact: &str, version: &str, gradle_metadata: bool) -> String { + let mut pom = POM_HEAD.to_string(); + if gradle_metadata { + pom.push_str(GRADLE_METADATA_HINT); + } + pom.push_str(&format!( + " 4.0.0\n{} {artifact}\n \ + {version}\n pom\n \ + \n \n{} \n \ + \n\n", + parent_block(), + dependency(VICTIM, VICTIM_VERSION) + .lines() + .map(|l| format!(" {l}\n")) + .collect::() + )); + pom +} + +fn file_entry(name: &str, bytes: &[u8]) -> serde_json::Value { + serde_json::json!({ + "name": name, + "url": name, + "size": bytes.len(), + "sha512": sha512_hex(bytes), + "sha256": sha256_hex(bytes), + "sha1": sha1_hex(bytes), + "md5": md5_hex(bytes), + }) +} + +fn module_head(artifact: &str, version: &str) -> serde_json::Value { + serde_json::json!({ + "formatVersion": "1.1", + "component": { + "group": GROUP, + "module": artifact, + "version": version, + "attributes": { "org.gradle.status": "release" } + }, + "createdBy": { "gradle": { "version": "8.14.3" } }, + "variants": [] + }) +} + +fn library_variant(name: &str, usage: &str, jar_name: &str, jar: &[u8]) -> serde_json::Value { + serde_json::json!({ + "name": name, + "attributes": { + "org.gradle.category": "library", + "org.gradle.dependency.bundling": "external", + "org.gradle.jvm.version": 8, + "org.gradle.libraryelements": "jar", + "org.gradle.usage": usage + }, + "files": [file_entry(jar_name, jar)] + }) +} + +fn victim_module(version: &str, jar: &[u8], sources: &[u8]) -> String { + let mut module = module_head(VICTIM, version); + let jar_name = format!("{VICTIM}-{version}.jar"); + let sources_name = format!("{VICTIM}-{version}-sources.jar"); + module["variants"] = serde_json::json!([ + library_variant("apiElements", "java-api", &jar_name, jar), + library_variant("runtimeElements", "java-runtime", &jar_name, jar), + { + "name": "sourcesElements", + "attributes": { + "org.gradle.category": "documentation", + "org.gradle.dependency.bundling": "external", + "org.gradle.docstype": "sources", + "org.gradle.usage": "java-runtime" + }, + "files": [file_entry(&sources_name, sources)] + } + ]); + serde_json::to_string_pretty(&module).unwrap() + "\n" +} + +fn platform_module() -> String { + let mut module = module_head(PLATFORM, PLATFORM_VERSION); + let constraint = serde_json::json!([{ + "group": GROUP, + "module": VICTIM, + "version": { "requires": VICTIM_VERSION } + }]); + module["variants"] = serde_json::json!([ + { + "name": "apiElements", + "attributes": { "org.gradle.category": "platform", "org.gradle.usage": "java-api" }, + "dependencyConstraints": constraint.clone() + }, + { + "name": "runtimeElements", + "attributes": { "org.gradle.category": "platform", "org.gradle.usage": "java-runtime" }, + "dependencyConstraints": constraint + } + ]); + serde_json::to_string_pretty(&module).unwrap() + "\n" +} + +fn maven_metadata(artifact: &str, versions: &[&str]) -> String { + let latest = versions.last().unwrap(); + let listed: String = versions + .iter() + .map(|v| format!(" {v}\n")) + .collect(); + format!( + "\n\n {GROUP}\n \ + {artifact}\n \n {latest}\n \ + {latest}\n \n{listed} \n \ + {LAST_UPDATED}\n \n\n" + ) +} + +// ── the repository ────────────────────────────────────────────────────── + +/// Every primary file of the repository, keyed by its maven2 path (no +/// leading `/`). Sidecars (checksums, signatures) are not included. +pub fn generate() -> BTreeMap> { + let mut repo = BTreeMap::new(); + let mut put = |path: String, bytes: Vec| { + assert!(repo.insert(path, bytes).is_none()); + }; + put( + repo_path(PARENT, PARENT_VERSION, None, "pom"), + parent_pom().into_bytes(), + ); + put( + format!("{GROUP_PATH}/{PARENT}/maven-metadata.xml"), + maven_metadata(PARENT, &[PARENT_VERSION]).into_bytes(), + ); + for version in VICTIM_VERSIONS { + let jar = victim_jar(version); + let sources = classifier_jar(VICTIM, version, "sources"); + let tests = classifier_jar(VICTIM, version, "tests"); + put( + repo_path(VICTIM, version, None, "module"), + victim_module(version, &jar, &sources).into_bytes(), + ); + put( + repo_path(VICTIM, version, None, "pom"), + jar_pom(VICTIM, version, true, &[]).into_bytes(), + ); + put(repo_path(VICTIM, version, None, "jar"), jar); + put(repo_path(VICTIM, version, Some("sources"), "jar"), sources); + put(repo_path(VICTIM, version, Some("tests"), "jar"), tests); + } + put( + format!("{GROUP_PATH}/{VICTIM}/maven-metadata.xml"), + maven_metadata(VICTIM, &VICTIM_VERSIONS).into_bytes(), + ); + for (artifact, victim) in [(CONSUMER, VICTIM_VERSION), (CONSUMER_RANGE, VICTIM_RANGE)] { + put( + repo_path(artifact, CONSUMER_VERSION, None, "pom"), + jar_pom(artifact, CONSUMER_VERSION, false, &[(VICTIM, victim)]).into_bytes(), + ); + put( + repo_path(artifact, CONSUMER_VERSION, None, "jar"), + notice_jar(artifact, CONSUMER_VERSION, Vec::new()), + ); + put( + format!("{GROUP_PATH}/{artifact}/maven-metadata.xml"), + maven_metadata(artifact, &[CONSUMER_VERSION]).into_bytes(), + ); + } + put( + repo_path(BOM, PLATFORM_VERSION, None, "pom"), + managing_pom(BOM, PLATFORM_VERSION, false).into_bytes(), + ); + put( + format!("{GROUP_PATH}/{BOM}/maven-metadata.xml"), + maven_metadata(BOM, &[PLATFORM_VERSION]).into_bytes(), + ); + put( + repo_path(PLATFORM, PLATFORM_VERSION, None, "pom"), + managing_pom(PLATFORM, PLATFORM_VERSION, true).into_bytes(), + ); + put( + repo_path(PLATFORM, PLATFORM_VERSION, None, "module"), + platform_module().into_bytes(), + ); + put( + format!("{GROUP_PATH}/{PLATFORM}/maven-metadata.xml"), + maven_metadata(PLATFORM, &[PLATFORM_VERSION]).into_bytes(), + ); + put( + repo_path(BUILDLOGIC, BUILDLOGIC_VERSION, None, "pom"), + jar_pom( + BUILDLOGIC, + BUILDLOGIC_VERSION, + false, + &[(VICTIM, VICTIM_VERSION)], + ) + .into_bytes(), + ); + put( + repo_path(BUILDLOGIC, BUILDLOGIC_VERSION, None, "jar"), + notice_jar( + BUILDLOGIC, + BUILDLOGIC_VERSION, + vec![( + "com/socketfixture/buildlogic/BuildLogic.class".to_string(), + buildlogic_class(), + )], + ), + ); + put( + format!("{GROUP_PATH}/{BUILDLOGIC}/maven-metadata.xml"), + maven_metadata(BUILDLOGIC, &[BUILDLOGIC_VERSION]).into_bytes(), + ); + repo +} + +/// Whether a repository file carries a committed `.asc`. +pub fn is_signed(path: &str) -> bool { + path.ends_with(".jar") || path.ends_with(".pom") || path.ends_with(".module") +} + +/// The committed `.asc` of every signed file, keyed by its repository path +/// with `.asc` appended. +pub fn signatures() -> BTreeMap> { + let root = fixtures_dir().join("signatures"); + generate() + .keys() + .filter(|p| is_signed(p)) + .filter_map(|p| { + let asc = format!("{p}.asc"); + std::fs::read(root.join(&asc)).ok().map(|b| (asc, b)) + }) + .collect() +} + +/// The full served repository: [`generate`] + [`signatures`] + checksum +/// sidecars of both. +pub fn repository() -> BTreeMap> { + let mut repo = generate(); + repo.extend(signatures()); + with_checksums(repo) +} + +/// `files` plus `.md5` / `.sha1` / `.sha256` / `.sha512` of each. +pub fn with_checksums(files: BTreeMap>) -> BTreeMap> { + let mut out = files.clone(); + for (path, bytes) in files { + for (ext, digest) in checksums(&bytes) { + out.insert(format!("{path}.{ext}"), digest.into_bytes()); + } + } + out +} + +fn checksums(bytes: &[u8]) -> [(&'static str, String); 4] { + [ + ("md5", md5_hex(bytes)), + ("sha1", sha1_hex(bytes)), + ("sha256", sha256_hex(bytes)), + ("sha512", sha512_hex(bytes)), + ] +} + +/// `SHA256SUMS` text for `files` (` ` lines, sorted). +pub fn sha256sums(files: &BTreeMap>) -> String { + files + .iter() + .map(|(path, bytes)| format!("{} {path}\n", sha256_hex(bytes))) + .collect() +} + +// ── the server ────────────────────────────────────────────────────────── + +/// The fake Central: every [`repository`] file at `/`, plus +/// overlays, over plain http. 404 for anything else. Requests are logged. +pub struct FakeCentral { + server: MockServer, + rt: tokio::runtime::Runtime, + files: Arc>>>, +} + +impl FakeCentral { + pub fn start() -> Self { + let rt = tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .enable_all() + .build() + .unwrap(); + let server = rt.block_on(MockServer::start()); + let files = Arc::new(Mutex::new(repository())); + let served = files.clone(); + rt.block_on( + Mock::given(method("GET")) + .and(path_regex("^/.+")) + .respond_with(move |request: &Request| { + let path = request.url.path().trim_start_matches('/').to_string(); + match served.lock().unwrap().get(&path) { + Some(bytes) => ResponseTemplate::new(200).set_body_bytes(bytes.clone()), + None => ResponseTemplate::new(404), + } + }) + .mount(&server), + ); + rt.block_on( + Mock::given(method("HEAD")) + .and(path_regex("^/.+")) + .respond_with({ + let served = files.clone(); + move |request: &Request| { + let path = request.url.path().trim_start_matches('/'); + if served.lock().unwrap().contains_key(path) { + ResponseTemplate::new(200) + } else { + ResponseTemplate::new(404) + } + } + }) + .mount(&server), + ); + FakeCentral { server, rt, files } + } + + /// The repository url (`http://127.0.0.1:`). + pub fn uri(&self) -> String { + self.server.uri() + } + + /// Serve `bytes` (plus checksum sidecars) at `path` (no leading `/`), + /// replacing whatever was there. + pub fn put(&self, path: &str, bytes: &[u8]) { + let one = BTreeMap::from([(path.to_string(), bytes.to_vec())]); + self.files.lock().unwrap().extend(with_checksums(one)); + } + + /// Stop serving `path` and its sidecars. + pub fn remove(&self, path: &str) { + let mut files = self.files.lock().unwrap(); + files.remove(path); + for (ext, _) in checksums(b"") { + files.remove(&format!("{path}.{ext}")); + } + } + + /// The service-built patched jar a member-keyed record swaps in, at + /// `/patched//-.jar`. Returns its url. + pub fn serve_patched_jar( + &self, + uuid: &str, + artifact: &str, + version: &str, + jar: &[u8], + ) -> String { + let path = format!("patched/{uuid}/{artifact}-{version}.jar"); + self.put(&path, jar); + format!("{}/{path}", self.uri()) + } + + /// Every request path seen so far (leading `/` kept), in order. + pub fn requests(&self) -> Vec { + self.rt + .block_on(self.server.received_requests()) + .unwrap_or_default() + .iter() + .map(|r| r.url.path().to_string()) + .collect() + } +} + +// ── digests ───────────────────────────────────────────────────────────── + +pub fn sha1_hex(bytes: &[u8]) -> String { + use sha1::{Digest, Sha1}; + hex::encode(Sha1::digest(bytes)) +} + +pub fn sha256_hex(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + hex::encode(Sha256::digest(bytes)) +} + +pub fn sha512_hex(bytes: &[u8]) -> String { + use sha2::{Digest, Sha512}; + hex::encode(Sha512::digest(bytes)) +} + +/// RFC 1321 MD5 (the dev-dependency set has no md5 crate; Gradle module +/// metadata and Maven sidecars still carry it). +pub fn md5_hex(bytes: &[u8]) -> String { + const S: [u32; 64] = [ + 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5, + 9, 14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10, + 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, + ]; + // floor(|sin(i + 1)| * 2^32), tabulated: libm `sin` is not bit-identical + // across platforms. + const K: [u32; 64] = [ + 0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, 0xf57c0faf, 0x4787c62a, 0xa8304613, + 0xfd469501, 0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be, 0x6b901122, 0xfd987193, + 0xa679438e, 0x49b40821, 0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa, 0xd62f105d, + 0x02441453, 0xd8a1e681, 0xe7d3fbc8, 0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed, + 0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a, 0xfffa3942, 0x8771f681, 0x6d9d6122, + 0xfde5380c, 0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70, 0x289b7ec6, 0xeaa127fa, + 0xd4ef3085, 0x04881d05, 0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665, 0xf4292244, + 0x432aff97, 0xab9423a7, 0xfc93a039, 0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1, + 0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1, 0xf7537e82, 0xbd3af235, 0x2ad7d2bb, + 0xeb86d391, + ]; + let mut state: [u32; 4] = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476]; + let mut msg = bytes.to_vec(); + let bit_len = (bytes.len() as u64).wrapping_mul(8); + msg.push(0x80); + while msg.len() % 64 != 56 { + msg.push(0); + } + msg.extend_from_slice(&bit_len.to_le_bytes()); + for chunk in msg.chunks(64) { + let m: Vec = chunk + .chunks(4) + .map(|w| u32::from_le_bytes([w[0], w[1], w[2], w[3]])) + .collect(); + let [mut a, mut b, mut c, mut d] = state; + for i in 0..64 { + let (f, g) = match i / 16 { + 0 => ((b & c) | (!b & d), i), + 1 => ((d & b) | (!d & c), (5 * i + 1) % 16), + 2 => (b ^ c ^ d, (3 * i + 5) % 16), + _ => (c ^ (b | !d), (7 * i) % 16), + }; + let rotated = a + .wrapping_add(f) + .wrapping_add(K[i]) + .wrapping_add(m[g]) + .rotate_left(S[i]); + a = d; + d = c; + c = b; + b = b.wrapping_add(rotated); + } + for (s, v) in state.iter_mut().zip([a, b, c, d]) { + *s = s.wrapping_add(v); + } + } + state + .iter() + .flat_map(|w| w.to_le_bytes()) + .map(|b| format!("{b:02x}")) + .collect() +} + +// ── a minimal class-file writer ───────────────────────────────────────── + +/// Just enough of JVMS §4 for public static methods without branches: +/// class version 52 (Java 8, loads on every JDK the matrix runs), no +/// StackMapTable needed, no constructor. +struct ClassFile { + pool: Vec>, + this: u16, + object: u16, + code_name: u16, + methods: Vec>, +} + +fn op_u2(op: u8, index: u16) -> Vec { + let [hi, lo] = index.to_be_bytes(); + vec![op, hi, lo] +} + +fn ldc(index: u16) -> Vec { + match u8::try_from(index) { + Ok(small) => vec![0x12, small], + Err(_) => op_u2(0x13, index), + } +} + +impl ClassFile { + fn new(name: &str) -> Self { + let mut c = ClassFile { + pool: Vec::new(), + this: 0, + object: 0, + code_name: 0, + methods: Vec::new(), + }; + c.this = c.class(name); + c.object = c.class("java/lang/Object"); + c.code_name = c.utf8("Code"); + c + } + + fn add(&mut self, entry: Vec) -> u16 { + if let Some(i) = self.pool.iter().position(|e| *e == entry) { + return i as u16 + 1; + } + self.pool.push(entry); + self.pool.len() as u16 + } + + fn utf8(&mut self, text: &str) -> u16 { + let mut entry = vec![1]; + entry.extend((text.len() as u16).to_be_bytes()); + entry.extend(text.as_bytes()); + self.add(entry) + } + + fn with_index(&mut self, tag: u8, indices: &[u16]) -> u16 { + let mut entry = vec![tag]; + for i in indices { + entry.extend(i.to_be_bytes()); + } + self.add(entry) + } + + fn class(&mut self, name: &str) -> u16 { + let name = self.utf8(name); + self.with_index(7, &[name]) + } + + fn string(&mut self, text: &str) -> u16 { + let text = self.utf8(text); + self.with_index(8, &[text]) + } + + fn name_and_type(&mut self, name: &str, descriptor: &str) -> u16 { + let name = self.utf8(name); + let descriptor = self.utf8(descriptor); + self.with_index(12, &[name, descriptor]) + } + + fn methodref(&mut self, class: &str, name: &str, descriptor: &str) -> u16 { + let class = self.class(class); + let nt = self.name_and_type(name, descriptor); + self.with_index(10, &[class, nt]) + } + + fn fieldref(&mut self, class: &str, name: &str, descriptor: &str) -> u16 { + let class = self.class(class); + let nt = self.name_and_type(name, descriptor); + self.with_index(9, &[class, nt]) + } + + /// `public static ` with no locals. + fn method(&mut self, name: &str, descriptor: &str, max_stack: u16, code: Vec) { + let name = self.utf8(name); + let descriptor = self.utf8(descriptor); + let mut m = Vec::new(); + m.extend(0x0009u16.to_be_bytes()); // ACC_PUBLIC | ACC_STATIC + m.extend(name.to_be_bytes()); + m.extend(descriptor.to_be_bytes()); + m.extend(1u16.to_be_bytes()); + m.extend(self.code_name.to_be_bytes()); + m.extend((12 + code.len() as u32).to_be_bytes()); + m.extend(max_stack.to_be_bytes()); + m.extend(0u16.to_be_bytes()); // max_locals + m.extend((code.len() as u32).to_be_bytes()); + m.extend(code); + m.extend(0u16.to_be_bytes()); // exception table + m.extend(0u16.to_be_bytes()); // attributes + self.methods.push(m); + } + + fn finish(self) -> Vec { + let mut out = vec![0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 52]; + out.extend((self.pool.len() as u16 + 1).to_be_bytes()); + for entry in &self.pool { + out.extend(entry); + } + out.extend(0x0031u16.to_be_bytes()); // ACC_PUBLIC | ACC_FINAL | ACC_SUPER + out.extend(self.this.to_be_bytes()); + out.extend(self.object.to_be_bytes()); + out.extend(0u16.to_be_bytes()); // interfaces + out.extend(0u16.to_be_bytes()); // fields + out.extend((self.methods.len() as u16).to_be_bytes()); + for m in &self.methods { + out.extend(m); + } + out.extend(0u16.to_be_bytes()); // attributes + out + } +} + +// ── regeneration ──────────────────────────────────────────────────────── + +/// Rewrite `SHA256SUMS` and re-sign every signed file with the committed +/// throwaway key (`gpg` with a scratch `GNUPGHOME`, a faked fixed signing +/// time, so the signatures are reproducible too). +fn regenerate(repo: &BTreeMap>) { + let dir = fixtures_dir(); + std::fs::write(dir.join("SHA256SUMS"), sha256sums(repo)).unwrap(); + let gnupg = tempfile::tempdir().unwrap(); + let gpg = |args: &[&str]| { + let out = std::process::Command::new("gpg") + .env("GNUPGHOME", gnupg.path()) + .args(["--batch", "--yes", "--quiet"]) + .args(args) + .output() + .expect("run gpg"); + // Only gpg's stderr goes in the message: the arguments name the + // signing-key file. + assert!( + out.status.success(), + "gpg failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + }; + let signing_key_path = dir.join("keys/signing-key.secret.asc"); + gpg(&["--import", signing_key_path.to_str().unwrap()]); + let scratch = tempfile::tempdir().unwrap(); + for (path, bytes) in repo.iter().filter(|(p, _)| is_signed(p)) { + let input = scratch.path().join("input"); + std::fs::write(&input, bytes).unwrap(); + let asc = dir.join("signatures").join(format!("{path}.asc")); + std::fs::create_dir_all(asc.parent().unwrap()).unwrap(); + gpg(&[ + "--faked-system-time", + "20260101T000000!", + "--digest-algo", + "SHA256", + "--no-emit-version", + "--armor", + "--local-user", + KEY_FINGERPRINT, + "--output", + asc.to_str().unwrap(), + "--detach-sign", + input.to_str().unwrap(), + ]); + } +} + +fn path_of(root: &Path, rel: &str) -> PathBuf { + rel.split('/').fold(root.to_path_buf(), |p, c| p.join(c)) +} + +// ── self-tests (integration crates get no cfg(test)) ──────────────────── + +mod jvm_fixture_repo_selftests { + use super::*; + + /// The generator reproduces the committed digests byte-for-byte on this + /// OS, and every signed file has its committed signature. + #[test] + fn jvm_fixture_repo_is_stable() { + let repo = generate(); + if std::env::var_os(REGENERATE_ENV).is_some_and(|v| !v.is_empty()) { + regenerate(&repo); + } + let committed = std::fs::read_to_string(fixtures_dir().join("SHA256SUMS")).unwrap(); + assert_eq!( + sha256sums(&repo), + committed, + "the generated fixture repository drifted from fixtures/SHA256SUMS; \ + rerun with {REGENERATE_ENV}=1 if the change is intended" + ); + let signatures = signatures(); + for path in repo.keys().filter(|p| is_signed(p)) { + let asc = signatures + .get(&format!("{path}.asc")) + .unwrap_or_else(|| panic!("no committed signature for {path}")); + assert!( + asc.starts_with(b"-----BEGIN PGP SIGNATURE-----"), + "{path}.asc" + ); + } + assert!(path_of(&fixtures_dir(), "keys/signing-key.public.asc").is_file()); + assert!(public_keyring_gpg().is_file()); + } + + #[test] + fn the_patched_jar_has_a_leading_zero_sha1() { + let repo = generate(); + let jar = &repo[&repo_path(VICTIM, VICTIM_VERSION, None, "jar")]; + assert!(sha1_hex(jar).starts_with('0'), "{}", sha1_hex(jar)); + let old = &repo[&repo_path(VICTIM, VICTIM_OLD, None, "jar")]; + assert_ne!(sha1_hex(old), sha1_hex(jar)); + } + + #[test] + fn module_metadata_describes_the_served_jar() { + let repo = generate(); + let jar = &repo[&repo_path(VICTIM, VICTIM_VERSION, None, "jar")]; + let module: serde_json::Value = + serde_json::from_slice(&repo[&repo_path(VICTIM, VICTIM_VERSION, None, "module")]) + .unwrap(); + let file = &module["variants"][1]["files"][0]; + assert_eq!(file["name"], "victim-1.10.0.jar"); + assert_eq!(file["size"], jar.len()); + assert_eq!(file["sha1"], sha1_hex(jar)); + assert_eq!(file["sha256"], sha256_hex(jar)); + assert_eq!(file["md5"], md5_hex(jar)); + let pom = String::from_utf8(repo[&repo_path(VICTIM, VICTIM_VERSION, None, "pom")].clone()) + .unwrap(); + assert!(pom.contains("published-with-gradle-metadata")); + assert!(pom + .contains("\n victim\n 1.10.0")); + let range = String::from_utf8( + repo[&repo_path(CONSUMER_RANGE, CONSUMER_VERSION, None, "pom")].clone(), + ) + .unwrap(); + assert!(range.contains("[1.9,1.11)")); + } + + #[test] + fn md5_matches_rfc1321_vectors() { + assert_eq!(md5_hex(b""), "d41d8cd98f00b204e9800998ecf8427e"); + assert_eq!(md5_hex(b"abc"), "900150983cd24fb0d6963f7d28e17f72"); + assert_eq!( + md5_hex( + b"12345678901234567890123456789012345678901234567890123456789012345678901234567890" + ), + "57edf4a22be3c955ac49da2e2107b67a" + ); + } + + #[test] + fn class_files_are_well_formed() { + let class = victim_class(VICTIM_VERSION, "patched"); + assert_eq!(&class[..8], &[0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 52]); + let text = victim_marker(VICTIM_VERSION, "patched"); + assert!(class.windows(text.len()).any(|w| w == text.as_bytes())); + assert_ne!(class, victim_class(VICTIM_VERSION, "pristine")); + assert!(buildlogic_class() + .windows(BUILDLOGIC_MARKER.len()) + .any(|w| w == BUILDLOGIC_MARKER.as_bytes())); + } + + #[test] + fn fake_central_serves_files_sidecars_and_overlays() { + let central = FakeCentral::start(); + let get = |path: &str| { + let url = format!("{}/{path}", central.uri()); + central.rt.block_on(async move { + let response = reqwest::get(url).await.unwrap(); + let status = response.status().as_u16(); + (status, response.bytes().await.unwrap().to_vec()) + }) + }; + let jar_path = repo_path(VICTIM, VICTIM_VERSION, None, "jar"); + let jar = generate()[&jar_path].clone(); + assert_eq!(get(&jar_path), (200, jar.clone())); + assert_eq!( + get(&format!("{jar_path}.sha1")), + (200, sha1_hex(&jar).into_bytes()) + ); + assert_eq!(get(&format!("{jar_path}.asc")).0, 200); + assert_eq!(get("com/socketfixture/nope/1/nope-1.jar").0, 404); + let url = central.serve_patched_jar("u-1", VICTIM, VICTIM_VERSION, b"patched"); + assert!(url.ends_with("/patched/u-1/victim-1.10.0.jar")); + assert_eq!( + get("patched/u-1/victim-1.10.0.jar"), + (200, b"patched".to_vec()) + ); + central.remove("patched/u-1/victim-1.10.0.jar"); + assert_eq!(get("patched/u-1/victim-1.10.0.jar").0, 404); + assert!(central.requests().contains(&format!("/{jar_path}"))); + } +} diff --git a/crates/socket-patch-cli/tests/maven_sidecar_cli.rs b/crates/socket-patch-cli/tests/maven_sidecar_cli.rs new file mode 100644 index 000000000..c268af9e5 --- /dev/null +++ b/crates/socket-patch-cli/tests/maven_sidecar_cli.rs @@ -0,0 +1,254 @@ +//! Maven `~/.m2` checksum sidecars (`.sha1` / `.md5`) through +//! the real CLI: a pom-only patch of a plain Maven project. Present and +//! matching the pre-patch bytes, they follow the patched bytes on apply +//! and are put back exactly on rollback; absent, none is created; one that +//! never described the file is left alone. Every rollback is byte-exact. + +#[path = "prebuilt_common/mod.rs"] +mod prebuilt_common; + +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::process::Command; + +use sha1::Digest as _; + +const PURL: &str = "pkg:maven/com.example/lib@2.0"; +const UUID: &str = "7c1e0c2d-7b4e-4c1a-9f0e-2a3b4c5d6e7f"; +const POM: &str = "lib-2.0.pom"; +const PRISTINE: &[u8] = + b"com.examplelib2.0\n"; +const PATCHED: &[u8] = + b"com.examplelib2.0\n"; + +fn git_sha256(bytes: &[u8]) -> String { + socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes(bytes) +} + +fn sha1_hex(bytes: &[u8]) -> String { + hex::encode(sha1::Sha1::digest(bytes)) +} + +/// RFC 1321 MD5 (no md5 crate among the dev-dependencies). +fn md5_hex(input: &[u8]) -> String { + const S: [u32; 64] = [ + 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5, + 9, 14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10, + 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, + ]; + let k: Vec = (0..64) + .map(|i| ((i as f64 + 1.0).sin().abs() * 4294967296.0) as u32) + .collect(); + let mut state: [u32; 4] = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476]; + let mut msg = input.to_vec(); + let bit_len = (input.len() as u64).wrapping_mul(8); + msg.push(0x80); + while msg.len() % 64 != 56 { + msg.push(0); + } + msg.extend_from_slice(&bit_len.to_le_bytes()); + for chunk in msg.chunks(64) { + let m: Vec = chunk + .chunks(4) + .map(|w| u32::from_le_bytes([w[0], w[1], w[2], w[3]])) + .collect(); + let [mut a, mut b, mut c, mut d] = state; + for i in 0..64 { + let (f, g) = match i / 16 { + 0 => ((b & c) | (!b & d), i), + 1 => ((d & b) | (!d & c), (5 * i + 1) % 16), + 2 => (b ^ c ^ d, (3 * i + 5) % 16), + _ => (c ^ (b | !d), (7 * i) % 16), + }; + let rotated = a + .wrapping_add(f) + .wrapping_add(k[i]) + .wrapping_add(m[g]) + .rotate_left(S[i]); + a = d; + d = c; + c = b; + b = b.wrapping_add(rotated); + } + for (s, v) in state.iter_mut().zip([a, b, c, d]) { + *s = s.wrapping_add(v); + } + } + state + .iter() + .flat_map(|w| w.to_le_bytes()) + .map(|b| format!("{b:02x}")) + .collect() +} + +/// A plain Maven project depending on the lib, its `~/.m2` copy holding +/// `sidecars` beside the pom, and a manifest patching the pom. +struct Fx { + _tmp: tempfile::TempDir, + proj: PathBuf, + m2: PathBuf, + dir: PathBuf, +} + +fn fx(sidecars: &[(&str, String)]) -> Fx { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().canonicalize().unwrap(); + let proj = root.join("proj"); + std::fs::create_dir_all(proj.join(".socket/blobs")).unwrap(); + std::fs::write( + proj.join("pom.xml"), + "4.0.0com.x\ + app1\ + com.examplelib2.0\ + \n", + ) + .unwrap(); + let m2 = root.join("m2"); + let dir = m2.join("com/example/lib/2.0"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(POM), PRISTINE).unwrap(); + for (name, text) in sidecars { + std::fs::write(dir.join(name), text).unwrap(); + } + for bytes in [PRISTINE, PATCHED] { + std::fs::write(proj.join(".socket/blobs").join(git_sha256(bytes)), bytes).unwrap(); + } + std::fs::write( + proj.join(".socket/manifest.json"), + serde_json::to_string_pretty(&serde_json::json!({ "patches": { PURL: { + "uuid": UUID, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { format!("package/{POM}"): { + "beforeHash": git_sha256(PRISTINE), + "afterHash": git_sha256(PATCHED), + } }, + "vulnerabilities": {}, + "description": "pom sidecar fixture", + "license": "MIT", + "tier": "free", + } } })) + .unwrap(), + ) + .unwrap(); + Fx { + _tmp: tmp, + proj, + m2, + dir, + } +} + +impl Fx { + fn run(&self, args: &[&str]) -> serde_json::Value { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_socket-patch")); + for (k, _) in std::env::vars_os() { + if k.to_string_lossy().starts_with("SOCKET_") { + cmd.env_remove(&k); + } + } + prebuilt_common::jvm_env::isolate_cli(&mut cmd); + let out = cmd + .args(args) + .args(["--json", "--offline", "--cwd", self.proj.to_str().unwrap()]) + .env("SOCKET_TELEMETRY_DISABLED", "1") + .env("SOCKET_NO_CONFIG", "1") + .env("MAVEN_REPO_LOCAL", &self.m2) + .env_remove("M2_HOME") + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!( + out.status.code(), + Some(0), + "{args:?}\n{stdout}\n{}", + String::from_utf8_lossy(&out.stderr) + ); + serde_json::from_str(stdout.trim()).unwrap() + } + + /// Every file of the m2 version dir. + fn m2_files(&self) -> BTreeMap> { + std::fs::read_dir(&self.dir) + .unwrap() + .flatten() + .map(|e| { + ( + e.file_name().to_string_lossy().into_owned(), + std::fs::read(e.path()).unwrap(), + ) + }) + .collect() + } + + fn read(&self, name: &str) -> String { + std::fs::read_to_string(self.dir.join(name)).unwrap() + } +} + +/// Both checksum files matched the pristine pom: rewritten to the patched +/// pom on apply (format kept: digest + file name, and an upper-case md5), +/// reported in `sidecars[]`, and put back byte for byte by rollback. +#[test] +fn matching_sidecars_follow_apply_and_rollback_exactly() { + let f = fx(&[ + ( + &format!("{POM}.sha1"), + format!("{} {POM}\n", sha1_hex(PRISTINE)), + ), + ( + &format!("{POM}.md5"), + md5_hex(PRISTINE).to_ascii_uppercase(), + ), + ]); + let before = f.m2_files(); + + let env = f.run(&["apply"]); + assert_eq!(std::fs::read(f.dir.join(POM)).unwrap(), PATCHED); + assert_eq!( + f.read(&format!("{POM}.sha1")), + format!("{} {POM}\n", sha1_hex(PATCHED)) + ); + assert_eq!( + f.read(&format!("{POM}.md5")), + md5_hex(PATCHED).to_ascii_uppercase() + ); + let sidecars = env["sidecars"].to_string(); + assert!( + sidecars.contains(&format!("{POM}.sha1")) && sidecars.contains(&format!("{POM}.md5")), + "{env}" + ); + + f.run(&["rollback"]); + assert_eq!(f.m2_files(), before, "rollback must be byte-exact"); +} + +/// No checksum files: apply and rollback create none, and rollback is +/// byte-exact. +#[test] +fn absent_sidecars_are_not_created() { + let f = fx(&[]); + let before = f.m2_files(); + f.run(&["apply"]); + assert_eq!(std::fs::read(f.dir.join(POM)).unwrap(), PATCHED); + assert_eq!(f.m2_files().len(), 1, "{:?}", f.m2_files().keys()); + f.run(&["rollback"]); + assert_eq!(f.m2_files(), before); +} + +/// A checksum file that never described the pom is left exactly as it is, +/// by apply and by rollback. +#[test] +fn mismatched_sidecar_is_untouched() { + let stale = format!("{}\n", "0".repeat(40)); + let f = fx(&[(&format!("{POM}.sha1"), stale.clone())]); + let before = f.m2_files(); + f.run(&["apply"]); + assert_eq!(f.read(&format!("{POM}.sha1")), stale); + f.run(&["rollback"]); + assert_eq!(f.m2_files(), before); +} + +#[test] +fn md5_fixture_matches_rfc1321() { + assert_eq!(md5_hex(b"abc"), "900150983cd24fb0d6963f7d28e17f72"); +} diff --git a/crates/socket-patch-cli/tests/prebuilt_common/mod.rs b/crates/socket-patch-cli/tests/prebuilt_common/mod.rs index 6ef93fa0d..f26a1bf01 100644 --- a/crates/socket-patch-cli/tests/prebuilt_common/mod.rs +++ b/crates/socket-patch-cli/tests/prebuilt_common/mod.rs @@ -15,6 +15,9 @@ use socket_patch_core::vendor::test_support::service_fixture::{ use wiremock::matchers::{method, path, path_regex}; use wiremock::{Mock, MockServer, ResponseTemplate}; +#[path = "../common/jvm_env.rs"] +pub mod jvm_env; + type Package = (String, Vec, Secondary); static PUBLISHED: OnceLock>> = OnceLock::new(); static MAVEN_METADATA: OnceLock>>>> = @@ -81,19 +84,16 @@ impl Server { let root = root.to_path_buf(); let extra: Vec<_> = env .iter() - .filter(|(k, _)| { - matches!( - *k, - "CARGO_HOME" - | "GOMODCACHE" - | "MAVEN_REPO_LOCAL" - | "NUGET_PACKAGES" - | "GEM_HOME" - | "VIRTUAL_ENV" - | "BUNDLE_PATH" - ) + .filter_map(|(k, v)| match *k { + "CARGO_HOME" | "GOMODCACHE" | "MAVEN_REPO_LOCAL" | "NUGET_PACKAGES" + | "GEM_HOME" | "VIRTUAL_ENV" | "BUNDLE_PATH" => Some(PathBuf::from(v)), + // The explicit JVM caches (`jvm_env::EXPLICIT`) are served + // as maven2 repositories from their `files-2.1` trees. + "GRADLE_USER_HOME" => Some(PathBuf::from(v).join(GRADLE_FILES21)), + "GRADLE_RO_DEP_CACHE" => Some(PathBuf::from(v).join(RO_FILES21)), + // sbt: "COURSIER_CACHE" => … + _ => None, }) - .map(|(_, v)| PathBuf::from(v)) .collect(); let (ready_tx, ready_rx) = mpsc::channel(); let (stop, stopped) = mpsc::channel(); @@ -184,12 +184,17 @@ async fn mount_project_with_roots(server: &MockServer, root: &Path, extra: Vec

PathBuf { "golang" => dir .to_string_lossy() .ends_with(&format!("{name}@{version}")), - "maven" => dir - .join(format!( - "{}-{version}.jar", - name.rsplit('/').next().unwrap() - )) - .is_file(), + "maven" => { + let jar = format!("{}-{version}.jar", name.rsplit('/').next().unwrap()); + if dir.join(&jar).is_file() { + true + } else if let Some(child) = files21_hash_child(dir, &jar) { + // A Gradle `files-2.1` version dir: the jar sits in its + // `/` child, which is the archive source. + return child; + } else { + false + } + } "nuget" => dir .join(format!("{}.{}.nupkg", name.to_lowercase(), version)) .is_file(), @@ -575,13 +587,24 @@ pub async fn mount_view_from_source( } /// Download fixtures for lifecycle setup. Package-manager offline checks are -/// separate commands and retain their original flags. +/// separate commands and retain their original flags. Every command also +/// gets the JVM isolation of `jvm_env::isolate_cli`: ambient Gradle / JVM +/// options scrubbed, `HOME` / `USERPROFILE` pinned to an empty stand-in, and +/// only the `jvm_env::EXPLICIT` caches named in `env` passed through. pub fn prepare_command( command: &mut std::process::Command, root: &Path, args: &[&str], env: &[(&str, &str)], ) -> Option { + // No ambient Gradle / JVM options and no real home (`common/jvm_env.rs`); + // the explicit JVM caches a test hands over are kept. + jvm_env::isolate_cli(command); + for (key, value) in env { + if jvm_env::EXPLICIT.contains(key) { + command.env(key, value); + } + } let download = matches!(args.first(), Some(&"vendor") | Some(&"repair")) && !args.contains(&"--revert"); if download { @@ -599,6 +622,105 @@ pub fn prepare_command( } } +// ── Gradle `files-2.1` caches ───────────────────────────────────────── + +/// The `files-2.1` tree under a `GRADLE_USER_HOME`. +pub const GRADLE_FILES21: &str = "caches/modules-2/files-2.1"; +/// The `files-2.1` tree under a `GRADLE_RO_DEP_CACHE`. +pub const RO_FILES21: &str = "modules-2/files-2.1"; +const GRADLE_FILES21_LEAF: &str = "files-2.1"; + +/// A `files-2.1` hash-dir name: 1-40 lowercase hex (Gradle may drop the +/// sha1's leading zeros). +pub fn is_sha1_dir(name: &str) -> bool { + (1..=40).contains(&name.len()) + && name + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +/// `////` (relative to `files-2.1`) +/// → the maven2 route `////`. +fn files21_route(relative: &str) -> Option { + let parts: Vec<&str> = relative.split('/').collect(); + let [group, artifact, version, hash, leaf] = parts.as_slice() else { + return None; + }; + is_sha1_dir(hash).then(|| format!("/{}/{artifact}/{version}/{leaf}", group.replace('.', "/"))) +} + +/// The `/` child of a `files-2.1` version dir that holds `leaf`. +fn files21_hash_child(dir: &Path, leaf: &str) -> Option { + let mut children: Vec = std::fs::read_dir(dir) + .ok()? + .flatten() + .filter(|e| e.file_name().to_str().is_some_and(is_sha1_dir)) + .map(|e| e.path()) + .filter(|p| p.join(leaf).is_file()) + .collect(); + children.sort(); + children.into_iter().next() +} + +/// `leaf` beside an installed jar: in `source` itself, or — for a +/// `files-2.1` hash dir — in a sibling hash dir of the same version. +fn maven_sibling(source: &Path, leaf: &str) -> Option { + let direct = source.join(leaf); + if direct.is_file() { + return Some(direct); + } + let name = source.file_name()?.to_str()?; + if !is_sha1_dir(name) { + return None; + } + files21_hash_child(source.parent()?, leaf).map(|dir| dir.join(leaf)) +} + +fn sha1_hex(bytes: &[u8]) -> String { + hex::encode(sha1::Sha1::digest(bytes)) +} + +/// Lay `files` (`(leaf, bytes)`) out the way Gradle caches a download: +/// `/caches/modules-2/files-2.1/////`, +/// each file under its own real sha1. `gav` is `group:artifact:version`. +/// Returns the version dir (what the crawler reports as the package path). +pub fn fabricate_files21(home: &Path, gav: &str, files: &[(&str, &[u8])]) -> PathBuf { + fabricate_files21_named(home, gav, files, |sha1| sha1.to_string()) +} + +/// [`fabricate_files21`] with the hash dirs named the way Gradle releases +/// that format the sha1 as a number do: leading zeros dropped. +pub fn fabricate_files21_unpadded(home: &Path, gav: &str, files: &[(&str, &[u8])]) -> PathBuf { + fabricate_files21_named(home, gav, files, |sha1| { + let trimmed = sha1.trim_start_matches('0'); + if trimmed.is_empty() { "0" } else { trimmed }.to_string() + }) +} + +fn fabricate_files21_named( + home: &Path, + gav: &str, + files: &[(&str, &[u8])], + name: impl Fn(&str) -> String, +) -> PathBuf { + let mut parts = gav.splitn(3, ':'); + let (Some(group), Some(artifact), Some(version)) = (parts.next(), parts.next(), parts.next()) + else { + panic!("fabricate_files21: `{gav}` is not group:artifact:version"); + }; + let dir = home + .join(GRADLE_FILES21) + .join(group) + .join(artifact) + .join(version); + for (leaf, bytes) in files { + let hash_dir = dir.join(name(&sha1_hex(bytes))); + std::fs::create_dir_all(&hash_dir).unwrap(); + std::fs::write(hash_dir.join(leaf), bytes).unwrap(); + } + dir +} + fn copy_tree(from: &Path, to: &Path) { std::fs::create_dir_all(to).unwrap(); for entry in std::fs::read_dir(from).unwrap() { @@ -635,3 +757,108 @@ pub async fn mount_download(server: &MockServer, purl: &str, uuid: &str, leaf: & .mount(server) .await; } + +// ── self-tests (integration crates get no cfg(test)) ─────────────────── + +mod prebuilt_common_selftests { + use super::*; + + fn envs(cmd: &std::process::Command) -> HashMap> { + cmd.get_envs() + .map(|(k, v)| { + ( + k.to_string_lossy().into_owned(), + v.map(|v| v.to_string_lossy().into_owned()), + ) + }) + .collect() + } + + /// A stray GRADLE_OPTS (`-Dgradle.user.home` beats GRADLE_USER_HOME) + /// never reaches the CLI; HOME is the empty stand-in; an explicit + /// GRADLE_USER_HOME handed to `prepare_command` survives. + #[test] + fn prepare_command_scrubs_a_stray_gradle_opts() { + let tmp = tempfile::tempdir().unwrap(); + let mut cmd = std::process::Command::new("socket-patch"); + cmd.env("GRADLE_OPTS", "-Dgradle.user.home=/real/.gradle") + .env("GRADLE_USER_HOME", "/real/.gradle") + .env("JAVA_OPTS", "-Xmx1g"); + let gradle_home = tmp.path().join("gradle-home"); + let fixture = prepare_command( + &mut cmd, + tmp.path(), + &["scan", "--json"], + &[("GRADLE_USER_HOME", gradle_home.to_str().unwrap())], + ); + assert!(fixture.is_none(), "scan needs no fixture server"); + let envs = envs(&cmd); + assert_eq!(envs["GRADLE_OPTS"], None); + assert_eq!(envs["JAVA_OPTS"], None); + assert_eq!(envs["GRADLE_RO_DEP_CACHE"], None); + assert_eq!( + envs["GRADLE_USER_HOME"].as_deref(), + gradle_home.to_str(), + "the explicit cache wins" + ); + let home = jvm_env::stand_in_home().to_string_lossy().into_owned(); + assert_eq!(envs["HOME"].as_deref(), Some(home.as_str())); + assert_eq!(envs["USERPROFILE"].as_deref(), Some(home.as_str())); + let args: Vec<_> = cmd.get_args().collect(); + assert_eq!(args, ["scan", "--json"]); + } + + #[test] + fn files21_layout_routes_and_install_detection() { + assert!(is_sha1_dir("0a1b") && is_sha1_dir(&"f".repeat(40))); + assert!(!is_sha1_dir("") && !is_sha1_dir(&"a".repeat(41)) && !is_sha1_dir("ABC")); + assert_eq!( + files21_route("com.socketfixture/victim/1.10.0/0abc/victim-1.10.0.jar").as_deref(), + Some("/com/socketfixture/victim/1.10.0/victim-1.10.0.jar") + ); + assert_eq!( + files21_route("com.socketfixture/victim/1.10.0/victim.jar"), + None + ); + assert_eq!(files21_route("g/a/v/not-a-hash/a-v.jar"), None); + + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().join("gradle-home"); + let jar: &[u8] = b"jar bytes"; + let pom: &[u8] = b""; + let version_dir = fabricate_files21( + &home, + "com.socketfixture:victim:1.10.0", + &[("victim-1.10.0.jar", jar), ("victim-1.10.0.pom", pom)], + ); + assert_eq!( + version_dir, + home.join("caches/modules-2/files-2.1/com.socketfixture/victim/1.10.0") + ); + let jar_dir = version_dir.join(sha1_hex(jar)); + assert_eq!( + std::fs::read(jar_dir.join("victim-1.10.0.jar")).unwrap(), + jar + ); + assert!(version_dir + .join(sha1_hex(pom)) + .join("victim-1.10.0.pom") + .is_file()); + + // The version dir resolves to the jar's hash dir, and the pom is + // found beside it in its own hash dir. + let purl = "pkg:maven/com.socketfixture/victim@1.10.0"; + let found = source_dir(tmp.path(), std::slice::from_ref(&version_dir), purl); + assert_eq!(found, jar_dir); + assert_eq!( + maven_sibling(&found, "victim-1.10.0.pom"), + Some(version_dir.join(sha1_hex(pom)).join("victim-1.10.0.pom")) + ); + assert_eq!(maven_sibling(&found, "victim-1.10.0.module"), None); + + // Unpadded naming drops the sha1's leading zeros. + let unpadded = fabricate_files21_unpadded(&home, "g:a:1", &[("a-1.jar", jar)]); + let want = sha1_hex(jar).trim_start_matches('0').to_string(); + assert!(unpadded.join(want).join("a-1.jar").is_file()); + } +} diff --git a/crates/socket-patch-cli/tests/vendor_jvm_cli.rs b/crates/socket-patch-cli/tests/vendor_jvm_cli.rs index 08ad1031c..ca21376e5 100644 --- a/crates/socket-patch-cli/tests/vendor_jvm_cli.rs +++ b/crates/socket-patch-cli/tests/vendor_jvm_cli.rs @@ -682,3 +682,558 @@ fn vex_reports_an_unreadable_jvm_layout_instead_of_an_unwired_patch() { } } } + +// ── Gradle: one case per vendored-Gradle issue (#395 #428 #429 #461 #487 +// #511 #533), plus the repairs that restore what each adds ── + +const FOO_JAR: &str = "proj/.socket/vendor/gradle/org/example/foo/1.0/foo-1.0.jar"; +const FOO_TREE: &str = "proj/.socket/vendor/gradle/org/example/foo/1.0"; +const FOO_METADATA: &str = "proj/.socket/vendor/gradle/org/example/foo/maven-metadata.xml"; +const FOO_MAVEN_TREE: &str = "proj/.socket/vendor/maven2/org/example/foo/1.0-socket.1d3c1fd2"; + +/// `socket(root, args)` run from `cwd` (relative to `root`) instead of +/// the project root; the fixture service reads `cwd`'s manifest. +fn socket_in(root: &Path, cwd: &str, args: &[&str]) -> (Option, serde_json::Value) { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_socket-patch")); + for (k, _) in std::env::vars_os() { + if k.to_string_lossy().starts_with("SOCKET_") { + cmd.env_remove(&k); + } + } + let dir = root.join(cwd); + let _fixture = prebuilt_common::prepare_command( + &mut cmd, + &dir, + args, + &[("MAVEN_REPO_LOCAL", root.join("m2").to_str().unwrap())], + ); + let out = cmd + .args(["--json", "--cwd", dir.to_str().unwrap()]) + .env("SOCKET_TELEMETRY_DISABLED", "1") + .env("SOCKET_NO_CONFIG", "1") + .env("MAVEN_REPO_LOCAL", root.join("m2")) + .env_remove("M2_HOME") + .output() + .expect("run socket-patch"); + let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + let env = serde_json::from_str(&stdout).unwrap_or_else(|e| { + panic!( + "{args:?}: not JSON ({e})\n{stdout}\n{}", + String::from_utf8_lossy(&out.stderr) + ) + }); + (out.status.code(), env) +} + +/// `vendor` refuses with `vendor_jvm_shape_unsupported` and `reason`, and +/// writes nothing. +fn assert_refused(root: &Path, reason: &str, also: &str) { + let before = snapshot(root); + let (code, env) = socket(root, &["vendor"]); + assert_ne!(code, Some(0), "{env}"); + let event = env["events"] + .as_array() + .unwrap() + .iter() + .find(|e| e["action"] == "failed") + .unwrap_or_else(|| panic!("no failed event: {env}")); + let error = event["error"].as_str().unwrap_or_default(); + assert!( + error.starts_with(&format!("reason: {reason}: ")) && error.contains(also), + "{env}" + ); + assert_eq!(snapshot(root), before, "a refusal writes nothing"); +} + +fn write(root: &Path, rel: &str, body: &[u8]) { + let path = root.join(rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, body).unwrap(); +} + +fn crlf(text: &[u8]) -> Vec { + String::from_utf8(text.to_vec()) + .unwrap() + .replace("\r\n", "\n") + .replace('\n', "\r\n") + .into_bytes() +} + +/// #395: a `pom.xml` beside the Gradle build vendors both builds in one +/// entry: the suffixed Maven tree and pin, and the Gradle tree and apply +/// line. `--check` and `vex` see both; the revert restores every byte. +#[test] +fn gradle_vendor_395_mixed_root_vendors_both_builds() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + fixture(root, Shape::Gradle, &[("foo", FOO_UUID)]); + write( + root, + "proj/pom.xml", + b"\n 4.0.0\n com.x\n app\n 1\n \n org.examplefoo1.0\n \n\n", + ); + let pristine = snapshot(root); + let env = ok(root, &["vendor"]); + assert_eq!(env["summary"]["applied"], 1, "{env}"); + assert!(root.join(FOO_JAR).is_file()); + assert!(root + .join(FOO_MAVEN_TREE) + .join("foo-1.0-socket.1d3c1fd2.jar") + .is_file()); + assert!(std::fs::read_to_string(root.join("proj/pom.xml")) + .unwrap() + .contains("1.0-socket.1d3c1fd2")); + assert!(std::fs::read_to_string(root.join("proj/settings.gradle")) + .unwrap() + .contains("apply from: '.socket/gradle/socket-patch.settings.gradle'")); + ok(root, &["vendor", "--check"]); + let vex = root.join("vex.json"); + ok( + root, + &[ + "vex", + "-O", + vex.to_str().unwrap(), + "--product", + "pkg:generic/x@1", + ], + ); + let doc: serde_json::Value = serde_json::from_slice(&std::fs::read(&vex).unwrap()).unwrap(); + assert_eq!(doc["statements"][0]["status"], "not_affected", "{doc}"); + std::fs::remove_file(vex).unwrap(); + ok(root, &["vendor", "--revert"]); + assert_eq!(snapshot(root), pristine); +} + +/// #395 repair: both trees of a mixed root come back from one download. +#[test] +fn gradle_vendor_395_repair_mixed_root() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + fixture(root, Shape::Gradle, &[("foo", FOO_UUID)]); + write( + root, + "proj/pom.xml", + b"4.0.0com.xapp1org.examplefoo1.0\n", + ); + ok(root, &["vendor"]); + let vendored = snapshot(root); + std::fs::remove_dir_all(root.join(FOO_TREE)).unwrap(); + std::fs::remove_dir_all(root.join(FOO_MAVEN_TREE)).unwrap(); + let env = ok(root, &["repair"]); + assert_eq!(env["events"][0]["action"], "rebuilt", "{env}"); + let mut repaired = snapshot(root); + let mut want = vendored.clone(); + repaired.remove(".socket/vendor/state.json"); + want.remove(".socket/vendor/state.json"); + assert_eq!(repaired, want); + ok(root, &["vendor", "--check"]); +} + +/// #428: vendoring from a subproject refuses with `not_build_root` and +/// writes nothing, the nested settings file included. +#[test] +fn gradle_vendor_428_subproject_refuses() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + fixture(root, Shape::Gradle, &[("foo", FOO_UUID)]); + std::fs::create_dir_all(root.join("proj/.git")).unwrap(); + for name in ["manifest.json", "blobs"] { + let from = root.join("proj/.socket").join(name); + let to = root.join("proj/app/.socket").join(name); + std::fs::create_dir_all(to.parent().unwrap()).unwrap(); + if from.is_dir() { + std::fs::create_dir_all(&to).unwrap(); + for e in std::fs::read_dir(&from).unwrap() { + let e = e.unwrap(); + std::fs::copy(e.path(), to.join(e.file_name())).unwrap(); + } + } else { + std::fs::copy(&from, &to).unwrap(); + } + } + let before = snapshot(root); + let (code, env) = socket_in(root, "proj/app", &["vendor"]); + assert_ne!(code, Some(0), "{env}"); + let event = &env["events"][0]; + assert_eq!(event["errorCode"], "vendor_jvm_shape_unsupported", "{env}"); + assert!( + event["error"] + .as_str() + .unwrap_or_default() + .starts_with("reason: not_build_root: run vendor from Gradle root "), + "{env}" + ); + assert_eq!(snapshot(root), before); + assert!(!root.join("proj/app/settings.gradle").exists()); +} + +/// #429: a `core.autocrlf=true` checkout (every text file CRLF) passes +/// `vendor --check`, and `vendor --revert` removes every owned file. +#[test] +fn gradle_vendor_429_crlf_checkout_checks_and_reverts_clean() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + fixture(root, Shape::Gradle, &[("foo", FOO_UUID)]); + write( + root, + "proj/buildSrc/build.gradle", + b"plugins { id 'groovy-gradle-plugin' }\n", + ); + let mut pristine = snapshot(root); + ok(root, &["vendor"]); + let text_files = [ + "proj/settings.gradle", + "proj/app/build.gradle", + "proj/buildSrc/build.gradle", + "proj/buildSrc/settings.gradle", + "proj/.socket/gradle/socket-patch.settings.gradle", + "proj/.socket/gradle/.gitattributes", + "proj/.socket/vendor/.gitattributes", + "proj/.socket/vendor/gradle-index.tsv", + "proj/.socket/vendor/gradle/.gitattributes", + FOO_METADATA, + ]; + for rel in text_files { + let bytes = std::fs::read(root.join(rel)).unwrap(); + std::fs::write(root.join(rel), crlf(&bytes)).unwrap(); + if let Some(Some(body)) = pristine.get_mut(rel.strip_prefix("proj/").unwrap()) { + *body = crlf(body); + } + } + let env = ok(root, &["vendor", "--check"]); + assert_eq!(env["summary"]["verified"], 1, "{env}"); + ok(root, &["vendor", "--revert"]); + assert_eq!(snapshot(root), pristine); +} + +/// #461: an `exclusiveContent` rule for the group in a subproject's build +/// script refuses, naming that file. +#[test] +fn gradle_vendor_461_subproject_rule_refuses() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + fixture(root, Shape::Gradle, &[("foo", FOO_UUID)]); + write( + root, + "proj/app/build.gradle", + b"plugins { id 'java' }\nrepositories {\n exclusiveContent {\n forRepository { mavenCentral() }\n filter { includeGroup 'org.example' }\n }\n}\ndependencies { implementation 'org.example:foo:1.0' }\n", + ); + assert_refused( + root, + "gradle_exclusive_content_conflict", + "app/build.gradle", + ); +} + +/// #487: pgp-only verification entries of the vendored pom and its parent +/// get a `sha256` beside the ``; `--check` passes and the revert is +/// byte-exact. +#[test] +fn gradle_vendor_487_pgp_only_entries_get_a_checksum() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + fixture(root, Shape::Gradle, &[("foo", FOO_UUID)]); + let foo_pom = b"4.0.0org.exampleparent1foo1.0"; + write(root, "m2/org/example/foo/1.0/foo-1.0.pom", foo_pom); + let parent_pom = b"4.0.0org.exampleparent1pom"; + write(root, "m2/org/example/parent/1/parent-1.pom", parent_pom); + let pgp = |name: &str| { + format!(" \n \n \n") + }; + let original = format!( + "\n\n \n true\n true\n \n \n \n{}{} \n \n{} \n \n\n", + pgp("foo-1.0.jar"), + pgp("foo-1.0.pom"), + pgp("parent-1.pom") + ); + write( + root, + "proj/gradle/verification-metadata.xml", + original.as_bytes(), + ); + ok(root, &["vendor"]); + let text = std::fs::read_to_string(root.join("proj/gradle/verification-metadata.xml")).unwrap(); + for (name, bytes) in [ + ("foo-1.0.pom", &foo_pom[..]), + ("parent-1.pom", &parent_pom[..]), + ] { + let want = format!( + " \n \n \n \n", + hex::encode(Sha256::digest(bytes)) + ); + assert!(text.contains(&want), "{name}:\n{text}"); + } + ok(root, &["vendor", "--check"]); + ok(root, &["vendor", "--revert"]); + assert_eq!( + std::fs::read_to_string(root.join("proj/gradle/verification-metadata.xml")).unwrap(), + original + ); +} + +/// #511: the tree carries the GA's derived `maven-metadata.xml`; a +/// deleted one is repaired; a range admitting no vendored version +/// refuses. +#[test] +fn gradle_vendor_511_derived_metadata_repair_and_range_refusal() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + fixture(root, Shape::Gradle, &[("foo", FOO_UUID)]); + write( + root, + "proj/app/build.gradle", + b"plugins { id 'java' }\nrepositories { mavenCentral() }\ndependencies { implementation 'org.example:foo:[0.9,1.1)' }\n", + ); + let env = ok(root, &["vendor"]); + assert!( + env.to_string().contains("reason: range_declared:"), + "the range is noted: {env}" + ); + let metadata = std::fs::read_to_string(root.join(FOO_METADATA)).unwrap(); + assert!( + metadata.contains("\n 1.0\n "), + "{metadata}" + ); + std::fs::remove_file(root.join(FOO_METADATA)).unwrap(); + let env = ok(root, &["repair"]); + assert_eq!(env["events"][0]["action"], "rebuilt", "{env}"); + assert_eq!( + std::fs::read_to_string(root.join(FOO_METADATA)).unwrap(), + metadata + ); + ok(root, &["vendor", "--check"]); + + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + fixture(root, Shape::Gradle, &[("foo", FOO_UUID)]); + write( + root, + "proj/app/build.gradle", + b"plugins { id 'java' }\nrepositories { mavenCentral() }\ndependencies { implementation 'org.example:foo:[2.0,3.0)' }\n", + ); + assert_refused(root, "gradle_range_excludes_vendored", "app/build.gradle"); +} + +/// #533: a declared classifier is vendored beside the jar (and a deleted +/// one repaired); one that exists nowhere refuses. +#[test] +fn gradle_vendor_533_repair_restores_classifier() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + fixture(root, Shape::Gradle, &[("foo", FOO_UUID)]); + write( + root, + "proj/app/build.gradle", + b"plugins { id 'java' }\nrepositories { mavenCentral() }\ndependencies {\n implementation 'org.example:foo:1.0'\n testImplementation 'org.example:foo:1.0:tests'\n}\n", + ); + let pristine = snapshot(root); + assert_refused(root, "classifier_unavailable", "foo:1.0:tests"); + let tests_jar = jar(b"TESTS\n"); + write(root, "m2/org/example/foo/1.0/foo-1.0-tests.jar", &tests_jar); + ok(root, &["vendor"]); + let vendored = root.join(FOO_TREE).join("foo-1.0-tests.jar"); + assert_eq!(std::fs::read(&vendored).unwrap(), tests_jar); + let index = std::fs::read_to_string(root.join("proj/.socket/vendor/gradle-index.tsv")).unwrap(); + assert!( + index.contains("org/example/foo/1.0/foo-1.0-tests.jar\t"), + "{index}" + ); + ok(root, &["vendor", "--check"]); + // A re-run keeps the committed classifier, declared or not (an + // unindexed file in the tree would fail the build). + let env = ok(root, &["vendor"]); + assert_eq!(env["events"][0]["errorCode"], "already_vendored", "{env}"); + let declared = std::fs::read(root.join("proj/app/build.gradle")).unwrap(); + write( + root, + "proj/app/build.gradle", + b"plugins { id 'java' }\nrepositories { mavenCentral() }\ndependencies { implementation 'org.example:foo:1.0' }\n", + ); + ok(root, &["vendor"]); + assert_eq!(std::fs::read(&vendored).unwrap(), tests_jar); + ok(root, &["vendor", "--check"]); + write(root, "proj/app/build.gradle", &declared); + std::fs::remove_file(&vendored).unwrap(); + let env = ok(root, &["repair"]); + assert_eq!(env["events"][0]["action"], "rebuilt", "{env}"); + assert_eq!(std::fs::read(&vendored).unwrap(), tests_jar); + ok(root, &["vendor", "--revert"]); + assert_eq!(snapshot(root), pristine); +} + +/// #533 + VEX: a declared classifier holding its own unpatched copy of +/// the patched member is vendored with a degraded warning, and `vex` +/// does not attest the package (the build may consume that copy). +#[test] +fn gradle_vendor_533_unpatched_classifier_copy_withholds_vex() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + fixture(root, Shape::Gradle, &[("foo", FOO_UUID)]); + write( + root, + "proj/app/build.gradle", + b"plugins { id 'java' }\nrepositories { mavenCentral() }\ndependencies { implementation 'org.example:foo:1.0:all' }\n", + ); + write( + root, + "m2/org/example/foo/1.0/foo-1.0-all.jar", + &jar(b"NOTICE foo\n"), + ); + let env = ok(root, &["vendor"]); + assert!( + env.to_string() + .contains("reason: classifier_unpatched_copy: foo-1.0-all.jar carries an unpatched copy of META-INF/NOTICE.txt"), + "{env}" + ); + ok(root, &["vendor", "--check"]); + let vex = root.join("vex.json"); + let (_, env) = socket( + root, + &[ + "vex", + "-O", + vex.to_str().unwrap(), + "--product", + "pkg:generic/x@1", + ], + ); + assert_eq!( + events(&env), + [( + purl("foo"), + "skipped".to_string(), + "vendor_unwired".to_string() + )], + "{env}" + ); + assert!(!vex.exists(), "{env}"); +} + +/// #429 repair: a deleted owned `.gitattributes` (and nothing else) is +/// rewritten by `repair` (no download), so the next autocrlf clone keeps the +/// script and index LF. +#[test] +fn gradle_vendor_429_repair_restores_missing_gitattributes() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + fixture(root, Shape::Gradle, &[("foo", FOO_UUID)]); + ok(root, &["vendor"]); + let vendored = snapshot(root); + for rel in [ + "proj/.socket/gradle/.gitattributes", + "proj/.socket/vendor/.gitattributes", + "proj/.socket/vendor/gradle/.gitattributes", + ] { + std::fs::remove_file(root.join(rel)).unwrap(); + } + let (code, env) = socket(root, &["vendor", "--check"]); + assert_ne!(code, Some(0), "{env}"); + ok(root, &["repair"]); + assert_eq!(snapshot(root), vendored); + ok(root, &["vendor", "--check"]); +} + +fn copy_dir(from: &Path, to: &Path) { + std::fs::create_dir_all(to).unwrap(); + for e in std::fs::read_dir(from).unwrap() { + let e = e.unwrap(); + if e.file_type().unwrap().is_dir() { + copy_dir(&e.path(), &to.join(e.file_name())); + } else { + std::fs::copy(e.path(), to.join(e.file_name())).unwrap(); + } + } +} + +/// #428 repair: a JVM ledger entry under a subproject of the Gradle build +/// is not repaired there (the real build would never read the restored +/// tree); nothing is written. +#[test] +fn gradle_vendor_428_repair_from_subproject_refuses() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + fixture(root, Shape::Gradle, &[("foo", FOO_UUID)]); + std::fs::create_dir_all(root.join("proj/.git")).unwrap(); + ok(root, &["vendor"]); + copy_dir(&root.join("proj/.socket"), &root.join("proj/app/.socket")); + std::fs::remove_file( + root.join("proj/app/.socket/vendor/gradle/org/example/foo/1.0/foo-1.0.jar"), + ) + .unwrap(); + let before = snapshot(root); + let (code, env) = socket_in(root, "proj/app", &["repair"]); + assert_ne!(code, Some(0), "{env}"); + assert!( + env.to_string() + .contains("reason: not_build_root: run vendor from Gradle root "), + "{env}" + ); + assert_eq!(snapshot(root), before); +} + +/// A single-pom root vendored before a Gradle build sat beside it keeps +/// its single-pom entry: the re-run warns that the Gradle build stays +/// unpatched and writes no Gradle wiring, `--check` passes, and the +/// revert restores every byte. +#[test] +fn gradle_vendor_395_legacy_single_pom_entry_is_not_migrated() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + fixture(root, Shape::Gradle, &[("foo", FOO_UUID)]); + write( + root, + "proj/pom.xml", + b"\n 4.0.0\n com.x\n app\n 1\n \n org.examplefoo1.0\n \n\n", + ); + let pristine = snapshot(root); + let settings = root.join("proj/settings.gradle"); + let parked = root.join("settings.gradle.parked"); + std::fs::rename(&settings, &parked).unwrap(); + ok(root, &["vendor"]); + let state = std::fs::read_to_string(root.join("proj/.socket/vendor/state.json")).unwrap(); + assert!(state.contains("maven_pom_repository"), "{state}"); + std::fs::rename(&parked, &settings).unwrap(); + let legacy = snapshot(root); + let env = ok(root, &["vendor"]); + assert!( + env.to_string().contains("reason: legacy_maven_root: "), + "{env}" + ); + assert_eq!(snapshot(root), legacy, "no Gradle wiring is added"); + ok(root, &["vendor", "--check"]); + ok(root, &["vendor", "--revert"]); + assert_eq!(snapshot(root), pristine); +} + +/// #395 repair: a mixed root whose Gradle tree directory is a link out of +/// the checkout is not repaired through it (the swap would delete and +/// rewrite the directory outside); the outside copy stays untouched. +#[cfg(unix)] +#[test] +fn gradle_vendor_395_repair_refuses_a_linked_gradle_tree() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + fixture(root, Shape::Gradle, &[("foo", FOO_UUID)]); + write( + root, + "proj/pom.xml", + b"4.0.0com.xapp1org.examplefoo1.0\n", + ); + ok(root, &["vendor"]); + let outside = root.join("outside"); + std::fs::rename(root.join("proj/.socket/vendor/gradle"), &outside).unwrap(); + std::fs::remove_file(outside.join("org/example/foo/1.0/foo-1.0.pom")).unwrap(); + std::os::unix::fs::symlink(&outside, root.join("proj/.socket/vendor/gradle")).unwrap(); + let listing = |dir: &Path| -> Vec<_> { + std::fs::read_dir(dir) + .unwrap() + .map(|e| e.unwrap().file_name()) + .collect() + }; + let before = listing(&outside.join("org/example/foo/1.0")); + let (code, env) = socket(root, &["repair"]); + assert_ne!(code, Some(0), "{env}"); + assert!(env.to_string().contains("vendor_path_unsafe"), "{env}"); + assert_eq!(listing(&outside.join("org/example/foo/1.0")), before); +} diff --git a/crates/socket-patch-core/src/crawlers/gradle_cache.rs b/crates/socket-patch-core/src/crawlers/gradle_cache.rs new file mode 100644 index 000000000..ef295ee27 --- /dev/null +++ b/crates/socket-patch-core/src/crawlers/gradle_cache.rs @@ -0,0 +1,990 @@ +//! Gradle's dependency cache on disk: the `files-2.1` layout and the +//! filesystem / process-environment adapters over the pure [`crate::gradle`] +//! model. +//! +//! Gradle caches every downloaded file at +//! `/caches/modules-2/files-2.1/////`: +//! the group keeps its dots, and each file sits in a directory named after +//! its own sha1 (some releases print the sha1 as a number and drop its +//! leading zeros, so a name may be shorter than 40 digits). A version's +//! jar, pom and `.module` therefore live in different hash directories, and +//! the same file name can appear in several (a re-download whose bytes +//! changed). The crawler reports the VERSION directory as the package path; +//! [`installed_copies`] expands it into the hash directories every join +//! site patches, verifies and rolls back. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::path::{Path, PathBuf}; + +use crate::crawlers::jvm_cache::Gav; +use crate::crawlers::maven_crawler::is_safe_maven_coordinate; +use crate::gradle::graph::{self, MavenLocal, ScriptGraph}; +use crate::gradle::home::{is_init_script_name, GradleHome}; +use crate::gradle::{Env, Os}; +use crate::manifest::schema::PatchFileInfo; + +/// The leaf directory name of a Gradle module cache. +pub const FILES21: &str = "files-2.1"; + +/// One cached file of a `files-2.1` tree. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct Entry { + pub gav: Gav, + /// The hash directory's name as spelled on disk. + pub hash_dir: String, + pub leaf: String, +} + +impl Entry { + /// The version directory of this entry under `root`. + pub fn version_dir(&self, root: &Path) -> PathBuf { + root.join(&self.gav.0).join(&self.gav.1).join(&self.gav.2) + } + + /// The file itself under `root`. + pub fn path(&self, root: &Path) -> PathBuf { + self.version_dir(root).join(&self.hash_dir).join(&self.leaf) + } +} + +/// Whether `name` spells a `files-2.1` hash directory: 1-40 lowercase hex +/// digits. +pub fn is_hash_dir_name(name: &str) -> bool { + (1..=40).contains(&name.len()) + && name + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +/// Whether the hash directory `dir_name` is the one Gradle names after +/// `sha1_hex`: both sides compared as 40-digit numbers (left-padded with +/// zeros), so a dropped leading zero still matches. +pub fn hash_eq(dir_name: &str, sha1_hex: &str) -> bool { + let sha1 = sha1_hex.to_ascii_lowercase(); + if !is_hash_dir_name(dir_name) || !is_hash_dir_name(&sha1) { + return false; + } + format!("{dir_name:0>40}") == format!("{sha1:0>40}") +} + +/// Whether `bytes` are the pristine download Gradle stored in the hash +/// directory `dir_name` (their sha1 names it). +pub fn pristine(dir_name: &str, bytes: &[u8]) -> bool { + use sha1::{Digest, Sha1}; + hash_eq(dir_name, &hex::encode(Sha1::digest(bytes))) +} + +/// Whether `path` is a version directory of a `files-2.1` tree +/// (`…/files-2.1///`), from its spelling alone. +pub fn is_gradle_version_dir(path: &Path) -> bool { + let mut up = path.ancestors().skip(3); + up.next() + .and_then(Path::file_name) + .is_some_and(|n| n == FILES21) +} + +/// The UTF-8 names of `dir`'s children, sorted, with whether each is a +/// directory (symlinks are not followed). Unreadable = empty. +fn children(dir: &Path) -> Vec<(String, bool)> { + let mut out: Vec<(String, bool)> = std::fs::read_dir(dir) + .into_iter() + .flatten() + .flatten() + .filter_map(|e| { + let name = e.file_name().into_string().ok()?; + let is_dir = e.file_type().ok()?.is_dir(); + Some((name, is_dir)) + }) + .collect(); + out.sort(); + out +} + +/// Cache bookkeeping that can sit beside the module directories when a +/// caller hands over a `modules-2`-level tree: never a group. +fn is_bookkeeping(name: &str) -> bool { + name.starts_with("metadata-") + || name.starts_with("transforms") + || name.starts_with("jars-") + || name.ends_with(".lock") +} + +/// Every cached file of the `files-2.1` tree at `root`: exactly three +/// literal directory levels (group, artifact, version), then a hash +/// directory ([`is_hash_dir_name`]) and its regular files. Bookkeeping +/// directories and lock files are skipped, as is any coordinate that +/// [`is_safe_maven_coordinate`] rejects. Sorted (walk order). +pub fn walk_files21(root: &Path) -> Vec { + let mut out = Vec::new(); + for (group, is_dir) in children(root) { + if !is_dir || is_bookkeeping(&group) { + continue; + } + let group_dir = root.join(&group); + for (artifact, is_dir) in children(&group_dir) { + if !is_dir { + continue; + } + let artifact_dir = group_dir.join(&artifact); + for (version, is_dir) in children(&artifact_dir) { + if !is_dir || !is_safe_maven_coordinate(&group, &artifact, &version) { + continue; + } + let gav: Gav = (group.clone(), artifact.clone(), version.clone()); + out.extend(version_dir_entries(&artifact_dir.join(&version), &gav)); + } + } + } + out +} + +/// The cached files of one version directory. +fn version_dir_entries(version_dir: &Path, gav: &Gav) -> Vec { + let mut out = Vec::new(); + for (hash_dir, is_dir) in children(version_dir) { + if !is_dir || !is_hash_dir_name(&hash_dir) { + continue; + } + for (leaf, is_dir) in children(&version_dir.join(&hash_dir)) { + if !is_dir { + out.push(Entry { + gav: gav.clone(), + hash_dir: hash_dir.clone(), + leaf, + }); + } + } + } + out +} + +/// Whether a version directory's files make it an installed module: some +/// hash directory holds `-.{jar,pom,module}`. +pub fn has_module_file<'a>(entries: impl IntoIterator) -> bool { + entries.into_iter().any(|e| { + let stem = format!("{}-{}", e.gav.1, e.gav.2); + e.leaf + .strip_prefix(&stem) + .is_some_and(|ext| matches!(ext, ".jar" | ".pom" | ".module")) + }) +} + +/// [`has_module_file`] for the version directory `root///`. +pub fn is_installed(root: &Path, gav: &Gav) -> bool { + let (g, a, v) = gav; + if !is_safe_maven_coordinate(g, a, v) { + return false; + } + has_module_file(&version_dir_entries(&root.join(g).join(a).join(v), gav)) +} + +/// The cached files of the `files-2.1` tree at `root`, grouped by version +/// directory (in walk order). +pub fn walk_versions(root: &Path) -> BTreeMap> { + let mut out: BTreeMap> = BTreeMap::new(); + for e in walk_files21(root) { + out.entry(e.gav.clone()).or_default().push(e); + } + out +} + +// ── installed copies ──────────────────────────────────────────────────── + +/// A version directory expanded into the hash directories that hold a +/// patch's files ([`installed_copies_detailed`]). +#[derive(Debug, Clone, Default, PartialEq)] +pub struct GradleTargets { + /// `(hash dir, the files it holds, keyed by leaf)`, one per hash dir + /// holding at least one of the files, sorted by directory. + pub targets: Vec<(PathBuf, HashMap)>, + /// The patch's keys no hash directory holds (also every key that is not + /// a single file name: a jar member, say). + pub missing: Vec, +} + +/// The directories a patch's `files` are joined onto for the package at +/// `pkg_path`, each with the keys it holds. +/// +/// For a Gradle version directory ([`is_gradle_version_dir`]) each key's +/// file name (`package/` prefix dropped) is looked up in every hash +/// directory: a file present in two hash dirs (a re-download whose bytes +/// changed) yields two targets, and a jar and its pom in different hash +/// dirs yield one target each, keyed by the bare file name. Keys no hash +/// dir holds stay joined onto the version dir itself, so verification +/// reports them as not found instead of dropping them. Any other path is +/// returned as is: `[(pkg_path, files)]`. +pub fn installed_copies( + pkg_path: &Path, + files: &HashMap, +) -> Vec<(PathBuf, HashMap)> { + if !is_gradle_version_dir(pkg_path) { + return vec![(pkg_path.to_path_buf(), files.clone())]; + } + let GradleTargets { + mut targets, + missing, + } = installed_copies_detailed(pkg_path, files); + if !missing.is_empty() { + let rest: HashMap = missing + .into_iter() + .filter_map(|k| files.get(&k).map(|info| (k, info.clone()))) + .collect(); + targets.push((pkg_path.to_path_buf(), rest)); + } + targets +} + +/// [`installed_copies`] with the keys no hash directory holds reported +/// apart. For a non-Gradle path every key is in the one identity target. +pub fn installed_copies_detailed( + pkg_path: &Path, + files: &HashMap, +) -> GradleTargets { + if !is_gradle_version_dir(pkg_path) { + return GradleTargets { + targets: vec![(pkg_path.to_path_buf(), files.clone())], + missing: Vec::new(), + }; + } + let hash_dirs: Vec = children(pkg_path) + .into_iter() + .filter(|(name, is_dir)| *is_dir && is_hash_dir_name(name)) + .map(|(name, _)| name) + .collect(); + let mut by_dir: BTreeMap> = BTreeMap::new(); + let mut missing = Vec::new(); + let mut keys: Vec<&String> = files.keys().collect(); + keys.sort(); + for key in keys { + let leaf = crate::patch::apply::normalize_file_path(key); + let holders: Vec<&String> = if leaf.is_empty() || leaf.contains(['/', '\\']) { + Vec::new() + } else { + hash_dirs + .iter() + .filter(|dir| pkg_path.join(dir).join(leaf).is_file()) + .collect() + }; + if holders.is_empty() { + missing.push(key.clone()); + continue; + } + for dir in holders { + by_dir + .entry(dir.clone()) + .or_default() + .insert(leaf.to_string(), files[key].clone()); + } + } + GradleTargets { + targets: by_dir + .into_iter() + .map(|(dir, files)| (pkg_path.join(dir), files)) + .collect(), + missing, + } +} + +// ── derived copies ────────────────────────────────────────────────────── + +/// How deep below a derived-cache root [`stale_derived_copies`] looks. +const DERIVED_DEPTH: usize = 8; +/// A bound on the entries [`stale_derived_copies`] visits per root; a walk +/// cut short by it is reported [`DerivedCopies::incomplete`]. +const DERIVED_ENTRIES: usize = 200_000; + +/// What [`stale_derived_copies`] found of the copies Gradle derived from a +/// cached jar. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct DerivedCopies { + /// Copies proven derived from the pristine jar: byte-identical to it, + /// or under a directory, or with a stem, that [`hash_eq`]s its sha1. + /// Sorted. + pub stale: Vec, + /// Files named after the jar (`jar_leaf`, `instrumented-`) + /// whose bytes are not the pristine jar's: Gradle may have derived + /// them from the pristine jar or from the patched one, and nothing in + /// the file says which. Sorted. + pub unknown: Vec, + /// The walk did not cover every derived-cache root (an unreadable + /// directory or file, or more entries under one than the walk visits), so + /// an empty `stale` does not prove there is no stale copy. + pub incomplete: bool, +} + +/// Copies of a cached jar that Gradle derived from it and keeps apart +/// (outside `files-2.1`), so patching the cached jar does not reach them: +/// files under `/caches/jars-*`, `caches/transforms-*` and +/// `caches//transforms` named `jar_leaf` (or +/// `instrumented-`), or under a directory, or with a stem, that +/// [`hash_eq`]s `pristine_sha1`. A name match counts as stale only when its +/// bytes are the pristine jar's; otherwise it is [`DerivedCopies::unknown`]. +/// Symlinks are not followed. One query of a fresh [`DerivedIndex`]; a +/// caller checking several jars builds the index once instead. +pub fn stale_derived_copies( + user_home: &Path, + jar_leaf: &str, + pristine_sha1: &str, +) -> DerivedCopies { + DerivedIndex::build(user_home).query(jar_leaf, pristine_sha1) +} + +/// [`stale_derived_copies`] visiting at most `max_entries` entries per root +/// (tests). +#[doc(hidden)] +pub fn stale_derived_copies_bounded( + user_home: &Path, + jar_leaf: &str, + pristine_sha1: &str, + max_entries: usize, +) -> DerivedCopies { + DerivedIndex::build_bounded(user_home, max_entries).query(jar_leaf, pristine_sha1) +} + +/// One walk of a Gradle user home's derived-cache roots, answering +/// [`stale_derived_copies`] for any number of jars. It keeps only the files +/// a query can match — `*.jar` files, and files whose stem or some +/// directory below the root looks like a sha1 (33–40 hex digits; Gradle's +/// own 32-digit workspace hashes do not) — so an Android home's extracted +/// resource trees cost the walk, not memory. +#[derive(Debug, Default)] +pub struct DerivedIndex { + /// `(root, file)` of every candidate. + files: Vec<(PathBuf, PathBuf)>, + incomplete: bool, +} + +impl DerivedIndex { + /// Walk `user_home`'s derived-cache roots (bounded per root). + pub fn build(user_home: &Path) -> Self { + Self::build_bounded(user_home, DERIVED_ENTRIES) + } + + /// [`DerivedIndex::build`] visiting at most `max_entries` per root. + #[doc(hidden)] + pub fn build_bounded(user_home: &Path, max_entries: usize) -> Self { + let sha1_like = |name: &str| name.len() >= 33 && is_hash_dir_name(name); + let caches = user_home.join("caches"); + let mut roots = Vec::new(); + for (name, is_dir) in children(&caches) { + if !is_dir { + continue; + } + if name.starts_with("jars-") || name.starts_with("transforms-") { + roots.push(caches.join(&name)); + } else if name.starts_with(|c: char| c.is_ascii_digit()) { + let transforms = caches.join(&name).join("transforms"); + if transforms.is_dir() { + roots.push(transforms); + } + } + } + let mut out = Self::default(); + for root in roots { + let mut visited = 0usize; + for entry in walkdir::WalkDir::new(&root) + .follow_links(false) + .max_depth(DERIVED_DEPTH) + .sort_by_file_name() + { + let entry = match entry { + Ok(entry) => entry, + Err(_) => { + out.incomplete = true; + continue; + } + }; + visited += 1; + if visited > max_entries { + out.incomplete = true; + break; + } + if !entry.file_type().is_file() { + continue; + } + let Some(name) = entry.file_name().to_str() else { + continue; + }; + let keep = name.ends_with(".jar") + || sha1_like(name.split('.').next().unwrap_or(name)) + || entry + .path() + .strip_prefix(&root) + .ok() + .and_then(Path::parent) + .is_some_and(|rel| { + rel.components() + .any(|c| c.as_os_str().to_str().is_some_and(sha1_like)) + }); + if keep { + out.files.push((root.clone(), entry.into_path())); + } + } + } + out + } + + /// Whether the walk did not cover every derived-cache root. + pub fn incomplete(&self) -> bool { + self.incomplete + } + + /// The [`DerivedCopies`] of the jar `jar_leaf` whose pristine bytes + /// hash to `pristine_sha1`. + pub fn query(&self, jar_leaf: &str, pristine_sha1: &str) -> DerivedCopies { + use sha1::{Digest, Sha1}; + + let instrumented = format!("instrumented-{jar_leaf}"); + let mut out = DerivedCopies { + incomplete: self.incomplete, + ..DerivedCopies::default() + }; + for (root, path) in &self.files { + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + let stem = name.split('.').next().unwrap_or(name); + let by_hash = hash_eq(stem, pristine_sha1) + || path + .strip_prefix(root) + .ok() + .and_then(Path::parent) + .is_some_and(|rel| { + rel.components().any(|c| { + c.as_os_str() + .to_str() + .is_some_and(|c| hash_eq(c, pristine_sha1)) + }) + }); + if by_hash { + out.stale.push(path.clone()); + } else if name == jar_leaf || name == instrumented { + match crate::utils::fs::read_regular_to_bytes_sync(path) { + Ok(bytes) if hash_eq(&hex::encode(Sha1::digest(&bytes)), pristine_sha1) => { + out.stale.push(path.clone()) + } + Ok(_) => out.unknown.push(path.clone()), + Err(_) => { + out.incomplete = true; + out.unknown.push(path.clone()); + } + } + } + } + out.stale.sort(); + out.unknown.sort(); + out + } +} + +// ── process environment ───────────────────────────────────────────────── + +/// The process environment behind [`Env`]. +pub struct ProcessEnv; + +impl Env for ProcessEnv { + fn var(&self, k: &str) -> Option { + std::env::var(k).ok() + } +} + +/// The account's home directory from the passwd database +/// (`getpwuid_r(getuid())->pw_dir`): what the JVM reports as `user.home` +/// on Linux and macOS, whatever `$HOME` says. `None` on Windows, and when +/// there is no entry or it names no directory. +#[cfg(unix)] +pub fn passwd_home() -> Option { + use std::ffi::CStr; + use std::os::unix::ffi::OsStrExt; + + let uid = unsafe { libc::getuid() }; + let mut buf: Vec = vec![0; 4096]; + // SAFETY: an all-zero `passwd` is a valid out-parameter; it is only + // read when `getpwuid_r` reports a result, and its strings point into + // `buf`, which outlives every read below. + let mut pwd: libc::passwd = unsafe { std::mem::zeroed() }; + let mut result: *mut libc::passwd = std::ptr::null_mut(); + loop { + // SAFETY: `buf` is writable for `buf.len()` bytes. + let rc = + unsafe { libc::getpwuid_r(uid, &mut pwd, buf.as_mut_ptr(), buf.len(), &mut result) }; + if rc == libc::ERANGE && buf.len() < (1 << 20) { + buf.resize(buf.len() * 2, 0); + continue; + } + break; + } + if result.is_null() || pwd.pw_dir.is_null() { + return None; + } + // SAFETY: a non-null `pw_dir` is a NUL-terminated string inside `buf`. + let dir = unsafe { CStr::from_ptr(pwd.pw_dir) }.to_bytes(); + (!dir.is_empty()).then(|| PathBuf::from(std::ffi::OsStr::from_bytes(dir))) +} + +#[cfg(not(unix))] +pub fn passwd_home() -> Option { + None +} + +/// The Gradle user home this process's Gradle would use +/// ([`GradleHome::resolve`] over the process environment and, on Unix, +/// [`passwd_home`]). +pub fn home_from_process_env() -> Option { + GradleHome::resolve(&ProcessEnv, Os::current(), passwd_home().as_deref()) +} + +/// `($HOME, passwd home)` when the Gradle user home came from the account's +/// home directory (no `gradle.user.home`, no `GRADLE_USER_HOME`) and `$HOME` +/// names another directory: Gradle follows the passwd entry, so its cache +/// is not under `$HOME/.gradle`. `None` on Windows and when they agree. +pub fn home_mismatch() -> Option<(PathBuf, PathBuf)> { + home_mismatch_with(&ProcessEnv, Os::current(), passwd_home().as_deref()) +} + +/// [`home_mismatch`] over an explicit environment. +pub fn home_mismatch_with( + env: &dyn Env, + os: Os, + passwd: Option<&Path>, +) -> Option<(PathBuf, PathBuf)> { + if os != Os::Unix { + return None; + } + let set = |k: &str| env.var(k).filter(|v| !v.is_empty()); + let explicit = set("GRADLE_USER_HOME").is_some() + || ["GRADLE_OPTS", "JAVA_OPTS"].iter().any(|k| { + set(k).is_some_and(|opts| { + crate::gradle::home::system_property(&opts, "gradle.user.home", os) + .is_some_and(|v| !v.is_empty()) + }) + }); + let home = PathBuf::from(set("HOME")?); + let passwd = passwd?.to_path_buf(); + let same = home == passwd + || home + .canonicalize() + .ok() + .is_some_and(|h| passwd.canonicalize().ok() == Some(h)); + (!explicit && !same).then_some((home, passwd)) +} + +// ── filesystem adapters ───────────────────────────────────────────────── + +/// A [`crate::gradle::TextReadFn`] over the directory `root`: reads the +/// forward-slash path relative to it as strict UTF-8 with a leading BOM +/// dropped. Missing, non-regular, unreadable and non-UTF-8 files are +/// `None`. A file larger than [`graph::MAX_FILE_BYTES`] is not read: it +/// comes back as that many spaces plus one, so the graph records it as too +/// large rather than missing. +pub fn fs_text_read(root: &Path) -> impl Fn(&str) -> Option + '_ { + move |rel: &str| { + let path = root.join(rel); + let meta = std::fs::metadata(&path).ok()?; + if !meta.is_file() { + return None; + } + if meta.len() > graph::MAX_FILE_BYTES as u64 { + return Some(" ".repeat(graph::MAX_FILE_BYTES + 1)); + } + crate::gradle::dsl::decode(&crate::utils::fs::read_regular_to_bytes_sync(&path).ok()?) + } +} + +/// A [`crate::gradle::ListFn`] over the directory `root`: the UTF-8 child +/// names of the forward-slash directory relative to it, directories +/// (symlinks followed) ending in `/`, sorted. Missing = empty. +pub fn fs_list(root: &Path) -> impl Fn(&str) -> Vec + '_ { + move |rel: &str| list_dir(&root.join(rel)) +} + +/// [`fs_list`] for an absolute directory (the shape `GradleHome`'s +/// listing callbacks take). +pub fn list_dir(dir: &Path) -> Vec { + let mut out: Vec = std::fs::read_dir(dir) + .into_iter() + .flatten() + .flatten() + .filter_map(|e| { + let name = e.file_name().into_string().ok()?; + let is_dir = std::fs::metadata(e.path()).is_ok_and(|m| m.is_dir()); + Some(if is_dir { format!("{name}/") } else { name }) + }) + .collect(); + out.sort(); + out +} + +// ── init scripts ──────────────────────────────────────────────────────── + +/// The Gradle init scripts that apply to a build, read. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct InitScripts { + /// `(path, text)` of each readable script, BOM dropped. + pub scripts: Vec<(String, String)>, + /// Scripts (or whole init-script sources) that exist or may exist but + /// could not be read: not UTF-8, unreadable, or a wrapper distribution + /// that is not unpacked yet. + pub unreadable: Vec, +} + +impl InitScripts { + fn read(&mut self, path: &Path) { + let Ok(meta) = std::fs::metadata(path) else { + return; + }; + if !meta.is_file() { + return; + } + let tag = path.to_string_lossy().into_owned(); + match crate::utils::fs::read_regular_to_bytes_sync(path) + .ok() + .and_then(|b| crate::gradle::dsl::decode(&b)) + { + Some(text) => self.scripts.push((tag, text)), + None => self.unreadable.push(tag), + } + } +} + +/// Every init script of the user home, `$GRADLE_HOME` and every unpacked +/// wrapper distribution ([`GradleHome::init_scripts_with`]), read. +pub fn read_init_scripts(home: &GradleHome) -> InitScripts { + let mut out = InitScripts::default(); + for path in home.init_scripts_with(&list_dir) { + out.read(&path); + } + out +} + +/// Where a build keeps its wrapper's properties, relative to its root. +const WRAPPER_PROPERTIES: &str = "gradle/wrapper/gradle-wrapper.properties"; + +/// The key/value pairs of a `.properties` file, read the way +/// `java.util.Properties.load(InputStream)` reads it: bytes are ISO-8859-1 +/// (a leading UTF-8 BOM is dropped), `#`/`!` lines are comments, a line +/// ending in an odd number of backslashes continues on the next, the key +/// ends at the first unescaped `=`, `:` or whitespace, and `\t`, `\n`, +/// `\r`, `\f`, `\uXXXX` and `\` escapes are resolved. A later key +/// wins. +fn parse_properties(bytes: &[u8]) -> HashMap { + let bytes = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes); + let text: String = bytes.iter().map(|&b| b as char).collect(); + let text = text.replace("\r\n", "\n"); + let is_ws = |c: char| matches!(c, ' ' | '\t' | '\x0c'); + let mut out = HashMap::new(); + let mut lines = text.split(['\n', '\r']); + while let Some(first) = lines.next() { + let first = first.trim_start_matches(is_ws); + if first.is_empty() || first.starts_with(['#', '!']) { + continue; + } + // Join continuation lines (an odd run of trailing backslashes). + let mut logical = String::new(); + let mut line = first.to_string(); + loop { + let trailing = line.chars().rev().take_while(|&c| c == '\\').count(); + if trailing % 2 == 0 { + logical.push_str(&line); + break; + } + logical.push_str(&line[..line.len() - 1]); + match lines.next() { + Some(next) => line = next.trim_start_matches(is_ws).to_string(), + None => break, + } + } + let mut chars = logical.chars().peekable(); + let mut key = String::new(); + let mut value = String::new(); + let mut in_key = true; + while let Some(c) = chars.next() { + if in_key && (c == '=' || c == ':' || is_ws(c)) { + in_key = false; + while chars.peek().is_some_and(|&c| is_ws(c)) { + chars.next(); + } + // Whitespace then `=`/`:` is one separator. + if is_ws(c) && chars.peek().is_some_and(|&c| c == '=' || c == ':') { + chars.next(); + while chars.peek().is_some_and(|&c| is_ws(c)) { + chars.next(); + } + } + continue; + } + let c = if c == '\\' { + match chars.next() { + Some('t') => '\t', + Some('n') => '\n', + Some('r') => '\r', + Some('f') => '\x0c', + Some('u') => { + let hex: String = (0..4).filter_map(|_| chars.next()).collect(); + u32::from_str_radix(&hex, 16) + .ok() + .and_then(char::from_u32) + .unwrap_or('\u{fffd}') + } + Some(other) => other, + None => continue, + } + } else { + c + }; + if in_key { + key.push(c); + } else { + value.push(c); + } + } + out.insert(key, value); + } + out +} + +/// What `gradle/wrapper/gradle-wrapper.properties` says about where the +/// wrapper's distribution unpacks. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WrapperProps { + /// `distributionUrl`, property escapes removed. + pub url: String, + /// `distributionBase=PROJECT`: relative to the build root instead of + /// the Gradle user home. + pub project_base: bool, + /// `distributionPath` (default `wrapper/dists`). + pub path: String, +} + +impl WrapperProps { + /// The properties of the wrapper of the build at `root`: `None` when it + /// has no `gradle/wrapper/gradle-wrapper.properties`, an error naming + /// the file when it exists but names no distribution (unreadable, too + /// large, or no `distributionUrl`). Read as `java.util.Properties` + /// does: ISO-8859-1, `=`, `:` or whitespace between key and value, + /// backslash escapes and continuation lines. + pub fn of(root: &Path) -> Option> { + let path = root.join(WRAPPER_PROPERTIES); + let meta = std::fs::metadata(&path).ok()?; + let unusable = |why: &str| Some(Err(format!("{} ({why})", path.display()))); + if !meta.is_file() { + return unusable("not a file"); + } + if meta.len() > graph::MAX_FILE_BYTES as u64 { + return unusable("too large"); + } + let Ok(bytes) = crate::utils::fs::read_regular_to_bytes_sync(&path) else { + return unusable("unreadable"); + }; + let mut props = parse_properties(&bytes); + let Some(url) = props + .remove("distributionUrl") + .filter(|u| !u.trim().is_empty()) + else { + return unusable("no distributionUrl"); + }; + Some(Ok(Self { + url: url.trim().to_string(), + project_base: props + .get("distributionBase") + .is_some_and(|b| b.trim() == "PROJECT"), + path: props + .remove("distributionPath") + .map(|p| p.trim().to_string()) + .filter(|p| !p.is_empty()) + .unwrap_or_else(|| "wrapper/dists".to_string()), + })) + } + + /// The distribution's directory name: the URL's file name without + /// `.zip`. + pub fn name(&self) -> &str { + let file = self.url.rsplit('/').next().unwrap_or(&self.url); + file.strip_suffix(".zip").unwrap_or(file) + } + + /// A stock Gradle distribution (which ships no init scripts). + pub fn is_stock(&self) -> bool { + let url = self.url.trim(); + [ + "https://services.gradle.org/distributions/", + "https://services.gradle.org/distributions-snapshots/", + ] + .iter() + .any(|p| url.starts_with(p)) + } +} + +/// The `init.d` directories of one wrapper distribution unpacked under +/// `dists` (`///

/init.d`). `None` when it is not +/// unpacked at all. +fn dist_init_dirs(dists: &Path, name: &str) -> Option> { + let subdirs = |d: &Path| -> Vec { + list_dir(d) + .into_iter() + .filter_map(|n| n.strip_suffix('/').map(|n| d.join(n))) + .collect() + }; + let mut unpacked = false; + let mut out = Vec::new(); + for hash in subdirs(&dists.join(name)) { + for top in subdirs(&hash) { + unpacked = true; + let init = top.join("init.d"); + if init.is_dir() { + out.push(init); + } + } + } + unpacked.then_some(out) +} + +/// The init scripts that apply to the build rooted at `build_root`: the user +/// home's fixed scripts and `init.d`, `$GRADLE_HOME/init.d`, and the +/// `init.d` of the distribution its wrapper names (wherever +/// `distributionBase` / `distributionPath` unpack it). Without a wrapper, +/// every unpacked wrapper distribution's `init.d` counts (the build may run +/// any of them). A wrapper whose distribution is not a stock Gradle one and +/// is not unpacked yet cannot be read: it is reported unreadable, so +/// `mavenLocal()` stays undetermined. +pub fn init_scripts_for_build(home: &GradleHome, build_root: &Path) -> InitScripts { + let wrapper = match WrapperProps::of(build_root) { + None => return read_init_scripts(home), + Some(Ok(wrapper)) => wrapper, + Some(Err(why)) => { + // A wrapper names a distribution this run cannot identify: its + // init.d may declare anything. + let mut out = read_init_scripts(home); + out.unreadable.push(format!("wrapper properties {why}")); + return out; + } + }; + let mut out = InitScripts::default(); + for path in home.init_script_paths() { + out.read(&path); + } + let base = if wrapper.project_base { + build_root + } else { + home.user_home.as_path() + }; + let dists = base.join(&wrapper.path); + let mut dirs = vec![home.user_home.join("init.d")]; + match dist_init_dirs(&dists, wrapper.name()) { + Some(found) => dirs.extend(found), + None if !wrapper.is_stock() => out.unreadable.push(format!( + "wrapper distribution {} (not unpacked under {})", + wrapper.url, + dists.display() + )), + None => {} + } + dirs.extend(home.gradle_home.as_ref().map(|g| g.join("init.d"))); + for dir in dirs { + for name in list_dir(&dir) { + if is_init_script_name(&name) { + out.read(&dir.join(name)); + } + } + } + out +} + +// ── the build and mavenLocal() ────────────────────────────────────────── + +/// Gradle build files (the Gradle half of `jvm_cache::JVM_PROJECT_MARKERS`). +pub const GRADLE_MARKERS: &[&str] = &[ + "build.gradle", + "build.gradle.kts", + "settings.gradle", + "settings.gradle.kts", +]; + +const SETTINGS: &[&str] = &["settings.gradle", "settings.gradle.kts"]; + +/// Whether `dir` holds a Gradle build or settings script. +pub fn has_gradle_marker(dir: &Path) -> bool { + GRADLE_MARKERS.iter().any(|m| dir.join(m).is_file()) +} + +/// The Gradle build roots to analyse for a cwd that is a Gradle project: +/// the cwd itself and, when it has no settings script, the nearest ancestor +/// that has one (Gradle searches upwards for the settings of a +/// subproject). Empty when the cwd has no Gradle marker. +pub fn build_roots(cwd: &Path) -> Vec { + if !has_gradle_marker(cwd) { + return Vec::new(); + } + let mut roots = vec![cwd.to_path_buf()]; + if !SETTINGS.iter().any(|s| cwd.join(s).is_file()) { + if let Some(up) = cwd + .ancestors() + .skip(1) + .find(|d| SETTINGS.iter().any(|s| d.join(s).is_file())) + { + roots.push(up.to_path_buf()); + } + } + roots +} + +/// The script graph of the build at `root`, with the init scripts that +/// apply to it (unreadable ones noted). +pub fn script_graph(root: &Path, init: &InitScripts) -> ScriptGraph { + let read = fs_text_read(root); + let list = fs_list(root); + let mut graph = ScriptGraph::collect(&read, &list, "", &init.scripts); + for tag in &init.unreadable { + graph.note_unreadable_init_script(tag); + } + graph +} + +/// Whether the Gradle build at `cwd` reads the Maven local repository +/// (`mavenLocal()`), across every build root ([`build_roots`]) and the init +/// scripts that apply. `home` is the Gradle user home (`None` = unknown, +/// so the init scripts are too). Declared wins over undetermined, which +/// wins over not declared. +pub fn maven_local(cwd: &Path, home: Option<&GradleHome>) -> MavenLocal { + let mut verdict = MavenLocal::NotDeclared; + for root in build_roots(cwd) { + let init = match home { + Some(home) => init_scripts_for_build(home, &root), + None => InitScripts { + unreadable: vec!["(no Gradle user home could be resolved)".to_string()], + ..InitScripts::default() + }, + }; + match script_graph(&root, &init).maven_local() { + declared @ MavenLocal::Declared(_) => return declared, + undetermined @ MavenLocal::Undetermined(_) => { + if verdict == MavenLocal::NotDeclared { + verdict = undetermined; + } + } + MavenLocal::NotDeclared => {} + } + } + verdict +} + +/// The modules locked by the lock files of the Gradle build at `cwd` +/// (every build root's graph-scoped lock files, +/// `ScriptGraph::lockfile_paths`). An annotation only: locks never narrow +/// discovery. +pub fn locked_gavs(cwd: &Path) -> BTreeSet { + let mut out = BTreeSet::new(); + for root in build_roots(cwd) { + let graph = script_graph(&root, &InitScripts::default()); + let read = fs_text_read(&root); + for rel in graph.lockfile_paths(&fs_list(&root)) { + let Some(text) = read(&rel) else { + continue; + }; + for e in crate::gradle::locks::parse(&text).entries { + out.insert((e.group, e.artifact, e.version)); + } + } + } + out +} diff --git a/crates/socket-patch-core/src/crawlers/jvm_cache.rs b/crates/socket-patch-core/src/crawlers/jvm_cache.rs new file mode 100644 index 000000000..68be876f8 --- /dev/null +++ b/crates/socket-patch-core/src/crawlers/jvm_cache.rs @@ -0,0 +1,236 @@ +//! Shared seam for JVM build tools whose artifacts land in different +//! caches (Maven's `~/.m2/repository`, Gradle's `modules-2`, Coursier, +//! Ivy). Every Maven-PURL discovery path goes through here: +//! +//! - [`JVM_PROJECT_MARKERS`]: the files that make a directory a JVM +//! project root (each build tool contributes its own). +//! - [`JvmCacheLayout`] / [`JvmCacheRoot`]: an installed-artifact cache +//! and how its directories spell coordinates. [`MavenCrawler`] crawls +//! and resolves PURLs per root, dispatching on the layout. +//! - [`locate_artifact`] / [`all_local_roots`]: every installed copy of +//! one artifact file across the local caches, for sourcing its bytes. +//! - [`project_dependency_set`]: the coordinates a project actually +//! resolves, from one provider per build tool (Gradle lock state, an sbt +//! lock, …). `None` means no provider could tell, so callers fall back to +//! the whole-cache crawl. +//! +//! Each build tool keeps its implementation in its own module and only +//! adds a variant / list entry / match arm here. +//! +//! [`MavenCrawler`]: super::MavenCrawler + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +/// Files whose presence makes a directory a JVM project root. +pub const JVM_PROJECT_MARKERS: &[&str] = &[ + // Maven + "pom.xml", + // Gradle + "build.gradle", + "build.gradle.kts", + "settings.gradle", + "settings.gradle.kts", +]; + +/// Whether `dir` holds any [`JVM_PROJECT_MARKERS`] file. +pub async fn is_jvm_project(dir: &Path) -> bool { + for marker in JVM_PROJECT_MARKERS { + if tokio::fs::metadata(dir.join(marker)).await.is_ok() { + return true; + } + } + false +} + +/// How a cache root's directories spell an artifact's coordinates. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum JvmCacheLayout { + /// `///-.{pom,jar}` + /// (Maven local repository). + Maven2, + /// `////` (Gradle's + /// `caches/modules-2/files-2.1`). + GradleModules2, + /// Coursier's per-repository-URL cache. + Coursier, + /// Ivy's `~/.ivy2` cache. + Ivy, +} + +impl JvmCacheLayout { + /// The layout of the cache rooted at `path`, from the root's own + /// spelling (each cache's root directory has a distinctive name). + /// Anything unrecognized is a Maven local repository. + pub fn classify(path: &Path) -> Self { + match path.file_name().and_then(|n| n.to_str()) { + Some("files-2.1") => Self::GradleModules2, + _ => Self::Maven2, + } + } +} + +/// One installed-artifact cache to crawl. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct JvmCacheRoot { + pub path: PathBuf, + pub layout: JvmCacheLayout, +} + +impl JvmCacheRoot { + pub fn new(path: PathBuf, layout: JvmCacheLayout) -> Self { + Self { path, layout } + } +} + +/// Maven coordinates `(group_id, artifact_id, version)`. +pub type Gav = (String, String, String); + +/// What a project resolves, as far as one build tool's provider can tell. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ProjectDependencySet { + /// The provider that answered (`"gradle"`, `"sbt"`, …). + pub provider: &'static str, + pub gavs: BTreeSet, +} + +/// The coordinates the project rooted at `root` resolves, from the first +/// build-tool provider that recognizes it. `None` = no provider knows +/// (callers fall back to every cached artifact). +pub fn project_dependency_set(root: &Path) -> Option { + // One entry per build tool; each returns `None` for a root it does not + // own or cannot read. + let providers: &[fn(&Path) -> Option] = &[]; + providers.iter().find_map(|provider| provider(root)) +} + +/// Every local JVM cache that exists on this machine (process environment), +/// whatever the build at `cwd` resolves from: for sourcing an artifact's +/// bytes ([`locate_artifact`]), never for discovery. See +/// [`all_local_roots_with`]. +pub fn all_local_roots(cwd: &Path) -> Vec { + all_local_roots_with(cwd, &super::maven_crawler::JvmEnv::from_process()) +} + +/// [`all_local_roots`] under the caches `env` names: the Gradle user home's +/// `files-2.1`, the read-only Gradle cache and the Maven local repository, +/// each when it is a directory. A Gradle build at `cwd` lists the Gradle +/// caches first; anything else the Maven local repository first. +pub fn all_local_roots_with(cwd: &Path, env: &super::maven_crawler::JvmEnv) -> Vec { + let mut gradle = Vec::new(); + if let Some(home) = &env.gradle { + for dir in std::iter::once(&home.files21).chain(&home.ro_files21) { + if dir.is_dir() { + gradle.push(JvmCacheRoot::new( + dir.clone(), + JvmCacheLayout::GradleModules2, + )); + } + } + } + let m2 = env + .m2_repo + .is_dir() + .then(|| JvmCacheRoot::new(env.m2_repo.clone(), JvmCacheLayout::Maven2)); + if super::gradle_cache::has_gradle_marker(cwd) { + gradle.extend(m2); + gradle + } else { + m2.into_iter().chain(gradle).collect() + } +} + +/// Every installed copy of one artifact file +/// (`-[-].`) under `root`: the one +/// repository path for [`JvmCacheLayout::Maven2`], every hash directory's +/// copy for [`JvmCacheLayout::GradleModules2`] (sorted). Only existing +/// regular files are returned; unsafe coordinates resolve to nothing. +pub fn locate_artifact( + root: &JvmCacheRoot, + gav: &Gav, + classifier: Option<&str>, + ext: &str, +) -> Vec { + let (group, artifact, version) = gav; + let classifier_ok = classifier.is_none_or(crate::patch::path_safety::is_safe_single_segment); + let ext_ok = crate::patch::path_safety::is_safe_single_segment(ext); + if !super::maven_crawler::is_safe_maven_coordinate(group, artifact, version) + || !classifier_ok + || !ext_ok + { + return Vec::new(); + } + let leaf = match classifier { + Some(c) => format!("{artifact}-{version}-{c}.{ext}"), + None => format!("{artifact}-{version}.{ext}"), + }; + match root.layout { + JvmCacheLayout::Maven2 => { + let path = root + .path + .join(group.replace('.', "/")) + .join(artifact) + .join(version) + .join(&leaf); + if path.is_file() { + vec![path] + } else { + Vec::new() + } + } + JvmCacheLayout::GradleModules2 => { + let version_dir = root.path.join(group).join(artifact).join(version); + let mut copies: Vec = std::fs::read_dir(&version_dir) + .into_iter() + .flatten() + .flatten() + .filter(|e| { + e.file_name() + .to_str() + .is_some_and(super::gradle_cache::is_hash_dir_name) + }) + .map(|e| e.path().join(&leaf)) + .filter(|p| p.is_file()) + .collect(); + copies.sort(); + copies + } + // sbt: Coursier / Ivy plug in here. + JvmCacheLayout::Coursier | JvmCacheLayout::Ivy => Vec::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classify_recognizes_gradle_files_root_and_defaults_to_maven2() { + let gradle = Path::new("/h/.gradle/caches/modules-2/files-2.1"); + assert_eq!( + JvmCacheLayout::classify(gradle), + JvmCacheLayout::GradleModules2 + ); + let m2 = Path::new("/h/.m2/repository"); + assert_eq!(JvmCacheLayout::classify(m2), JvmCacheLayout::Maven2); + assert_eq!( + JvmCacheLayout::classify(Path::new("")), + JvmCacheLayout::Maven2 + ); + } + + #[test] + fn no_provider_means_whole_cache_fallback() { + assert_eq!(project_dependency_set(Path::new("/nonexistent")), None); + } + + #[tokio::test] + async fn every_marker_makes_a_jvm_project() { + for marker in JVM_PROJECT_MARKERS { + let dir = tempfile::tempdir().unwrap(); + assert!(!is_jvm_project(dir.path()).await); + std::fs::write(dir.path().join(marker), "").unwrap(); + assert!(is_jvm_project(dir.path()).await, "{marker}"); + } + } +} diff --git a/crates/socket-patch-core/src/crawlers/maven_crawler.rs b/crates/socket-patch-core/src/crawlers/maven_crawler.rs index da2a15846..d9564b395 100644 --- a/crates/socket-patch-core/src/crawlers/maven_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/maven_crawler.rs @@ -2,8 +2,13 @@ use std::borrow::Cow; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; +use super::gradle_cache; +use super::jvm_cache::{self, JvmCacheLayout, JvmCacheRoot}; use super::types::{CrawledPackage, CrawlerOptions}; use super::walk_pool::{par_map, run_walk}; +use crate::gradle::graph::MavenLocal; +use crate::gradle::home::GradleHome; +use crate::gradle::{Env, Os}; use crate::patch::path_safety; use crate::utils::fs::is_dir; @@ -536,12 +541,167 @@ pub(crate) fn is_safe_maven_coordinate(group_id: &str, artifact_id: &str, versio && path_safety::is_safe_single_segment(version) } +// --------------------------------------------------------------------------- +// Cache roots +// --------------------------------------------------------------------------- + +/// The JVM caches a run resolves against: the Maven local repository and +/// the Gradle user home. [`JvmEnv::from_process`] reads the process +/// environment; [`JvmEnv::resolve`] an explicit one (tests). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JvmEnv { + /// The Maven local repository (see [`MavenCrawler::get_maven_repo_paths`]). + pub m2_repo: PathBuf, + /// The Gradle user home; `None` when none can be resolved. + pub gradle: Option, +} + +impl JvmEnv { + /// The caches of this process: `$MAVEN_REPO_LOCAL` / `$M2_HOME` / + /// `~/.m2`, and [`gradle_cache::home_from_process_env`]. + pub fn from_process() -> Self { + Self { + m2_repo: MavenCrawler::m2_repo_path(), + gradle: gradle_cache::home_from_process_env(), + } + } + + /// The caches `env` names. `home_dir` is the account's home (on Unix + /// the passwd entry's), which Gradle prefers to `$HOME`. + pub fn resolve(env: &dyn Env, os: Os, home_dir: Option<&Path>) -> Self { + Self { + m2_repo: m2_repo_path_with(env, home_dir), + gradle: GradleHome::resolve(env, os, home_dir), + } + } + + /// The existing Gradle `files-2.1` caches: the user home's, then the + /// read-only one. + async fn gradle_roots(&self) -> Vec { + let mut roots = Vec::new(); + let Some(home) = &self.gradle else { + return roots; + }; + for dir in std::iter::once(&home.files21).chain(&home.ro_files21) { + if is_dir(dir).await { + roots.push(JvmCacheRoot::new( + dir.clone(), + JvmCacheLayout::GradleModules2, + )); + } + } + roots + } + + /// Whether `path` is the read-only Gradle cache + /// (`$GRADLE_RO_DEP_CACHE/modules-2/files-2.1`), which is scanned but + /// never written. + pub fn is_ro_root(&self, path: &Path) -> bool { + self.gradle + .as_ref() + .and_then(|h| h.ro_files21.as_deref()) + .is_some_and(|ro| ro == path) + } +} + +/// Whether `path` is the read-only Gradle cache of this process +/// ([`JvmEnv::is_ro_root`]). +pub fn is_ro_root(path: &Path) -> bool { + JvmEnv::from_process().is_ro_root(path) +} + +/// The Maven local repository `env` names: `$MAVEN_REPO_LOCAL`, else +/// `$M2_HOME/repository`, else `/.m2/repository` with `` = +/// `$HOME`, `$USERPROFILE`, `home_dir`, or `~`. A set-but-empty variable +/// counts as unset (see [`MavenCrawler::m2_repo_path`]). +pub fn m2_repo_path_with(env: &dyn Env, home_dir: Option<&Path>) -> PathBuf { + let set = |k: &str| env.var(k).filter(|v| !v.is_empty()); + if let Some(repo_local) = set("MAVEN_REPO_LOCAL") { + return PathBuf::from(repo_local); + } + if let Some(m2_home) = set("M2_HOME") { + return PathBuf::from(m2_home).join("repository"); + } + let home = set("HOME") + .or_else(|| set("USERPROFILE")) + .map(PathBuf::from) + .or_else(|| home_dir.map(Path::to_path_buf)) + .unwrap_or_else(|| PathBuf::from("~")); + home.join(".m2").join("repository") +} + +/// A `--global-prefix` as the cache root it names: a Gradle user home +/// (`.gradle`), its `caches/modules-2`, or a read-only cache's `modules-2` +/// stands for the existing `files-2.1` inside it. Anything else (a +/// `files-2.1` itself, a Maven repository — even one named `caches`) is +/// returned unchanged. +pub fn normalize_prefix(prefix: &Path) -> PathBuf { + let inner = match prefix.file_name().and_then(|n| n.to_str()) { + Some(".gradle") => prefix + .join("caches") + .join("modules-2") + .join(gradle_cache::FILES21), + Some("modules-2") => prefix.join(gradle_cache::FILES21), + _ => return prefix.to_path_buf(), + }; + if inner.is_dir() { + inner + } else { + prefix.to_path_buf() + } +} + +/// Whether a local scan of `cwd` counts the Maven local repository. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum M2Gate { + /// Not a Gradle-only project (a `pom.xml`, or no Gradle marker): m2 + /// counts as always. + NotGradleOnly, + /// A Gradle build that declares `mavenLocal()` (in this script). + Declared(String), + /// A Gradle build whose scripts or init scripts could not all be read + /// literally, so `mavenLocal()` cannot be ruled out: m2 is kept. + Undetermined(String), + /// A Gradle-only build that never reads m2. + Ignored, +} + +/// See [`M2Gate`]. Reads the build's scripts and the init scripts of +/// `env`'s Gradle user home. +pub fn m2_gate(cwd: &Path, env: &JvmEnv) -> M2Gate { + if cwd.join("pom.xml").exists() || !gradle_cache::has_gradle_marker(cwd) { + return M2Gate::NotGradleOnly; + } + match gradle_cache::maven_local(cwd, env.gradle.as_ref()) { + MavenLocal::Declared(at) => M2Gate::Declared(at), + MavenLocal::Undetermined(why) => M2Gate::Undetermined(why), + MavenLocal::NotDeclared => M2Gate::Ignored, + } +} + +/// Whether a Gradle-only build at `cwd` ignores the Maven local repository +/// (process environment; see [`m2_gate`]). +pub fn gradle_m2_ignored(cwd: &Path) -> bool { + m2_gate(cwd, &JvmEnv::from_process()) == M2Gate::Ignored +} + +/// Why `mavenLocal()` could not be ruled out for the Gradle build at +/// `cwd`, when m2 is kept for that reason (process environment). +pub fn maven_local_undetermined(cwd: &Path) -> Option { + match m2_gate(cwd, &JvmEnv::from_process()) { + M2Gate::Undetermined(why) => Some(why), + _ => None, + } +} + // --------------------------------------------------------------------------- // MavenCrawler // --------------------------------------------------------------------------- -/// Maven/Java ecosystem crawler for discovering packages in the local -/// Maven repository (`~/.m2/repository/`). +/// Maven/Java ecosystem crawler for discovering packages in the JVM +/// artifact caches: the local Maven repository (`~/.m2/repository/`) and +/// Gradle's module cache (`~/.gradle/caches/modules-2/files-2.1`, plus the +/// read-only `$GRADLE_RO_DEP_CACHE`). pub struct MavenCrawler; impl MavenCrawler { @@ -554,56 +714,131 @@ impl MavenCrawler { // Public API // ------------------------------------------------------------------ - /// Get Maven repository paths based on options. + /// Every JVM artifact cache whose packages a scan reports, tagged with + /// its layout ([`Self::get_jvm_cache_roots_with`] over the process + /// environment). + pub async fn get_jvm_cache_roots(&self, options: &CrawlerOptions) -> Vec { + self.get_jvm_cache_roots_with(options, &JvmEnv::from_process()) + .await + } + + /// Every JVM artifact cache whose packages a scan reports, under the + /// caches `env` names. Order: Gradle's `files-2.1`, the read-only + /// Gradle cache, the Maven local repository. /// - /// In global mode, returns `~/.m2/repository/` (respects `$M2_HOME`, - /// `$MAVEN_REPO_LOCAL`, `--global-prefix`). + /// - `--global-prefix` names one root, its layout + /// [`JvmCacheLayout::classify`]'d after [`normalize_prefix`] (a Gradle + /// user home, `caches/modules-2` or `modules-2` stands for the + /// `files-2.1` inside it). + /// - In local mode nothing is returned unless the cwd is a JVM project + /// ([`jvm_cache::is_jvm_project`]), so non-Java projects are never + /// scanned against a shared cache. + /// - The Gradle caches count for a Gradle build (a Gradle marker in the + /// cwd) or in global mode. + /// - The Maven local repository counts in global mode, for a `pom.xml` + /// or a cwd with no Gradle marker, and for a Gradle build that reads + /// it: `mavenLocal()` declared in the build's scripts or an init + /// script, or not ruled out ([`m2_gate`]). A Gradle-only build that + /// never declares it does not resolve from `~/.m2`, so its contents + /// are not that build's packages (#551). /// - /// In local mode, only returns the Maven repo if the cwd contains - /// `pom.xml`, `build.gradle`, `build.gradle.kts`, `settings.gradle`, - /// or `settings.gradle.kts` (prevents scanning for non-Java projects). + /// Lock files never narrow any of this. + pub async fn get_jvm_cache_roots_with( + &self, + options: &CrawlerOptions, + env: &JvmEnv, + ) -> Vec { + if let Some(ref custom) = options.global_prefix { + let path = normalize_prefix(custom); + let layout = JvmCacheLayout::classify(&path); + return vec![JvmCacheRoot::new(path, layout)]; + } + if !options.global && !jvm_cache::is_jvm_project(&options.cwd).await { + return Vec::new(); + } + let gradle_build = !options.global && { + let cwd = options.cwd.clone(); + run_walk(move || gradle_cache::has_gradle_marker(&cwd)).await + }; + let mut roots = Vec::new(); + if options.global || gradle_build { + roots.extend(env.gradle_roots().await); + } + let m2 = options.global || { + let (cwd, env) = (options.cwd.clone(), env.clone()); + run_walk(move || m2_gate(&cwd, &env)).await != M2Gate::Ignored + }; + if m2 && is_dir(&env.m2_repo).await { + roots.push(JvmCacheRoot::new( + env.m2_repo.clone(), + JvmCacheLayout::Maven2, + )); + } + roots + } + + /// The caches the existing PURL join sites (apply, rollback, vendor, + /// VEX) resolve against ([`Self::find_by_purls`]): the Maven local + /// repository, even when a Gradle build does not read it (its bytes are + /// still a valid source for vendoring), and any other non-Gradle root + /// of [`Self::get_jvm_cache_roots`]. Gradle `files-2.1` roots are left + /// out: their packages are version directories that only a caller + /// expanding them through [`gradle_cache::installed_copies`] can join + /// file keys onto, so they come from [`Self::get_maven_copy_paths`] + /// instead. Each path's layout is recovered by + /// [`JvmCacheLayout::classify`] in [`Self::find_by_purls`]. pub async fn get_maven_repo_paths( &self, options: &CrawlerOptions, ) -> Result, std::io::Error> { - if options.global || options.global_prefix.is_some() { - if let Some(ref custom) = options.global_prefix { - return Ok(vec![custom.clone()]); - } - let repo = Self::m2_repo_path(); - if is_dir(&repo).await { - return Ok(vec![repo]); - } - return Ok(Vec::new()); - } - - // Local mode: only return Maven repo if this looks like a Java/Maven/Gradle project - let java_markers = [ - "pom.xml", - "build.gradle", - "build.gradle.kts", - "settings.gradle", - "settings.gradle.kts", - ]; + self.get_maven_repo_paths_with(options, &JvmEnv::from_process()) + .await + } - let mut is_java_project = false; - for marker in &java_markers { - if tokio::fs::metadata(options.cwd.join(marker)).await.is_ok() { - is_java_project = true; - break; - } - } + /// [`Self::get_maven_repo_paths`] under the caches `env` names. + pub async fn get_maven_repo_paths_with( + &self, + options: &CrawlerOptions, + env: &JvmEnv, + ) -> Result, std::io::Error> { + let mut paths = self.get_maven_copy_paths_with(options, env).await?; + paths.retain(|p| JvmCacheLayout::classify(p) != JvmCacheLayout::GradleModules2); + Ok(paths) + } + + /// Every cache holding installed copies of a PURL, for callers that + /// expand Gradle version directories through + /// [`gradle_cache::installed_copies`]: [`Self::get_maven_repo_paths`]'s + /// roots plus the Gradle `files-2.1` caches of + /// [`Self::get_jvm_cache_roots`]. The Maven local repository comes + /// first, so a caller that takes the first copy keeps resolving where + /// it always did. + pub async fn get_maven_copy_paths( + &self, + options: &CrawlerOptions, + ) -> Result, std::io::Error> { + self.get_maven_copy_paths_with(options, &JvmEnv::from_process()) + .await + } - if !is_java_project { - return Ok(Vec::new()); + /// [`Self::get_maven_copy_paths`] under the caches `env` names. + pub async fn get_maven_copy_paths_with( + &self, + options: &CrawlerOptions, + env: &JvmEnv, + ) -> Result, std::io::Error> { + let jvm = options.global_prefix.is_none() + && (options.global || jvm_cache::is_jvm_project(&options.cwd).await); + let mut paths = Vec::new(); + if jvm && is_dir(&env.m2_repo).await { + paths.push(env.m2_repo.clone()); } - - let repo = Self::m2_repo_path(); - if is_dir(&repo).await { - Ok(vec![repo]) - } else { - Ok(Vec::new()) + for root in self.get_jvm_cache_roots_with(options, env).await { + if !paths.contains(&root.path) { + paths.push(root.path); + } } + Ok(paths) } /// Crawl all discovered Maven repository paths and return every @@ -612,14 +847,12 @@ impl MavenCrawler { let mut packages = Vec::new(); let mut seen = HashSet::new(); - let repo_paths = self.get_maven_repo_paths(options).await.unwrap_or_default(); - - for repo_path in repo_paths { + for root in self.get_jvm_cache_roots(options).await { // The walkdir walk and POM reads are blocking: run each repo // on the walk pool so concurrently crawled ecosystems keep // making progress (the dedup set rides along and comes back). let (found, returned_seen) = run_walk(move || { - let found = MavenCrawler.scan_maven_repo(&repo_path, &mut seen); + let found = MavenCrawler.scan_cache_root(&root, &mut seen); (found, seen) }) .await; @@ -640,6 +873,16 @@ impl MavenCrawler { src_path: &Path, purls: &[String], ) -> Result, std::io::Error> { + match JvmCacheLayout::classify(src_path) { + JvmCacheLayout::Maven2 => {} + JvmCacheLayout::GradleModules2 => { + let src = src_path.to_path_buf(); + let purls = purls.to_vec(); + return Ok(run_walk(move || Self::find_in_files21(&src, &purls)).await); + } + // Other layouts plug in here; until then they resolve nothing. + JvmCacheLayout::Coursier | JvmCacheLayout::Ivy => return Ok(HashMap::new()), + } let mut result: HashMap = HashMap::new(); for purl in purls { @@ -707,17 +950,7 @@ impl MavenCrawler { /// Same rule as `nuget_home()`, `deno_dir()`, `go_crawler`'s /// `get_gomodcache`, and `utils::fs::home_dir`. fn m2_repo_path() -> PathBuf { - if let Ok(repo_local) = std::env::var("MAVEN_REPO_LOCAL") { - if !repo_local.is_empty() { - return PathBuf::from(repo_local); - } - } - if let Ok(m2_home) = std::env::var("M2_HOME") { - if !m2_home.is_empty() { - return PathBuf::from(m2_home).join("repository"); - } - } - crate::utils::fs::home_dir().join(".m2").join("repository") + m2_repo_path_with(&gradle_cache::ProcessEnv, None) } /// Scan a Maven repository directory and return all valid packages found. @@ -742,6 +975,82 @@ impl MavenCrawler { self.scan_maven_repo_chunked(repo_path, seen, POM_PARSE_CHUNK) } + /// Crawl one cache root according to its layout. + fn scan_cache_root( + &self, + root: &JvmCacheRoot, + seen: &mut HashSet, + ) -> Vec { + match root.layout { + JvmCacheLayout::Maven2 => self.scan_maven_repo(&root.path, seen), + JvmCacheLayout::GradleModules2 => Self::scan_files21(&root.path, seen), + // Other layouts plug in here; until then they crawl nothing. + JvmCacheLayout::Coursier | JvmCacheLayout::Ivy => Vec::new(), + } + } + + /// Crawl a Gradle `files-2.1` tree: one package per version directory + /// that holds `-.{jar,pom,module}` in some hash + /// directory ([`gradle_cache::has_module_file`]), its path the VERSION + /// directory ([`gradle_cache::installed_copies`] expands it). The + /// coordinates are the directory names; no POM is read. Lock files never + /// narrow this: a dependency of an unlocked configuration (buildscript, + /// plugins) is as installed as a locked one. + fn scan_files21(root: &Path, seen: &mut HashSet) -> Vec { + let mut results = Vec::new(); + for ((group_id, artifact_id, version), entries) in gradle_cache::walk_versions(root) { + if !gradle_cache::has_module_file(&entries) { + continue; + } + let purl = crate::utils::purl::build_maven_purl(&group_id, &artifact_id, &version); + if seen.insert(purl.clone()) { + results.push(CrawledPackage { + path: root.join(&group_id).join(&artifact_id).join(&version), + name: artifact_id, + version, + namespace: Some(group_id), + purl, + }); + } + } + results + } + + /// [`Self::find_by_purls`] over a Gradle `files-2.1` tree: the version + /// directory `///` when it is + /// installed ([`gradle_cache::is_installed`]). + fn find_in_files21(root: &Path, purls: &[String]) -> HashMap { + let mut result = HashMap::new(); + for purl in purls { + let Some((group_id, artifact_id, version)) = crate::utils::purl::parse_maven_purl(purl) + else { + continue; + }; + let gav = ( + group_id.into_owned(), + artifact_id.into_owned(), + version.into_owned(), + ); + // SECURITY: `is_installed` refuses unsafe coordinates before + // joining them onto the root (see `is_safe_maven_coordinate`). + if !gradle_cache::is_installed(root, &gav) { + continue; + } + let (group_id, artifact_id, version) = gav; + result.insert( + purl.clone(), + CrawledPackage { + path: root.join(&group_id).join(&artifact_id).join(&version), + name: artifact_id, + version, + namespace: Some(group_id), + purl: purl.clone(), + }, + ); + } + result + } + /// [`Self::scan_maven_repo`] over an explicit chunk size, so tests can /// cross the chunk boundary on a small fixture. fn scan_maven_repo_chunked( diff --git a/crates/socket-patch-core/src/crawlers/mod.rs b/crates/socket-patch-core/src/crawlers/mod.rs index b0c257f50..cd9a51a88 100644 --- a/crates/socket-patch-core/src/crawlers/mod.rs +++ b/crates/socket-patch-core/src/crawlers/mod.rs @@ -3,6 +3,8 @@ pub mod composer_crawler; pub mod deno_crawler; pub mod fuzzy_match; pub mod go_crawler; +pub mod gradle_cache; +pub mod jvm_cache; mod listing; pub mod maven_crawler; #[cfg(test)] diff --git a/crates/socket-patch-core/src/formats/registry.rs b/crates/socket-patch-core/src/formats/registry.rs index 04d5e6312..6fb0baf6c 100644 --- a/crates/socket-patch-core/src/formats/registry.rs +++ b/crates/socket-patch-core/src/formats/registry.rs @@ -6,11 +6,12 @@ //! //! The roles intentionally diverge per file (a binary Bun lock has a native //! reader and is never text-scanned for wiring; `pnpm-lock.yml` is only a -//! package-manager marker; Gradle scripts are read by the hosted Maven -//! planner for their presence only); each divergence is one flag on one -//! row. Paths are root-relative with `/` separators. Dynamic sets — PEP 751 -//! / PEP 723 Python locks, vlt importer manifests, requirements `-r` -//! includes, Rush's nested pnpm locks — are enumerated by their callers. +//! package-manager marker; Gradle build scripts are read but never edited); +//! each divergence is one flag on one row. Paths are root-relative with `/` +//! separators. Dynamic sets — PEP 751 / PEP 723 Python locks, vlt importer +//! manifests, requirements `-r` includes, Rush's nested pnpm locks, the +//! Gradle script graph and its lock files — are enumerated by their +//! callers. /// Read by the hosted planners (`scan --mode hosted`, the in-memory /// engine's candidate reads). @@ -152,12 +153,38 @@ const REGISTRY: &[FormatFile] = &[ "maven", HOSTED | PRESENCE_ONLY, ), - // Gradle build scripts are never edited — their presence only feeds the - // maven planner's paste-able `exclusiveContent` snippet warning. - row("settings.gradle", "maven", HOSTED | PRESENCE_ONLY), - row("settings.gradle.kts", "maven", HOSTED | PRESENCE_ONLY), + // ── gradle (the maven ecosystem's Gradle builds) ── + // Settings files carry the apply line of the hosted and the vendored + // owned scripts. Build scripts are never edited: their presence makes + // the root a Gradle build, and the hosted planner reads them (with the + // rest of the script graph, which the engine walks dynamically). + row("settings.gradle", "maven", HOSTED | VENDORED), + row("settings.gradle.kts", "maven", HOSTED | VENDORED), row("build.gradle", "maven", HOSTED | PRESENCE_ONLY), row("build.gradle.kts", "maven", HOSTED | PRESENCE_ONLY), + // The root project's lock files; every other project's are walked + // through the script graph. Never ROOT: the in-memory engine would + // take them for maven roots (`ecosystem_unsupported_in_memory`). + row("gradle.lockfile", "maven", HOSTED | PROBE), + row("buildscript-gradle.lockfile", "maven", HOSTED | PROBE), + row("settings-gradle.lockfile", "maven", HOSTED | PROBE), + // Never created; an existing one gets the suffixed component. + row("gradle/verification-metadata.xml", "maven", HOSTED), + // Read for the wrapper's Gradle version (hosted patches need 6.8+). + row( + "gradle/wrapper/gradle-wrapper.properties", + "maven", + HOSTED | PRESENCE_ONLY, + ), + // The hosted planner's owned index and script. + row(".socket/gradle/hosted-index.tsv", "maven", HOSTED | PROBE), + row( + ".socket/gradle/socket-patch.hosted.settings.gradle", + "maven", + HOSTED | PROBE, + ), + // The vendored Gradle index: ledger-less repair finds its rows. + row(".socket/vendor/gradle-index.tsv", "maven", VENDORED | PROBE), // deno.lock is deliberately absent: deno is its own ecosystem // (JSR-crawled) and no planner edits its integrity entries. ]; @@ -229,6 +256,15 @@ mod tests { assert_eq!(hosted_file_ecosystem(".cargo/config"), Some("cargo")); assert_eq!(hosted_file_ecosystem("checksums.sha256"), Some("maven")); assert_eq!(hosted_file_ecosystem("build.gradle"), None); + assert_eq!(hosted_file_ecosystem("settings.gradle.kts"), Some("maven")); + assert_eq!(hosted_file_ecosystem("sub/gradle.lockfile"), Some("maven")); + assert_eq!(hosted_file_ecosystem("hosted-index.tsv"), Some("maven")); + assert_eq!( + hosted_file_ecosystem("gradle/verification-metadata.xml"), + Some("maven") + ); + assert_eq!(hosted_file_ecosystem("gradle-wrapper.properties"), None); + assert_eq!(hosted_file_ecosystem("gradle-index.tsv"), None); assert_eq!(hosted_file_ecosystem("Pipfile"), None); assert_eq!(hosted_file_ecosystem("package.json"), None); assert_eq!(hosted_file_ecosystem("NuGet.Config"), Some("nuget")); diff --git a/crates/socket-patch-core/src/gradle/dsl.rs b/crates/socket-patch-core/src/gradle/dsl.rs new file mode 100644 index 000000000..11e74cf07 --- /dev/null +++ b/crates/socket-patch-core/src/gradle/dsl.rs @@ -0,0 +1,826 @@ +//! A small Groovy / Kotlin script tokenizer: enough of either DSL to find +//! blocks, calls and string literals without being fooled by comments or +//! strings. +//! +//! Comments are skipped (Kotlin block comments nest). A string literal is +//! one [`Tok::Str`]: Groovy `'…'`, `"…"`, `'''…'''`, `"""…"""` and Kotlin +//! `"…"`, `"""…"""` (raw, no escapes) and `'c'`. A string is `literal` +//! only when its value is fully known: any `$name` / `${…}` interpolation, +//! an escape we do not decode or a missing close quote makes it +//! non-literal (its `value` then holds the raw text, interpolations kept). +//! Everything else is an identifier, a number or one punctuation byte. +//! Slashy strings are not recognised (a `/` is punctuation). + +/// Which DSL a script is written in. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Dsl { + Groovy, + Kotlin, +} + +/// The DSL of a script from its file name: `*.gradle.kts` (and `*.kts`) is +/// Kotlin, `*.gradle` is Groovy, anything else is not a Gradle script. +pub fn dsl_of(rel: &str) -> Option { + if rel.ends_with(".kts") { + Some(Dsl::Kotlin) + } else if rel.ends_with(".gradle") { + Some(Dsl::Groovy) + } else { + None + } +} + +/// `s` without a leading UTF-8 byte-order mark. +pub fn strip_bom(s: &str) -> &str { + s.strip_prefix('\u{feff}').unwrap_or(s) +} + +/// Script bytes as text: a leading BOM is dropped and anything that is not +/// UTF-8 is `None` (unparseable), never decoded lossily. +pub fn decode(bytes: &[u8]) -> Option { + let bytes = bytes.strip_prefix(b"\xef\xbb\xbf").unwrap_or(bytes); + String::from_utf8(bytes.to_vec()).ok() +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Tok { + Ident(String), + /// A string literal. `literal` is false when it interpolates, uses an + /// escape we do not decode, or is unterminated. + Str { + value: String, + literal: bool, + }, + /// A run of digits and the letters / underscores glued to it. + Num(String), + Punct(u8), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Token { + pub tok: Tok, + /// Byte range in the source. + pub start: usize, + pub end: usize, +} + +/// Tokenize `text` (a BOM is skipped). +pub fn tokens(text: &str, dsl: Dsl) -> Vec { + lex(text, dsl).0 +} + +/// Whether `text` tokenizes cleanly: every string and block comment is +/// closed and every bracket is matched. A script that fails this is +/// treated as unparseable by the callers that must fail safe. +pub fn well_formed(text: &str, dsl: Dsl) -> bool { + let (toks, clean) = lex(text, dsl); + if !clean { + return false; + } + let mut stack = Vec::new(); + for t in &toks { + match t.tok { + Tok::Punct(c @ (b'(' | b'[' | b'{')) => stack.push(c), + Tok::Punct(c @ (b')' | b']' | b'}')) => { + let want = match c { + b')' => b'(', + b']' => b'[', + _ => b'{', + }; + if stack.pop() != Some(want) { + return false; + } + } + _ => {} + } + } + stack.is_empty() +} + +/// The tokens and whether every string / comment was closed. +fn lex(src: &str, dsl: Dsl) -> (Vec, bool) { + let b = src.as_bytes(); + let mut out = Vec::new(); + let mut clean = true; + let mut i = if src.starts_with('\u{feff}') { 3 } else { 0 }; + // A `#!` first line (shebang) is a comment in both DSLs. + if b[i..].starts_with(b"#!") { + while i < b.len() && b[i] != b'\n' { + i += 1; + } + } + while i < b.len() { + let c = b[i]; + if c.is_ascii_whitespace() { + i += 1; + } else if b[i..].starts_with(b"//") { + while i < b.len() && b[i] != b'\n' { + i += 1; + } + } else if b[i..].starts_with(b"/*") { + match skip_block_comment(b, i, dsl == Dsl::Kotlin) { + Some(end) => i = end, + None => { + clean = false; + i = b.len(); + } + } + } else if c == b'\'' || c == b'"' { + let s = scan_string(src, i, dsl); + clean &= s.closed; + out.push(Token { + tok: Tok::Str { + value: s.value, + literal: s.literal, + }, + start: i, + end: s.end, + }); + i = s.end; + } else if c == b'`' && dsl == Dsl::Kotlin { + // A backticked Kotlin identifier. + let start = i; + let close = src[i + 1..].find(['`', '\n']).map(|j| i + 1 + j); + match close { + Some(j) if b[j] == b'`' => { + out.push(Token { + tok: Tok::Ident(src[i + 1..j].to_string()), + start, + end: j + 1, + }); + i = j + 1; + } + _ => { + out.push(Token { + tok: Tok::Punct(b'`'), + start, + end: i + 1, + }); + i += 1; + } + } + } else if c.is_ascii_digit() { + let start = i; + while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == b'_') { + i += 1; + } + out.push(Token { + tok: Tok::Num(src[start..i].to_string()), + start, + end: i, + }); + } else if is_ident_start(src, i) { + let start = i; + while i < b.len() && is_ident_part(src, i) { + i += src[i..].chars().next().map_or(1, char::len_utf8); + } + out.push(Token { + tok: Tok::Ident(src[start..i].to_string()), + start, + end: i, + }); + } else if c.is_ascii() { + out.push(Token { + tok: Tok::Punct(c), + start: i, + end: i + 1, + }); + i += 1; + } else { + i += src[i..].chars().next().map_or(1, char::len_utf8); + } + } + (out, clean) +} + +fn is_ident_start(src: &str, i: usize) -> bool { + src[i..] + .chars() + .next() + .is_some_and(|ch| ch.is_alphabetic() || ch == '_' || ch == '$') +} + +fn is_ident_part(src: &str, i: usize) -> bool { + src[i..] + .chars() + .next() + .is_some_and(|ch| ch.is_alphanumeric() || ch == '_' || ch == '$') +} + +/// The end of the block comment opening at `i`; `None` when unclosed. +fn skip_block_comment(b: &[u8], mut i: usize, nests: bool) -> Option { + let mut depth = 0usize; + while i < b.len() { + if b[i..].starts_with(b"/*") && (nests || depth == 0) { + depth += 1; + i += 2; + } else if b[i..].starts_with(b"*/") { + depth -= 1; + i += 2; + if depth == 0 { + return Some(i); + } + } else { + i += 1; + } + } + None +} + +struct Scanned { + value: String, + literal: bool, + closed: bool, + end: usize, +} + +/// Scan the string literal whose opening quote is at `start`. +fn scan_string(src: &str, start: usize, dsl: Dsl) -> Scanned { + let b = src.as_bytes(); + let q = b[start]; + let triple = b[start..].starts_with(&[q, q, q]) && !(dsl == Dsl::Kotlin && q == b'\''); + // Kotlin raw strings decode no escapes; every other form does. + let raw = dsl == Dsl::Kotlin && triple; + // Groovy single-quoted strings never interpolate. + let templates = q == b'"'; + let mut i = start + if triple { 3 } else { 1 }; + let mut value = String::new(); + let mut literal = true; + loop { + if i >= b.len() { + return Scanned { + value, + literal: false, + closed: false, + end: b.len(), + }; + } + if triple { + if b[i..].starts_with(&[q, q, q]) { + // Kotlin: a run of more than three quotes keeps the extras. + let mut run = 3; + while dsl == Dsl::Kotlin && b.get(i + run) == Some(&q) { + run += 1; + } + for _ in 3..run { + value.push(q as char); + } + i += run; + break; + } + } else if b[i] == q { + i += 1; + break; + } else if b[i] == b'\n' { + return Scanned { + value, + literal: false, + closed: false, + end: i, + }; + } + if b[i] == b'\\' && !raw { + match decode_escape(src, i) { + Some((ch, len)) => { + if let Some(ch) = ch { + value.push(ch); + } + i += len; + } + None => { + literal = false; + let len = src[i + 1..].chars().next().map_or(0, char::len_utf8); + value.push_str(&src[i..i + 1 + len]); + i += 1 + len; + } + } + continue; + } + if b[i] == b'$' && templates { + if b.get(i + 1) == Some(&b'{') { + literal = false; + let end = skip_template(src, i + 1, dsl); + value.push_str(&src[i..end]); + i = end; + continue; + } + let ident = i + 1 < b.len() && is_ident_start(src, i + 1) && b[i + 1] != b'$'; + if ident || dsl == Dsl::Groovy { + // Groovy rejects a bare `$` in a GString; either way the + // value is not known. + literal = false; + } + } + let ch = src[i..].chars().next().unwrap_or('\u{fffd}'); + value.push(ch); + i += ch.len_utf8(); + } + Scanned { + value, + literal, + closed: true, + end: i, + } +} + +/// Decode the escape at `i` (a backslash): `(char, bytes consumed)`, with +/// `None` for a line continuation. `None` overall for an escape we do not +/// decode. +fn decode_escape(src: &str, i: usize) -> Option<(Option, usize)> { + let b = src.as_bytes(); + let e = *b.get(i + 1)?; + let ch = match e { + b'\\' | b'\'' | b'"' | b'$' => e as char, + b'n' => '\n', + b't' => '\t', + b'r' => '\r', + b'b' => '\u{8}', + b'f' => '\u{c}', + b'\n' => return Some((None, 2)), + b'u' => { + let hex = src.get(i + 2..i + 6)?; + if !hex.bytes().all(|h| h.is_ascii_hexdigit()) { + return None; + } + return Some((Some(char::from_u32(u32::from_str_radix(hex, 16).ok()?)?), 6)); + } + _ => return None, + }; + Some((Some(ch), 2)) +} + +/// The end (one past the `}`) of the `${…}` template whose `{` is at +/// `open`; nested strings and braces are skipped. +fn skip_template(src: &str, open: usize, dsl: Dsl) -> usize { + let b = src.as_bytes(); + let mut depth = 0usize; + let mut i = open; + while i < b.len() { + match b[i] { + b'{' => depth += 1, + b'}' => { + depth -= 1; + if depth == 0 { + return i + 1; + } + } + b'\'' | b'"' => { + i = scan_string(src, i, dsl).end; + continue; + } + _ => {} + } + i += 1; + } + b.len() +} + +// ── token helpers ─────────────────────────────────────────────────────────────── + +pub fn is_ident(t: Option<&Token>, name: &str) -> bool { + matches!(t, Some(Token { tok: Tok::Ident(n), .. }) if n == name) +} + +pub fn is_punct(t: Option<&Token>, p: u8) -> bool { + matches!(t, Some(Token { tok: Tok::Punct(c), .. }) if *c == p) +} + +/// The literal value of a string token. +pub fn literal_of(t: Option<&Token>) -> Option<&str> { + match t { + Some(Token { + tok: Tok::Str { + value, + literal: true, + }, + .. + }) => Some(value), + _ => None, + } +} + +/// Index of the bracket closing the `(`, `[` or `{` at `open`. +pub fn matching_close(toks: &[Token], open: usize) -> Option { + let (o, c) = match toks.get(open)?.tok { + Tok::Punct(b'(') => (b'(', b')'), + Tok::Punct(b'[') => (b'[', b']'), + Tok::Punct(b'{') => (b'{', b'}'), + _ => return None, + }; + let mut depth = 0usize; + for (i, t) in toks.iter().enumerate().skip(open) { + match t.tok { + Tok::Punct(p) if p == o => depth += 1, + Tok::Punct(p) if p == c => { + depth = depth.checked_sub(1)?; + if depth == 0 { + return Some(i); + } + } + _ => {} + } + } + None +} + +/// Every `name {` block at any depth: `(open index, close index)` of the +/// braces. +pub fn all_blocks(toks: &[Token], name: &str) -> Vec<(usize, usize)> { + (0..toks.len()) + .filter(|&i| is_ident(toks.get(i), name) && is_punct(toks.get(i + 1), b'{')) + .filter_map(|i| matching_close(toks, i + 1).map(|c| (i + 1, c))) + .collect() +} + +/// The string values (literal or not) in `toks`. +pub fn strings(toks: &[Token]) -> impl Iterator { + toks.iter().filter_map(|t| match &t.tok { + Tok::Str { value, .. } => Some(value.as_str()), + _ => None, + }) +} + +/// Whether a line break separates tokens `a` and `b` (`a` before `b`). +pub fn newline_between(text: &str, toks: &[Token], a: usize, b: usize) -> bool { + match (toks.get(a), toks.get(b)) { + (Some(x), Some(y)) if x.end <= y.start => text[x.end..y.start].contains('\n'), + _ => false, + } +} + +// ── calls ─────────────────────────────────────────────────────────────────────── + +/// One argument of a call. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CallArg { + /// The name of a named argument (Groovy `group: 'g'`, Kotlin + /// `group = "g"`). + pub name: Option, + /// The value when it is exactly one literal string. + pub literal: Option, + /// The value's token range `[first, last)`. + pub first: usize, + pub last: usize, +} + +/// One call of a named method: `f(a, b)`, `f(a) { … }`, Groovy's +/// parenthesis-free `f a, b` and a closure-only `f { … }`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CallSite { + /// Token index of the callee name. + pub callee: usize, + /// The identifier before a `.` / `?.` in front of the callee + /// (`settings.include` → `settings`); `Some("")` when the receiver is + /// an expression (`project(':a').include`). + pub receiver: Option, + /// 1-based line of the callee. + pub line: usize, + pub args: Vec, + /// Token range `(open, close)` of a trailing closure. + pub closure: Option<(usize, usize)>, + /// Token index one past the call (closure included). + pub end: usize, +} + +impl CallSite { + /// Every argument's literal value, or `None` when any argument is not a + /// literal string (or the call has none). + pub fn literals(&self) -> Option> { + if self.args.is_empty() { + return None; + } + self.args.iter().map(|a| a.literal.clone()).collect() + } + + /// The literal value of the named argument `name`; `Some(None)` when + /// it is present but not a literal. + pub fn named(&self, name: &str) -> Option> { + self.args + .iter() + .find(|a| a.name.as_deref() == Some(name)) + .map(|a| a.literal.as_deref()) + } +} + +/// Every call of `callee` in `text`. +pub fn literal_strings_in_call(text: &str, dsl: Dsl, callee: &str) -> Vec { + call_sites(text, &tokens(text, dsl), callee) +} + +/// Every call of `callee` among `toks` (the tokens of `text`). +pub fn call_sites(text: &str, toks: &[Token], callee: &str) -> Vec { + (0..toks.len()) + .filter(|&i| is_ident(toks.get(i), callee)) + .filter_map(|i| call_at(text, toks, i)) + .collect() +} + +/// The call whose callee is token `i`, if `i` is called at all. +pub fn call_at(text: &str, toks: &[Token], i: usize) -> Option { + let prev = |k: usize| i.checked_sub(k).and_then(|p| toks.get(p)); + // A named-argument key or a declaration (`fun include(`) is no call. + if is_ident(prev(1), "fun") || is_ident(prev(1), "def") { + return None; + } + let receiver = if is_punct(prev(1), b'.') { + let recv = if is_punct(prev(2), b'?') { + prev(3) + } else { + prev(2) + }; + Some(match recv { + Some(Token { + tok: Tok::Ident(n), .. + }) => n.clone(), + _ => String::new(), + }) + } else { + None + }; + let line = super::line_of(text, toks[i].start); + let next = toks.get(i + 1)?; + let (args, mut end) = match next.tok { + Tok::Punct(b'(') => { + let close = matching_close(toks, i + 1)?; + (split_args(toks, i + 2, close), close + 1) + } + Tok::Punct(b'{') => (Vec::new(), i + 1), + Tok::Punct(_) => return None, + _ if newline_between(text, toks, i, i + 1) => return None, + _ => { + let end = command_end(text, toks, i + 1); + (split_args(toks, i + 1, end), end) + } + }; + let closure = if is_punct(toks.get(end), b'{') { + let close = matching_close(toks, end)?; + let c = (end, close); + end = close + 1; + Some(c) + } else { + None + }; + Some(CallSite { + callee: i, + receiver, + line, + args, + closure, + end, + }) +} + +/// The end (token index) of a Groovy command expression's argument list, +/// or of an assignment's right-hand side, starting at `from`: a line +/// break, `;` or an unmatched closer at bracket depth 0 (a trailing `,` +/// continues the list on the next line), or a `{` at depth 0 (a trailing +/// closure). +pub fn command_end(text: &str, toks: &[Token], from: usize) -> usize { + let mut depth = 0isize; + let mut i = from; + while i < toks.len() { + match toks[i].tok { + Tok::Punct(b'(' | b'[') => depth += 1, + Tok::Punct(b'{') if depth > 0 => depth += 1, + // A `{` at depth 0 is a trailing closure. + Tok::Punct(b'{') => return i, + Tok::Punct(b')' | b']' | b'}') => { + if depth == 0 { + return i; + } + depth -= 1; + } + Tok::Punct(b';') if depth == 0 => return i, + _ => {} + } + if depth == 0 + && i > from + && newline_between(text, toks, i - 1, i) + && !is_punct(toks.get(i - 1), b',') + { + return i; + } + i += 1; + } + i +} + +/// Split `toks[from..to]` at depth-0 commas. +fn split_args(toks: &[Token], from: usize, to: usize) -> Vec { + let mut out = Vec::new(); + if from >= to { + return out; + } + let mut depth = 0isize; + let mut start = from; + for i in from..=to { + let at_end = i == to; + if !at_end { + match toks[i].tok { + Tok::Punct(b'(' | b'[' | b'{') => depth += 1, + Tok::Punct(b')' | b']' | b'}') => depth -= 1, + _ => {} + } + } + if at_end || (depth == 0 && is_punct(toks.get(i), b',')) { + if i > start { + out.push(make_arg(toks, start, i)); + } + start = i + 1; + } + } + out +} + +fn make_arg(toks: &[Token], first: usize, last: usize) -> CallArg { + let key = match &toks[first].tok { + Tok::Ident(n) => Some(n.clone()), + Tok::Str { + value, + literal: true, + } => Some(value.clone()), + _ => None, + }; + let named = key.is_some() + && (is_punct(toks.get(first + 1), b':') + || (is_punct(toks.get(first + 1), b'=') && !is_punct(toks.get(first + 2), b'='))); + let (name, vfirst) = if named { + (key, first + 2) + } else { + (None, first) + }; + let literal = (last == vfirst + 1) + .then(|| literal_of(toks.get(vfirst)).map(str::to_string)) + .flatten(); + CallArg { + name, + literal, + first: vfirst, + last, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A compact rendering of the tokens: `I:name`, `S:value` (literal), + /// `s:value` (not literal), `N:digits`, `P:c`. + fn render(text: &str, dsl: Dsl) -> Vec { + tokens(text, dsl) + .into_iter() + .map(|t| match t.tok { + Tok::Ident(n) => format!("I:{n}"), + Tok::Str { + value, + literal: true, + } => format!("S:{value}"), + Tok::Str { value, .. } => format!("s:{value}"), + Tok::Num(n) => format!("N:{n}"), + Tok::Punct(c) => format!("P:{}", c as char), + }) + .collect() + } + + #[test] + fn groovy_golden_tokens() { + let table: &[(&str, &[&str])] = &[ + ("include ':a'", &["I:include", "S::a"]), + ("x \"a$b\"", &["I:x", "s:a$b"]), + ("x \"a${b.c('}')}d\"", &["I:x", "s:a${b.c('}')}d"]), + ("x \"a\\$b\"", &["I:x", "S:a$b"]), + ("x 'a$b'", &["I:x", "S:a$b"]), + ("x '''a\n'b'\n'''", &["I:x", "S:a\n'b'\n"]), + ("x \"\"\"a\n$b\"\"\"", &["I:x", "s:a\n$b"]), + ("x 'it\\'s'", &["I:x", "S:it's"]), + ("x '\\u0041\\n'", &["I:x", "S:A\n"]), + ("x '\\q'", &["I:x", "s:\\q"]), + ("// 'no'\nx /* 'no' */ y", &["I:x", "I:y"]), + ("a /* /* */ b", &["I:a", "I:b"]), + ("v = 1.10", &["I:v", "P:=", "N:1", "P:.", "N:10"]), + ("#!/usr/bin/env groovy\nx", &["I:x"]), + ("x 'unterminated\ny", &["I:x", "s:unterminated", "I:y"]), + ("x \"a\" + \"b\"", &["I:x", "S:a", "P:+", "S:b"]), + ]; + for (src, want) in table { + assert_eq!(render(src, Dsl::Groovy), *want, "{src:?}"); + } + } + + #[test] + fn kotlin_golden_tokens() { + let table: &[(&str, &[&str])] = &[ + ("include(\":a\")", &["I:include", "P:(", "S::a", "P:)"]), + ("x(\"a$b\")", &["I:x", "P:(", "s:a$b", "P:)"]), + ("x(\"a${'$'}b\")", &["I:x", "P:(", "s:a${'$'}b", "P:)"]), + ("x(\"a$\")", &["I:x", "P:(", "S:a$", "P:)"]), + ("x(\"a\\$b\")", &["I:x", "P:(", "S:a$b", "P:)"]), + ("x(\"\"\"a\\nb\"\"\")", &["I:x", "P:(", "S:a\\nb", "P:)"]), + ("x(\"\"\"a$b\"\"\")", &["I:x", "P:(", "s:a$b", "P:)"]), + ("x(\"\"\"q\"\"\"\")", &["I:x", "P:(", "S:q\"", "P:)"]), + ("x('c')", &["I:x", "P:(", "S:c", "P:)"]), + ("a /* x /* y */ z */ b", &["I:a", "I:b"]), + ( + "`my-conf`(\"g:a:1\")", + &["I:my-conf", "P:(", "S:g:a:1", "P:)"], + ), + ("val é = 1", &["I:val", "I:é", "P:=", "N:1"]), + ]; + for (src, want) in table { + assert_eq!(render(src, Dsl::Kotlin), *want, "{src:?}"); + } + } + + #[test] + fn gstring_interpolation_is_non_literal_but_escaped_dollar_is() { + let toks = tokens("apply from: \"$rootDir/x.gradle\"", Dsl::Groovy); + assert!( + matches!(&toks[3].tok, Tok::Str { literal: false, value } if value == "$rootDir/x.gradle") + ); + let toks = tokens("apply from: \"\\$rootDir/x.gradle\"", Dsl::Groovy); + assert_eq!(literal_of(toks.get(3)), Some("$rootDir/x.gradle")); + } + + #[test] + fn bom_is_skipped() { + let src = "\u{feff}include ':a'\n"; + assert_eq!(render(src, Dsl::Groovy), ["I:include", "S::a"]); + assert_eq!(strip_bom(src), "include ':a'\n"); + assert_eq!(strip_bom("x"), "x"); + assert_eq!( + decode(b"\xef\xbb\xbfinclude ':a'").as_deref(), + Some("include ':a'") + ); + assert_eq!(decode(b"include '\xff'"), None); + let calls = literal_strings_in_call(src, Dsl::Groovy, "include"); + assert_eq!(calls[0].literals(), Some(vec![":a".to_string()])); + assert_eq!(calls[0].line, 1); + } + + #[test] + fn well_formed_detects_unclosed() { + assert!(well_formed("a { b(c) [d] }", Dsl::Groovy)); + assert!(!well_formed("a { b(c) ", Dsl::Groovy)); + assert!(!well_formed("a ) (", Dsl::Groovy)); + assert!(!well_formed("x 'open", Dsl::Groovy)); + assert!(!well_formed("x /* open", Dsl::Kotlin)); + assert!(!well_formed("x \"\"\"open", Dsl::Kotlin)); + } + + #[test] + fn dsl_of_by_extension() { + assert_eq!(dsl_of("a/settings.gradle"), Some(Dsl::Groovy)); + assert_eq!(dsl_of("build.gradle.kts"), Some(Dsl::Kotlin)); + assert_eq!(dsl_of("init.d/x.init.kts"), Some(Dsl::Kotlin)); + assert_eq!(dsl_of("pom.xml"), None); + } + + #[test] + fn call_forms() { + let g = "include ':a', ':b'\nsettings.include(':c')\ninclude \"$x\"\ninclude ':d',\n ':e'\nfoo.include ':f'\n"; + let calls = literal_strings_in_call(g, Dsl::Groovy, "include"); + let lits: Vec<_> = calls.iter().map(CallSite::literals).collect(); + assert_eq!( + lits, + vec![ + Some(vec![":a".into(), ":b".into()]), + Some(vec![":c".into()]), + None, + Some(vec![":d".into(), ":e".into()]), + Some(vec![":f".into()]), + ] + ); + assert_eq!(calls[1].receiver.as_deref(), Some("settings")); + assert_eq!(calls[4].receiver.as_deref(), Some("foo")); + assert_eq!(calls[3].line, 4); + + let k = "include(listOf(\"x\"))\ninclude(\":a\", \":b\")\n"; + let calls = literal_strings_in_call(k, Dsl::Kotlin, "include"); + assert_eq!(calls.len(), 2); + assert_eq!(calls[0].literals(), None); + assert_eq!(calls[0].args.len(), 1); + assert_eq!(calls[1].literals().map(|v| v.len()), Some(2)); + } + + #[test] + fn named_args_and_closures() { + let g = "implementation group: 'g', name: 'a', version: \"$v\"\n"; + let c = &literal_strings_in_call(g, Dsl::Groovy, "implementation")[0]; + assert_eq!(c.named("group"), Some(Some("g"))); + assert_eq!(c.named("name"), Some(Some("a"))); + assert_eq!(c.named("version"), Some(None)); + assert_eq!(c.named("classifier"), None); + + let k = "implementation(group = \"g\", name = \"a\") { isTransitive = false }\nx == y\n"; + let toks = tokens(k, Dsl::Kotlin); + let c = &call_sites(k, &toks, "implementation")[0]; + assert_eq!(c.named("group"), Some(Some("g"))); + let (open, close) = c.closure.expect("closure"); + assert!(is_punct(toks.get(open), b'{') && is_punct(toks.get(close), b'}')); + + // A property assignment is not a call. + let g = "classifier = 'tests'\nversion 'x'\n"; + assert!(literal_strings_in_call(g, Dsl::Groovy, "classifier").is_empty()); + assert_eq!(literal_strings_in_call(g, Dsl::Groovy, "version").len(), 1); + } +} diff --git a/crates/socket-patch-core/src/gradle/eol.rs b/crates/socket-patch-core/src/gradle/eol.rs new file mode 100644 index 000000000..e4d37210d --- /dev/null +++ b/crates/socket-patch-core/src/gradle/eol.rs @@ -0,0 +1,97 @@ +//! Line endings of the text files socket-patch owns or edits in a Gradle +//! build. A clone with `core.autocrlf=true` checks those files out with +//! CRLF, so "is this still our file" compares must not see the line-ending +//! difference, and edits must hand the file back in the style they found. + +use std::borrow::Cow; + +/// Whether `a` and `b` are equal once every `\r\n` is read as `\n`. +/// Nothing else is normalised: a lone `\r`, trailing whitespace or a +/// missing final newline still differ. +pub fn eol_eq(a: &[u8], b: &[u8]) -> bool { + to_lf(a) == to_lf(b) +} + +/// Whether the file uses CRLF: its first line break is `\r\n`. A file +/// without any line break is not CRLF. +pub fn sniff_crlf(text: &[u8]) -> bool { + match text.iter().position(|&b| b == b'\n') { + Some(i) => i > 0 && text[i - 1] == b'\r', + None => false, + } +} + +/// `text` with every line break spelled `\r\n` when `crlf`, else `\n`. +/// Existing `\r\n` pairs are folded first, so the result never holds +/// `\r\r\n`. +pub fn apply_eol(text: &str, crlf: bool) -> String { + let lf = text.replace("\r\n", "\n"); + if crlf { + lf.replace('\n', "\r\n") + } else { + lf + } +} + +/// The line break [`sniff_crlf`] found in `text`. +pub fn newline_of(text: &str) -> &'static str { + if sniff_crlf(text.as_bytes()) { + "\r\n" + } else { + "\n" + } +} + +/// `bytes` with every `\r\n` folded to `\n` (borrowed when there is none). +pub fn to_lf(bytes: &[u8]) -> Cow<'_, [u8]> { + if !bytes.windows(2).any(|w| w == b"\r\n") { + return Cow::Borrowed(bytes); + } + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'\r' && bytes.get(i + 1) == Some(&b'\n') { + i += 1; + continue; + } + out.push(bytes[i]); + i += 1; + } + Cow::Owned(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn eol_eq_ignores_only_crlf() { + assert!(eol_eq(b"a\nb\n", b"a\r\nb\r\n")); + assert!(eol_eq(b"a\r\nb", b"a\nb")); + assert!(eol_eq(b"", b"")); + assert!(!eol_eq(b"a\n", b"a")); + assert!(!eol_eq(b"a\rb", b"a\nb")); + assert!(!eol_eq(b"a \n", b"a\n")); + } + + #[test] + fn sniff_reads_the_first_break() { + assert!(sniff_crlf(b"a\r\nb\n")); + assert!(!sniff_crlf(b"a\nb\r\n")); + assert!(!sniff_crlf(b"abc")); + assert!(!sniff_crlf(b"\n")); + assert!(sniff_crlf(b"\r\n")); + assert_eq!(newline_of("x\r\ny"), "\r\n"); + assert_eq!(newline_of("x"), "\n"); + } + + #[test] + fn apply_eol_round_trips() { + assert_eq!(apply_eol("a\nb\n", true), "a\r\nb\r\n"); + assert_eq!(apply_eol("a\r\nb\n", true), "a\r\nb\r\n"); + assert_eq!(apply_eol("a\r\nb\r\n", false), "a\nb\n"); + assert_eq!(apply_eol("a", true), "a"); + let crlf = "x\r\ny\r\n"; + assert_eq!(apply_eol(&apply_eol(crlf, false), true), crlf); + } +} diff --git a/crates/socket-patch-core/src/gradle/graph.rs b/crates/socket-patch-core/src/gradle/graph.rs new file mode 100644 index 000000000..486660fa0 --- /dev/null +++ b/crates/socket-patch-core/src/gradle/graph.rs @@ -0,0 +1,2847 @@ +//! The script graph of a Gradle checkout: every settings and build script +//! Gradle would evaluate that can be found statically, and the queries +//! the modes need over it. +//! +//! [`ScriptGraph::collect`] starts from the root settings and follows: +//! +//! - literal `include` forms (with their implied parents), literal +//! `projectDir` / `buildFileName` overrides, and each project's build +//! script; +//! - `buildSrc/` and literal `includeBuild` roots (recursively, with their +//! own subprojects), plus the precompiled convention plugins under +//! `src/main/{groovy,kotlin}` of those builds; +//! - literal `apply from` targets, recursively with a visited set; +//! - each build's `gradle/libs.versions.toml` and literal +//! `versionCatalogs { from(files(…)) }` catalogs; +//! - the init scripts the caller supplies (scanned only for `mavenLocal` +//! and unresolved targets). +//! +//! Anything that cannot be followed statically (an interpolated or +//! computed path, a URL, a path escaping the root, a missing or oversized +//! file, a script that does not tokenize cleanly, a cap) is recorded in +//! [`ScriptGraph::unresolved`]; callers that must fail safe treat a +//! non-empty list as "could be anything". Caps: `apply from` and +//! included-build nesting ≤ 8 deep, ≤ 512 files, ≤ 1 MiB per file. +//! +//! All paths are forward-slash and in the caller's [`TextReadFn`] space +//! (so they already include `root_rel`); init scripts keep the caller's +//! tag as their `rel`. + +use std::collections::BTreeSet; + +use super::dsl::{ + self, all_blocks, call_at, call_sites, command_end, is_ident, is_punct, literal_of, + matching_close, Dsl, Tok, Token, +}; +use super::locks; +use super::selector::{parse_selector, Selector}; +use super::{join_rel, line_of, parent_rel, resolve_rel, ListFn, TextReadFn}; + +/// `apply from` and included-build nesting depth. +pub const MAX_DEPTH: usize = 8; +/// Script, catalog and init-script files collected. +pub const MAX_FILES: usize = 512; +/// Bytes per file. +pub const MAX_FILE_BYTES: usize = 1 << 20; + +/// Build-script text marking a project that builds Gradle plugins. +const PLUGIN_PROJECT_MARKERS: &[&str] = &[ + "java-gradle-plugin", + "groovy-gradle-plugin", + "kotlin-dsl", + "gradlePlugin", +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ScriptKind { + Settings, + Build, + /// A precompiled script plugin of `buildSrc` or an included build. + ConventionPlugin, + /// A binary plugin's source (`.kt`, `.java`, `.groovy`) in `buildSrc` + /// or a plugin project of an included build. Lexed with the Kotlin + /// (`.kt`) or Groovy (`.java`, `.groovy`) tokenizer, which is enough to + /// find `mavenLocal()`, plugin ids and coordinates in it. + PluginSource, + /// The target of an `apply from`. + Applied, + Init, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Script { + pub rel: String, + pub kind: ScriptKind, + pub dsl: Dsl, + /// The root directory of the build the script belongs to (`""` for + /// init scripts). + pub build: String, + pub text: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum BuildKind { + Root, + BuildSrc, + Included, +} + +/// One Gradle build of the checkout. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Build { + pub dir: String, + pub kind: BuildKind, + /// The settings script, when there is one. + pub settings: Option, + pub projects: Vec, +} + +/// One project of a build. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Project { + /// The Gradle path (`:` for the root project, `:a:b`). + pub path: String, + pub dir: String, + /// The build script, when there is one. + pub build_script: Option, +} + +/// The kind of reference that could not be followed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Site { + ApplyFrom, + Include, + ProjectDir, + BuildFileName, + IncludeBuild, + Catalog, + /// The script itself (oversized, unreadable or malformed). + Script, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Reason { + /// Interpolated or computed. + NonLiteral, + Url, + /// Absolute, or climbs above the root. + Escapes, + /// Names a file that cannot be read. + Missing, + TooLarge, + /// Does not tokenize cleanly. + Unparseable, + /// Relative to a context only known when the plugin is applied (an + /// `apply from` inside a convention plugin or init script). + Contextual, + DepthCap, + FileCap, +} + +/// A reference the graph could not follow. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Unresolved { + /// The script holding the reference. + pub rel: String, + /// 1-based line (0 for [`Site::Script`]). + pub line: usize, + pub site: Site, + pub reason: Reason, + /// The source text of the reference, trimmed to one line. + pub snippet: String, +} + +/// A version catalog file. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Catalog { + pub rel: String, + pub text: String, +} + +/// Whether the build (or a Gradle init script) adds `mavenLocal()`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MavenLocal { + /// Declared in this script (`rel`). + Declared(String), + NotDeclared, + /// Something could not be read, so it cannot be ruled out. + Undetermined(String), +} + +/// A rich version constraint (`version { strictly … }`, or `…!!`). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RichVersion { + pub strictly: Option, + pub require: Option, + pub prefer: Option, + pub reject: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DeclKind { + /// An exact version, or none / a non-literal one. + Plain, + /// A range, prefix (`1.+`) or `latest.*` selector. + Range, + Rich(RichVersion), + /// Requests a classifier artifact. + Classifier, + /// A `[libraries]` entry of a version catalog. + Catalog, +} + +/// One declaration of a module. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Decl { + pub rel: String, + pub line: usize, + pub kind: DeclKind, + /// The version (selector) text; `None` when absent or not literal. + pub version: Option, + pub rich: Option, + pub classifier: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum FilterKind { + Group, + GroupAndSubgroups, + GroupByRegex, + Module, + ModuleByRegex, + Version, + VersionByRegex, +} + +/// One `include*` rule of an `exclusiveContent { filter { … } }`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FilterRule { + pub kind: FilterKind, + /// The arguments; `None` for a non-literal one. + pub args: Vec>, +} + +/// One `exclusiveContent` block. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExclusiveFilter { + pub rel: String, + pub line: usize, + /// Every string in its `forRepository` part (names, URLs). + pub repo_strings: Vec, + pub rules: Vec, +} + +/// Whether `filter` could route `group:artifact` to its repository. A +/// non-literal argument, a regex that does not compile or a malformed rule +/// counts as a claim. +pub fn filter_claims_group(filter: &ExclusiveFilter, group: &str, artifact: &str) -> bool { + filter.rules.iter().any(|r| rule_claims(r, group, artifact)) +} + +fn rule_claims(rule: &FilterRule, g: &str, a: &str) -> bool { + let eq = |i: usize, want: &str| { + rule.args + .get(i) + .cloned() + .flatten() + .is_none_or(|v| v == want) + }; + let re = |i: usize, want: &str| match rule.args.get(i).cloned().flatten() { + None => true, + Some(pat) => regex::Regex::new(&format!("^(?:{pat})$")).map_or(true, |r| r.is_match(want)), + }; + let arity = |n: usize| rule.args.len() < n; + match rule.kind { + FilterKind::Group => arity(1) || eq(0, g), + FilterKind::GroupAndSubgroups => { + arity(1) + || rule.args[0] + .as_deref() + .is_none_or(|p| g == p || g.starts_with(&format!("{p}."))) + } + FilterKind::GroupByRegex => arity(1) || re(0, g), + FilterKind::Module | FilterKind::Version => arity(2) || (eq(0, g) && eq(1, a)), + FilterKind::ModuleByRegex | FilterKind::VersionByRegex => { + arity(2) || (re(0, g) && re(1, a)) + } + } +} + +/// The statically known script graph of a checkout. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ScriptGraph { + pub root: String, + pub builds: Vec, + pub scripts: Vec