diff --git a/.gitattributes b/.gitattributes index 858080655..e27b01a69 100644 --- a/.gitattributes +++ b/.gitattributes @@ -29,3 +29,7 @@ crates/socket-patch-core/tests/fixtures/vendor/** -text # compares the result byte for byte, so a CRLF checkout would change both # 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. +crates/socket-patch-core/src/vendor/jvm/socket-patch.settings.gradle -text diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d51607c9..d1c1f16a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -729,7 +729,8 @@ jobs: retention-days: 3 e2e: - needs: [test, e2e-build] + # These jobs consume e2e-build's binaries and can run alongside unit tests. + needs: [e2e-build] strategy: fail-fast: false matrix: @@ -1069,6 +1070,18 @@ jobs: - {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'} # 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. @@ -1238,28 +1251,46 @@ jobs: php-version: '8.2' tools: composer:${{ matrix.composer }} - - name: Setup Java (Maven legs) - if: matrix.maven != '' + - name: Setup Java (Maven and Gradle legs) + if: matrix.maven != '' || matrix.gradle != '' uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin - java-version: '17' + java-version: ${{ matrix.java || '17' }} - - name: Install Maven ${{ matrix.maven }} - if: matrix.maven != '' + - name: Install Maven ${{ matrix.maven || '3.9.16' }} + if: matrix.maven != '' || matrix.gradle != '' # Straight from the Apache archive (sha512-verified), so a leg gets # exactly the release it names rather than the runner's Maven. shell: bash env: - MAVEN_VERSION: ${{ matrix.maven }} + MAVEN_VERSION: ${{ matrix.maven || '3.9.16' }} run: | major="${MAVEN_VERSION%%.*}" url="https://archive.apache.org/dist/maven/maven-${major}/${MAVEN_VERSION}/binaries/apache-maven-${MAVEN_VERSION}-bin.tar.gz" curl -fsSL --retry 3 "$url" -o "$RUNNER_TEMP/maven.tgz" - sum="$(curl -fsSL --retry 3 "$url.sha512" | cut -d' ' -f1)" - echo "$sum $RUNNER_TEMP/maven.tgz" | shasum -a 512 -c - - tar -xzf "$RUNNER_TEMP/maven.tgz" -C "$RUNNER_TEMP" - echo "SOCKET_PATCH_MAVEN_E2E_MVN=$RUNNER_TEMP/apache-maven-${MAVEN_VERSION}/bin/mvn" >> "$GITHUB_ENV" + 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 accepts native Windows paths for both archive and destination. + 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" >> "$GITHUB_ENV" + + - name: Install Gradle ${{ matrix.gradle }} + if: 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: Setup .NET SDK if: matrix.dotnet != '' @@ -1363,8 +1394,10 @@ 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 != '' && '1' || '' }} - SOCKET_PATCH_MAVEN_E2E_VERSION: ${{ matrix.maven }} + SOCKET_PATCH_MAVEN_E2E_REQUIRED: ${{ (matrix.maven != '' || matrix.gradle != '') && '1' || '' }} + SOCKET_PATCH_MAVEN_E2E_VERSION: ${{ matrix.maven || (matrix.gradle != '' && '3.9.16') || '' }} + SOCKET_PATCH_GRADLE_E2E_REQUIRED: ${{ matrix.gradle != '' && '1' || '' }} + SOCKET_PATCH_GRADLE_E2E_VERSION: ${{ matrix.gradle }} SOCKET_PATCH_DOTNET_E2E_REQUIRED: ${{ matrix.dotnet != '' && '1' || '' }} SOCKET_PATCH_DOTNET_E2E_VERSION: ${{ matrix.dotnet }} SOCKET_PATCH_DENO_E2E_REQUIRED: ${{ matrix.deno != '' && '1' || '' }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 028853db4..e80101ace 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,12 @@ and `vendor` (committed patched packages), with `list` for inspection. See the ### Added +- Vendored Maven reactors and Gradle builds, with committed repositories, + reversible wiring, repair, rollback, and VEX. Reactors use suffixed versions; + Gradle preserves coordinates and lockfiles, checks artifact hashes, and updates + existing verification metadata. `vendor --check` audits artifacts and wiring + offline; `--local-repo` checks Maven cache conflicts and `--maven-config=none` + selects the fallback file repository. Single-POM vendoring is unchanged. - `socket.yml` patch policy for paths, ecosystems, packages, severity, and per-run limits. `scan --package`, `--min-severity`, `--max-new-patches`, and `--no-socket-yml` support targeted and gradual rollout. Already-patched packages diff --git a/README.md b/README.md index 07ac2951a..e74246412 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,10 @@ The CLI supports npm, PyPI, Cargo, Go, RubyGems, Maven, Composer, NuGet, and Den Mode and package-manager support vary: Deno uses agent mode, for example. Check the [ecosystem support matrix](docs/ecosystems.md) before choosing a mode. +Vendored Maven reactors and Gradle 6.8+ builds are supported. See +[JVM vendoring](docs/design/maven-vendoring.md) for supported project shapes, +cache behavior, and offline checks. + ## Common commands ```sh diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 0a6c90b53..afe5eac12 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -42,7 +42,7 @@ Rows are in `--help` order (v5.0): the hosted/vendored workflow (`scan` → `vex ## Global arguments -Every subcommand accepts the same set of "global" flags via a single shared `GlobalArgs` struct that's `#[command(flatten)]`-ed into each per-command struct (`crates/socket-patch-cli/src/args.rs`). Subcommands that don't actually consume a given flag accept it silently — e.g. `list --global` parses fine and is a no-op. Every flag also has an environment-variable binding; precedence is **CLI arg > env var > default** — and for exactly three keys (`--api-token`, `--org`, `--api-url`) the JS socket-cli's persisted login sits between env var and default: **CLI arg > env var (canonical, then `SOCKET_CLI_*` alias) > socket-cli `config.json` > default**. See "Persisted configuration" under Environment variables. +Every subcommand accepts the same set of "global" flags via a single shared `GlobalArgs` struct that's `#[command(flatten)]`-ed into each per-command struct (`crates/socket-patch-cli/src/args.rs`). Subcommands that don't actually consume a given flag accept it silently — e.g. `list --global` parses fine and is a no-op. For flags with an environment-variable binding, precedence is **CLI arg > env var > default** — and for exactly three keys (`--api-token`, `--org`, `--api-url`) the JS socket-cli's persisted login sits between env var and default: **CLI arg > env var (canonical, then `SOCKET_CLI_*` alias) > socket-cli `config.json` > default**. See "Persisted configuration" under Environment variables. | Long | Short | Env var | Default | Type | Semantic | |---|---|---|---|---|---| @@ -55,6 +55,7 @@ Every subcommand accepts the same set of "global" flags via a single shared `Glo | `--ecosystems` | `-e` | `SOCKET_ECOSYSTEMS` | (all) | CSV → `Vec` | Restrict to these ecosystems | | `--download-mode` | — | `SOCKET_DOWNLOAD_MODE` | **`diff`** | enum: `diff` \| `file` (`package` was removed and is rejected) | Patch artifact format | | `--vendor-source` | — | `SOCKET_VENDOR_SOURCE` | **`auto`** | enum: `auto` \| `service` \| `build` | How `vendor` acquires the installable artifact (see "Prebuilt vendor artifacts") | +| `--maven-config` | — | — | (recorded choice, else `auto`) | enum: `auto` \| `none` | Maven reactor vendoring: write the repository tail (`auto`) or use only the fallback file repository (`none`). The choice persists in the vendor ledger. | | `--vendor-url` | — | `SOCKET_VENDOR_URL` | (active API/proxy base) | string | Base host for the vendoring-service package-reference request | | `--patch-server-url` | — | `SOCKET_PATCH_SERVER_URL` | (server-returned) | string | Override the host of the prebuilt-archive download URL (local-dev / testing) | | `--offline` | — | `SOCKET_OFFLINE` | `false` | bool | **Strict airgap on every command** — never contact the network | @@ -87,6 +88,8 @@ Beyond the globals above, each subcommand defines a small set of local arguments | `apply` | `--check` | — | Read-only audit that the committed **Go** `replace`-redirects match the manifest (CI / GitHub-App auditing) — Go ONLY (cargo patches in place, so there is no redirect to audit). Lock-free, crawl-free, offline-safe; exits 0 in sync, 1 on drift. Vendored modules are excluded from the audit | | `vendor` | `--force` / `-f` | `SOCKET_FORCE` | Tolerate missing patch-target files in the stage + bypass the variant probe. A beforeHash mismatch no longer needs it: vendor staging auto-overwrites with the verified patched content (`vendor_content_mismatch_overwritten` warning) | | `vendor` | `--revert` | `SOCKET_VENDOR_REVERT` | Undo vendoring: restore recorded original lockfile fragments + remove `.socket/vendor/` artifacts. Works without a manifest. A package vendored over a hosted pin returns to its upstream registry entry, never to hosted (see "Takeover reconciliation") | +| `vendor` | `--check` | — | Offline, read-only artifact and wiring audit; exits 1 on drift. Conflicts with `--revert`. | +| `vendor` | `--local-repo ` | — | With `--check`, also inspect suffixed Maven jar/POM copies in this cache for conflicts. | | `apply`, `scan`, `vendor` | `--vex` | `SOCKET_VEX` | Generate an OpenVEX 0.2.0 document at this path on a successful run; see "embedded VEX" below | | `apply`, `scan`, `vendor` | `--vex-product`, `--vex-no-verify`, `--vex-doc-id`, `--vex-compact` | `SOCKET_VEX_PRODUCT`, `SOCKET_VEX_NO_VERIFY`, `SOCKET_VEX_DOC_ID`, `SOCKET_VEX_COMPACT` | Passthrough to the embedded VEX builder; mirror the standalone `vex` knobs. Inert unless `--vex` is set | | `scan` | positional `[PATHS]...` | — | (v5.0) Meaning depends on the mode. **Hosted / vendored** (bare `scan` included): each PATH, or directory glob (`apps/*`), is a project directory scanned on its own as if it were `--cwd`. **Agent** (and a mode-less `--prune`/`--global` report): path globs scoping DISCOVERY to packages installed under matching paths (`packages/foo`, `apps/**`). See "Path-scoped scans" below | @@ -1722,3 +1725,24 @@ Every item in this document is locked in by at least one of: - **Async `run()` integration tests** in `tests/cli_parse_list.rs`, `tests/cli_parse_remove.rs` — exercise the no-network error paths and assert JSON shape via `serde_json::from_str::` + per-key assertions. If you add a new flag/subcommand/JSON key, add a test here that locks the new surface in the same PR. + + +### Vendored JVM support (v5) + +Maven reactors and Gradle 6.8+ route to the JVM backend automatically. Ledger +entries use ecosystem `jvm` with Maven PURLs. Revert, remove, rollback and repair +share the v5 vendored backend; existing prototype wiring remains readable. +See [the JVM design](../../docs/design/maven-vendoring.md) for supported shapes. + +`vendor --check` is an offline, read-only audit. Healthy entries emit `verified` +with `vendor_check_ok`; drift emits `failed` with `vendor_check_failed`, a +`partialFailure` envelope and exit 1. Missing ledger entries fail with +`vendor_ledger_missing`. Offline upstream metadata is reported as the run warning +`vendor_jvm_upstream_unverified`. The check never starts an API client or writes +lock/recovery files. `--check` conflicts with `--revert`. + +`vendor --check --local-repo ` additionally checks existing suffixed Maven +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. diff --git a/crates/socket-patch-cli/src/args.rs b/crates/socket-patch-cli/src/args.rs index 412ed9d7b..13731f268 100644 --- a/crates/socket-patch-cli/src/args.rs +++ b/crates/socket-patch-cli/src/args.rs @@ -176,6 +176,11 @@ pub struct GlobalArgs { )] pub vendor_source: String, + /// Vendored Maven: auto writes the repository tail; none uses only the file repository. + /// The choice is preserved on subsequent runs. + #[arg(help_heading = GLOBAL_OPTIONS, long, value_parser = ["auto", "none"])] + pub maven_config: Option, + /// Base URL for the patch vendoring service. Defaults to the active API base (`--api-url`) when /// authenticated or the proxy base (`--proxy-url`) otherwise. Override to /// point `vendor` at staging / local dev independently of `--api-url`. @@ -507,6 +512,7 @@ impl GlobalArgs { use_public_proxy: bool, ) -> VendorServiceConfig { VendorServiceConfig { + maven_config: self.maven_config.as_deref().map(|v| v != "none"), source: VendorSource::parse(&self.vendor_source).unwrap_or_default(), client, use_public_proxy, @@ -675,6 +681,7 @@ impl Default for GlobalArgs { ecosystems: None, download_mode: "diff".to_string(), vendor_source: "auto".to_string(), + maven_config: None, vendor_url: None, patch_server_url: None, offline: false, diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index a4e60c7e0..dfdfedb21 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -93,6 +93,14 @@ pub struct VendorArgs { )] pub revert: bool, + /// Verify committed artifacts and JVM wiring offline without changing files. + #[arg(long, conflicts_with = "revert")] + pub check: bool, + + /// Also check suffixed Maven jars in this local repository for conflicting bytes. + #[arg(long, requires = "check", hide_short_help = true)] + pub local_repo: Option, + /// On a successful vendor, also generate an OpenVEX 0.2.0 document /// (same contract as `apply --vex`). #[command(flatten)] @@ -168,11 +176,13 @@ pub(crate) async fn dispatch_vendor_one( } // Maven and NuGet have no registry-fetch rung — `fetch_and_stage` serves // no fetcher for either and `stage_local_artifact` is npm-only — so their - // source is always the crawler's own directory. + // source is the crawler's own directory. A ledger-driven maven re-run on + // a cold cache gets a deferred hint instead: the committed tree answers + // an in-sync re-run, and anything else refuses for the missing jar. macro_rules! vend_installed { ($backend:path) => {{ debug_assert!( - matches!(pkg_path, PackageSource::Installed(_)), + eco == "maven" || matches!(pkg_path, PackageSource::Installed(_)), "{eco} has no fetch rung; a pending source would need materialising" ); $backend( @@ -244,7 +254,7 @@ pub(crate) async fn dispatch_revert_one_opts( "golang" => vendor::golang::revert_go_vendor_opts(entry, project_root, opts).await, "composer" => vendor::composer_lock::revert_composer_opts(entry, project_root, opts).await, "nuget" => vendor::nuget_feed::revert_nuget_opts(entry, project_root, opts).await, - "maven" => vendor::maven_repo::revert_maven_opts(entry, project_root, opts).await, + "maven" | "jvm" => vendor::maven_repo::revert_maven_opts(entry, project_root, opts).await, other => RevertOutcome::failed(format!( "this build has no vendor backend for ecosystem `{other}`" )), @@ -643,6 +653,9 @@ pub(crate) fn note_classic_migration_risk( } pub async fn run(args: VendorArgs) -> i32 { + if args.check { + return run_check(&args).await; + } apply_env_toggles(&args.common); let manifest_path = args.common.resolved_manifest_path(); @@ -873,6 +886,81 @@ pub async fn run(args: VendorArgs) -> i32 { exit } +/// Read-only audit: no API client, lock recovery, staging, or telemetry is started. +async fn run_check(args: &VendorArgs) -> i32 { + let root = &args.common.project_root(); + let local_repo = args.local_repo.as_ref().map(|p| args.common.cwd.join(p)); + let mut env = Envelope::new(Command::Vendor); + let state = match load_state(root).await { + Ok(state) => state, + Err(e) => { + return emit_eject_refusal(&args.common, "vendor_state_unreadable", &e.to_string()) + } + }; + if state.entries.is_empty() + && [ + ".socket/vendor/maven2", + ".socket/vendor/gradle", + ".socket/vendor/gradle-index.tsv", + ] + .iter() + .any(|rel| root.join(rel).exists()) + { + return emit_eject_refusal(&args.common, "vendor_ledger_missing", "JVM artifacts exist without a vendor ledger; restore .socket/vendor/state.json from version control"); + } + let manifest_path = args.common.resolved_manifest_path(); + let manifest = match read_manifest(&manifest_path).await { + Ok(m) => m.unwrap_or_default(), + Err(e) => return emit_eject_refusal(&args.common, "manifest_unreadable", &e.to_string()), + }; + let mut entries: Vec<_> = state.entries.iter().collect(); + entries.sort_by_key(|(key, _)| *key); + for (key, entry) in entries { + let record = entry.record.as_ref().or_else(|| manifest.patches.get(key)); + let mut failure = match record { + Some(record) => match vendor::check_vendored_artifact(root, entry, record).await { + vendor::ArtifactHealth::Healthy => None, + health => Some(format!("artifact verification failed: {health:?}")), + }, + None => Some("patch record missing; restore the manifest or vendor ledger".to_string()), + }; + if failure.is_none() && vendor::jvm::apply::is_jvm_entry(entry) { + failure = vendor::jvm::apply::check_entry(root, entry, local_repo.as_deref()).err(); + } + if vendor::jvm::apply::upstream_unverified(entry) { + env.warnings.push(RunWarning {code: "vendor_jvm_upstream_unverified".into(), detail: format!("{key}: upstream metadata was accepted offline; run vendor online to verify registry checksums")}); + } + let event = match failure { + Some(reason) => { + PatchEvent::new(PatchAction::Failed, key).with_reason("vendor_check_failed", reason) + } + None => PatchEvent::new(PatchAction::Verified, key) + .with_reason("vendor_check_ok", "committed artifact and wiring verified"), + }; + if !args.common.json && (!args.common.silent || event.action == PatchAction::Failed) { + println!("{}: {}", key, event.reason.as_deref().unwrap_or("verified")); + } + env.record(event); + } + for key in manifest + .patches + .keys() + .filter(|k| !state.entries.contains_key(*k)) + { + if !args.common.json { + eprintln!("{key}: patch has no vendored ledger entry"); + } + env.record(PatchEvent::new(PatchAction::Failed, key).with_reason( + "vendor_ledger_missing", + "patch has no vendored ledger entry", + )); + } + if args.common.json { + println!("{}", env.to_pretty_json()); + } + i32::from(env.summary.failed != 0) +} + /// A refused eject: the JSON error envelope (`status: error`) or an /// `Error:` line (printed even under `--silent`). Exit 1; nothing touched. fn emit_eject_refusal(common: &GlobalArgs, code: &'static str, message: &str) -> i32 { @@ -935,7 +1023,11 @@ impl EjectSnapshot { let mut out = std::collections::BTreeSet::new(); for eco in std::fs::read_dir(&base).into_iter().flatten().flatten() { if eco.file_type().is_ok_and(|t| t.is_dir()) { - for unit in std::fs::read_dir(eco.path()).into_iter().flatten().flatten() { + for unit in std::fs::read_dir(eco.path()) + .into_iter() + .flatten() + .flatten() + { out.insert(unit.path()); } } @@ -971,7 +1063,8 @@ impl EjectSnapshot { let path = self.root.join(rel); let result = match bytes { Some(bytes) => { - socket_patch_core::utils::fs::atomic_write_bytes_preserving_mode(&path, bytes).await + socket_patch_core::utils::fs::atomic_write_bytes_preserving_mode(&path, bytes) + .await } None => match tokio::fs::remove_file(&path).await { Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), @@ -1032,7 +1125,11 @@ async fn run_eject(args: &VendorArgs, pins: Vec) -> i32 { if !common.json && !common.silent { println!( "{} {} into .socket/vendor/...", - if common.dry_run { "Would eject" } else { "Ejecting" }, + if common.dry_run { + "Would eject" + } else { + "Ejecting" + }, plural(pins.len(), "hosted package", "hosted packages") ); } @@ -1105,8 +1202,14 @@ async fn run_eject(args: &VendorArgs, pins: Vec) -> i32 { if common.json { println!("{}", env.to_pretty_json()); } - track_outcomes_for_vendor(true, &env, common.dry_run, api_token.as_deref(), org_slug.as_deref()) - .await; + track_outcomes_for_vendor( + true, + &env, + common.dry_run, + api_token.as_deref(), + org_slug.as_deref(), + ) + .await; return 1; } @@ -1147,8 +1250,14 @@ async fn run_eject(args: &VendorArgs, pins: Vec) -> i32 { if common.json { println!("{}", env.to_pretty_json()); } - track_outcomes_for_vendor(true, &env, common.dry_run, api_token.as_deref(), org_slug.as_deref()) - .await; + track_outcomes_for_vendor( + true, + &env, + common.dry_run, + api_token.as_deref(), + org_slug.as_deref(), + ) + .await; return 1; } @@ -1158,13 +1267,15 @@ async fn run_eject(args: &VendorArgs, pins: Vec) -> i32 { let mut env = Envelope::new(Command::Vendor); env.dry_run = true; for pin in &pins { - env.record(PatchEvent::new(PatchAction::Applied, pin.purl.clone()).with_reason( - "eject_planned", - format!( - "would restore the upstream registry entry ({}) and vendor the patch", - pin.files.join(", ") + env.record( + PatchEvent::new(PatchAction::Applied, pin.purl.clone()).with_reason( + "eject_planned", + format!( + "would restore the upstream registry entry ({}) and vendor the patch", + pin.files.join(", ") + ), ), - )); + ); if !common.json && !common.silent { println!( "Would eject {} (restore {}, then vendor into .socket/vendor/)", @@ -1174,12 +1285,16 @@ async fn run_eject(args: &VendorArgs, pins: Vec) -> i32 { } } if args.vex.vex.is_some() && !common.json && !common.silent { - println!("{}", crate::commands::vex::format_vex_dry_run_skip("vendored")); + println!( + "{}", + crate::commands::vex::format_vex_dry_run_skip("vendored") + ); } if common.json { println!("{}", env.to_pretty_json()); } - track_outcomes_for_vendor(false, &env, true, api_token.as_deref(), org_slug.as_deref()).await; + track_outcomes_for_vendor(false, &env, true, api_token.as_deref(), org_slug.as_deref()) + .await; return 0; } @@ -1234,7 +1349,10 @@ async fn run_eject(args: &VendorArgs, pins: Vec) -> i32 { if let Some(why) = restore_failure { env.mark_error(EnvelopeError::new("redirect_revert_failed", why.clone())); if !common.json { - eprintln!("Error: {}", crate::commands::rollback::capitalize_first(&why)); + eprintln!( + "Error: {}", + crate::commands::rollback::capitalize_first(&why) + ); } exit = 1; } else { @@ -1315,7 +1433,10 @@ async fn run_eject(args: &VendorArgs, pins: Vec) -> i32 { if let Some(vex_path) = args.vex.vex.as_ref() { if common.dry_run { if !common.json && !common.silent { - println!("{}", crate::commands::vex::format_vex_dry_run_skip("vendored")); + println!( + "{}", + crate::commands::vex::format_vex_dry_run_skip("vendored") + ); } } else { let params = args.vex.to_build_params(); @@ -1573,6 +1694,32 @@ async fn sweep_stale_artifact( stale: StaleArtifact, ) { let StaleArtifact { candidate, prev } = stale; + // A JVM tree is not a uuid dir: the replaced entry's own tree files go, + // minus any path a live entry records (a Gradle update rewrites them). + if vendor::jvm::apply::is_jvm_entry(&prev) { + let removed = if common.dry_run { + Ok(false) + } else { + vendor::jvm::apply::sweep_replaced_tree(&common.cwd, &prev, state.entries.values()) + .await + }; + match removed { + Ok(true) => env.record( + PatchEvent::new(PatchAction::Removed, candidate).with_reason( + "vendor_stale_artifact_removed", + "previous patch uuid's vendored artifact removed", + ), + ), + Ok(false) => {} + Err(detail) => record_warning( + env, + &candidate, + &VendorWarning::new("vendor_stale_artifact_kept", detail), + common, + ), + } + return; + } let still_referenced = state .entries .values() @@ -3011,7 +3158,12 @@ pub(crate) async fn vendor_records_reusing( continue; } for (code, detail) in &restore.warnings { - record_warning(env, candidate, &VendorWarning::new(code, detail.clone()), common); + record_warning( + env, + candidate, + &VendorWarning::new(code, detail.clone()), + common, + ); } if common.dry_run { record_warning( @@ -3681,7 +3833,12 @@ async fn run_revert(args: &VendorArgs, env: &mut Envelope) -> i32 { // reconcile): dispatch → drift-keep → per-entry ledger save. Only the // event vocabulary and the human lines are this command's. let reverted = VendoredBackend::new(common, None) - .revert(&recorded, &mut state, RevertOpts::new(common.dry_run), false) + .revert( + &recorded, + &mut state, + RevertOpts::new(common.dry_run), + false, + ) .await; for RevertedEntry { key: purl, @@ -4183,7 +4340,11 @@ mod plan_gate_tests { .unwrap(); let packages = [ ("pkg:composer/psr/cache@1.0.0", "psr/cache", UUID_A), - ("pkg:composer/psr/http-message@1.1.0", "psr/http-message", UUID_B), + ( + "pkg:composer/psr/http-message@1.1.0", + "psr/http-message", + UUID_B, + ), ("pkg:composer/psr/log@3.0.2", "psr/log", UUID_C), ]; let mut all_packages: Vec<(String, StagedSource)> = Vec::new(); diff --git a/crates/socket-patch-cli/src/commands/vex_sources.rs b/crates/socket-patch-cli/src/commands/vex_sources.rs index 719923870..174c0f007 100644 --- a/crates/socket-patch-cli/src/commands/vex_sources.rs +++ b/crates/socket-patch-cli/src/commands/vex_sources.rs @@ -338,6 +338,23 @@ pub(crate) async fn plan(common: &GlobalArgs, sources: Sources, assume_live: &[S let mut liveness = LedgerLiveness::new(root, &discovery, redirect.as_ref()); let mut based: Vec<(Cand, Basis)> = Vec::new(); for mut cand in cands { + if let Some(entry) = cand + .vendor_entry + .as_ref() + .filter(|e| socket_patch_core::vendor::jvm::apply::is_jvm_entry(e)) + { + if let Err(detail) = + socket_patch_core::vendor::jvm::apply::entry_wired_checked(root, entry) + { + const CODE: &str = "vendor_jvm_shape_unsupported"; + gated.push(failed(&cand.key, CODE)); + notes.push(note( + CODE, + format!("{}: cannot establish JVM wiring: {detail}", cand.key), + )); + continue; + } + } let basis = if let Some(vref) = cand .discovered .iter() diff --git a/crates/socket-patch-cli/tests/cli_global_args.rs b/crates/socket-patch-cli/tests/cli_global_args.rs index cb1bc8fdb..3a19ef43d 100644 --- a/crates/socket-patch-cli/tests/cli_global_args.rs +++ b/crates/socket-patch-cli/tests/cli_global_args.rs @@ -87,6 +87,9 @@ fn global_flag_cases() -> Vec<(&'static str, Option<&'static str>, fn(&GlobalArg ("--download-mode", Some("file"), |c| { assert_eq!(c.download_mode, "file") }), + ("--maven-config", Some("none"), |c| { + assert_eq!(c.maven_config.as_deref(), Some("none")) + }), ("--vendor-source", Some("service"), |c| { assert_eq!(c.vendor_source, "service") }), @@ -237,13 +240,14 @@ fn global_flag_cases_cover_every_global_field() { vendor_source: _, vendor_url: _, patch_server_url: _, + maven_config: _, } = common; - // 26 fields ↔ 26 long-flag cases. Bump both this count and add a case when + // 27 fields ↔ 27 long-flag cases. Bump both this count and add a case when // the destructure above forces you to add a field. assert_eq!( global_flag_cases().len(), - 26, + 27, "every GlobalArgs field needs a long-flag case in global_flag_cases()", ); @@ -767,7 +771,11 @@ fn empty_nonbool_env_vars_do_not_crash_the_binary() { envelope["status"], "success", "blank env vars must fall back to defaults: {envelope}", ); - assert_eq!(out.status.code(), Some(0), "an empty project lists with exit 0"); + assert_eq!( + out.status.code(), + Some(0), + "an empty project lists with exit 0" + ); } /// `save_and_clear_global_env` must clear **every** env var `GlobalArgs` diff --git a/crates/socket-patch-cli/tests/covgap_commands_vendor.rs b/crates/socket-patch-cli/tests/covgap_commands_vendor.rs index b079903f5..b8f62b99e 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_vendor.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_vendor.rs @@ -162,6 +162,8 @@ fn vendor_args(cwd: &Path) -> VendorArgs { }, force: false, revert: false, + check: false, + local_repo: None, vex: Default::default(), } } diff --git a/crates/socket-patch-cli/tests/e2e_vendor_composer_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_composer_build.rs index a42519e93..ce9036920 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_composer_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_composer_build.rs @@ -1364,7 +1364,8 @@ fn composer_vendor_keeps_files_mirror_filters_would_drop() { "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" ); assert!( - stderr.contains("Warning (vendor_composer_mirror_filters_neutralized)"), + stderr.contains("Warning:") + && stderr.contains("Composer's path mirror would have skipped files"), "the neutralization is surfaced:\n{stderr}" ); assert!( diff --git a/crates/socket-patch-cli/tests/e2e_vendor_jvm_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_jvm_build.rs new file mode 100644 index 000000000..748793ede --- /dev/null +++ b/crates/socket-patch-cli/tests/e2e_vendor_jvm_build.rs @@ -0,0 +1,1040 @@ +//! Real-tool capstones for the v5 JVM vendored backend +//! (`docs/design/maven-vendoring.md`): Maven reactors and Gradle builds. +//! +//! Both start from the ACTUAL registry bytes of `commons-text:1.10.0` (see +//! `maven_build_common`), stage a marker patch on its `META-INF/NOTICE.txt` +//! (manifest + blob), run the real binary's `vendor --json --offline`, and: +//! +//! * **Maven reactor** — an aggregator root, a separate corp parent module +//! and two modules (`a` declares the base literally, with CRLF + tabs; +//! `b` reaches it only through sibling `a`). The plan's edits are asserted +//! exactly (2-line `.mvn/maven.config`, the `maven2` tree, the corp +//! parent's repository + pin, `a`'s rewrite, aggregator and `b` +//! untouched). A fresh checkout with commons-text purged from the local +//! repository then builds offline from the root and from `cd a`, and +//! every resolved classpath carries the vendored suffixed jar with the +//! patched NOTICE. `vendor --revert` restores every file byte-for-byte. +//! * **Gradle multi-project** — Kotlin DSL settings with +//! `FAIL_ON_PROJECT_REPOS` and `mavenCentral()`, `app` → `lib`, STRICT +//! `lockAllConfigurations()` with lockfiles written before vendoring. After +//! 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. +//! +//! 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`. + +#[path = "maven_build_common/mod.rs"] +mod maven_build_common; + +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +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) { + 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 out = cmd + .args(args) + .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(); + let env = serde_json::from_str(&stdout) + .unwrap_or_else(|e| panic!("{args:?}: not JSON ({e})\n{stdout}\n{stderr}")); + (out.status.code(), env, stderr) +} + +fn vendor(proj: &Path, m2: &Path) -> serde_json::Value { + let (code, env, stderr) = socket( + proj, + m2, + &[ + "vendor", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!(code, Some(0), "vendor: {env}\n{stderr}"); + assert_eq!(env["summary"]["failed"], 0, "{env}"); + env +} + +fn revert(proj: &Path, m2: &Path) { + let (code, env, stderr) = socket( + proj, + m2, + &[ + "vendor", + "--revert", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!(code, Some(0), "vendor --revert: {env}\n{stderr}"); +} + +/// What `get` saves for an agent-mode patch: the manifest record + the +/// after-hash blob (so `vendor --offline` needs no network). +fn stage_manifest(proj: &Path, member_before: &[u8], member_after: &[u8]) { + let record = serde_json::json!({ + "uuid": UUID, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { MEMBER: { + "beforeHash": git_sha256(member_before), + "afterHash": git_sha256(member_after), + } }, + "vulnerabilities": { "GHSA-vendor-jvm-real": { + "cves": ["CVE-2026-7203"], "summary": "s", "severity": "high", "description": "d" + } }, + "description": "jvm vendored capstone", + "license": "MIT", + "tier": "free", + }); + let manifest = serde_json::json!({ "patches": { purl(): record } }); + std::fs::create_dir_all(proj.join(".socket/blobs")).unwrap(); + std::fs::write( + proj.join(".socket/manifest.json"), + serde_json::to_string_pretty(&manifest).unwrap(), + ) + .unwrap(); + std::fs::write( + proj.join(".socket/blobs").join(git_sha256(member_after)), + member_after, + ) + .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>, + after: &BTreeMap>, +) -> (Vec, Vec, Vec) { + let changed = after + .iter() + .filter(|(k, v)| before.get(*k).is_some_and(|b| b != *v)) + .map(|(k, _)| k.clone()) + .collect(); + let added = after + .keys() + .filter(|k| !before.contains_key(*k)) + .cloned() + .collect(); + let removed = before + .keys() + .filter(|k| !after.contains_key(*k)) + .cloned() + .collect(); + (changed, added, removed) +} + +fn assert_restored(proj: &Path, before: &BTreeMap>) { + let after = snapshot(proj); + let (changed, added, removed) = diff(before, &after); + assert!( + changed.is_empty() && added.is_empty() && removed.is_empty(), + "revert must restore the tree byte-for-byte: changed {changed:?}, added {added:?}, \ + removed {removed:?}" + ); +} + +/// A fresh checkout of `proj` in `dst`: every committable file, minus the +/// manifest and blobs (a vendored checkout builds without them). +fn fresh_checkout_all(proj: &Path, dst: &Path) { + for (rel, bytes) in snapshot(proj) { + if rel == ".socket/manifest.json" || rel.starts_with(".socket/blobs/") { + continue; + } + let to = dst.join(&rel); + std::fs::create_dir_all(to.parent().unwrap()).unwrap(); + std::fs::write(to, bytes).unwrap(); + } +} + +/// Drop the base and suffixed fixture versions from the local repository +/// (other commons-text versions stay: [`CLASSPATH_PLUGIN`] runs on one). +fn purge(m2: &Path) { + for version in [VERSION, SV] { + let dir = repo_dir(m2, version); + if dir.exists() { + std::fs::remove_dir_all(&dir).unwrap(); + } + } +} + +fn text(bytes: &[u8]) -> &str { + std::str::from_utf8(bytes).unwrap() +} + +// ── P1: multi-module Maven reactor ────────────────────────────────────── + +const AGGREGATOR_POM: &str = r#" + + 4.0.0 + com.example + aggregator + 1.0.0 + pom + + corp-parent + a + b + + +"#; + +const CORP_PARENT_POM: &str = r#" + + 4.0.0 + + com.example + corp-parent + 1.0.0 + pom + + UTF-8 + + +"#; + +const B_POM: &str = r#" + + 4.0.0 + + com.example + corp-parent + 1.0.0 + ../corp-parent + + b + + + com.example + a + ${project.version} + + + +"#; + +/// Module `a`: CRLF line endings and tab indentation, a comment, and the +/// patched library declared at its literal base version. +fn a_pom() -> String { + [ + r#""#, + r#""#, + "\t4.0.0", + "\t", + "\t\tcom.example", + "\t\tcorp-parent", + "\t\t1.0.0", + "\t\t../corp-parent/pom.xml", + "\t", + "\ta", + "\t", + "\t\t", + "\t\t", + "\t\t\torg.apache.commons", + "\t\t\tcommons-text", + "\t\t\t1.10.0", + "\t\t", + "\t", + "", + "", + ] + .join("\r\n") +} + +fn write_reactor(proj: &Path) { + for (rel, body) in [ + ("pom.xml", AGGREGATOR_POM.to_string()), + ("corp-parent/pom.xml", CORP_PARENT_POM.to_string()), + ("a/pom.xml", a_pom()), + ("b/pom.xml", B_POM.to_string()), + ] { + let path = proj.join(rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, body).unwrap(); + } +} + +fn maven_tree_rel() -> String { + format!(".socket/vendor/maven2/{GROUP_PATH}/{ARTIFACT}/{SV}") +} + +/// `package` + `build-classpath` into each module's `target/cp.txt`. +fn mvn_classpath(mvn: &Mvn, cwd: &Path, m2: &Path, settings: &Path, offline: bool) -> Output { + let goal = format!("{CLASSPATH_PLUGIN}:build-classpath"); + let mut args = vec!["package", goal.as_str(), "-Dmdep.outputFile=target/cp.txt"]; + if offline { + args.insert(0, "-o"); + } + mvn.run(cwd, m2, settings, &args) +} + +/// The commons-text entry of `/target/cp.txt`. +fn classpath_entry(module_dir: &Path) -> PathBuf { + let cp = std::fs::read_to_string(module_dir.join("target/cp.txt")) + .unwrap_or_else(|e| panic!("{}: no target/cp.txt ({e})", module_dir.display())); + let sep = if cfg!(windows) { ';' } else { ':' }; + let hits: Vec<&str> = cp + .trim() + .split(sep) + .filter(|p| p.contains(ARTIFACT)) + .collect(); + assert_eq!(hits.len(), 1, "{}: classpath {cp}", module_dir.display()); + PathBuf::from(hits[0]) +} + +fn assert_vendored_on_classpath(module_dir: &Path, patched: &[u8], what: &str) { + let entry = classpath_entry(module_dir); + assert_eq!( + entry.file_name().unwrap().to_string_lossy(), + format!("{ARTIFACT}-{SV}.jar"), + "{what}: resolved {}", + entry.display() + ); + let jar = std::fs::read(&entry).unwrap(); + assert_jar_patched(&jar, patched, what); + println!("{what}: resolved {}", entry.display()); +} + +#[test] +#[ignore = "real Maven + Maven Central (fixture); run with --ignored"] +fn maven_reactor_vendor_fresh_checkout_offline_build_and_byte_exact_revert() { + const SUITE: &str = "e2e_vendor_jvm_build::maven_reactor"; + let Some(mvn) = Mvn::detect(SUITE) else { + return; + }; + let tmp = tempfile::tempdir().unwrap(); + let root = fixture_root(&tmp); + let m2 = root.join("m2"); + let proj = root.join("proj"); + let settings = root.join("settings.xml"); + write_settings(&settings, &[]); + + // Registry bytes + every plugin the offline builds need. + let Some((jar, upstream_pom)) = warm_fixture(SUITE, &mvn, &root.join("warm"), &m2, &settings) + else { + return; + }; + write_reactor(&proj); + let out = mvn_classpath(&mvn, &proj, &m2, &settings, false); + assert!(ok(&out), "pre-vendor reactor build:\n{}", dump(&out)); + let entry = classpath_entry(&proj.join("b")); + assert_eq!( + std::fs::read(&entry).unwrap(), + jar, + "pre-vendor `b` resolves Central's jar transitively" + ); + // Maven 4 keeps its project-local repository in `.mvn/target/`. + for dir in [ + "target", + "corp-parent/target", + "a/target", + "b/target", + ".mvn/target", + ] { + let _ = std::fs::remove_dir_all(proj.join(dir)); + } + let _ = std::fs::remove_dir(proj.join(".mvn")); + assert!(!proj.join(".mvn").exists(), "no .mvn before vendoring"); + + let (orig, patched) = patched_member(&jar, UUID); + stage_manifest(&proj, &orig, &patched); + let before = snapshot(&proj); + + let env = vendor(&proj, &m2); + assert_eq!(env["summary"]["applied"], 1, "{env}"); + println!("vendor envelope: {env}"); + let vendored = snapshot(&proj); + let (changed, added, removed) = diff(&before, &vendored); + let tree = maven_tree_rel(); + let mut want_added = vec![ + ".mvn/maven.config".to_string(), + ".socket/vendor/maven2/.gitattributes".to_string(), + format!("{tree}/{ARTIFACT}-{SV}.jar"), + format!("{tree}/{ARTIFACT}-{SV}.jar.sha1"), + format!("{tree}/{ARTIFACT}-{SV}.pom"), + format!("{tree}/{ARTIFACT}-{SV}.pom.sha1"), + format!("{tree}/socket-patch.vendor.json"), + ".socket/vendor/state.json".to_string(), + ]; + want_added.sort(); + assert_eq!(added, want_added, "added files"); + assert!(removed.is_empty(), "removed {removed:?}"); + assert_eq!( + changed, + vec!["a/pom.xml".to_string(), "corp-parent/pom.xml".to_string()], + "only the literal module and the local root are edited (aggregator and `b` untouched)" + ); + + assert_eq!( + text(&vendored[".mvn/maven.config"]), + "-Daether.offline.protocols=file\n\ + -Dmaven.repo.local.tail=${session.rootDirectory}/.socket/vendor/maven2\n" + ); + assert_eq!( + text(&vendored[".socket/vendor/maven2/.gitattributes"]), + "* -text\n" + ); + let vendored_jar = &vendored[&format!("{tree}/{ARTIFACT}-{SV}.jar")]; + assert_jar_patched(vendored_jar, &patched, "vendored jar"); + assert_eq!( + text(&vendored[&format!("{tree}/{ARTIFACT}-{SV}.jar.sha1")]), + sha1_hex(vendored_jar) + ); + let tree_pom = &vendored[&format!("{tree}/{ARTIFACT}-{SV}.pom")]; + assert_eq!( + text(&vendored[&format!("{tree}/{ARTIFACT}-{SV}.pom.sha1")]), + sha1_hex(tree_pom) + ); + let tree_pom = text(tree_pom); + assert!( + tree_pom.contains(&format!("{SV}")) + && tree_pom.contains("commons-lang3"), + "suffixed pom keeps the upstream graph:\n{tree_pom}" + ); + assert_eq!( + tree_pom.replace(SV, VERSION), + text(&upstream_pom), + "the suffixed pom differs from upstream only in its version" + ); + + // `a`: exactly the version element rewritten; CRLF, tabs, comment kept. + assert_eq!( + text(&vendored["a/pom.xml"]), + a_pom().replace( + "1.10.0", + &format!("{SV}") + ) + ); + let corp = text(&vendored["corp-parent/pom.xml"]); + assert!( + corp.starts_with(&CORP_PARENT_POM[..CORP_PARENT_POM.find("").unwrap()]), + "the corp parent's content before the insertions is untouched:\n{corp}" + ); + for needle in [ + "", + "socket-patch-vendor", + "file://${maven.multiModuleProjectDirectory}/.socket/vendor/maven2", + "fail", + "", + "", + &format!("{SV}"), + ] { + assert!(corp.contains(needle), "corp parent lacks {needle}:\n{corp}"); + } + assert_eq!(corp.matches("").count(), 1, "{corp}"); + assert_eq!(corp.matches(SV).count(), 1, "one pin:\n{corp}"); + + // Idempotent: a second vendor run changes no project file. + vendor(&proj, &m2); + let again = snapshot(&proj); + let (changed, added, removed) = diff(&vendored, &again); + assert!( + changed.iter().all(|p| p == ".socket/vendor/state.json") + && added.is_empty() + && removed.is_empty(), + "re-vendor: changed {changed:?}, added {added:?}, removed {removed:?}" + ); + + // FRESH CHECKOUT: commons-text only from the committed tree. + let fresh = root.join("fresh"); + fresh_checkout_all(&proj, &fresh); + purge(&m2); + let out = mvn_classpath(&mvn, &fresh, &m2, &settings, true); + assert!(ok(&out), "fresh offline reactor build:\n{}", dump(&out)); + assert_vendored_on_classpath(&fresh.join("a"), &patched, "root build, module a"); + assert_vendored_on_classpath( + &fresh.join("b"), + &patched, + "root build, module b (transitive via sibling a)", + ); + let lang3 = std::fs::read_to_string(fresh.join("b/target/cp.txt")).unwrap(); + assert!( + lang3.contains(TRANSITIVE_JAR), + "the suffixed pom's transitive resolves: {lang3}" + ); + let _ = std::fs::remove_dir_all(fresh.join("a/target")); + purge(&m2); + let goal = format!("{CLASSPATH_PLUGIN}:build-classpath"); + let out = mvn.run( + &fresh.join("a"), + &m2, + &settings, + &["-o", &goal, "-Dmdep.outputFile=target/cp.txt"], + ); + assert!(ok(&out), "fresh offline `cd a` build:\n{}", dump(&out)); + assert_vendored_on_classpath(&fresh.join("a"), &patched, "cd a"); + + // Byte-exact revert of the source project. + revert(&proj, &m2); + assert_restored(&proj, &before); + assert!(!proj.join(".mvn").exists(), ".mvn residue"); + assert!( + !proj.join(".socket/vendor").exists(), + ".socket/vendor residue" + ); +} + +// ── 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") } +} +val socketSettingsJar = java.util.jar.JarFile(java.io.File(org.apache.commons.text.StringSubstitutor::class.java.protectionDomain.codeSource.location.toURI())) +println("SOCKET-SETTINGS-PATCHED " + socketSettingsJar.use { jar -> jar.getInputStream(jar.getJarEntry("META-INF/NOTICE.txt")).bufferedReader().readText().contains("SOCKET-PATCH-MAVEN-E2E-MARKER") }) +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + mavenCentral() + } +} + +rootProject.name = "jvm-e2e" +include("app", "lib") +"#; + +const GRADLE_LIB: &str = r#"plugins { + `java-library` +} + +dependencies { + api("org.apache.commons:commons-text:1.10.0") +} + +dependencyLocking { + lockAllConfigurations() + lockMode.set(LockMode.STRICT) +} +"#; + +const GRADLE_APP: &str = r#"plugins { + java +} + +dependencies { + implementation(project(":lib")) +} + +dependencyLocking { + lockAllConfigurations() + lockMode.set(LockMode.STRICT) +} + +tasks.register("printRuntimeClasspath") { + val runtime = configurations.named("runtimeClasspath") + doLast { + runtime.get().files.forEach { println("SOCKET-CP " + it.absolutePath) } + } +} +"#; + +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() +} + +/// 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() +} + +fn gradle_tree_rel() -> String { + format!(".socket/vendor/gradle/{GROUP_PATH}/{ARTIFACT}/{VERSION}") +} + +fn assert_gradle_vendored(out: &Output, checkout: &Path, patched: &[u8], what: &str) { + assert!(ok(out), "{what}:\n{}", dump(out)); + assert!( + String::from_utf8_lossy(&out.stdout).contains("SOCKET-SETTINGS-PATCHED true"), + "{what}: settings buildscript must load the patched jar:\n{}", + dump(out) + ); + let cp = gradle_classpath(out); + let hits: Vec<&PathBuf> = cp + .iter() + .filter(|p| p.to_string_lossy().contains(ARTIFACT)) + .collect(); + let want = checkout.join(format!("{}/{ARTIFACT}-{VERSION}.jar", gradle_tree_rel())); + assert_eq!( + hits, + vec![&want], + "{what}: :app runtimeClasspath must resolve the vendored jar:\n{cp:?}" + ); + assert_jar_patched(&std::fs::read(&want).unwrap(), patched, what); + assert!( + cp.iter() + .any(|p| p.file_name().is_some_and(|n| n == TRANSITIVE_JAR)), + "{what}: the vendored pom's transitive resolves: {cp:?}" + ); + println!("{what}: resolved {}", want.display()); +} + +#[test] +#[ignore = "real Gradle + Maven + 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(); + let root = fixture_root(&tmp); + let gradle_home = root.join("gradle-home"); + 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; + }; + 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(); + + let proj = root.join("proj"); + write_gradle_project(&proj); + let out = gradle.run( + &proj, + &gradle_home, + &[":app:dependencies", ":lib:dependencies", "--write-locks"], + ); + if !ok(&out) { + gradle_skip( + SUITE, + &format!( + "writing lockfiles against Maven Central failed:\n{}", + dump(&out) + ), + ); + return; + } + let locked = lockfiles(&proj); + for project in ["app", "lib"] { + assert!( + locked + .iter() + .any(|(rel, body)| rel.starts_with(&format!("{project}/")) + && text(body).contains(&format!("{GROUP}:{ARTIFACT}:{VERSION}"))), + "{project} locks the patched GAV: {:?}", + locked.keys() + ); + } + let out = gradle.run( + &proj, + &gradle_home, + &[ + ":app:printRuntimeClasspath", + "--write-verification-metadata", + "sha256", + ], + ); + assert!(ok(&out), "pre-vendor build:\n{}", dump(&out)); + assert!( + gradle_classpath(&out) + .iter() + .any(|p| p.starts_with(&gradle_home) && p.to_string_lossy().contains(ARTIFACT)), + "pre-vendor :app resolves Central's jar from the Gradle cache:\n{}", + dump(&out) + ); + + // Remove parent entries to prove vendor adds the metadata needed by the local POM. + let verification = proj.join("gradle/verification-metadata.xml"); + let verification_text = std::fs::read_to_string(&verification).unwrap(); + let parents = regex::Regex::new(r#"(?s)\s*]*>.*?"#).unwrap(); + std::fs::write( + &verification, + parents.replace_all(&verification_text, "").as_bytes(), + ) + .unwrap(); + let (orig, patched) = patched_member(&jar, UUID); + stage_manifest(&proj, &orig, &patched); + let before = snapshot(&proj); + + let env = vendor(&proj, &m2); + assert_eq!(env["summary"]["applied"], 1, "{env}"); + println!("vendor envelope: {env}"); + let vendored = snapshot(&proj); + let (changed, added, removed) = diff(&before, &vendored); + let tree = gradle_tree_rel(); + let mut want_added = vec![ + 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(), + format!("{tree}/{ARTIFACT}-{VERSION}.jar"), + format!("{tree}/{ARTIFACT}-{VERSION}.pom"), + format!("{tree}/socket-patch.vendor.json"), + ".socket/vendor/state.json".to_string(), + ]; + want_added.sort(); + assert_eq!(added, want_added, "added files"); + assert!(removed.is_empty(), "removed {removed:?}"); + assert_eq!( + changed, + vec!["gradle/verification-metadata.xml".to_string(), "settings.gradle.kts".to_string()], + "settings and existing verification metadata are edited; lockfiles and build scripts stay byte-unchanged" + ); + assert!(text(&vendored["settings.gradle.kts"]).ends_with(&format!("{APPLY_LINE}\n"))); + assert!(text(&vendored["settings.gradle.kts"]).contains("exclusiveContent")); + assert_eq!( + text(&vendored[socket_patch_core::vendor::jvm::gradle::SCRIPT_REL]), + socket_patch_core::vendor::jvm::gradle::SCRIPT + ); + let vendored_jar = &vendored[&format!("{tree}/{ARTIFACT}-{VERSION}.jar")]; + assert_jar_patched(vendored_jar, &patched, "vendored jar"); + assert_ne!(vendored_jar, &jar); + assert_eq!( + text(&vendored[socket_patch_core::vendor::jvm::gradle::INDEX_REL]), + format!( + "#socket-patch-gradle-index 1\n{GROUP}:{ARTIFACT}:{VERSION}\t{GROUP_PATH}/{ARTIFACT}/\ + {VERSION}/{ARTIFACT}-{VERSION}.jar\t{}\t{UUID}\n{GROUP}:{ARTIFACT}:{VERSION}\t\ + {GROUP_PATH}/{ARTIFACT}/{VERSION}/{ARTIFACT}-{VERSION}.pom\t{}\t{UUID}\n", + sha256_hex(vendored_jar), + sha256_hex(&vendored[&format!("{tree}/{ARTIFACT}-{VERSION}.pom")]), + ) + ); + + // Idempotent: a second vendor run changes no project file. + vendor(&proj, &m2); + let (changed, added, removed) = diff(&vendored, &snapshot(&proj)); + assert!( + changed.iter().all(|p| p == ".socket/vendor/state.json") + && added.is_empty() + && removed.is_empty(), + "re-vendor: changed {changed:?}, added {added:?}, removed {removed:?}" + ); + + // Fresh checkout (no manifest, blobs, .gradle or build output). + let fresh = root.join("fresh"); + fresh_checkout_all(&proj, &fresh); + let out = gradle.run(&fresh, &gradle_home, &[":app:printRuntimeClasspath"]); + assert_gradle_vendored(&out, &fresh, &patched, "online"); + let out = gradle.run( + &fresh, + &gradle_home, + &["--offline", ":app:printRuntimeClasspath"], + ); + assert_gradle_vendored(&out, &fresh, &patched, "--offline"); + assert_eq!( + lockfiles(&fresh), + locked, + "lockfiles unchanged by the vendored builds" + ); + + // A tampered vendored jar fails the build at configuration time. + let jar_path = fresh.join(format!("{tree}/{ARTIFACT}-{VERSION}.jar")); + let tampered = jar_with_member(vendored_jar, MEMBER, b"tampered\n"); + std::fs::write(&jar_path, &tampered).unwrap(); + let out = gradle.run( + &fresh, + &gradle_home, + &["--offline", ":app:printRuntimeClasspath"], + ); + let log = dump(&out); + assert!( + !ok(&out), + "a tampered vendored jar must fail the build:\n{log}" + ); + assert!( + log.contains("Dependency verification failed") || log.contains("socket-patch:"), + "unexpected tamper failure: {log}" + ); + // The index remains enforced when the user's Gradle verification is off. + let out = gradle.run( + &fresh, + &gradle_home, + &[ + "--offline", + "--dependency-verification=off", + ":app:printRuntimeClasspath", + ], + ); + let log = dump(&out); + assert!( + !ok(&out), + "the index must reject a tampered jar even with Gradle verification off: {log}" + ); + // Java prints native separators, while a Windows PathBuf can retain the + // forward slashes in the fixture's relative repository path. + assert!( + log.replace('\\', "/").contains(&format!( + "socket-patch: {} has sha256 {}, pinned {}", + jar_path.to_string_lossy().replace('\\', "/"), + sha256_hex(&tampered), + sha256_hex(vendored_jar) + )), + "the socket-patch integrity message:\n{log}" + ); + std::fs::write(&jar_path, vendored_jar).unwrap(); + let out = gradle.run( + &fresh, + &gradle_home, + &["--offline", ":app:printRuntimeClasspath"], + ); + assert_gradle_vendored(&out, &fresh, &patched, "restored jar"); + println!("{SUITE}: Gradle {} green", gradle.version); + + // Byte-exact revert of the source project. + revert(&proj, &m2); + assert_restored(&proj, &before); + assert!( + !proj.join(".socket/vendor").exists(), + ".socket/vendor residue" + ); + assert!( + !proj.join(".socket/gradle").exists(), + ".socket/gradle residue" + ); +} diff --git a/crates/socket-patch-cli/tests/in_process_rollback_vendored.rs b/crates/socket-patch-cli/tests/in_process_rollback_vendored.rs index 34430eaa0..0c0da7380 100644 --- a/crates/socket-patch-cli/tests/in_process_rollback_vendored.rs +++ b/crates/socket-patch-cli/tests/in_process_rollback_vendored.rs @@ -194,6 +194,8 @@ fn vendor_args(cwd: &Path) -> VendorArgs { }, force: false, revert: false, + check: false, + local_repo: None, vex: Default::default(), } } diff --git a/crates/socket-patch-cli/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs index 492538f11..e701c7f79 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor.rs @@ -217,6 +217,8 @@ fn vendor_args(cwd: &Path) -> VendorArgs { }, force: false, revert: false, + check: false, + local_repo: None, vex: Default::default(), } } diff --git a/crates/socket-patch-cli/tests/vendor_jvm_cli.rs b/crates/socket-patch-cli/tests/vendor_jvm_cli.rs new file mode 100644 index 000000000..06a4624f5 --- /dev/null +++ b/crates/socket-patch-cli/tests/vendor_jvm_cli.rs @@ -0,0 +1,676 @@ +//! CLI flows of the v5 JVM vendored backend over synthetic offline +//! fixtures: a reactor (an aggregator and one module) and a +//! Gradle multi-project, each fed by a fake local Maven repository. +//! +//! These pin the review findings that only show through the CLI's own +//! bookkeeping (ledger carry-forward, the stale-uuid sweep, per-package +//! rollback, repair, `vex`): two patches revert in any order, a patch +//! update re-wires and reverts to pristine, a re-run needs no jar source, +//! and a tampered ledger or an escaping symlink is refused. + +use std::collections::BTreeMap; +use std::io::Write as _; +use std::path::Path; +use std::process::Command; + +use sha2::{Digest as _, Sha256}; + +const FOO_UUID: &str = "1d3c1fd2-7b4e-4c1a-9f0e-2a3b4c5d6e7f"; +const BAR_UUID: &str = "9a8b7c6d-7b4e-4c1a-9f0e-2a3b4c5d6e7f"; +const FOO_UPDATE_UUID: &str = "2e4d6f80-7b4e-4c1a-9f0e-2a3b4c5d6e7f"; +const MEMBER: &str = "META-INF/NOTICE.txt"; + +#[derive(Clone, Copy, PartialEq)] +enum Shape { + Reactor, + Gradle, +} + +fn git_sha256(bytes: &[u8]) -> String { + socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes(bytes) +} + +fn purl(name: &str) -> String { + format!("pkg:maven/org.example/{name}@1.0") +} + +fn jar(notice: &[u8]) -> Vec { + let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let opts = zip::write::SimpleFileOptions::default(); + for (name, bytes) in [ + ("META-INF/MANIFEST.MF", &b"Manifest-Version: 1.0\n"[..]), + (MEMBER, notice), + ] { + zw.start_file(name, opts).unwrap(); + zw.write_all(bytes).unwrap(); + } + zw.finish().unwrap().into_inner() +} + +/// `root/{m2,proj}` for `shape` depending on every `(name, uuid)` package. +fn fixture(root: &Path, shape: Shape, packages: &[(&str, &str)]) { + let proj = root.join("proj"); + std::fs::create_dir_all(proj.join(".socket/blobs")).unwrap(); + let mut patches = serde_json::Map::new(); + for (name, uuid) in packages { + let dir = root.join(format!("m2/org/example/{name}/1.0")); + std::fs::create_dir_all(&dir).unwrap(); + let before = format!("NOTICE {name}\n").into_bytes(); + let after = [before.as_slice(), b"PATCHED\n"].concat(); + std::fs::write(dir.join(format!("{name}-1.0.jar")), jar(&before)).unwrap(); + std::fs::write( + dir.join(format!("{name}-1.0.pom")), + format!( + "4.0.0org.example\ + {name}1.0\n" + ), + ) + .unwrap(); + std::fs::write(proj.join(".socket/blobs").join(git_sha256(&after)), &after).unwrap(); + patches.insert( + purl(name), + serde_json::json!({ + "uuid": uuid, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { MEMBER: { + "beforeHash": git_sha256(&before), + "afterHash": git_sha256(&after), + } }, + "vulnerabilities": { "GHSA-xxxx-yyyy-zzzz": { + "cves": ["CVE-2026-0001"], "summary": "s", "severity": "high", "description": "d" + } }, + "description": "x", + "license": "MIT", + "tier": "free", + }), + ); + } + std::fs::write( + proj.join(".socket/manifest.json"), + serde_json::to_string_pretty(&serde_json::json!({ "patches": patches })).unwrap(), + ) + .unwrap(); + match shape { + Shape::Reactor => { + std::fs::create_dir_all(proj.join(".mvn/wrapper")).unwrap(); + std::fs::write( + proj.join(".mvn/wrapper/maven-wrapper.properties"), + "distributionUrl=https://repo.maven.apache.org/apache-maven-3.9.16-bin.zip\n", + ) + .unwrap(); + let deps: String = packages + .iter() + .map(|(name, _)| { + format!( + "\n org.example\ + {name}1.0" + ) + }) + .collect(); + std::fs::write( + proj.join("pom.xml"), + "\n 4.0.0\n com.x\ + agg1pom\n \ + \n a\n \n\n", + ) + .unwrap(); + std::fs::create_dir_all(proj.join("a")).unwrap(); + std::fs::write( + proj.join("a/pom.xml"), + format!( + "\r\n 4.0.0\r\n com.x\ + agg1\r\n \ + a\r\n {deps}\r\n \r\n\ + \r\n" + ), + ) + .unwrap(); + } + Shape::Gradle => { + std::fs::write( + proj.join("settings.gradle"), + "plugins {\n id 'org.gradle.toolchains.foojay-resolver-convention' version '0.8.0'\n}\nrootProject.name = 'x'\ninclude 'app'\n", + ) + .unwrap(); + std::fs::create_dir_all(proj.join("app")).unwrap(); + let deps: String = packages + .iter() + .map(|(name, _)| format!(" implementation 'org.example:{name}:1.0'\n")) + .collect(); + std::fs::write( + proj.join("app/build.gradle"), + format!("plugins {{ id 'java' }}\nrepositories {{ mavenCentral() }}\ndependencies {{\n{deps}}}\n"), + ) + .unwrap(); + } + } +} + +/// `socket-patch --json --offline --cwd /proj`; `(exit, envelope)`. +fn socket(root: &Path, 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 proj = root.join("proj"); + let out = cmd + .args(args) + .args(["--json", "--offline", "--cwd", proj.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) +} + +fn ok(root: &Path, args: &[&str]) -> serde_json::Value { + let (code, env) = socket(root, 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 +} + +/// Every file and directory under `proj`, minus the manifest and blobs +/// (the inputs the tests edit). +fn snapshot(root: &Path) -> BTreeMap>> { + fn walk(base: &Path, dir: &Path, out: &mut BTreeMap>>) { + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + let rel = path + .strip_prefix(base) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + if rel == ".socket/manifest.json" || rel == ".socket/blobs" { + continue; + } + if std::fs::symlink_metadata(&path).unwrap().is_dir() { + out.insert(format!("{rel}/"), None); + walk(base, &path, out); + } else { + out.insert(rel, Some(std::fs::read(&path).unwrap())); + } + } + } + let proj = root.join("proj"); + let mut out = BTreeMap::new(); + walk(&proj, &proj, &mut out); + out +} + +fn wiring_text(root: &Path) -> String { + snapshot(root) + .into_iter() + .filter(|(rel, _)| { + !rel.starts_with(".socket/vendor/maven2/") && !rel.starts_with(".socket/vendor/gradle/") + }) + .filter(|(rel, _)| rel != ".socket/vendor/state.json") + .filter_map(|(_, bytes)| bytes.map(|b| String::from_utf8_lossy(&b).into_owned())) + .collect() +} + +fn events(env: &serde_json::Value) -> Vec<(String, String, String)> { + env["events"] + .as_array() + .unwrap() + .iter() + .map(|e| { + let s = |k: &str| e[k].as_str().unwrap_or_default().to_string(); + (s("purl"), s("action"), s("errorCode")) + }) + .collect() +} + +/// Rolling back one of two vendored packages leaves the other wired (a +/// re-vendor finds it in sync), and reverting the rest — with the switch +/// unset — restores every byte and directory. +#[test] +fn two_patches_roll_back_one_at_a_time_to_pristine() { + for shape in [Shape::Reactor, Shape::Gradle] { + for first in ["foo", "bar"] { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + fixture(root, shape, &[("foo", FOO_UUID), ("bar", BAR_UUID)]); + let pristine = snapshot(root); + let env = ok(root, &["vendor"]); + assert_eq!(env["summary"]["applied"], 2, "{env}"); + ok(root, &["rollback", &purl(first)]); + let other = if first == "foo" { "bar" } else { "foo" }; + let env = ok(root, &["vendor"]); + assert_eq!( + events(&env), + [( + purl(other), + "skipped".to_string(), + "already_vendored".to_string() + )], + "{env}" + ); + ok(root, &["vendor", "--revert"]); + assert_eq!(snapshot(root), pristine, "first={first}"); + } + } +} + +/// A patch update (same GAV, new uuid) re-points every wiring fragment at +/// the new patch, sweeps the old Maven tree for real, and reverts to +/// pristine. +#[test] +fn patch_update_rewires_sweeps_and_reverts_to_pristine() { + for shape in [Shape::Reactor, Shape::Gradle] { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + fixture(root, shape, &[("foo", FOO_UUID)]); + let pristine = snapshot(root); + ok(root, &["vendor"]); + let manifest_path = root.join("proj/.socket/manifest.json"); + let manifest = std::fs::read_to_string(&manifest_path).unwrap(); + std::fs::write(&manifest_path, manifest.replace(FOO_UUID, FOO_UPDATE_UUID)).unwrap(); + let env = ok(root, &["vendor"]); + let removed = events(&env) + .iter() + .any(|(_, _, code)| code == "vendor_stale_artifact_removed"); + let old_tree = root.join("proj/.socket/vendor/maven2/org/example/foo/1.0-socket.1d3c1fd2"); + assert_eq!(removed, shape == Shape::Reactor, "{env}"); + assert!(!old_tree.exists()); + let wiring = wiring_text(root); + assert!( + !wiring.contains("1d3c1fd2"), + "old uuid still wired:\n{wiring}" + ); + assert!(wiring.contains(if shape == Shape::Reactor { + "1.0-socket.2e4d6f80" + } else { + FOO_UPDATE_UUID + })); + ok(root, &["vendor", "--revert"]); + assert_eq!(snapshot(root), pristine); + } +} + +/// A re-run over the committed tree needs no jar source (cold cache, even +/// `--vendor-source=service --offline`); `vex` attests the entry; a deleted +/// tree jar is rebuilt by `repair`; the whole thing still reverts clean. +#[test] +fn cold_cache_rerun_vex_repair_and_revert() { + for shape in [Shape::Reactor, Shape::Gradle] { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + fixture(root, shape, &[("foo", FOO_UUID)]); + let pristine = snapshot(root); + ok(root, &["vendor"]); + let m2 = root.join("m2"); + let parked = root.join("m2-parked"); + std::fs::rename(&m2, &parked).unwrap(); + for args in [&["vendor"][..], &["vendor", "--vendor-source=service"]] { + let env = ok(root, args); + assert_eq!( + events(&env), + [( + purl("foo"), + "skipped".to_string(), + "already_vendored".to_string() + )], + "{args:?}: {env}" + ); + } + 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::rename(&parked, &m2).unwrap(); + let tree_jar = if shape == Shape::Reactor { + "proj/.socket/vendor/maven2/org/example/foo/1.0-socket.1d3c1fd2/foo-1.0-socket.1d3c1fd2.jar" + } else { + "proj/.socket/vendor/gradle/org/example/foo/1.0/foo-1.0.jar" + }; + std::fs::remove_file(root.join(tree_jar)).unwrap(); + let env = ok(root, &["repair"]); + assert_eq!(env["events"][0]["action"], "rebuilt", "{env}"); + assert!(root.join(tree_jar).is_file()); + let expected = std::fs::read(root.join(tree_jar)).unwrap(); + std::fs::write(root.join(tree_jar), jar(b"CORRUPT\n")).unwrap(); + let env = ok(root, &["repair"]); + assert_eq!(env["events"][0]["action"], "rebuilt", "{env}"); + assert_eq!(std::fs::read(root.join(tree_jar)).unwrap(), expected); + ok(root, &["vendor", "--revert"]); + assert_eq!(snapshot(root), pristine); + } +} + +/// `rollback --preserve-state` keeps the tree and the ledger entry. +#[test] +fn preserve_state_keeps_the_tree() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + fixture(root, Shape::Reactor, &[("foo", FOO_UUID)]); + ok(root, &["vendor"]); + ok(root, &["rollback", "--preserve-state"]); + let proj = root.join("proj"); + assert!(proj + .join( + ".socket/vendor/maven2/org/example/foo/1.0-socket.1d3c1fd2/foo-1.0-socket.1d3c1fd2.jar" + ) + .is_file()); + assert!(!std::fs::read_to_string(proj.join("a/pom.xml")) + .unwrap() + .contains("socket")); + assert!( + std::fs::read_to_string(proj.join(".socket/vendor/state.json")) + .unwrap() + .contains(FOO_UUID) + ); +} + +/// A forged JVM ledger entry naming `.git/config` is refused before any +/// write, using only allowed JVM paths. +#[test] +fn forged_ledger_entries_touch_nothing() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + fixture(root, Shape::Reactor, &[("foo", FOO_UUID)]); + let proj = root.join("proj"); + std::fs::create_dir_all(proj.join(".git")).unwrap(); + std::fs::write(proj.join(".git/config"), "[core]\n").unwrap(); + std::fs::create_dir_all(proj.join("src")).unwrap(); + std::fs::write(proj.join("src/Main.java"), "class Main {}\n").unwrap(); + let sha = hex::encode(Sha256::digest(b"class Main {}\n")); + for (uuid, wiring) in [ + ( + FOO_UUID, + serde_json::json!([{ "file": ".git/config", "kind": "maven_pom_fragment", + "action": "rewritten", "key": "repository", + "new": { "op": "replace", "from": "[core]\n\tfsmonitor = touch PWNED\n", "to": "[core]\n" } }]), + ), + ( + "../../../NOT-A-UUID", + serde_json::json!([{ "file": "src/Main.java", "kind": "jvm_vendor_tree", + "action": "added", "new": sha }]), + ), + ] { + let state = serde_json::json!({ "version": 1, "entries": { purl("foo"): { + "ecosystem": "maven", "basePurl": purl("foo"), "uuid": uuid, + "artifact": { "path": "x", "sha256": "" }, "wiring": wiring, + } } }); + std::fs::create_dir_all(proj.join(".socket/vendor")).unwrap(); + std::fs::write(proj.join(".socket/vendor/state.json"), state.to_string()).unwrap(); + let (code, env) = socket(root, &["vendor", "--revert"]); + assert_ne!(code, Some(0), "{env}"); + assert_eq!( + std::fs::read(proj.join(".git/config")).unwrap(), + b"[core]\n" + ); + assert!(proj.join("src/Main.java").is_file()); + } +} + +/// A build directory symlinked out of the checkout is refused with +/// `build_file_outside_root`, and nothing is written through it. +#[cfg(unix)] +#[test] +fn escaping_symlinks_are_refused() { + for link in [".mvn", ".socket/vendor", "a"] { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + fixture(root, Shape::Reactor, &[("foo", FOO_UUID)]); + let proj = root.join("proj"); + let outside = root.join("outside"); + std::fs::create_dir_all(&outside).unwrap(); + if link == ".mvn" { + std::fs::remove_dir_all(proj.join(".mvn")).unwrap(); + } + if link == "a" { + std::fs::rename(proj.join("a/pom.xml"), outside.join("pom.xml")).unwrap(); + std::fs::remove_dir(proj.join("a")).unwrap(); + } + std::os::unix::fs::symlink(&outside, proj.join(link)).unwrap(); + let before: Vec<_> = std::fs::read_dir(&outside) + .unwrap() + .map(|e| e.unwrap().path()) + .collect(); + let (code, env) = socket(root, &["vendor"]); + assert_ne!(code, Some(0), "{link}: {env}"); + assert_eq!( + env["events"][0]["errorCode"], "vendor_jvm_shape_unsupported", + "{link}: {env}" + ); + assert!( + env["events"][0]["error"] + .as_str() + .unwrap_or_default() + .starts_with("reason: build_file_outside_root: "), + "{link}: {env}" + ); + let after: Vec<_> = std::fs::read_dir(&outside) + .unwrap() + .map(|e| e.unwrap().path()) + .collect(); + assert_eq!(before, after, "{link}: wrote outside the checkout"); + } +} + +#[test] +fn offline_check_detects_metadata_and_wiring_drift_without_writes() { + for shape in [Shape::Reactor, Shape::Gradle] { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + fixture(root, shape, &[("foo", FOO_UUID)]); + ok(root, &["vendor"]); + let before = snapshot(root); + ok(root, &["vendor", "--check"]); + assert_eq!(snapshot(root), before); + let pom = match shape { + Shape::Reactor => "proj/.socket/vendor/maven2/org/example/foo/1.0-socket.1d3c1fd2/foo-1.0-socket.1d3c1fd2.pom", + Shape::Gradle => "proj/.socket/vendor/gradle/org/example/foo/1.0/foo-1.0.pom", + }; + let original = std::fs::read(root.join(pom)).unwrap(); + std::fs::write(root.join(pom), b"tampered").unwrap(); + let corrupt = snapshot(root); + let (code, env) = socket(root, &["vendor", "--check"]); + assert_eq!(code, Some(1), "{env}"); + assert_eq!(snapshot(root), corrupt); + std::fs::write(root.join(pom), original).unwrap(); + let wiring = if shape == Shape::Reactor { + "proj/a/pom.xml" + } else { + "proj/settings.gradle" + }; + let text = std::fs::read_to_string(root.join(wiring)).unwrap(); + let drifted = if shape == Shape::Reactor { + text.replace("1.0-socket.1d3c1fd2", "1.0") + } else { + text.lines() + .filter(|l| !l.contains("apply from:")) + .collect::>() + .join("\n") + }; + std::fs::write(root.join(wiring), drifted).unwrap(); + let (code, env) = socket(root, &["vendor", "--check"]); + assert_eq!(code, Some(1), "{env}"); + } +} + +#[test] +fn maven_config_none_survives_rerun_and_checks_conflicting_local_cache() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + fixture(root, Shape::Reactor, &[("foo", FOO_UUID)]); + let before = snapshot(root); + ok(root, &["vendor", "--maven-config=none"]); + assert!(!root.join("proj/.mvn/maven.config").exists()); + ok(root, &["vendor"]); + assert!(!root.join("proj/.mvn/maven.config").exists()); + ok(root, &["vendor", "--check"]); + let m2 = root.join("other-cache"); + let jar = m2.join("org/example/foo/1.0-socket.1d3c1fd2/foo-1.0-socket.1d3c1fd2.jar"); + std::fs::create_dir_all(jar.parent().unwrap()).unwrap(); + std::fs::write(&jar, b"conflicting bytes").unwrap(); + let (code, env) = socket( + root, + &["vendor", "--check", "--local-repo", m2.to_str().unwrap()], + ); + assert_eq!(code, Some(1), "{env}"); + std::fs::remove_dir_all(m2).unwrap(); + ok(root, &["vendor", "--revert"]); + assert_eq!(snapshot(root), before); +} + +#[test] +fn gradle_old_wrapper_refuses_before_writes() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + fixture(root, Shape::Gradle, &[("foo", FOO_UUID)]); + std::fs::create_dir_all(root.join("proj/gradle/wrapper")).unwrap(); + std::fs::write( + root.join("proj/gradle/wrapper/gradle-wrapper.properties"), + "distributionUrl=https\\://services.gradle.org/distributions/gradle-6.7.1-bin.zip\n", + ) + .unwrap(); + let before = snapshot(root); + let (code, env) = socket(root, &["vendor"]); + assert_eq!(code, Some(1), "{env}"); + assert!(env.to_string().contains("gradle_below_6_8"), "{env}"); + assert_eq!(snapshot(root), before); +} + +#[test] +fn gradle_verification_parents_and_boms_are_shared_and_revert_in_either_order() { + for (first, components) in [ + ("foo", "\n "), + ("bar", "\n "), + ("foo", ""), + ("bar", ""), + ] { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + fixture(root, Shape::Gradle, &[("foo", FOO_UUID), ("bar", BAR_UUID)]); + for name in ["foo", "bar"] { + std::fs::write(root.join(format!("m2/org/example/{name}/1.0/{name}-1.0.pom")), format!("4.0.0org.exampleparent1{name}1.02")).unwrap(); + } + for (name, body) in [ + ("parent", "1org.examplebom${bom.version}importpom"), + ("bom", ""), + ] { + let version = if name == "bom" { "2" } else { "1" }; + let dir = root.join(format!("m2/org/example/{name}/{version}")); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(format!("{name}-{version}.pom")), format!("org.example{name}{version}{body}")).unwrap(); + } + let default_bom = root.join("m2/org/example/bom/1"); + std::fs::create_dir_all(&default_bom).unwrap(); + std::fs::write(default_bom.join("bom-1.pom"), "org.examplebom1").unwrap(); + let verification = root.join("proj/gradle/verification-metadata.xml"); + std::fs::create_dir_all(verification.parent().unwrap()).unwrap(); + let original = format!("\n true\n {components}\n\n"); + std::fs::write(&verification, &original).unwrap(); + let before = snapshot(root); + ok(root, &["vendor"]); + ok(root, &["vendor", "--check"]); + let text = std::fs::read_to_string(&verification).unwrap(); + assert!(text.contains("name=\"parent\""), "{text}"); + assert!(text.contains("name=\"bom\" version=\"1\""), "{text}"); + assert!(text.contains("name=\"bom\" version=\"2\""), "{text}"); + let drifted = text.replace("name=\"parent\"", "name=\"not-parent\""); + std::fs::write(&verification, drifted).unwrap(); + let (code, env) = socket(root, &["vendor", "--check"]); + assert_eq!(code, Some(1), "{env}"); + std::fs::write(&verification, &text).unwrap(); + ok(root, &["remove", &purl(first)]); + assert!(std::fs::read_to_string(&verification) + .unwrap() + .contains("name=\"parent\"")); + ok(root, &["vendor", "--revert"]); + assert_eq!(std::fs::read_to_string(&verification).unwrap(), original); + // remove changes the manifest by design; all build wiring remains byte exact. + let mut after = snapshot(root); + let mut before = before; + after.remove(".socket/manifest.json"); + before.remove(".socket/manifest.json"); + assert_eq!(after, before); + } +} + +#[test] +fn check_refuses_a_missing_ledger_and_honors_manifest_path() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + fixture(root, Shape::Gradle, &[("foo", FOO_UUID)]); + ok(root, &["vendor"]); + let nested = root.join("proj/elsewhere"); + std::fs::create_dir_all(nested.join(".socket")).unwrap(); + std::fs::write(nested.join(".socket/manifest.json"), "{\"patches\":{}}").unwrap(); + // A manifest path selects its project root, even with a different --cwd. + let env = ok( + root, + &[ + "vendor", + "--check", + "--manifest-path", + "elsewhere/.socket/manifest.json", + ], + ); + assert_eq!(env["summary"]["verified"], 0); + std::fs::remove_file(root.join("proj/.socket/manifest.json")).unwrap(); + std::fs::remove_file(root.join("proj/.socket/vendor/state.json")).unwrap(); + let before = snapshot(root); + let (code, env) = socket(root, &["vendor", "--check"]); + assert_eq!(code, Some(1), "{env}"); + assert!(env.to_string().contains("vendor_ledger_missing"), "{env}"); + assert_eq!(snapshot(root), before); +} + +#[test] +fn vex_reports_an_unreadable_jvm_layout_instead_of_an_unwired_patch() { + for shape in [Shape::Reactor, Shape::Gradle] { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + fixture(root, shape, &[("foo", FOO_UUID)]); + ok(root, &["vendor"]); + let rel = if shape == Shape::Reactor { + "proj/a/pom.xml" + } else { + "proj/.socket/vendor/gradle-index.tsv" + }; + std::fs::write(root.join(rel), "INVALID").unwrap(); + let vex = root.join("vex.json"); + let (_, env) = socket( + root, + &[ + "vex", + "-O", + vex.to_str().unwrap(), + "--product", + "pkg:generic/x@1", + ], + ); + assert!( + env.to_string().contains("vendor_jvm_shape_unsupported"), + "{env}" + ); + assert!(!env.to_string().contains("vendor_unwired"), "{env}"); + if let Ok(bytes) = std::fs::read(vex) { + assert!(!String::from_utf8_lossy(&bytes).contains("not_affected")); + } + } +} diff --git a/crates/socket-patch-core/src/api/vendor_prefetch.rs b/crates/socket-patch-core/src/api/vendor_prefetch.rs index 212fdd294..d2c76d079 100644 --- a/crates/socket-patch-core/src/api/vendor_prefetch.rs +++ b/crates/socket-patch-core/src/api/vendor_prefetch.rs @@ -1420,6 +1420,7 @@ mod tests { out } let cfg = crate::vendor::VendorServiceConfig { + maven_config: None, source: crate::vendor::VendorSource::Auto, client: Some(client(&server.uri())), use_public_proxy: false, diff --git a/crates/socket-patch-core/src/utils/group_commit.rs b/crates/socket-patch-core/src/utils/group_commit.rs index d00a2da3a..e8f2284fe 100644 --- a/crates/socket-patch-core/src/utils/group_commit.rs +++ b/crates/socket-patch-core/src/utils/group_commit.rs @@ -167,7 +167,15 @@ fn is_captured(rel: &Path) -> bool { return false; } let spelled = rel.to_string_lossy().replace('\\', "/"); - if LEDGERS.contains(&spelled.as_str()) { + if LEDGERS.contains(&spelled.as_str()) + || [ + ".socket/vendor/gradle-index.tsv", + ".socket/gradle/socket-patch.settings.gradle", + ".socket/vendor/maven2/.gitattributes", + ".socket/vendor/gradle/.gitattributes", + ] + .contains(&spelled.as_str()) + { return true; } !rel.components() @@ -1061,6 +1069,11 @@ mod tests { (".cargo/config.toml", true), (".socket/vendor/state.json", true), (".socket/vendor/redirect-state.json", true), + (".socket/vendor/gradle-index.tsv", true), + (".socket/gradle/socket-patch.settings.gradle", true), + (".socket/vendor/maven2/.gitattributes", true), + (".socket/vendor/gradle/.gitattributes", true), + (".socket/vendor/gradle/g/a/1/a-1.jar", false), (".socket/vendor/npm/u/left-pad-1.3.0.tgz", false), (".socket/manifest.json", false), ("packages/a/.socket/vendor/npm/u/a.tgz", false), diff --git a/crates/socket-patch-core/src/vendor/cargo.rs b/crates/socket-patch-core/src/vendor/cargo.rs index 2e728264c..1aa0cde16 100644 --- a/crates/socket-patch-core/src/vendor/cargo.rs +++ b/crates/socket-patch-core/src/vendor/cargo.rs @@ -3574,6 +3574,7 @@ mod tests { fn cargo_service_cfg(uri: &str, source: VendorSource, offline: bool) -> VendorServiceConfig { VendorServiceConfig { + maven_config: None, source, client: Some( ApiClient::new(ApiClientOptions { diff --git a/crates/socket-patch-core/src/vendor/composer_lock.rs b/crates/socket-patch-core/src/vendor/composer_lock.rs index 03117b367..34ff95c19 100644 --- a/crates/socket-patch-core/src/vendor/composer_lock.rs +++ b/crates/socket-patch-core/src/vendor/composer_lock.rs @@ -2277,6 +2277,7 @@ mod tests { fn composer_service_cfg(uri: &str, source: VendorSource, offline: bool) -> VendorServiceConfig { VendorServiceConfig { + maven_config: None, source, client: Some( ApiClient::new(ApiClientOptions { diff --git a/crates/socket-patch-core/src/vendor/gem.rs b/crates/socket-patch-core/src/vendor/gem.rs index 078f452d0..463d4113c 100644 --- a/crates/socket-patch-core/src/vendor/gem.rs +++ b/crates/socket-patch-core/src/vendor/gem.rs @@ -4589,6 +4589,7 @@ mod tests { fn gem_service_cfg(uri: &str, source: VendorSource, offline: bool) -> VendorServiceConfig { VendorServiceConfig { + maven_config: None, source, client: Some( ApiClient::new(ApiClientOptions { diff --git a/crates/socket-patch-core/src/vendor/golang.rs b/crates/socket-patch-core/src/vendor/golang.rs index 83477a03c..1b14ff72b 100644 --- a/crates/socket-patch-core/src/vendor/golang.rs +++ b/crates/socket-patch-core/src/vendor/golang.rs @@ -1677,6 +1677,7 @@ mod tests { fn go_service_cfg(uri: &str, source: VendorSource, offline: bool) -> VendorServiceConfig { VendorServiceConfig { + maven_config: None, source, client: Some( ApiClient::new(ApiClientOptions { diff --git a/crates/socket-patch-core/src/vendor/jvm/apply.rs b/crates/socket-patch-core/src/vendor/jvm/apply.rs new file mode 100644 index 000000000..9ad6fc780 --- /dev/null +++ b/crates/socket-patch-core/src/vendor/jvm/apply.rs @@ -0,0 +1,1293 @@ +//! Disk side of the JVM backend: write a [`JvmPlan`], record it, +//! and revert it. +//! +//! Records: the planners' fragment records, plus +//! * [`TREE_KIND`] — one per vendored artifact file, `new` = its sha256. +//! Revert deletes it while the hash still matches; +//! * [`CREATED_DIR_KIND`] — a directory vendor created; removed on revert +//! once empty. +//! +//! SECURITY: state.json is committed and tamper-able, so every recorded path +//! must be one a planner can produce for the entry's own coordinates, and +//! every path is resolved component by component: a symlink leaving the +//! checkout fails closed (`build_file_outside_root`), one inside it is +//! followed, so a symlinked build file is edited through its link. + +use std::cell::RefCell; +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +use serde_json::Value; + +use crate::patch::path_safety::is_canonical_uuid; +use crate::utils::fs::{ + atomic_write_bytes_preserving_mode, read_regular_to_bytes_sync, remove_file, +}; +use crate::utils::group_commit; +use crate::utils::purl::parse_maven_purl; + +use super::super::state::{VendorEntry, WiringAction, WiringRecord}; +use super::super::{RevertOpts, RevertOutcome, VendorWarning}; +use super::{ + gradle, maven_reactor, op_of, op_str, safe_coordinates, sha256_hex, Coords, JvmPlan, JvmUnplan, + CONFIG_LINE_KIND, CREATED_DIR_KIND, KINDS, OWNED_FILE_KIND, POM_FRAGMENT_KIND, + SETTINGS_FRAGMENT_KIND, TREE_KIND, VERIFICATION_FRAGMENT_KIND, +}; + +/// Whether `entry` was written by this backend: it has wiring and every +/// record is one of this backend's kinds. +pub fn is_jvm_entry(entry: &VendorEntry) -> bool { + !entry.wiring.is_empty() + && entry + .wiring + .iter() + .all(|w| KINDS.contains(&w.kind.as_str())) +} + +/// Offline inputs have not been authenticated by independent registry checksums. +pub fn upstream_unverified(entry: &VendorEntry) -> bool { + is_jvm_entry(entry) + && !entry + .wiring + .iter() + .any(|w| w.kind == super::UPSTREAM_KIND && op_of(w) == "registry_verified") +} + +/// The validated `(group, artifact, version)` of a JVM entry: a maven purl +/// in the JVM coordinate grammar and a canonical uuid. +pub fn entry_gav(entry: &VendorEntry) -> Result<(String, String, String), String> { + if !is_canonical_uuid(&entry.uuid) { + return Err(format!("non-canonical patch uuid {:?}", entry.uuid)); + } + let (g, a, v) = parse_maven_purl(&entry.base_purl) + .ok_or_else(|| format!("not a maven purl: {:?}", entry.base_purl))?; + if !safe_coordinates(&g, &a, &v) { + return Err(format!("unsafe maven coordinates in {:?}", entry.base_purl)); + } + Ok((g.into_owned(), a.into_owned(), v.into_owned())) +} + +/// A project-relative path with no `..`, no empty or absolute segment and +/// no `.git` segment. +fn safe_rel(rel: &str) -> bool { + !rel.is_empty() + && !rel.starts_with('/') + && !rel.contains('\\') + && !rel.contains(':') + && rel + .split('/') + .all(|s| !s.is_empty() && s != "." && s != ".." && !s.eq_ignore_ascii_case(".git")) +} + +fn is_settings_file(rel: &str) -> bool { + matches!( + rel.rsplit('/').next(), + Some("settings.gradle" | "settings.gradle.kts") + ) +} + +/// A text file some planner edits or owns. +fn is_wiring_file(rel: &str) -> bool { + let under_owned = rel.starts_with(".socket/") || rel.starts_with(".mvn/"); + rel == maven_reactor::MAVEN_CONFIG + || is_owned_file(rel) + || (!under_owned && (rel.ends_with(".xml") || is_settings_file(rel))) +} + +fn is_owned_file(rel: &str) -> bool { + [ + maven_reactor::GITATTRIBUTES_REL, + gradle::GITATTRIBUTES_REL, + gradle::SCRIPT_REL, + gradle::INDEX_REL, + ] + .contains(&rel) +} + +/// A file directly in `c`'s own Maven or Gradle version directory. +fn is_own_tree_file(rel: &str, c: &Coords<'_>) -> bool { + [maven_reactor::tree_dir(c), gradle::tree_dir(c)] + .iter() + .any(|dir| { + rel.strip_prefix(dir.as_str()) + .and_then(|r| r.strip_prefix('/')) + .is_some_and(|name| !name.is_empty() && !name.contains('/')) + }) +} + +/// A directory a plan may create. +fn is_creatable_dir(rel: &str) -> bool { + rel == ".mvn" || rel == ".socket" || rel.starts_with(".socket/") +} + +/// Whether record `w` of the entry for `c` names a path its kind allows. +fn record_allowed(w: &WiringRecord, c: &Coords<'_>) -> bool { + let rel = w.file.as_str(); + safe_rel(rel) + && match w.kind.as_str() { + POM_FRAGMENT_KIND => { + rel.ends_with(".xml") && !rel.starts_with(".socket/") && !rel.starts_with(".mvn/") + } + CONFIG_LINE_KIND => rel == maven_reactor::MAVEN_CONFIG, + SETTINGS_FRAGMENT_KIND => is_settings_file(rel) && !rel.starts_with(".socket/"), + VERIFICATION_FRAGMENT_KIND => rel == gradle::VERIFICATION_REL, + OWNED_FILE_KIND => is_owned_file(rel), + TREE_KIND | super::UPSTREAM_KIND => is_own_tree_file(rel, c), + CREATED_DIR_KIND => is_creatable_dir(rel), + _ => false, + } +} + +/// Reads project files for the planners, resolving every path inside the +/// checkout (see the module doc) and honouring an open group commit. The +/// first path that resolved outside the checkout is kept for the caller's +/// refusal. +pub struct ProjectReader { + root: PathBuf, + canonical: Option, + escaped: RefCell>, + read_error: RefCell>, +} + +impl ProjectReader { + pub fn new(root: &Path) -> Self { + Self { + root: root.to_path_buf(), + canonical: std::fs::canonicalize(root).ok(), + escaped: RefCell::new(None), + read_error: RefCell::new(None), + } + } + + /// Regular files only; a directory, special file or unsafe path reads + /// as missing. + pub fn read(&self, rel: &str) -> Option> { + let path = match self.resolve(rel) { + Ok(path) => path, + Err(e) => { + self.read_error.borrow_mut().get_or_insert(e); + return None; + } + }; + match group_commit::read(&path).unwrap_or_else(|| read_regular_to_bytes_sync(&path)) { + Ok(bytes) => Some(bytes), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => { + self.read_error + .borrow_mut() + .get_or_insert(format!("{rel}: {e}")); + None + } + } + } + + /// The first path that resolved outside the checkout. + pub fn escaped(&self) -> Option { + self.escaped.borrow().clone() + } + + /// `rel` under the canonical root, following in-checkout symlinks. + fn resolve(&self, rel: &str) -> Result { + if !safe_rel(rel) { + return Err(format!("unsafe path {rel:?}")); + } + let Some(canonical) = &self.canonical else { + return Err(format!( + "cannot resolve project root {}", + self.root.display() + )); + }; + let segments: Vec<&str> = rel.split('/').collect(); + let mut cur = canonical.clone(); + for (i, seg) in segments.iter().enumerate() { + let next = cur.join(seg); + match std::fs::symlink_metadata(&next) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Ok(segments[i + 1..].iter().fold(next, |p, s| p.join(s))); + } + Err(e) => return Err(format!("cannot inspect {}: {e}", next.display())), + Ok(meta) if meta.file_type().is_symlink() => { + let target = std::fs::canonicalize(&next) + .ok() + .filter(|t| t.starts_with(canonical)); + let Some(target) = target else { + let at = segments[..=i].join("/"); + self.escaped.borrow_mut().get_or_insert(at.clone()); + return Err(format!( + "{at} is a symlink that leaves the project (or dangles)" + )); + }; + cur = target; + } + Ok(_) => cur = next, + } + } + Ok(cur) + } +} + +/// Read a project file for the planners (see [`ProjectReader::read`]). +pub fn read_project_file(root: &Path, rel: &str) -> Option> { + ProjectReader::new(root).read(rel) +} + +/// The `build_file_outside_root` refusal detail for `rel`. +pub fn outside_root_detail(rel: &str) -> String { + format!( + "reason: build_file_outside_root: {rel} is a symlink that leaves the project (or \ + dangles); vendoring only edits files inside the checkout" + ) +} + +/// Whether `existing` at the tree path `rel` is a file an earlier vendoring +/// wrote: listed with its hash in the directory's marker, or the marker. +fn is_vendored_tree_file(reader: &ProjectReader, rel: &str, existing: &[u8]) -> bool { + let Some((dir, name)) = rel.rsplit_once('/') else { + return false; + }; + let parse = |bytes: &[u8]| serde_json::from_slice::(bytes).ok(); + if name == maven_reactor::MARKER_FILE { + return parse(existing) + .is_some_and(|m| m.get("uuid").is_some() && m.get("schema").is_some()); + } + reader + .read(&format!("{dir}/{}", maven_reactor::MARKER_FILE)) + .and_then(|m| parse(&m)) + .and_then(|m| { + m.get("files")? + .get(name)? + .get("sha256")? + .as_str() + .map(str::to_string) + }) + .is_some_and(|sha| sha == sha256_hex(existing)) +} + +/// Write `plan` under `root` and return its records: the plan's fragment +/// records, then the created directories and the tree files. Nothing is +/// written for a plan that fails validation. +pub async fn write_plan(root: &Path, plan: &JvmPlan) -> Result, String> { + let state = super::super::state::load_state(root) + .await + .map_err(|e| format!("vendor_state_unreadable: {e}"))?; + let reader = ProjectReader::new(root); + let mut targets = Vec::new(); + for w in &plan.writes { + let allowed = if w.tree { + w.rel.starts_with(".socket/vendor/maven2/") + || w.rel.starts_with(".socket/vendor/gradle/") + } else { + is_wiring_file(&w.rel) + }; + if !allowed || !safe_rel(&w.rel) { + return Err(format!( + "refusing to write {:?}: not a vendoring path", + w.rel + )); + } + let path = reader.resolve(&w.rel)?; + match reader.read(&w.rel) { + Some(existing) if w.tree && existing != w.bytes => { + let owned = state.entries.values().any(|e| { + let Ok((g, a, v)) = entry_gav(e) else { + return false; + }; + let c = Coords { + group_id: &g, + artifact_id: &a, + version: &v, + uuid: &e.uuid, + }; + is_jvm_entry(e) + && e.wiring.iter().any(|r| { + r.kind == TREE_KIND && r.file == w.rel && record_allowed(r, &c) + }) + }); + if !owned && !is_vendored_tree_file(&reader, &w.rel, &existing) { + return Err(format!( + "{} already exists and was not written by socket-patch; refusing to \ + overwrite it", + w.rel + )); + } + } + None if path.exists() => { + return Err(format!( + "{} is not a regular file; refusing to edit it", + w.rel + )); + } + _ => {} + } + targets.push((w, path)); + } + + let mut created_dirs: Vec = Vec::new(); + for w in &plan.writes { + let mut prefix = String::new(); + let segments: Vec<&str> = w.rel.split('/').collect(); + for seg in &segments[..segments.len() - 1] { + if !prefix.is_empty() { + prefix.push('/'); + } + prefix.push_str(seg); + if created_dirs.contains(&prefix) || reader.resolve(&prefix)?.is_dir() { + continue; + } + if !is_creatable_dir(&prefix) { + return Err(format!("refusing to create directory {prefix:?}")); + } + created_dirs.push(prefix.clone()); + } + } + + let mut records = plan.records.clone(); + records.extend(created_dirs.into_iter().map(|dir| WiringRecord { + file: dir, + kind: CREATED_DIR_KIND.to_string(), + action: WiringAction::Added, + key: None, + original: None, + new: None, + })); + // Artifacts first: nothing names them until the wiring lands. + targets.sort_by_key(|(w, _)| !w.tree); + for (w, path) in targets { + write_bytes(&path, &w.bytes) + .await + .map_err(|e| format!("failed to write {}: {e}", w.rel))?; + } + let mut tree: Vec<(String, String)> = plan.tree_files.clone(); + tree.extend( + plan.writes + .iter() + .filter(|w| w.tree) + .map(|w| (w.rel.clone(), sha256_hex(&w.bytes))), + ); + tree.sort(); + tree.dedup(); + records.extend(tree.into_iter().map(|(file, sha)| WiringRecord { + file, + kind: TREE_KIND.to_string(), + action: WiringAction::Added, + key: None, + original: None, + new: Some(Value::String(sha)), + })); + Ok(records) +} + +async fn write_bytes(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + atomic_write_bytes_preserving_mode(path, bytes).await +} + +/// Complete `records` from other JVM entries: an `adopt` record takes +/// the creation record of the peer that wrote that shared fragment, a +/// rewrite of another patch's suffixed version takes its pristine +/// `original`, and the directories a peer created that this entry now +/// relies on are listed too. The ledger entry this one replaces is a peer. +pub fn inherit_peer_records<'e>( + records: &mut Vec, + peers: impl IntoIterator, +) { + let peer_records: Vec<&WiringRecord> = peers + .into_iter() + .filter(|e| is_jvm_entry(e)) + .flat_map(|e| &e.wiring) + .collect(); + let same = + |p: &WiringRecord, r: &WiringRecord| p.file == r.file && p.kind == r.kind && p.key == r.key; + for r in records.iter_mut() { + if op_of(r) == "adopt" { + if let Some(p) = peer_records + .iter() + .find(|p| same(p, r) && op_of(p) != "adopt") + { + *r = (*p).clone(); + } + } else if r.kind == POM_FRAGMENT_KIND + && r.action == WiringAction::Rewritten + && r.original.is_none() + { + if let Some(p) = peer_records + .iter() + .find(|p| same(p, r) && p.original.is_some()) + { + r.original = p.original.clone(); + } + } else if r.kind == VERIFICATION_FRAGMENT_KIND && op_str(r, "from").is_none() { + let from = peer_records + .iter() + .find(|p| same(p, r)) + .and_then(|p| op_str(p, "from")); + if let (Some(from), Some(op)) = (from, r.new.as_mut().and_then(Value::as_object_mut)) { + op.insert("from".to_string(), Value::String(from.to_string())); + } + } + } + let mut needed: BTreeSet = BTreeSet::new(); + for r in records.iter().filter(|r| r.kind != CREATED_DIR_KIND) { + let mut dir = r.file.as_str(); + while let Some((parent, _)) = dir.rsplit_once('/') { + needed.insert(parent.to_string()); + dir = parent; + } + } + for p in peer_records.iter().filter(|p| p.kind == CREATED_DIR_KIND) { + let listed = records + .iter() + .any(|r| r.kind == CREATED_DIR_KIND && r.file == p.file); + if needed.contains(&p.file) && !listed { + records.push((*p).clone()); + } + } +} + +fn drifted(detail: String) -> VendorWarning { + VendorWarning::new("vendor_lock_entry_drifted", format!("{detail}; left alone")) +} + +/// Whether the wiring belongs to the Gradle planner. +fn is_gradle(wiring: &[WiringRecord]) -> bool { + wiring.iter().any(|w| { + matches!( + w.kind.as_str(), + SETTINGS_FRAGMENT_KIND | VERIFICATION_FRAGMENT_KIND + ) || w.file == gradle::INDEX_REL + || w.file == gradle::SCRIPT_REL + || w.file.starts_with(&format!("{}/", gradle::TREE_ROOT)) + }) +} + +/// Revert the JVM `entry`: its own fragments now, shared fragments +/// only once no other patch references them, so peers stay wired whatever +/// the revert order. A fragment still present but in a shape vendor did +/// not write is left alone with `vendor_lock_entry_drifted`; while it still +/// references the tree, the tree is kept too (`kept_artifact`). +pub async fn revert(root: &Path, entry: &VendorEntry, opts: RevertOpts) -> RevertOutcome { + let (g, a, v) = match entry_gav(entry) { + Ok(gav) => gav, + Err(e) => return RevertOutcome::failed(format!("refusing revert: {e}")), + }; + let c = Coords { + group_id: &g, + artifact_id: &a, + version: &v, + uuid: &entry.uuid, + }; + if let Some(w) = entry.wiring.iter().find(|w| !record_allowed(w, &c)) { + return RevertOutcome::failed(format!( + "refusing revert: recorded {} path {:?} is not one vendoring writes for {}", + w.kind, w.file, entry.base_purl + )); + } + let reader = ProjectReader::new(root); + let read = |rel: &str| reader.read(rel); + let peers = match super::super::state::load_state(root).await { + Ok(state) => state, + Err(e) => return RevertOutcome::failed(format!("vendor_state_unreadable: {e}")), + }; + let records: Vec<_> = entry + .wiring + .iter() + .filter(|w| { + !(w.kind == VERIFICATION_FRAGMENT_KIND + && w.key.as_deref().is_some_and(|k| k.starts_with("metadata:")) + && peers.entries.values().any(|peer| { + peer.base_purl != entry.base_purl + && entry_wired(root, peer) + && peer + .wiring + .iter() + .any(|p| p.kind == w.kind && p.file == w.file && p.key == w.key) + })) + }) + .cloned() + .collect(); + let unplan: JvmUnplan = if is_gradle(&entry.wiring) { + gradle::unplan(&read, &c, &records) + } else { + maven_reactor::unplan(&read, &c, &entry.wiring) + }; + if let Some(rel) = reader.escaped() { + return RevertOutcome::failed(format!( + "refusing revert: {rel} is a symlink that leaves the project" + )); + } + let mut warnings: Vec = unplan.drifted.into_iter().map(drifted).collect(); + let mut kept = unplan.still_wired; + let mut present = Vec::new(); + if !kept && !opts.keep_artifact { + for w in entry.wiring.iter().filter(|w| w.kind == TREE_KIND) { + let Some(bytes) = reader.read(&w.file) else { + continue; + }; + if w.new.as_ref().and_then(Value::as_str) != Some(sha256_hex(&bytes).as_str()) { + warnings.push(drifted(format!("{} was modified", w.file))); + kept = true; + } + present.push(w); + } + } + if opts.dry_run { + return RevertOutcome { + success: true, + warnings, + error: None, + kept_artifact: kept, + }; + } + let fail = |warnings: Vec, e: String| RevertOutcome { + success: false, + warnings, + error: Some(e), + kept_artifact: false, + }; + + let mut removed: Vec = Vec::new(); + for (rel, bytes) in &unplan.changes { + if !is_wiring_file(rel) { + return fail( + warnings, + format!("refusing revert: {rel:?} is not a vendoring path"), + ); + } + let path = match reader.resolve(rel) { + Ok(path) => path, + Err(e) => return fail(warnings, format!("refusing revert: {e}")), + }; + let res = match bytes { + Some(bytes) => write_bytes(&path, bytes).await, + None => { + removed.push(rel.clone()); + remove_file(&path).await + } + }; + if let Err(e) = res { + return fail(warnings, format!("failed to restore {rel}: {e}")); + } + } + + if !kept && !opts.keep_artifact { + // One modified file keeps the whole tree: a partial artifact is + // worse than a kept one. + for w in present.into_iter().filter(|_| !kept) { + let res = match reader.resolve(&w.file) { + Ok(path) => remove_file(&path).await.map_err(|e| e.to_string()), + Err(e) => Err(e), + }; + if let Err(e) = res { + return fail(warnings, format!("failed to remove {}: {e}", w.file)); + } + removed.push(w.file.clone()); + } + } + prune_dirs(&reader, &entry.wiring, &removed).await; + RevertOutcome { + success: true, + warnings, + error: None, + kept_artifact: kept, + } +} + +/// Owned directories pruned once empty, up to and including themselves. +const OWNED_DIRS: &[&str] = &[ + ".socket/vendor/maven2", + ".socket/vendor/gradle", + ".socket/gradle", +]; + +/// Remove, deepest first and only when empty, the parents of `removed` up to +/// their owned root, and every directory `wiring` records as created. +async fn prune_dirs(reader: &ProjectReader, wiring: &[WiringRecord], removed: &[String]) { + let mut dirs: BTreeSet = wiring + .iter() + .filter(|w| w.kind == CREATED_DIR_KIND) + .map(|w| w.file.clone()) + .collect(); + for rel in removed { + let mut dir = rel.as_str(); + while let Some((parent, _)) = dir.rsplit_once('/') { + if !OWNED_DIRS + .iter() + .any(|o| parent == *o || parent.starts_with(&format!("{o}/"))) + { + break; + } + dirs.insert(parent.to_string()); + dir = parent; + } + } + let mut dirs: Vec = dirs.into_iter().collect(); + dirs.sort_by_key(|d| std::cmp::Reverse(d.matches('/').count())); + for dir in dirs { + if let Ok(path) = reader.resolve(&dir) { + group_commit::remove_dir_after_commit(&path).await; + } + } +} + +/// After a patch update replaced `prev`, delete its tree files that no live +/// entry records (a Maven update moves to a new suffixed-version directory; +/// a Gradle one rewrote the same paths). `Ok(true)` when anything went. +pub async fn sweep_replaced_tree<'e>( + root: &Path, + prev: &VendorEntry, + live: impl IntoIterator, +) -> Result { + let (g, a, v) = entry_gav(prev)?; + let c = Coords { + group_id: &g, + artifact_id: &a, + version: &v, + uuid: &prev.uuid, + }; + let live_files: BTreeSet<&str> = live + .into_iter() + .flat_map(|e| &e.wiring) + .filter(|w| w.kind == TREE_KIND) + .map(|w| w.file.as_str()) + .collect(); + let reader = ProjectReader::new(root); + let mut removed = Vec::new(); + for w in prev.wiring.iter().filter(|w| w.kind == TREE_KIND) { + if !record_allowed(w, &c) || live_files.contains(w.file.as_str()) { + continue; + } + let Some(bytes) = reader.read(&w.file) else { + continue; + }; + if w.new.as_ref().and_then(Value::as_str) != Some(sha256_hex(&bytes).as_str()) { + continue; + } + let path = reader.resolve(&w.file)?; + remove_file(&path) + .await + .map_err(|e| format!("failed to remove {}: {e}", w.file))?; + removed.push(w.file.clone()); + } + prune_dirs(&reader, &[], &removed).await; + Ok(!removed.is_empty()) +} + +/// Whether the project still wires the JVM `entry` (its suffixed version in +/// a reactor pom; its index rows plus the root apply line for Gradle). +pub fn entry_wired(root: &Path, entry: &VendorEntry) -> bool { + // Failure to read does not prove that deleting a tree is safe. Attestation + // uses the checked variant and reports the diagnostic instead. + entry_wired_checked(root, entry).unwrap_or(true) +} + +pub fn entry_wired_checked(root: &Path, entry: &VendorEntry) -> Result { + let (g, a, v) = entry_gav(entry)?; + let c = Coords { + group_id: &g, + artifact_id: &a, + version: &v, + uuid: &entry.uuid, + }; + let reader = ProjectReader::new(root); + let read = |rel: &str| reader.read(rel); + let wired = if is_gradle(&entry.wiring) { + gradle::wired_checked(&read, &c).map_err(|e| e.detail) + } else { + maven_reactor::wired_checked(&read, &c).map_err(|e| e.detail) + }; + if let Some(e) = reader.read_error.borrow().as_ref() { + return Err(e.clone()); + } + wired +} + +/// Verify every recorded file plus the effective wiring, without writes or network I/O. +pub fn check_entry( + root: &Path, + entry: &VendorEntry, + local_repo: Option<&Path>, +) -> Result<(), String> { + let (g, a, v) = entry_gav(entry)?; + let c = Coords { + group_id: &g, + artifact_id: &a, + version: &v, + uuid: &entry.uuid, + }; + let reader = ProjectReader::new(root); + let read = |rel: &str| reader.read(rel); + for w in &entry.wiring { + if !record_allowed(w, &c) { + return Err(format!("unsafe wiring record: {}", w.file)); + } + if w.kind == TREE_KIND { + let bytes = read(&w.file).ok_or_else(|| format!("missing or unreadable {}", w.file))?; + if w.new.as_ref().and_then(Value::as_str) != Some(sha256_hex(&bytes).as_str()) { + return Err(format!("hash mismatch: {}", w.file)); + } + } + if w.kind == VERIFICATION_FRAGMENT_KIND + && w.key.as_deref().is_some_and(|k| k.starts_with("metadata:")) + { + let bytes = read(&w.file).ok_or_else(|| format!("missing {}", w.file))?; + let text = std::str::from_utf8(&bytes).map_err(|e| e.to_string())?; + if !gradle::metadata_record_present(text, w) { + return Err(format!( + "upstream verification entry drifted: {}", + w.key.as_deref().unwrap_or("") + )); + } + } + } + let gradle = is_gradle(&entry.wiring); + let (jar, pom, module) = if gradle { + gradle::committed(&read, &c) + } else { + maven_reactor::committed(&read, &c).map(|(j, p)| (j, p, None)) + } + .ok_or_else(|| "committed JVM tree is incomplete".to_string())?; + let patch = super::JvmPatch { + group_id: &g, + artifact_id: &a, + version: &v, + uuid: &entry.uuid, + jar: &jar, + upstream_pom: &pom, + upstream_module: module.as_deref(), + }; + let plan = if gradle { + gradle::plan(&read, &patch) + } else { + let config = !entry.wiring.iter().any(|w| op_of(w) == "config_none"); + maven_reactor::plan_with_config(&read, &patch, config) + } + .map_err(|e| e.detail)?; + if let Some(w) = plan.writes.first() { + return Err(format!("vendored wiring or metadata drifted: {}", w.rel)); + } + if !entry_wired_checked(root, entry)? { + return Err("vendored artifact is no longer wired into the build".into()); + } + let dir = reader.resolve(&plan.tree_dir)?; + for item in std::fs::read_dir(&dir).map_err(|e| e.to_string())? { + let item = item.map_err(|e| e.to_string())?; + let rel = format!("{}/{}", plan.tree_dir, item.file_name().to_string_lossy()); + if !entry + .wiring + .iter() + .any(|w| w.kind == TREE_KIND && w.file == rel) + { + return Err(format!("unindexed vendored file: {rel}")); + } + } + if !gradle { + if let Some(repo) = local_repo { + for ext in ["jar", "pom"] { + let sv = c.suffixed_version(); + let name = format!("{a}-{sv}.{ext}"); + let cached = repo.join(c.group_path()).join(&a).join(&sv).join(&name); + if cached.exists() { + let bytes = read_regular_to_bytes_sync(&cached).map_err(|e| e.to_string())?; + if Some(bytes) != read(&format!("{}/{name}", plan.tree_dir)) { + return Err(format!( + "local Maven cache conflicts with vendored bytes: {}", + cached.display() + )); + } + } + } + } + } + if let Some(rel) = reader.escaped() { + return Err(outside_root_detail(&rel)); + } + Ok(()) +} + +/// The vendored jar of the JVM `entry` for `uuid`, project-relative: the +/// artifact path must be the entry's own tree jar (the Maven directory +/// carries the uuid; the Gradle one's marker must name it). +pub fn checked_tree_jar(root: &Path, entry: &VendorEntry, uuid: &str) -> Result { + let unsafe_path = || "vendor_path_unsafe".to_string(); + if !is_jvm_entry(entry) { + return Err(unsafe_path()); + } + let (g, a, v) = entry_gav(entry).map_err(|_| unsafe_path())?; + if entry.uuid != uuid { + return Err("vendor_uuid_mismatch".to_string()); + } + let c = Coords { + group_id: &g, + artifact_id: &a, + version: &v, + uuid, + }; + let rel = entry.artifact.path.as_str(); + ProjectReader::new(root) + .resolve(rel) + .map_err(|_| unsafe_path())?; + let maven = format!( + "{}/{a}-{}.jar", + maven_reactor::tree_dir(&c), + c.suffixed_version() + ); + let gradle_jar = format!("{}/{a}-{v}.jar", gradle::tree_dir(&c)); + if rel == maven { + return Ok(maven); + } + if rel != gradle_jar { + return Err(unsafe_path()); + } + let marker = ProjectReader::new(root) + .read(&format!("{}/{}", gradle::tree_dir(&c), gradle::MARKER_NAME)) + .and_then(|m| serde_json::from_slice::(&m).ok()); + match marker + .as_ref() + .and_then(|m| m.get("uuid")) + .and_then(Value::as_str) + { + Some(u) if u == uuid => Ok(gradle_jar), + _ => Err("vendor_uuid_mismatch".to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::super::FileWrite; + use super::*; + + const UUID: &str = "1d3c1fd2-5e6f-4a7b-8c9d-0e1f2a3b4c5d"; + + fn coords() -> Coords<'static> { + Coords { + group_id: "g", + artifact_id: "a", + version: "1", + uuid: UUID, + } + } + + fn entry(wiring: Vec) -> VendorEntry { + serde_json::from_value(serde_json::json!({ + "ecosystem": "maven", + "basePurl": "pkg:maven/g/a@1", + "uuid": UUID, + "artifact": { "path": ".socket/vendor/maven2/g/a/1-socket.1d3c1fd2/a-1-socket.1d3c1fd2.jar", "sha256": "" }, + "wiring": serde_json::to_value(wiring).unwrap(), + })) + .unwrap() + } + + fn record(kind: &str, file: &str) -> WiringRecord { + WiringRecord { + file: file.to_string(), + kind: kind.to_string(), + action: WiringAction::Added, + key: Some("owned".into()), + original: None, + new: Some(serde_json::json!({ "op": "create" })), + } + } + + fn tree_file(name: &str) -> String { + format!(".socket/vendor/maven2/g/a/1-socket.1d3c1fd2/{name}") + } + + fn plan(writes: Vec) -> JvmPlan { + JvmPlan { + writes, + ..JvmPlan::default() + } + } + + #[test] + fn recorded_paths_are_whitelisted_per_kind() { + let c = coords(); + let ok = [ + (POM_FRAGMENT_KIND, "a/pom.xml"), + (POM_FRAGMENT_KIND, "mod/custom.xml"), + (CONFIG_LINE_KIND, ".mvn/maven.config"), + (SETTINGS_FRAGMENT_KIND, "settings.gradle"), + (SETTINGS_FRAGMENT_KIND, "build-logic/settings.gradle.kts"), + ( + VERIFICATION_FRAGMENT_KIND, + "gradle/verification-metadata.xml", + ), + ( + OWNED_FILE_KIND, + ".socket/gradle/socket-patch.settings.gradle", + ), + (OWNED_FILE_KIND, ".socket/vendor/maven2/.gitattributes"), + ( + TREE_KIND, + ".socket/vendor/maven2/g/a/1-socket.1d3c1fd2/a-1-socket.1d3c1fd2.jar", + ), + (TREE_KIND, ".socket/vendor/gradle/g/a/1/a-1.jar"), + (CREATED_DIR_KIND, ".mvn"), + (CREATED_DIR_KIND, ".socket/vendor/maven2/g"), + ]; + for (kind, file) in ok { + assert!(record_allowed(&record(kind, file), &c), "{kind} {file}"); + } + let bad = [ + (POM_FRAGMENT_KIND, ".git/config"), + (POM_FRAGMENT_KIND, "src/Main.java"), + (POM_FRAGMENT_KIND, ".GIT/x.xml"), + (POM_FRAGMENT_KIND, "a/.git/x.xml"), + (POM_FRAGMENT_KIND, ".socket/vendor/x.xml"), + (POM_FRAGMENT_KIND, "../x/pom.xml"), + (POM_FRAGMENT_KIND, "/etc/pom.xml"), + (CONFIG_LINE_KIND, ".mvn/jvm.config"), + (SETTINGS_FRAGMENT_KIND, "build.gradle"), + (VERIFICATION_FRAGMENT_KIND, "gradle/other.xml"), + (OWNED_FILE_KIND, ".socket/vendor/state.json"), + ( + TREE_KIND, + ".socket/vendor/maven2/g/a/1-socket.99999999/a.jar", + ), + ( + TREE_KIND, + ".socket/vendor/maven2/g/a/1-socket.1d3c1fd2/x/a.jar", + ), + (TREE_KIND, ".socket/vendor/gradle/g/b/1/b-1.jar"), + (TREE_KIND, "src/Main.java"), + (CREATED_DIR_KIND, "src"), + (CREATED_DIR_KIND, ".git"), + ("jvm_file_snapshot", "pom.xml"), + ]; + for (kind, file) in bad { + assert!(!record_allowed(&record(kind, file), &c), "{kind} {file}"); + } + } + + #[test] + fn jvm_entries_need_only_jvm_kinds_and_a_canonical_uuid() { + let tree = WiringRecord { + kind: TREE_KIND.into(), + ..record(TREE_KIND, &tree_file("a-1-socket.1d3c1fd2.jar")) + }; + assert!(is_jvm_entry(&entry(vec![tree.clone()]))); + assert!(!is_jvm_entry(&entry(vec![]))); + let legacy = record("maven_pom_repository", "pom.xml"); + assert!(!is_jvm_entry(&entry(vec![tree.clone(), legacy]))); + let mut bad = entry(vec![tree]); + bad.uuid = "../../../NOT-A-UUID".into(); + assert!(entry_gav(&bad).is_err()); + bad.uuid = UUID.into(); + bad.base_purl = "pkg:maven/com.ex&le/a@1".into(); + assert!(entry_gav(&bad).is_err()); + } + + #[tokio::test] + async fn tampered_records_fail_closed_before_any_write() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir_all(root.join(".git")).unwrap(); + std::fs::write(root.join(".git/config"), "[core]\n").unwrap(); + std::fs::create_dir_all(root.join("src")).unwrap(); + std::fs::write(root.join("src/Main.java"), "class Main {}\n").unwrap(); + let forged = [ + WiringRecord { + new: Some(Value::String(sha256_hex(b"class Main {}\n"))), + ..record(TREE_KIND, "src/Main.java") + }, + WiringRecord { + original: Some(Value::String("[core]\n\tfsmonitor = x\n".into())), + new: Some(serde_json::json!({ "op": "replace", "from": "x", "to": "[core]\n" })), + ..record(POM_FRAGMENT_KIND, ".git/config") + }, + ]; + for w in forged { + let out = revert(root, &entry(vec![w]), RevertOpts::new(false)).await; + assert!(!out.success, "{out:?}"); + } + assert_eq!( + std::fs::read(root.join(".git/config")).unwrap(), + b"[core]\n" + ); + assert!(root.join("src/Main.java").is_file()); + let mut e = entry(vec![record( + OWNED_FILE_KIND, + ".socket/gradle/socket-patch.settings.gradle", + )]); + e.uuid = "../../../NOT-A-UUID".into(); + assert!(!revert(root, &e, RevertOpts::new(false)).await.success); + } + + #[cfg(unix)] + #[tokio::test] + async fn symlinks_leaving_the_checkout_are_never_followed() { + let dir = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write(outside.path().join("pom.xml"), "").unwrap(); + std::os::unix::fs::symlink(outside.path(), root.join("lnk")).unwrap(); + std::os::unix::fs::symlink(outside.path(), root.join(".mvn")).unwrap(); + let reader = ProjectReader::new(root); + assert_eq!(reader.read("lnk/pom.xml"), None); + assert_eq!(reader.escaped().as_deref(), Some("lnk")); + // Writing through a symlinked `.mvn` or `.socket` is refused. + let p = plan(vec![FileWrite { + rel: ".mvn/maven.config".into(), + bytes: b"-Da=b\n".to_vec(), + tree: false, + }]); + assert!(write_plan(root, &p).await.is_err()); + assert!(!outside.path().join("maven.config").exists()); + std::os::unix::fs::symlink(outside.path(), root.join(".socket")).unwrap(); + let p = plan(vec![FileWrite { + rel: ".socket/vendor/maven2/g/a/1/a.jar".into(), + bytes: b"JAR".to_vec(), + tree: true, + }]); + assert!(write_plan(root, &p).await.is_err()); + assert!(!outside.path().join("vendor").exists()); + } + + #[cfg(unix)] + #[tokio::test] + async fn an_in_checkout_symlinked_file_is_edited_through_its_link() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir_all(root.join("a")).unwrap(); + std::fs::write(root.join("a/real.xml"), "\n").unwrap(); + std::os::unix::fs::symlink("real.xml", root.join("a/pom.xml")).unwrap(); + let reader = ProjectReader::new(root); + assert_eq!( + reader.read("a/pom.xml").as_deref(), + Some(&b"\n"[..]) + ); + assert_eq!(reader.escaped(), None); + let p = plan(vec![FileWrite { + rel: "a/pom.xml".into(), + bytes: b"\n".to_vec(), + tree: false, + }]); + write_plan(root, &p).await.unwrap(); + let meta = std::fs::symlink_metadata(root.join("a/pom.xml")).unwrap(); + assert!(meta.file_type().is_symlink()); + assert_eq!( + std::fs::read(root.join("a/real.xml")).unwrap(), + b"\n" + ); + } + + #[tokio::test] + async fn unresolved_root_never_disables_confinement() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("missing"); + let reader = ProjectReader::new(&root); + assert!(reader.resolve("pom.xml").is_err()); + let p = plan(vec![FileWrite { + rel: "pom.xml".into(), + bytes: b"".to_vec(), + tree: false, + }]); + assert!(write_plan(&root, &p).await.is_err()); + assert!(!root.exists()); + } + + #[tokio::test] + async fn dry_run_reports_tree_retained_for_live_drift() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join(gradle::INDEX_REL); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, "MALFORMED\n").unwrap(); + let out = revert( + temp.path(), + &entry(vec![record(OWNED_FILE_KIND, gradle::INDEX_REL)]), + RevertOpts::new(true), + ) + .await; + assert!( + out.success && out.kept_artifact && out.drift_skipped(), + "{out:?}" + ); + assert_eq!(std::fs::read_to_string(path).unwrap(), "MALFORMED\n"); + } + + #[tokio::test] + async fn write_plan_rejects_paths_outside_the_vendoring_set() { + let dir = tempfile::tempdir().unwrap(); + for (rel, tree) in [ + ("../evil", false), + ("src/Main.java", false), + (".git/config", false), + ("src/lib.jar", true), + (".socket/vendor/state.json", false), + ("newdir/pom.xml", false), + ] { + let p = plan(vec![FileWrite { + rel: rel.into(), + bytes: Vec::new(), + tree, + }]); + assert!(write_plan(dir.path(), &p).await.is_err(), "{rel}"); + } + assert!(!dir.path().join("newdir").exists()); + } + + #[tokio::test] + async fn tree_files_never_overwrite_foreign_bytes() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let jar = tree_file("a-1-socket.1d3c1fd2.jar"); + std::fs::create_dir_all(root.join(&jar).parent().unwrap()).unwrap(); + std::fs::write(root.join(&jar), b"FOREIGN").unwrap(); + let p = plan(vec![FileWrite { + rel: jar.clone(), + bytes: b"JAR".to_vec(), + tree: true, + }]); + assert!(write_plan(root, &p).await.is_err()); + assert_eq!(std::fs::read(root.join(&jar)).unwrap(), b"FOREIGN"); + // Listed in the directory's marker: an earlier vendoring's bytes. + let marker = serde_json::json!({ + "files": { "a-1-socket.1d3c1fd2.jar": { "sha256": sha256_hex(b"FOREIGN"), "size": 7 } }, + "schema": 1, + "uuid": UUID, + }); + std::fs::write( + root.join(tree_file("socket-patch.vendor.json")), + serde_json::to_vec(&marker).unwrap(), + ) + .unwrap(); + write_plan(root, &p).await.unwrap(); + assert_eq!(std::fs::read(root.join(&jar)).unwrap(), b"JAR"); + } + + #[test] + fn peer_records_fill_adopts_originals_and_created_dirs() { + let created = WiringRecord { + file: ".mvn/maven.config".into(), + kind: CONFIG_LINE_KIND.into(), + action: WiringAction::Added, + key: Some("config".into()), + original: None, + new: Some(serde_json::json!({ "op": "config", "created": true, "appended": "x\n" })), + }; + let version = |original: Option<&str>| WiringRecord { + file: "a/pom.xml".into(), + kind: POM_FRAGMENT_KIND.into(), + action: WiringAction::Rewritten, + key: Some("version:g:a:dependencies:0".into()), + original: original.map(|o| Value::String(o.into())), + new: Some(serde_json::json!({ "op": "version", "to": "1-socket.1d3c1fd2" })), + }; + let mvn_dir = WiringRecord { + file: ".mvn".into(), + kind: CREATED_DIR_KIND.into(), + action: WiringAction::Added, + key: None, + original: None, + new: None, + }; + let other_dir = WiringRecord { + file: ".socket/gradle".into(), + ..mvn_dir.clone() + }; + let peer = entry(vec![ + created.clone(), + version(Some("${ct}")), + mvn_dir.clone(), + other_dir, + ]); + let mut records = vec![ + super::super::adopt(".mvn/maven.config", CONFIG_LINE_KIND, "config"), + super::super::adopt("pom.xml", POM_FRAGMENT_KIND, "repository"), + version(None), + ]; + inherit_peer_records(&mut records, [&peer]); + assert_eq!(records[0], created); + assert_eq!(op_of(&records[1]), "adopt", "no peer wrote it"); + assert_eq!(records[2], version(Some("${ct}"))); + assert_eq!(records[3], mvn_dir); + assert_eq!( + records.len(), + 4, + "a directory this entry does not use is not inherited" + ); + } + + #[tokio::test] + async fn revert_of_a_tree_keeps_modified_files_and_prunes_empty_dirs() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let p = plan(vec![ + FileWrite { + rel: tree_file("a-1-socket.1d3c1fd2.jar"), + bytes: b"JAR".to_vec(), + tree: true, + }, + FileWrite { + rel: tree_file("a-1-socket.1d3c1fd2.pom"), + bytes: b"POM".to_vec(), + tree: true, + }, + ]); + let records = write_plan(root, &p).await.unwrap(); + // Created dirs listed first (as a carried-forward entry would): + // they are still pruned after the files. + let mut first_dirs = records.clone(); + first_dirs.sort_by_key(|r| r.kind != CREATED_DIR_KIND); + let out = revert(root, &entry(first_dirs.clone()), RevertOpts::new(false)).await; + assert!(out.success && out.warnings.is_empty(), "{out:?}"); + assert!(!root.join(".socket").exists()); + + write_plan(root, &p).await.unwrap(); + std::fs::write(root.join(tree_file("a-1-socket.1d3c1fd2.pom")), b"EDITED").unwrap(); + let out = revert(root, &entry(first_dirs.clone()), RevertOpts::new(false)).await; + assert!( + out.success && out.drift_skipped() && out.kept_artifact, + "{out:?}" + ); + assert!(root.join(tree_file("a-1-socket.1d3c1fd2.pom")).is_file()); + + let out = revert( + root, + &entry(first_dirs), + RevertOpts { + dry_run: false, + keep_artifact: true, + }, + ) + .await; + assert!(out.success && !out.kept_artifact, "{out:?}"); + assert!( + root.join(tree_file("a-1-socket.1d3c1fd2.jar")).is_file(), + "--preserve-state keeps the tree" + ); + } + + #[tokio::test] + async fn sweep_replaced_tree_spares_live_paths() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let p = plan(vec![ + FileWrite { + rel: tree_file("a-1-socket.1d3c1fd2.jar"), + bytes: b"JAR".to_vec(), + tree: true, + }, + FileWrite { + rel: tree_file("a-1-socket.1d3c1fd2.pom"), + bytes: b"POM".to_vec(), + tree: true, + }, + ]); + let records = write_plan(root, &p).await.unwrap(); + let prev = entry(records.clone()); + let live = entry( + records + .into_iter() + .filter(|r| r.file.ends_with(".pom")) + .collect(), + ); + assert!(sweep_replaced_tree(root, &prev, [&live]).await.unwrap()); + assert!(!root.join(tree_file("a-1-socket.1d3c1fd2.jar")).exists()); + assert!(root.join(tree_file("a-1-socket.1d3c1fd2.pom")).is_file()); + assert!(!sweep_replaced_tree(root, &prev, [&live]).await.unwrap()); + } +} diff --git a/crates/socket-patch-core/src/vendor/jvm/archive.rs b/crates/socket-patch-core/src/vendor/jvm/archive.rs new file mode 100644 index 000000000..c8df0f6af --- /dev/null +++ b/crates/socket-patch-core/src/vendor/jvm/archive.rs @@ -0,0 +1,140 @@ +//! Server-compatible JAR encoding (archiver 7's stored ZIP format). +//! Keep upstream entry order and executable bits; append new entries in name +//! order, drop signature metadata, and write fixed timestamps without extras. + +use std::collections::{BTreeMap, BTreeSet}; +use std::io::Cursor; + +pub(crate) fn is_signature(name: &str) -> bool { + let upper = name.to_ascii_uppercase(); + upper == ".SIGNATURE.P7S" + || upper.strip_prefix("META-INF/").is_some_and(|n| { + !n.contains('/') + && [".SF", ".RSA", ".DSA", ".EC"] + .iter() + .any(|ext| n.ends_with(ext)) + }) +} + +/// Canonicalize a locally patched JAR using the upstream archive's ordering. +/// Both archives are decoded with the vendor verifier's size/entry limits. +pub(crate) fn canonical_jar(upstream: &[u8], patched: &[u8]) -> Result, String> { + super::super::verify::read_zip_bytes_to_map(upstream)?; + let mut bodies = super::super::verify::read_zip_bytes_to_map(patched)?; + let mut original = zip::ZipArchive::new(Cursor::new(upstream)).map_err(|e| e.to_string())?; + let mut order = Vec::new(); + let mut seen = BTreeSet::new(); + for i in 0..original.len() { + let file = original.by_index(i).map_err(|e| e.to_string())?; + if file.is_dir() { + continue; + } + let name = file.name().to_string(); + if !seen.insert(name.clone()) { + return Err(format!("duplicate upstream JAR entry: {name}")); + } + let mode = if file.unix_mode().is_some_and(|m| m & 0o111 != 0) { + 0o100755 + } else { + 0o100644 + }; + order.push((name, mode)); + } + let mut additions: Vec<_> = bodies + .keys() + .filter(|name| !seen.contains(*name)) + .cloned() + .collect(); + additions.sort(); + order.extend(additions.into_iter().map(|name| (name, 0o100644))); + let mut patched_zip = zip::ZipArchive::new(Cursor::new(patched)).map_err(|e| e.to_string())?; + let mut crcs = BTreeMap::new(); + for i in 0..patched_zip.len() { + let file = patched_zip.by_index(i).map_err(|e| e.to_string())?; + crcs.insert(file.name().to_string(), file.crc32()); + } + let mut out = Vec::new(); + let mut directory = Vec::new(); + let mut count = 0u16; + for (name, mode) in order { + let Some(bytes) = bodies.remove(&name) else { + continue; + }; + if is_signature(&name) { + continue; + } + let offset = u32::try_from(out.len()).map_err(|_| "JAR exceeds ZIP32 limits")?; + let size = u32::try_from(bytes.len()).map_err(|_| "JAR member exceeds ZIP32 limits")?; + let len = u16::try_from(name.len()).map_err(|_| "JAR member name too long")?; + let flags = if name.is_ascii() { 0 } else { 0x800 }; + let crc = crcs[&name]; + u32s(&mut out, &[0x04034b50]); + u16s(&mut out, &[10, flags, 0, 0, 33]); + u32s(&mut out, &[crc, size, size]); + u16s(&mut out, &[len, 0]); + out.extend_from_slice(name.as_bytes()); + out.extend_from_slice(&bytes); + u32s(&mut directory, &[0x02014b50]); + u16s(&mut directory, &[0x032d, 10, flags, 0, 0, 33]); + u32s(&mut directory, &[crc, size, size]); + u16s(&mut directory, &[len, 0, 0, 0, 0]); + u32s(&mut directory, &[(mode << 16) | 0x20, offset]); + directory.extend_from_slice(name.as_bytes()); + count = count.checked_add(1).ok_or("too many JAR members")?; + } + let offset = u32::try_from(out.len()).map_err(|_| "JAR exceeds ZIP32 limits")?; + let size = u32::try_from(directory.len()).map_err(|_| "JAR directory exceeds ZIP32 limits")?; + out.extend_from_slice(&directory); + u32s(&mut out, &[0x06054b50]); + u16s(&mut out, &[0, 0, count, count]); + u32s(&mut out, &[size, offset]); + u16s(&mut out, &[0]); + Ok(out) +} + +fn u16s(out: &mut Vec, values: &[u16]) { + for v in values { + out.extend_from_slice(&v.to_le_bytes()); + } +} +fn u32s(out: &mut Vec, values: &[u32]) { + for v in values { + out.extend_from_slice(&v.to_le_bytes()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn matches_archiver_7_byte_for_byte() { + // Fixture generated by fixtures/repack/generate.cjs. + let expected = include_bytes!("fixtures/repack/archiver-7.0.1.jar"); + let mut upstream = zip::ZipWriter::new(Cursor::new(Vec::new())); + for (name, bytes, mode) in [ + ("z.txt", "first\n", 0o644), + ("META-INF/NOTICE.txt", "original\n", 0o644), + ("bin/run", "exec\n", 0o755), + ("café.txt", "unicode\n", 0o644), + ("META-INF/SIGN.RSA", "signature", 0o644), + ] { + upstream + .start_file( + name, + zip::write::SimpleFileOptions::default().unix_permissions(mode), + ) + .unwrap(); + upstream.write_all(bytes.as_bytes()).unwrap(); + } + let upstream = upstream.finish().unwrap().into_inner(); + let mut bodies = super::super::super::verify::read_zip_bytes_to_map(&upstream).unwrap(); + bodies.insert("META-INF/NOTICE.txt".into(), b"patched\n".to_vec()); + let mut entries: Vec<_> = bodies.into_iter().map(|(n, b)| (n, b, 0o644)).collect(); + entries.sort_by(|a, b| a.0.cmp(&b.0)); + let rebuilt = super::super::super::common::write_zip_entries(&entries).unwrap(); + assert_eq!(canonical_jar(&upstream, &rebuilt).unwrap(), expected); + assert_eq!(canonical_jar(expected, expected).unwrap(), expected); + } +} diff --git a/crates/socket-patch-core/src/vendor/jvm/fixtures/repack/archiver-7.0.1.jar b/crates/socket-patch-core/src/vendor/jvm/fixtures/repack/archiver-7.0.1.jar new file mode 100644 index 000000000..4fabed191 Binary files /dev/null and b/crates/socket-patch-core/src/vendor/jvm/fixtures/repack/archiver-7.0.1.jar differ diff --git a/crates/socket-patch-core/src/vendor/jvm/fixtures/repack/generate.cjs b/crates/socket-patch-core/src/vendor/jvm/fixtures/repack/generate.cjs new file mode 100644 index 000000000..ddc9a0553 --- /dev/null +++ b/crates/socket-patch-core/src/vendor/jvm/fixtures/repack/generate.cjs @@ -0,0 +1,17 @@ +// Regenerate with archiver@7.0.1, the patch server's repackDirToZip encoder. +// npm install --prefix /tmp/jvm-repack --ignore-scripts archiver@7.0.1 +// NODE_PATH=/tmp/jvm-repack/node_modules node generate.cjs +const fs = require('node:fs'); +const path = require('node:path'); +const archiver = require('archiver'); +(async () => { + const zip = archiver('zip', { zlib: { level: 0 }, store: true }); + const chunks = []; + zip.on('data', chunk => chunks.push(chunk)); + zip.on('error', error => { throw error; }); + zip.on('end', () => fs.writeFileSync(path.join(__dirname, 'archiver-7.0.1.jar'), Buffer.concat(chunks))); + for (const [name, body, mode] of [['z.txt', 'first\n', 0o644], ['META-INF/NOTICE.txt', 'patched\n', 0o644], ['bin/run', 'exec\n', 0o755], ['café.txt', 'unicode\n', 0o644]]) { + zip.append(Buffer.from(body), { name, date: new Date(0), mode }); + } + await zip.finalize(); +})(); diff --git a/crates/socket-patch-core/src/vendor/jvm/gradle.rs b/crates/socket-patch-core/src/vendor/jvm/gradle.rs new file mode 100644 index 000000000..9b22cf98b --- /dev/null +++ b/crates/socket-patch-core/src/vendor/jvm/gradle.rs @@ -0,0 +1,2935 @@ +//! Gradle planner. See the module doc of [`super`] and +//! `docs/design/maven-vendoring.md`. +//! +//! The patched artifact keeps its GAV in `.socket/vendor/gradle/`, +//! listed with its sha256 in `.socket/vendor/gradle-index.tsv`. The +//! owned static script [`SCRIPT`] reads the index, checks every hash at +//! configuration time (index verification) and routes the GAV to the tree with +//! `exclusiveContent`. Each wired settings file gets one apply line, plus an +//! in-block `pluginManagement` entry when it has settings-level `plugins{}`. +//! An existing `gradle/verification-metadata.xml` gets the patched jar hash. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde_json::json; + +use super::super::state::{WiringAction, WiringRecord}; +use super::{ + adopt, changes_between, finish_writes, fragment, op_of, op_str, owned_file, replace_op, + sha256_hex, undo_replace, Coords, FileWrite, JvmPatch, JvmPlan, JvmRefusal, JvmUnplan, + JvmWarning, ReadFn, OWNED_FILE_KIND, SETTINGS_FRAGMENT_KIND, VERIFICATION_FRAGMENT_KIND, +}; + +pub use super::safe_coordinates; + +/// The owned settings script. Its bytes change only with a CLI release. +pub const SCRIPT: &str = include_str!("socket-patch.settings.gradle"); +/// Where [`SCRIPT`] lives, project-relative. +pub const SCRIPT_REL: &str = ".socket/gradle/socket-patch.settings.gradle"; +/// The Gradle-only artifact tree root. +pub const TREE_ROOT: &str = ".socket/vendor/gradle"; +/// The tree root's `.gitattributes`, shared by every Gradle patch. +pub const GITATTRIBUTES_REL: &str = ".socket/vendor/gradle/.gitattributes"; +/// The derived index the script reads. +pub const INDEX_REL: &str = ".socket/vendor/gradle-index.tsv"; +pub const INDEX_HEADER: &str = "#socket-patch-gradle-index 1"; +pub const VERIFICATION_REL: &str = "gradle/verification-metadata.xml"; +pub const MARKER_NAME: &str = "socket-patch.vendor.json"; +/// Repository name shared by the script and the in-block entry: the script +/// skips a handler that already holds it. +const REPO_NAME: &str = "socketPatchVendor"; +/// Upstream poms of Gradle-published modules carry this; Gradle then follows +/// the `.module`, so vendoring the pom alone changes the graph. +const GRADLE_METADATA_MARKER: &str = "published-with-gradle-metadata"; + +/// The tree directory of `c` (same GAV). +pub fn tree_dir(c: &Coords<'_>) -> String { + format!( + "{TREE_ROOT}/{}/{}/{}", + c.group_path(), + c.artifact_id, + c.version + ) +} + +/// The committed tree of `c` as `(jar, pom, module)`: the tree holds the +/// upstream pom and module verbatim. `None` when the jar or pom is missing. +pub fn committed(read: ReadFn<'_>, c: &Coords<'_>) -> Option { + let dir = tree_dir(c); + let (a, v) = (c.artifact_id, c.version); + Some(( + read(&format!("{dir}/{a}-{v}.jar"))?, + read(&format!("{dir}/{a}-{v}.pom"))?, + read(&format!("{dir}/{a}-{v}.module")), + )) +} + +/// Plan vendoring `patch` into the Gradle build rooted at the project root. +pub fn plan(read: ReadFn<'_>, patch: &JvmPatch<'_>) -> Result { + if super::wrapper_version(read, "gradle").is_some_and(|v| v < (6, 8, 0)) { + return Err(shape_refusal( + "gradle_below_6_8", + "vendored dependencies require Gradle 6.8 or newer".into(), + )); + } + let (g, a, v) = (patch.group_id, patch.artifact_id, patch.version); + if !safe_coordinates(g, a, v) { + return Err(JvmRefusal { + code: "unsafe_coordinates", + detail: format!("unsafe gradle coordinates `{g}:{a}:{v}`"), + }); + } + if patch.uuid.is_empty() + || !patch + .uuid + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-') + { + return Err(JvmRefusal { + code: "unsafe_coordinates", + detail: format!("non-canonical patch uuid {:?}", patch.uuid), + }); + } + let pom_text = String::from_utf8_lossy(patch.upstream_pom); + if patch.upstream_module.is_none() && pom_text.contains(GRADLE_METADATA_MARKER) { + return Err(JvmRefusal { + code: "vendor_jvm_upstream_unavailable", + detail: format!( + "reason: module_unavailable: {a}-{v}.pom declares Gradle module metadata but no \ + {a}-{v}.module was found; run one online build (`./gradlew dependencies`)" + ), + }); + } + if let Some(module) = patch.upstream_module { + if String::from_utf8_lossy(module).contains("\"available-at\"") { + return Err(shape_refusal( + "android_or_kmp", + format!("{a}-{v}.module redirects with available-at (a multiplatform module); use hosted mode"), + )); + } + } + + let mut warnings = Vec::new(); + let mut writes = Vec::new(); + let mut records = Vec::new(); + + let root = read_settings(read, "")?; + let root_build = read_build_script(read, "")?; + if let Some((rel, text)) = &root_build { + check_android(rel, text)?; + check_exclusive_content(rel, text, patch)?; + } + + let mut targets = vec![root]; + if let Some(bs) = read_buildsrc(read)? { + targets.push(bs); + } + // Literal includeBuild targets, found recursively from the root settings. + let mut seen: BTreeSet = targets.iter().map(|t| t.dir.clone()).collect(); + let mut i = 0; + while i < targets.len() { + if targets[i].dir != "buildSrc" { + let text = targets[i].text.clone().unwrap_or_default(); + let (found, warns) = included_builds(&targets[i].rel, &targets[i].dir, &text); + warnings.extend(warns); + for dir in found { + if !seen.insert(dir.clone()) { + continue; + } + match read_included(read, &dir)? { + Some(t) => targets.push(t), + None => warnings.push(degraded( + "unwired_build_logic", + format!( + "{}: includeBuild('{dir}') has no settings or build script in the \ + checkout; that build stays unpatched", + targets[i].rel + ), + )), + } + } + } + i += 1; + } + + for t in &targets { + let Some(text) = &t.text else { continue }; + check_android(&t.rel, text)?; + check_exclusive_content(&t.rel, text, patch)?; + } + + let coords = patch.coords(); + for t in &targets { + let prefix = prefix_of(&t.dir); + let mut text = t.text.clone().unwrap_or_default(); + if t.text.is_some() { + for (scope, key_prefix, section) in [ + ("pluginManagement", "in_block", "in_block_section"), + ("buildscript", "buildscript", "buildscript_section"), + ] { + match scoped_in_block_entry(&text, t.kotlin, &prefix, &coords, scope) { + InBlock::Unneeded => {} + InBlock::Present => { + let key = format!("{key_prefix}:{g}:{a}:{v}"); + records.push(adopt(&t.rel, SETTINGS_FRAGMENT_KIND, &key)); + records.push(adopt(&t.rel, SETTINGS_FRAGMENT_KIND, section)); + } + InBlock::Edited { + start, + end, + text: inserted, + } => { + let mut added = in_block_records( + &t.rel, &text, start, end, &inserted, t.kotlin, &prefix, &coords, + ); + if scope == "buildscript" { + for w in &mut added { + w.key = + w.key.as_ref().map(|k| k.replace("in_block", "buildscript")); + } + } + records.extend(added); + text = format!("{}{inserted}{}", &text[..start], &text[end..]); + } + InBlock::Unwired(why) => warnings.push(degraded( + "settings_plugins_unwired", + format!( + "{}: {why}; settings plugins may resolve the unpatched {g}:{a}:{v}", + t.rel + ), + )), + } + } + } + let line = apply_line(t.kotlin, &prefix); + if has_apply_line(&text, &prefix) { + records.push(adopt(&t.rel, SETTINGS_FRAGMENT_KIND, "apply")); + } else { + let appended = append_line(&text, &line); + let op = match &t.text { + None => json!({ "op": "create", "text": appended }), + Some(_) => json!({ + "op": "line", + "text": &appended[text.len()..], + "line": line, + }), + }; + records.push(fragment( + &t.rel, + SETTINGS_FRAGMENT_KIND, + "apply", + WiringAction::Added, + None, + op, + )); + text = appended; + } + writes.push(text_write(&t.rel, text.into_bytes())); + } + + let tree_dir = tree_dir(&coords); + let jar_name = format!("{a}-{v}.jar"); + let pom_name = format!("{a}-{v}.pom"); + let module_name = format!("{a}-{v}.module"); + let mut files: Vec<(&str, &[u8])> = + vec![(&jar_name, patch.jar), (&pom_name, patch.upstream_pom)]; + if let Some(module) = patch.upstream_module { + files.push((&module_name, module)); + } + files.sort_by(|x, y| x.0.cmp(y.0)); + let gav = format!("{g}:{a}:{v}"); + let mut rows = Vec::new(); + for (name, bytes) in &files { + writes.push(FileWrite { + rel: format!("{tree_dir}/{name}"), + bytes: bytes.to_vec(), + tree: true, + }); + rows.push(format!( + "{gav}\t{}/{}/{v}/{name}\t{}\t{}", + patch.group_path(), + a, + sha256_hex(bytes), + patch.uuid + )); + } + writes.push(FileWrite { + rel: format!("{tree_dir}/{MARKER_NAME}"), + bytes: marker_json(patch, &files).into_bytes(), + tree: true, + }); + let existing_index = read(INDEX_REL); + let index = merge_index(existing_index.as_deref(), &gav, rows)?; + records.push(created_or_adopted(INDEX_REL, existing_index.is_some())); + writes.push(text_write(INDEX_REL, index.into_bytes())); + records.push(created_or_adopted(SCRIPT_REL, read(SCRIPT_REL).is_some())); + writes.push(text_write(SCRIPT_REL, SCRIPT.as_bytes().to_vec())); + records.push(owned_file(read, GITATTRIBUTES_REL, &mut writes)); + + if let Some(bytes) = read(VERIFICATION_REL) { + let text = String::from_utf8(bytes).map_err(|_| { + shape_refusal( + "gradle_verification_unparseable", + format!("{VERIFICATION_REL} is not UTF-8"), + ) + })?; + let hashes = ArtifactHashes { + jar: sha256_hex(patch.jar), + pom: sha256_hex(patch.upstream_pom), + module: patch.upstream_module.map(sha256_hex), + }; + let (start, end, replacement) = update_verification(&text, patch, &hashes)?; + if unverified_parent_chain(&text, &pom_text, patch.upstream_module) { + warnings.push(degraded( + "verification_parent_chain_unhandled", + format!( + "{VERIFICATION_REL}: {a}-{v}.pom has a parent or imported platform the file \ + may not list, and those entries are not added; run `./gradlew --write-verification-metadata sha256 help` \ + if verification fails" + ), + )); + } + let new = format!("{}{replacement}{}", &text[..start], &text[end..]); + records.push(adopt( + VERIFICATION_REL, + VERIFICATION_FRAGMENT_KIND, + "components_section", + )); + if new != text { + // Replacing an earlier patch's hash for this GAV: the user's + // element is in that patch's record, which the caller carries. + let mut from = + Some(&text[start..end]).filter(|f| !f.contains("origin=\"socket-patch\"")); + let mut replacement = replacement; + if text[start..end].starts_with("") { + let nl = newline_of(&text); + let indent = line_indent(&text, start); + let shell = format!("{nl}{indent}"); + records.pop(); + records.push(fragment( + VERIFICATION_REL, + VERIFICATION_FRAGMENT_KIND, + "components_section", + WiringAction::Rewritten, + None, + replace_op(&text[start..end], &shell), + )); + replacement = replacement["".len() + nl.len() + ..replacement.len() - indent.len() - "".len()] + .to_string(); + from = Some(""); + } + let action = if from == Some("") { + WiringAction::Added + } else { + WiringAction::Rewritten + }; + records.push(fragment( + VERIFICATION_REL, + VERIFICATION_FRAGMENT_KIND, + &format!("hash:{gav}"), + action, + None, + json!({ "op": "replace", "from": from, "to": replacement }), + )); + } + writes.push(text_write(VERIFICATION_REL, new.into_bytes())); + } + + Ok(JvmPlan { + tree_files: super::tree_files(&writes), + writes: finish_writes(read, writes), + records, + warnings, + jar_rel: format!("{tree_dir}/{jar_name}"), + tree_dir, + }) +} + +/// `../` once per segment of `dir`: the script path from that build. +fn prefix_of(dir: &str) -> String { + "../".repeat(if dir.is_empty() { + 0 + } else { + dir.split('/').count() + }) +} + +fn created_or_adopted(rel: &str, existed: bool) -> WiringRecord { + if existed { + adopt(rel, OWNED_FILE_KIND, "owned") + } else { + fragment( + rel, + OWNED_FILE_KIND, + "owned", + WiringAction::Added, + None, + json!({ "op": "create" }), + ) + } +} + +/// The records of an in-block insertion replacing `text[start..end]` with +/// `inserted`: the per-patch entry line, and the shell around it when the +/// insertion created one (`in_block_section`, with the rest of the lines +/// it touches as context so a lone `gradlePluginPortal()` stays unique). +#[allow(clippy::too_many_arguments)] +fn in_block_records( + rel: &str, + text: &str, + start: usize, + end: usize, + inserted: &str, + kotlin: bool, + prefix: &str, + c: &Coords<'_>, +) -> Vec { + let entry = in_block_line(kotlin, prefix, c); + let key = format!("in_block:{}:{}:{}", c.group_id, c.artifact_id, c.version); + let from = &text[start..end]; + let Some(at) = inserted.find(&entry) else { + return Vec::new(); + }; + let line_start = inserted[..at].rfind('\n').map_or(0, |n| n + 1); + let line_end = inserted[at..] + .find('\n') + .map_or(inserted.len(), |n| at + n + 1); + let shell = format!("{}{}", &inserted[..line_start], &inserted[line_end..]); + if shell == from { + return vec![ + fragment( + rel, + SETTINGS_FRAGMENT_KIND, + &key, + WiringAction::Added, + None, + json!({ "op": "in_block", "line": entry, "from": from, "to": inserted }), + ), + adopt(rel, SETTINGS_FRAGMENT_KIND, "in_block_section"), + ]; + } + let new = format!("{}{inserted}{}", &text[..start], &text[end..]); + let ctx_start = new[..start].rfind('\n').map_or(0, |n| n + 1); + let after = start + inserted.len(); + let ctx_end = new[after..].find('\n').map_or(new.len(), |n| after + n); + let (left, right) = (&new[ctx_start..start], &new[after..ctx_end]); + vec![ + fragment( + rel, + SETTINGS_FRAGMENT_KIND, + &key, + WiringAction::Added, + None, + json!({ "op": "in_block", "line": entry }), + ), + fragment( + rel, + SETTINGS_FRAGMENT_KIND, + "in_block_section", + WiringAction::Added, + None, + replace_op( + &format!("{left}{from}{right}"), + &format!("{left}{shell}{right}"), + ), + ), + ] +} + +/// Plan the revert of `c`'s wiring: its in-block entries, index +/// rows and verification hash go now; apply lines, the script, the index +/// and the tree `.gitattributes` once no other patch has an index row. +pub fn unplan(read: ReadFn<'_>, c: &Coords<'_>, records: &[WiringRecord]) -> JvmUnplan { + let mut drifted = Vec::new(); + let mut files: BTreeSet = records + .iter() + .filter(|w| { + matches!( + w.kind.as_str(), + SETTINGS_FRAGMENT_KIND | VERIFICATION_FRAGMENT_KIND + ) + }) + .map(|w| w.file.clone()) + .collect(); + files.insert(INDEX_REL.to_string()); + let mut before: BTreeMap> = files + .into_iter() + .filter_map(|rel| { + let text = match read(&rel) { + None => None, + Some(bytes) => Some(String::from_utf8(bytes).ok()?), + }; + Some((rel, text)) + }) + .collect(); + let mut after = before.clone(); + let recs = |rel: &str| { + records + .iter() + .filter(move |w| w.file == rel && w.kind == SETTINGS_FRAGMENT_KIND) + .collect::>() + }; + let in_block_key = format!("in_block:{}:{}:{}", c.group_id, c.artifact_id, c.version); + for (rel, text) in after.iter_mut() { + let Some(t) = text.as_mut() else { continue }; + if !is_settings_file(rel) { + continue; + } + let dir = rel.rsplit_once('/').map_or("", |(d, _)| d); + let entry = in_block_line(rel.ends_with(".kts"), &prefix_of(dir), c); + for w in recs(rel) { + if w.key.as_deref() != Some(in_block_key.as_str()) + && w.key.as_deref() + != Some(in_block_key.replace("in_block:", "buildscript:").as_str()) + { + continue; + } + if let (Some(from), Some(to)) = (op_str(w, "from"), op_str(w, "to")) { + if let Some(undone) = undo_replace(t, from, to) { + *t = undone; + } + } + } + while let Some(cut) = remove_line(t, &entry) { + *t = cut; + } + for w in recs(rel) { + if matches!( + w.key.as_deref(), + Some("in_block_section" | "buildscript_section") + ) { + if let (Some(from), Some(to)) = (op_str(w, "from"), op_str(w, "to")) { + if let Some(undone) = undo_replace(t, from, to) { + *t = undone; + } + } + } + } + } + for w in records + .iter() + .rev() + .filter(|w| { + w.kind == VERIFICATION_FRAGMENT_KIND && w.key.as_deref() != Some("components_section") + }) + .chain(records.iter().filter(|w| { + w.kind == VERIFICATION_FRAGMENT_KIND && w.key.as_deref() == Some("components_section") + })) + { + let Some(Some(t)) = after.get_mut(&w.file) else { + continue; + }; + let Some(to) = op_str(w, "to") else { + continue; + }; + let Some(from) = op_str(w, "from") else { + if t.contains(to) { + drifted.push(format!( + "{}: no pre-vendor entry is recorded for {}:{}:{}", + w.file, c.group_id, c.artifact_id, c.version + )); + } + continue; + }; + match undo_replace(t, from, to) { + Some(undone) => *t = undone, + None if t.contains(to) => drifted.push(format!( + "{} holds the patched hash for {}:{}:{} in an unexpected shape", + w.file, c.group_id, c.artifact_id, c.version + )), + None => {} + } + } + + let rows: Option> = match after.get(INDEX_REL) { + Some(Some(index)) => index_rows(index), + _ => Some(Vec::new()), + }; + let Some(rows) = rows else { + drifted.push(format!( + "{INDEX_REL} is malformed; its rows were left alone" + )); + return JvmUnplan { + changes: changes_between(&before, &after), + drifted, + still_wired: true, + }; + }; + let others: Vec = rows + .into_iter() + .filter(|r| r.split('\t').nth(3) != Some(c.uuid)) + .collect(); + if others.is_empty() { + after.insert(INDEX_REL.to_string(), None); + for (rel, text) in after.iter_mut() { + if !is_settings_file(rel) { + continue; + } + for w in recs(rel) + .into_iter() + .filter(|w| w.key.as_deref() == Some("apply")) + { + let Some(t) = text.as_deref() else { break }; + let written = op_str(w, "text").unwrap_or_default(); + *text = match op_of(w) { + "create" if t == written => None, + "create" => { + Some(remove_line(t, written.trim()).unwrap_or_else(|| t.to_string())) + } + "line" => Some( + match t.strip_suffix(written).filter(|_| !written.is_empty()) { + Some(cut) => cut.to_string(), + None => remove_line(t, op_str(w, "line").unwrap_or_default()) + .unwrap_or_else(|| t.to_string()), + }, + ), + _ => Some(t.to_string()), + }; + } + } + for rel in [SCRIPT_REL, GITATTRIBUTES_REL] { + let created = records + .iter() + .any(|w| w.kind == OWNED_FILE_KIND && w.file == rel && op_of(w) == "create"); + let current = read(rel); + let expected = if rel == SCRIPT_REL { + SCRIPT + } else { + super::TREE_GITATTRIBUTES + }; + let ours = current.as_deref() == Some(expected.as_bytes()); + if created && ours && current.is_some() { + before.insert(rel.to_string(), Some(String::new())); + after.insert(rel.to_string(), None); + } else if created && current.is_some() && !ours { + drifted.push(format!("{rel} was modified")); + } + } + } else { + let mut index = format!("{INDEX_HEADER}\n"); + for row in &others { + index.push_str(row); + index.push('\n'); + } + after.insert(INDEX_REL.to_string(), Some(index)); + } + JvmUnplan { + changes: changes_between(&before, &after), + drifted, + still_wired: false, + } +} + +/// Whether the root settings file applies the script and the index lists +/// `c`'s rows: the liveness proof `vex` needs for this layout. +pub fn wired(read: ReadFn<'_>, c: &Coords<'_>) -> bool { + let gav = format!("{}:{}:{}", c.group_id, c.artifact_id, c.version); + let indexed = read(INDEX_REL) + .and_then(|b| String::from_utf8(b).ok()) + .and_then(|index| index_rows(&index)) + .is_some_and(|rows| { + rows.iter().any(|r| { + let cols: Vec<&str> = r.split('\t').collect(); + cols.first() == Some(&gav.as_str()) && cols.get(3) == Some(&c.uuid) + }) + }); + let applied = ["settings.gradle", "settings.gradle.kts"] + .iter() + .any(|rel| { + read(rel) + .and_then(|b| String::from_utf8(b).ok()) + .is_some_and(|text| has_apply_line(&text, "")) + }); + indexed && applied +} + +pub fn wired_checked(read: ReadFn<'_>, c: &Coords<'_>) -> Result { + read_settings(read, "")?; + if let Some(bytes) = read(INDEX_REL) { + let index = std::str::from_utf8(&bytes).ok().and_then(index_rows); + if index.is_none() { + return Err(shape_refusal( + "gradle_index_unreadable", + "the vendored Gradle index is malformed".into(), + )); + } + } + Ok(wired(read, c)) +} + +fn is_settings_file(rel: &str) -> bool { + let name = rel.rsplit('/').next().unwrap_or(rel); + name == "settings.gradle" || name == "settings.gradle.kts" +} + +/// `text` without its first whole line whose trimmed body is `line`. +fn remove_line(text: &str, line: &str) -> Option { + let lines: Vec<&str> = text.split_inclusive('\n').collect(); + let i = lines.iter().position(|l| l.trim() == line)?; + Some(format!( + "{}{}", + lines[..i].concat(), + lines[i + 1..].concat() + )) +} + +/// The rows of an index, `None` when it is malformed. +fn index_rows(index: &str) -> Option> { + let mut lines = index.lines().map(|l| l.strip_suffix('\r').unwrap_or(l)); + if lines.next() != Some(INDEX_HEADER) { + return None; + } + let mut rows = Vec::new(); + for line in lines.filter(|l| !l.is_empty()) { + if !valid_index_row(line) { + return None; + } + rows.push(line.to_string()); + } + Some(rows) +} + +fn text_write(rel: &str, bytes: Vec) -> FileWrite { + FileWrite { + rel: rel.to_string(), + bytes, + tree: false, + } +} + +fn shape_refusal(reason: &str, msg: String) -> JvmRefusal { + JvmRefusal { + code: "vendor_jvm_shape_unsupported", + detail: format!("reason: {reason}: {msg}"), + } +} + +fn degraded(reason: &str, msg: String) -> JvmWarning { + JvmWarning { + code: "vendor_jvm_degraded", + detail: format!("reason: {reason}: {msg}"), + } +} + +// ── settings files ────────────────────────────────────────────────────────────── + +/// One settings file to wire: `dir` is the build directory ("" = root). +#[derive(Debug, Clone)] +struct Target { + dir: String, + rel: String, + /// Current text; `None` when vendor creates the file. + text: Option, + kotlin: bool, +} + +fn join_rel(dir: &str, name: &str) -> String { + if dir.is_empty() { + name.to_string() + } else { + format!("{dir}/{name}") + } +} + +fn read_text(read: ReadFn<'_>, rel: &str) -> Result, JvmRefusal> { + match read(rel) { + None => Ok(None), + Some(bytes) => String::from_utf8(bytes) + .map(Some) + .map_err(|_| shape_refusal("build_file_unreadable", format!("{rel} is not UTF-8"))), + } +} + +/// Gradle's own lookup order: the Groovy name wins over the Kotlin one. +fn read_script( + read: ReadFn<'_>, + dir: &str, + stem: &str, +) -> Result, JvmRefusal> { + for ext in [".gradle", ".gradle.kts"] { + let rel = join_rel(dir, &format!("{stem}{ext}")); + if let Some(text) = read_text(read, &rel)? { + return Ok(Some((rel, text))); + } + } + Ok(None) +} + +fn read_build_script(read: ReadFn<'_>, dir: &str) -> Result, JvmRefusal> { + read_script(read, dir, "build") +} + +/// The settings file of `dir`, or the one to create there. A created file +/// takes its DSL from the build's own build script, else `default_kotlin`. +fn settings_target( + read: ReadFn<'_>, + dir: &str, + default_kotlin: bool, +) -> Result { + if let Some((rel, text)) = read_script(read, dir, "settings")? { + let kotlin = rel.ends_with(".kts"); + return Ok(Target { + dir: dir.to_string(), + rel, + text: Some(text), + kotlin, + }); + } + let kotlin = match read_build_script(read, dir)? { + Some((rel, _)) => rel.ends_with(".kts"), + None => default_kotlin, + }; + Ok(Target { + dir: dir.to_string(), + rel: join_rel( + dir, + if kotlin { + "settings.gradle.kts" + } else { + "settings.gradle" + }, + ), + text: None, + kotlin, + }) +} + +fn read_settings(read: ReadFn<'_>, dir: &str) -> Result { + settings_target(read, dir, false) +} + +fn read_buildsrc(read: ReadFn<'_>) -> Result, JvmRefusal> { + let present = [ + "build.gradle", + "build.gradle.kts", + "settings.gradle", + "settings.gradle.kts", + ] + .iter() + .any(|f| read(&format!("buildSrc/{f}")).is_some()); + if !present { + return Ok(None); + } + settings_target(read, "buildSrc", false).map(Some) +} + +/// The settings target of an included build, `None` when the directory +/// holds neither a settings nor a build script (nothing to wire, and no +/// directory is created for it). +fn read_included(read: ReadFn<'_>, dir: &str) -> Result, JvmRefusal> { + if read_script(read, dir, "settings")?.is_none() && read_build_script(read, dir)?.is_none() { + return Ok(None); + } + settings_target(read, dir, false).map(Some) +} + +fn apply_line(kotlin: bool, prefix: &str) -> String { + if kotlin { + format!("apply(from = \"{prefix}{SCRIPT_REL}\") // socket-patch") + } else { + format!("apply from: '{prefix}{SCRIPT_REL}' // socket-patch") + } +} + +/// Any live `apply from` naming our script at this prefix, in either DSL, +/// counts (a user who reformatted the line keeps it); one inside a comment +/// does not. +fn has_apply_line(text: &str, prefix: &str) -> bool { + let path = format!("{prefix}{SCRIPT_REL}"); + let toks = lex(text); + (0..toks.len()).any(|i| { + if !is_ident(toks.get(i), "apply") { + return false; + } + let mut j = i + 1; + if is_punct(toks.get(j), b'(') { + j += 1; + } + is_ident(toks.get(j), "from") + && (is_punct(toks.get(j + 1), b':') || is_punct(toks.get(j + 1), b'=')) + && matches!(toks.get(j + 2), Some(Token { tok: Tok::Str { value, .. }, .. }) if *value == path) + }) +} + +/// The newline the file already uses (CRLF when its first line ends so). +fn newline_of(text: &str) -> &'static str { + match text.find('\n') { + Some(i) if i > 0 && text.as_bytes()[i - 1] == b'\r' => "\r\n", + _ => "\n", + } +} + +fn append_line(text: &str, line: &str) -> String { + let nl = newline_of(text); + let mut out = text.to_string(); + if !out.is_empty() && !out.ends_with('\n') { + out.push_str(nl); + } + out.push_str(line); + out.push_str(nl); + out +} + +// ── a small Groovy/Kotlin lexer ───────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Tok { + Ident(String), + /// A string literal. `literal` is false when it interpolates (`$`) or + /// uses an escape we do not decode. + Str { + value: String, + literal: bool, + }, + Punct(u8), +} + +#[derive(Debug, Clone)] +struct Token { + tok: Tok, + start: usize, + end: usize, +} + +/// Tokenize enough of a build script to find blocks, calls and string +/// literals: comments are skipped, strings (including triple-quoted) are one +/// token, everything else is an identifier or a single punctuation byte. +/// Slashy strings are not recognised (a `/` is punctuation). +fn lex(src: &str) -> Vec { + let b = src.as_bytes(); + let mut out = Vec::new(); + let mut i = 0; + 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"/*") { + i = src[i + 2..].find("*/").map_or(b.len(), |j| i + 2 + j + 2); + } else if c == b'\'' || c == b'"' { + let start = i; + let triple = b[i..].starts_with(&[c, c, c]); + i += if triple { 3 } else { 1 }; + let mut value = String::new(); + let mut literal = true; + loop { + if i >= b.len() { + literal = false; + break; + } + if triple { + if b[i..].starts_with(&[c, c, c]) { + i += 3; + break; + } + } else if b[i] == c { + i += 1; + break; + } else if b[i] == b'\n' { + literal = false; + break; + } + if b[i] == b'\\' && !triple { + match b.get(i + 1) { + Some(&e @ (b'\\' | b'\'' | b'"')) => value.push(e as char), + _ => literal = false, + } + i += 2; + continue; + } + if b[i] == b'$' && c == b'"' { + literal = false; + } + let ch = src[i..].chars().next().unwrap_or('\u{fffd}'); + value.push(ch); + i += ch.len_utf8().max(1); + } + out.push(Token { + tok: Tok::Str { value, literal }, + start, + end: i.min(b.len()), + }); + } else if c.is_ascii_alphabetic() || c == b'_' || c == b'$' { + let start = i; + while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == b'_' || b[i] == b'$') { + i += 1; + } + 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 +} + +fn is_ident(t: Option<&Token>, name: &str) -> bool { + matches!(t, Some(Token { tok: Tok::Ident(n), .. }) if n == name) +} + +fn is_punct(t: Option<&Token>, p: u8) -> bool { + matches!(t, Some(Token { tok: Tok::Punct(c), .. }) if *c == p) +} + +/// Index of the `}` matching the `{` at `open`. +fn matching_close(toks: &[Token], open: usize) -> Option { + let mut depth = 0usize; + for (i, t) in toks.iter().enumerate().skip(open) { + match t.tok { + Tok::Punct(b'{') => depth += 1, + Tok::Punct(b'}') => { + depth = depth.checked_sub(1)?; + if depth == 0 { + return Some(i); + } + } + _ => {} + } + } + None +} + +/// A `name {` block directly inside `toks[from..to]` (brace depth 0 there): +/// `(name index, open index, close index)`. +fn find_block(toks: &[Token], from: usize, to: usize, name: &str) -> Option<(usize, usize, usize)> { + let mut depth = 0usize; + let mut i = from; + while i < to { + match toks[i].tok { + Tok::Punct(b'{') => depth += 1, + Tok::Punct(b'}') => depth = depth.saturating_sub(1), + Tok::Ident(ref n) if depth == 0 && n == name && is_punct(toks.get(i + 1), b'{') => { + let close = matching_close(toks, i + 1)?; + return Some((i, i + 1, close)); + } + _ => {} + } + i += 1; + } + None +} + +/// Every `name {` block at any depth: `(open index, close index)`. +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() +} + +fn strings(toks: &[Token]) -> impl Iterator { + toks.iter().filter_map(|t| match &t.tok { + Tok::Str { value, .. } => Some(value.as_str()), + _ => None, + }) +} + +// ── refusals ──────────────────────────────────────────────────────────────────── + +fn check_android(rel: &str, text: &str) -> Result<(), JvmRefusal> { + let toks = lex(text); + let hit = strings(&toks) + .find(|s| { + s.starts_with("com.android.") || s.starts_with("org.jetbrains.kotlin.multiplatform") + }) + .map(str::to_string) + .or_else(|| { + toks.windows(4).find_map(|w| { + let kmp = is_ident(Some(&w[0]), "kotlin") + && is_punct(Some(&w[1]), b'(') + && matches!(&w[2].tok, Tok::Str { value, .. } if value == "multiplatform") + && is_punct(Some(&w[3]), b')'); + kmp.then(|| "kotlin(\"multiplatform\")".to_string()) + }) + }); + match hit { + Some(id) => Err(shape_refusal( + "android_or_kmp", + format!("{rel} uses {id}; Android and Kotlin Multiplatform builds need hosted mode"), + )), + None => Ok(()), + } +} + +/// A user `exclusiveContent` that claims the patched group for another +/// repository would make ours unreachable ("Could not find"). +fn check_exclusive_content(rel: &str, text: &str, patch: &JvmPatch<'_>) -> Result<(), JvmRefusal> { + let toks = lex(text); + for (open, close) in all_blocks(&toks, "exclusiveContent") { + let body = &toks[open..close]; + if strings(body).any(|s| s == REPO_NAME || s.starts_with(&format!("{REPO_NAME}_"))) { + continue; + } + let g = patch.group_id; + if strings(body).any(|s| s == g || s.starts_with(&format!("{g}:"))) { + return Err(shape_refusal( + "gradle_exclusive_content_conflict", + format!( + "{rel} has an exclusiveContent rule for {g}; drop {g}:{} from it", + patch.artifact_id + ), + )); + } + } + Ok(()) +} + +// ── includeBuild ──────────────────────────────────────────────────────────────── + +/// Literal `includeBuild` targets of the settings file of `dir`, as +/// project-relative directories; non-literal or escaping ones are warned. +fn included_builds(rel: &str, dir: &str, text: &str) -> (Vec, Vec) { + let toks = lex(text); + let mut found = Vec::new(); + let mut warns = Vec::new(); + for (i, t) in toks.iter().enumerate() { + if !matches!(&t.tok, Tok::Ident(n) if n == "includeBuild") { + continue; + } + // `x.includeBuild(…)` on anything but `settings` is not this build's. + let prev = |k: usize| i.checked_sub(k).and_then(|p| toks.get(p)); + if is_punct(prev(1), b'.') && !is_ident(prev(2), "settings") { + continue; + } + // `includeBuild('p')`, `includeBuild("p") { … }` or Groovy `includeBuild 'p'`. + let arg = if is_punct(toks.get(i + 1), b'(') { + match (toks.get(i + 2), toks.get(i + 3)) { + ( + Some(Token { + tok: + Tok::Str { + value, + literal: true, + }, + .. + }), + Some(close), + ) if is_punct(Some(close), b')') => Some(value.clone()), + _ => None, + } + } else { + match toks.get(i + 1) { + Some(Token { + tok: + Tok::Str { + value, + literal: true, + }, + .. + }) => Some(value.clone()), + _ => None, + } + }; + let snippet = &text[t.start..toks.get(i + 3).map_or(t.end, |x| x.end).min(text.len())]; + match arg.as_deref().map(|p| resolve_dir(dir, p)) { + Some(Some(target)) if !target.is_empty() => found.push(target), + Some(Some(_)) => {} + Some(None) => warns.push(degraded( + "unwired_build_logic", + format!( + "{rel}: {snippet} points outside the project root; that build stays unpatched" + ), + )), + None => warns.push(degraded( + "unwired_build_logic", + format!("{rel}: {snippet} is not a literal path; that build stays unpatched"), + )), + } + } + (found, warns) +} + +/// `base` joined with the relative path `p`, normalised; `None` when `p` is +/// absolute or leaves the root. +fn resolve_dir(base: &str, p: &str) -> Option { + if p.starts_with('/') || p.contains('\\') || p.contains(':') { + return None; + } + let mut parts: Vec<&str> = base.split('/').filter(|s| !s.is_empty()).collect(); + for seg in p.split('/') { + match seg { + "" | "." => {} + ".." => { + parts.pop()?; + } + s => parts.push(s), + } + } + Some(parts.join("/")) +} + +// ── in-block pluginManagement entry ────────────────────────────────────── + +enum InBlock { + Unneeded, + /// The entry is already there (a re-run, or a patch update of the GAV). + Present, + /// Replace `text[start..end]` with `text`. + Edited { + start: usize, + end: usize, + text: String, + }, + Unwired(String), +} + +fn in_block_line(kotlin: bool, prefix: &str, c: &Coords<'_>) -> String { + let (g, a, v) = (c.group_id, c.artifact_id, c.version); + let suffix = &sha256_hex(format!("{g}:{a}:{v}").as_bytes())[..16]; + let name = format!("{REPO_NAME}_{suffix}"); + if kotlin { + format!( + "exclusiveContent {{ forRepository {{ maven {{ name = \"{name}\"; url = File(settingsDir, \"{prefix}{TREE_ROOT}\").toURI() }} }}; filter {{ includeVersion(\"{g}\", \"{a}\", \"{v}\") }} }} // socket-patch" + ) + } else { + format!( + "exclusiveContent {{ forRepository {{ maven {{ name = '{name}'; url = new File(settingsDir, '{prefix}{TREE_ROOT}').toURI() }} }}; filter {{ includeVersion('{g}', '{a}', '{v}') }} }} // socket-patch" + ) + } +} + +/// Settings-level `plugins{}` resolves before the apply line runs, so the +/// patched GAV gets its own first entry in `pluginManagement.repositories`. +fn scoped_in_block_entry( + text: &str, + kotlin: bool, + prefix: &str, + patch: &Coords<'_>, + scope: &str, +) -> InBlock { + let toks = lex(text); + if scope == "pluginManagement" && find_block(&toks, 0, toks.len(), "plugins").is_none() { + return InBlock::Unneeded; + } + let entry = in_block_line(kotlin, prefix, patch); + let nl = newline_of(text); + let unit = indent_unit(text); + let Some((pm_name, pm_open, pm_close)) = find_block(&toks, 0, toks.len(), scope) else { + if scope == "buildscript" { + return InBlock::Unneeded; + } + // pluginManagement must be the first statement: after imports only. + let at = first_statement_offset(text, &toks); + let block = format!( + "pluginManagement {{{nl}{unit}repositories {{{nl}{unit}{unit}{entry}{nl}{unit}{unit}gradlePluginPortal(){nl}{unit}}}{nl}}}{nl}" + ); + return InBlock::Edited { + start: at, + end: at, + text: block, + }; + }; + if text[toks[pm_open].end..toks[pm_close].start] + .lines() + .any(|l| l.trim() == entry) + { + return InBlock::Present; + } + let pm_indent = line_indent(text, toks[pm_name].start); + let repos_ref = (pm_open + 1..pm_close).find(|&i| { + is_ident(toks.get(i), "repositories") && depth_between(&toks, pm_open + 1, i) == 0 + }); + match repos_ref { + Some(r) if is_punct(toks.get(r + 1), b'{') => { + let Some(close) = matching_close(&toks, r + 1) else { + return InBlock::Unwired( + "unbalanced pluginManagement.repositories block".to_string(), + ); + }; + let r_indent = line_indent(text, toks[r].start); + let inner = format!("{r_indent}{}", nested_unit(&r_indent, &pm_indent, unit)); + let empty = close == r + 2; + let body = if empty && scope == "pluginManagement" { + format!("{inner}{entry}{nl}{inner}gradlePluginPortal(){nl}") + } else { + format!("{inner}{entry}{nl}") + }; + insert_after_brace(text, toks[r + 1].end, &body, &r_indent, nl) + } + Some(_) => { + InBlock::Unwired("pluginManagement.repositories is not a literal block".to_string()) + } + None => { + let inner = format!("{pm_indent}{}", nested_unit(&pm_indent, "", unit)); + let default_repo = if scope == "pluginManagement" { + format!("{inner}{unit}gradlePluginPortal(){nl}") + } else { + String::new() + }; + let body = format!( + "{inner}repositories {{{nl}{inner}{unit}{entry}{nl}{default_repo}{inner}}}{nl}" + ); + insert_after_brace(text, toks[pm_open].end, &body, &pm_indent, nl) + } + } +} + +fn depth_between(toks: &[Token], from: usize, to: usize) -> isize { + toks[from..to].iter().fold(0, |d, t| match t.tok { + Tok::Punct(b'{') => d + 1, + Tok::Punct(b'}') => d - 1, + _ => d, + }) +} + +/// Insert `body` (whole lines) right after the `{` ending at `brace_end`. +/// When code follows the brace on its line, that code moves to its own line. +fn insert_after_brace(text: &str, brace_end: usize, body: &str, indent: &str, nl: &str) -> InBlock { + let eol = text[brace_end..] + .find('\n') + .map_or(text.len(), |j| brace_end + j); + let rest = text[brace_end..eol].trim_end_matches('\r').trim(); + if rest.is_empty() || rest.starts_with("//") { + let at = if eol < text.len() { eol + 1 } else { eol }; + let lead = if eol == text.len() { nl } else { "" }; + return InBlock::Edited { + start: at, + end: at, + text: format!("{lead}{body}"), + }; + } + let code_at = brace_end + + (text[brace_end..].len() - text[brace_end..].trim_start_matches([' ', '\t']).len()); + let unit = indent_unit(text); + InBlock::Edited { + start: brace_end, + end: code_at, + text: format!("{nl}{body}{indent}{unit}"), + } +} + +/// The indentation step a nested block uses, from the enclosing two. +fn nested_unit<'a>(inner: &'a str, outer: &str, fallback: &'a str) -> &'a str { + match inner.strip_prefix(outer) { + Some(step) if !step.is_empty() => step, + _ => fallback, + } +} + +/// The file's indentation unit: a tab when some line starts with one, else +/// the smallest non-zero leading-space run (4 when none). +fn indent_unit(text: &str) -> &'static str { + if text.lines().any(|l| l.starts_with('\t')) { + return "\t"; + } + let min = text + .lines() + .filter(|l| !l.trim().is_empty()) + .map(|l| l.len() - l.trim_start_matches(' ').len()) + .filter(|&n| n > 0) + .min() + .unwrap_or(4); + [" ", " ", " ", " ", " "][min.clamp(2, 4)] +} + +fn line_indent(text: &str, at: usize) -> String { + let start = text[..at].rfind('\n').map_or(0, |i| i + 1); + text[start..at] + .chars() + .take_while(|c| *c == ' ' || *c == '\t') + .collect() +} + +/// Byte offset of the start of the first line holding code other than an +/// `import` (Kotlin and Groovy both require imports first). +fn first_statement_offset(text: &str, toks: &[Token]) -> usize { + let mut i = 0; + while is_ident(toks.get(i), "import") { + let line_end = text[toks[i].start..] + .find('\n') + .map_or(text.len(), |j| toks[i].start + j); + while i < toks.len() && toks[i].start < line_end { + i += 1; + } + } + match toks.get(i) { + Some(t) => text[..t.start].rfind('\n').map_or(0, |j| j + 1), + None => text.len(), + } +} + +// ── tree marker and index ─────────────────────────────────────────────────────── + +fn json_str(s: &str) -> String { + serde_json::to_string(s).unwrap_or_else(|_| "\"\"".to_string()) +} + +/// The marker: sorted keys, 2-space indent, trailing newline. +fn marker_json(patch: &JvmPatch<'_>, files: &[(&str, &[u8])]) -> String { + let mut out = String::from("{\n \"files\": {"); + for (n, (name, bytes)) in files.iter().enumerate() { + out.push_str(if n == 0 { "\n" } else { ",\n" }); + out.push_str(&format!( + " {}: {{\n \"sha256\": \"{}\",\n \"size\": {}\n }}", + json_str(name), + sha256_hex(bytes), + bytes.len() + )); + } + let purl = format!( + "pkg:maven/{}/{}@{}", + patch.group_id, patch.artifact_id, patch.version + ); + out.push_str(&format!( + "\n }},\n \"purl\": {},\n \"schema\": 1,\n \"tool\": \"gradle\",\n \"uuid\": {},\n \"version\": {}\n}}\n", + json_str(&purl), + json_str(patch.uuid), + json_str(patch.version) + )); + out +} + +/// Whether an existing index row is one the script would accept. +fn valid_index_row(row: &str) -> bool { + let cols: Vec<&str> = row.split('\t').collect(); + let [gav, path, sha, uuid] = cols.as_slice() else { + return false; + }; + let parts: Vec<&str> = gav.split(':').collect(); + let [g, a, v] = parts.as_slice() else { + return false; + }; + let dir = format!("{}/{a}/{v}/", g.replace('.', "/")); + safe_coordinates(g, a, v) + && path + .strip_prefix(&dir) + .is_some_and(|n| n.starts_with(&format!("{a}-{v}")) && !n.contains('/')) + && sha.len() == 64 + && sha + .bytes() + .all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)) + && !uuid.is_empty() + && !uuid.chars().any(char::is_whitespace) +} + +/// Merge `rows` for `gav` into the existing index, replacing that GAV's +/// rows. A malformed existing index is refused rather than rewritten. +fn merge_index( + existing: Option<&[u8]>, + gav: &str, + rows: Vec, +) -> Result { + let bad = |why: String| { + shape_refusal( + "build_file_unreadable", + format!("{INDEX_REL}: {why}; restore it from git"), + ) + }; + let mut all: BTreeSet = BTreeSet::new(); + if let Some(bytes) = existing { + let text = std::str::from_utf8(bytes).map_err(|_| bad("not UTF-8".to_string()))?; + let mut lines = text.lines().map(|l| l.strip_suffix('\r').unwrap_or(l)); + if lines.next() != Some(INDEX_HEADER) { + return Err(bad("unknown header".to_string())); + } + for (n, line) in lines.enumerate() { + if line.is_empty() { + continue; + } + if !valid_index_row(line) { + return Err(bad(format!("malformed row {}", n + 2))); + } + if line.split('\t').next() != Some(gav) { + all.insert(line.to_string()); + } + } + } + all.extend(rows); + let mut out = format!("{INDEX_HEADER}\n"); + for row in all { + out.push_str(&row); + out.push('\n'); + } + Ok(out) +} + +// ── gradle/verification-metadata.xml ───────────────────────────────────── + +struct ArtifactHashes { + jar: String, + pom: String, + module: Option, +} + +/// Whether Gradle may verify metadata of the vendored pom's parent chain or +/// imported platforms that the file does not list (read from the file +/// repository, the pom is parsed before the `.module` redirect). A parent +/// the file already lists is fine; imports and platforms always warn. +pub(crate) fn verifies_metadata(verification: &str) -> bool { + let masked = mask_xml_comments(verification); + !xml_elements(&masked, 0, masked.len(), "verify-metadata") + .iter() + .any(|&(_, tag_end, end)| { + end > tag_end && masked[tag_end..end - "".len()].trim() == "false" + }) +} + +fn unverified_parent_chain(verification: &str, pom: &str, module: Option<&[u8]>) -> bool { + let vm = mask_xml_comments(verification); + if !verifies_metadata(&vm) { + return false; + } + let masked = mask_xml_comments(pom); + if masked.contains("import") + || module.is_some_and(|m| String::from_utf8_lossy(m).contains("\"platform\"")) + { + return true; + } + let Some(&(_, tag_end, end)) = xml_elements(&masked, 0, masked.len(), "parent").first() else { + return false; + }; + let child = |name: &str| { + let (open, close) = (format!("<{name}>"), format!("")); + let body = &masked[tag_end..end]; + let s = body.find(&open)? + open.len(); + body[s..] + .find(&close) + .map(|e| body[s..s + e].trim().to_string()) + }; + let (Some(g), Some(a), Some(v)) = (child("groupId"), child("artifactId"), child("version")) + else { + return true; + }; + !xml_elements(&vm, 0, vm.len(), "component") + .iter() + .any(|&(s, t, _)| { + let tag = &vm[s..t]; + xml_attr(tag, "group") == Some(g.as_str()) + && xml_attr(tag, "name") == Some(a.as_str()) + && xml_attr(tag, "version") == Some(v.as_str()) + }) +} + +/// `text` with every `` replaced by spaces (same byte offsets), so +/// commented-out elements are never matched. +fn mask_xml_comments(text: &str) -> String { + let mut bytes = text.as_bytes().to_vec(); + let mut from = 0; + while let Some(j) = text[from..].find("") + .map_or(text.len(), |k| start + k + 3); + for b in &mut bytes[start..end] { + if *b != b'\n' && *b != b'\r' { + *b = b' '; + } + } + from = end; + } + String::from_utf8(bytes).unwrap_or_default() +} + +/// The value of attribute `name` in the start tag `tag`. +fn xml_attr<'a>(tag: &'a str, name: &str) -> Option<&'a str> { + let mut rest = tag; + loop { + let at = rest.find(name)?; + let before = rest[..at].chars().last(); + let after = rest[at + name.len()..].trim_start(); + rest = &rest[at + name.len()..]; + if !before.is_some_and(char::is_whitespace) { + continue; + } + let Some(after) = after.strip_prefix('=') else { + continue; + }; + let after = after.trim_start(); + let quote = after.chars().next()?; + if quote != '"' && quote != '\'' { + return None; + } + let body = &after[1..]; + return body.find(quote).map(|e| &body[..e]); + } +} + +/// Elements named `name` inside `masked[from..to]`: (start, end of start +/// tag, end of element). Self-closing elements end with their start tag. +fn xml_elements(masked: &str, from: usize, to: usize, name: &str) -> Vec<(usize, usize, usize)> { + let open = format!("<{name}"); + let close = format!(""); + let mut out = Vec::new(); + let mut i = from; + while let Some(j) = masked[i..to].find(&open) { + let s = i + j; + let next = masked.as_bytes().get(s + open.len()).copied(); + if !matches!(next, Some(b' ' | b'\t' | b'\r' | b'\n' | b'>' | b'/')) { + i = s + open.len(); + continue; + } + let Some(tag_end) = masked[s..to].find('>').map(|k| s + k + 1) else { + break; + }; + let end = if masked[..tag_end].ends_with("/>") { + tag_end + } else { + match masked[tag_end..to].find(&close) { + Some(k) => tag_end + k + close.len(), + None => break, + } + }; + out.push((s, tag_end, end)); + i = end; + } + out +} + +fn artifact_element(name: &str, sha: &str, indent: &str, unit: &str, nl: &str) -> String { + format!("{nl}{indent}{unit}{nl}{indent}") +} + +/// Replace the patched jar's hash in an existing verification file, or add +/// a component for the GAV, as the edit `(start, end, replacement)`. +/// Trusted artifacts and keys are never touched. +fn update_verification( + text: &str, + patch: &JvmPatch<'_>, + h: &ArtifactHashes, +) -> Result<(usize, usize, String), JvmRefusal> { + update_verification_component(text, patch, h, None) +} + +fn update_verification_component( + text: &str, + patch: &JvmPatch<'_>, + h: &ArtifactHashes, + metadata_extension: Option<&str>, +) -> Result<(usize, usize, String), JvmRefusal> { + let unparseable = |why: &str| { + shape_refusal( + "gradle_verification_unparseable", + format!("{VERIFICATION_REL}: {why}"), + ) + }; + let masked = mask_xml_comments(text); + let nl = newline_of(text); + const UNIT: &str = " "; + let (a, v) = (patch.artifact_id, patch.version); + let jar_name = format!("{a}-{v}.{}", metadata_extension.unwrap_or("jar")); + + let Some(&(cs_start, cs_tag_end, cs_end)) = + xml_elements(&masked, 0, masked.len(), "components").first() + else { + return Err(unparseable("no element")); + }; + let self_closing = cs_tag_end == cs_end; + let comps = if self_closing { + Vec::new() + } else { + xml_elements(&masked, cs_tag_end, cs_end, "component") + }; + let key = (patch.group_id, a, v); + for &(s, tag_end, end) in &comps { + let tag = &masked[s..tag_end]; + let (Some(g), Some(n), Some(ver)) = ( + xml_attr(tag, "group"), + xml_attr(tag, "name"), + xml_attr(tag, "version"), + ) else { + return Err(unparseable("a lacks group, name or version")); + }; + if (g, n, ver) != key { + continue; + } + if tag_end == end { + return Err(unparseable("the patched component is empty")); + } + let arts = xml_elements(&masked, tag_end, end, "artifact"); + let comp_indent = line_indent(text, s); + for &(as_, at_end, ae) in &arts { + if xml_attr(&masked[as_..at_end], "name") == Some(jar_name.as_str()) { + if metadata_extension.is_some() { + return Ok((as_, ae, text[as_..ae].to_string())); + } + let indent = line_indent(text, as_); + let el = artifact_element(&jar_name, &h.jar, &indent, UNIT, nl); + return Ok((as_, ae, el)); + } + } + // No jar entry: insert one in name order, as Gradle writes them. + let indent = format!("{comp_indent}{UNIT}"); + let el = artifact_element(&jar_name, &h.jar, &indent, UNIT, nl); + let before = arts + .iter() + .find(|&&(as_, at_end, _)| { + xml_attr(&masked[as_..at_end], "name").is_some_and(|n| n > jar_name.as_str()) + }) + .map(|&(as_, _, _)| as_); + let at = before.map_or_else( + || line_start(text, end - "".len()), + |as_| line_start(text, as_), + ); + return Ok((at, at, format!("{indent}{el}{nl}"))); + } + + let cs_indent = line_indent(text, cs_start); + let comp_indent = comps.first().map_or_else( + || format!("{cs_indent}{UNIT}"), + |&(s, _, _)| line_indent(text, s), + ); + let art_indent = format!("{comp_indent}{UNIT}"); + let mut arts = vec![ + (jar_name.clone(), h.jar.clone()), + (format!("{a}-{v}.pom"), h.pom.clone()), + ]; + if metadata_extension.is_some() { + arts.truncate(1); + } + if let Some(m) = &h.module { + arts.push((format!("{a}-{v}.module"), m.clone())); + } + arts.sort(); + let mut comp = format!( + "{comp_indent}{nl}", + patch.group_id + ); + for (name, sha) in &arts { + comp.push_str(&format!( + "{art_indent}{}{nl}", + artifact_element(name, sha, &art_indent, UNIT, nl) + )); + } + comp.push_str(&format!("{comp_indent}{nl}")); + if self_closing { + let el = format!("{nl}{comp}{cs_indent}"); + return Ok((cs_start, cs_end, el)); + } + let before = comps.iter().find(|&&(s, tag_end, _)| { + let tag = &masked[s..tag_end]; + ( + xml_attr(tag, "group").unwrap_or(""), + xml_attr(tag, "name").unwrap_or(""), + xml_attr(tag, "version").unwrap_or(""), + ) > key + }); + let at = match before { + Some(&(s, _, _)) => line_start(text, s), + None => line_start(text, cs_end - "".len()), + }; + Ok((at, at, comp)) +} + +/// One upstream parent or BOM whose metadata Gradle must verify. +pub(crate) struct MetadataArtifact { + pub group: String, + pub artifact: String, + pub version: String, + pub bytes: Vec, + pub extension: &'static str, +} + +/// A recorded parent/BOM must still have its POM verification entry. For entries +/// we inserted, require the recorded checksum; pre-existing policy stays user-owned. +pub(crate) fn metadata_record_present(text: &str, record: &WiringRecord) -> bool { + let Some(gav) = record + .key + .as_deref() + .and_then(|k| k.strip_prefix("metadata:")) + else { + return false; + }; + let parts: Vec<_> = gav.split(':').collect(); + if parts.len() != 3 && parts.len() != 4 { + return false; + } + let masked = mask_xml_comments(text); + let name = format!( + "{}-{}.{}", + parts[1], + parts[2], + parts.get(3).unwrap_or(&"pom") + ); + xml_elements(&masked, 0, masked.len(), "component") + .iter() + .any(|&(start, tag_end, end)| { + let tag = &masked[start..tag_end]; + if xml_attr(tag, "group") != Some(parts[0]) + || xml_attr(tag, "name") != Some(parts[1]) + || xml_attr(tag, "version") != Some(parts[2]) + { + return false; + } + xml_elements(&masked, tag_end, end, "artifact") + .iter() + .any(|&(s, t, e)| { + if xml_attr(&masked[s..t], "name") != Some(name.as_str()) { + return false; + } + match op_str(record, "to") { + None => true, + Some(to) => { + xml_elements(to, 0, to.len(), "sha256") + .iter() + .all(|&(hs, ht, _)| { + xml_attr(&to[hs..ht], "value").is_some_and(|hash| { + xml_elements(&masked, t, e, "sha256").iter().any( + |&(cs, ct, _)| { + xml_attr(&masked[cs..ct], "value") == Some(hash) + }, + ) + }) + }) + } + } + }) + }) +} + +/// Add only missing parent/BOM metadata to an existing verification file. +pub(crate) fn add_verification_metadata( + read: ReadFn<'_>, + plan: &mut JvmPlan, + metadata: &[MetadataArtifact], +) -> Result<(), JvmRefusal> { + let Some(original) = read(VERIFICATION_REL) else { + return Ok(()); + }; + let mut bytes = plan + .writes + .iter() + .find(|w| w.rel == VERIFICATION_REL) + .map(|w| w.bytes.clone()) + .unwrap_or(original); + for m in metadata { + let text = String::from_utf8(bytes).map_err(|_| { + shape_refusal( + "gradle_verification_unparseable", + "metadata is not UTF-8".into(), + ) + })?; + let patch = JvmPatch { + group_id: &m.group, + artifact_id: &m.artifact, + version: &m.version, + uuid: "", + jar: &[], + upstream_pom: &m.bytes, + upstream_module: None, + }; + let sha = sha256_hex(&m.bytes); + let h = ArtifactHashes { + jar: sha.clone(), + pom: sha, + module: None, + }; + let (start, end, to) = update_verification_component(&text, &patch, &h, Some(m.extension))?; + let key = format!( + "metadata:{}:{}:{}:{}", + m.group, m.artifact, m.version, m.extension + ); + if to == text[start..end] { + plan.records + .push(adopt(VERIFICATION_REL, VERIFICATION_FRAGMENT_KIND, &key)); + } else { + plan.records.push(fragment( + VERIFICATION_REL, + VERIFICATION_FRAGMENT_KIND, + &key, + WiringAction::Added, + None, + replace_op(&text[start..end], &to), + )); + } + bytes = format!("{}{to}{}", &text[..start], &text[end..]).into_bytes(); + } + plan.writes.retain(|w| w.rel != VERIFICATION_REL); + if read(VERIFICATION_REL).as_deref() != Some(bytes.as_slice()) { + plan.writes.push(text_write(VERIFICATION_REL, bytes)); + } + plan.warnings.retain(|w| { + !w.detail + .starts_with("reason: verification_parent_chain_unhandled:") + }); + Ok(()) +} + +/// Start of the line holding `at`, when only whitespace precedes `at` on it; +/// else `at` itself. +fn line_start(text: &str, at: usize) -> usize { + let start = text[..at].rfind('\n').map_or(0, |i| i + 1); + if text[start..at].chars().all(|c| c == ' ' || c == '\t') { + start + } else { + at + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::super::{apply, detect, Shape}; + use super::*; + + const UUID: &str = "5e6f7081-92a3-4b4c-8d5e-6f708192a3b4"; + const GSON_POM: &[u8] = + b"com.google.code.gsongson-parent2.10.1\n"; + + fn patch() -> JvmPatch<'static> { + JvmPatch { + group_id: "com.google.code.gson", + artifact_id: "gson", + version: "2.10.1", + uuid: UUID, + jar: b"PATCHED-JAR", + upstream_pom: b"\n", + upstream_module: None, + } + } + + fn fs(files: &[(&str, &str)]) -> BTreeMap> { + files + .iter() + .map(|(k, v)| (k.to_string(), v.as_bytes().to_vec())) + .collect() + } + + fn run(files: &BTreeMap>, p: &JvmPatch<'_>) -> Result { + let read = |rel: &str| files.get(rel).cloned(); + plan(&read, p) + } + + /// Apply `plan` onto `files`, returning the new file map. + fn applied(files: &BTreeMap>, plan: &JvmPlan) -> BTreeMap> { + let mut out = files.clone(); + for w in &plan.writes { + out.insert(w.rel.clone(), w.bytes.clone()); + } + out + } + + fn text_of<'a>(plan: &'a JvmPlan, rel: &str) -> &'a str { + let w = plan + .writes + .iter() + .find(|w| w.rel == rel) + .unwrap_or_else(|| panic!("no write for {rel}")); + std::str::from_utf8(&w.bytes).unwrap() + } + + fn assert_idempotent( + files: &BTreeMap>, + p: &JvmPatch<'_>, + ) -> BTreeMap> { + let first = run(files, p).unwrap(); + let after = applied(files, &first); + let again = run(&after, p).unwrap(); + assert!( + again.writes.is_empty(), + "re-plan wrote {:?}", + again.writes.iter().map(|w| &w.rel).collect::>() + ); + after + } + + fn reasons(plan: &JvmPlan) -> Vec { + plan.warnings + .iter() + .map(|w| w.detail.split(':').nth(1).unwrap_or("").trim().to_string()) + .collect() + } + + #[test] + fn script_asset_is_sane() { + assert!(SCRIPT.starts_with("// Generated by socket-patch (vendored mode). Do not edit.\n")); + assert!(SCRIPT.contains(&format!("'{INDEX_HEADER}'"))); + assert!(SCRIPT.contains(&format!("'{REPO_NAME}'"))); + assert!(SCRIPT.contains(&format!("'{MARKER_NAME}'"))); + assert!(SCRIPT.ends_with("}\n")); + assert!(!SCRIPT.contains('\t') && !SCRIPT.contains('\r')); + assert!(SCRIPT.lines().all(|l| l == l.trim_end())); + assert_eq!(SCRIPT.matches('{').count(), SCRIPT.matches('}').count()); + } + + #[test] + fn groovy_settings_gets_the_apply_line_and_the_tree() { + let files = fs(&[("settings.gradle", "rootProject.name = 'x'\ninclude 'app'\n")]); + let plan = run(&files, &patch()).unwrap(); + assert_eq!( + text_of(&plan, "settings.gradle"), + "rootProject.name = 'x'\ninclude 'app'\napply from: '.socket/gradle/socket-patch.settings.gradle' // socket-patch\n" + ); + let rels: Vec<(&str, bool)> = plan + .writes + .iter() + .map(|w| (w.rel.as_str(), w.tree)) + .collect(); + assert_eq!( + rels, + vec![ + (".socket/gradle/socket-patch.settings.gradle", false), + (".socket/vendor/gradle-index.tsv", false), + (".socket/vendor/gradle/.gitattributes", false), + (".socket/vendor/gradle/com/google/code/gson/gson/2.10.1/gson-2.10.1.jar", true), + (".socket/vendor/gradle/com/google/code/gson/gson/2.10.1/gson-2.10.1.pom", true), + (".socket/vendor/gradle/com/google/code/gson/gson/2.10.1/socket-patch.vendor.json", true), + ("settings.gradle", false), + ] + ); + assert_eq!( + plan.tree_dir, + ".socket/vendor/gradle/com/google/code/gson/gson/2.10.1" + ); + assert_eq!(plan.jar_rel, format!("{}/gson-2.10.1.jar", plan.tree_dir)); + assert_eq!(text_of(&plan, SCRIPT_REL), SCRIPT); + assert_eq!( + text_of(&plan, ".socket/vendor/gradle/.gitattributes"), + "* -text\n" + ); + assert!(plan.warnings.is_empty(), "{:?}", plan.warnings); + } + + #[test] + fn kotlin_settings_crlf_and_missing_newline_are_preserved() { + let files = fs(&[( + "settings.gradle.kts", + "rootProject.name = \"x\"\r\ninclude(\"app\")", + )]); + let plan = run(&files, &patch()).unwrap(); + assert_eq!( + text_of(&plan, "settings.gradle.kts"), + "rootProject.name = \"x\"\r\ninclude(\"app\")\r\napply(from = \".socket/gradle/socket-patch.settings.gradle\") // socket-patch\r\n" + ); + assert_idempotent(&files, &patch()); + } + + #[test] + fn tabs_comments_and_unrelated_content_are_untouched() { + let src = "// apply from: '.socket/gradle/socket-patch.settings.gradle'\n\tinclude 'a' /* x */\n\n"; + let files = fs(&[("settings.gradle", src)]); + let plan = run(&files, &patch()).unwrap(); + let out = text_of(&plan, "settings.gradle"); + assert_eq!(out, format!("{src}{}\n", apply_line(false, ""))); + } + + #[test] + fn missing_settings_is_created_in_the_build_script_dsl() { + let files = fs(&[("build.gradle.kts", "plugins { java }\n")]); + let plan = run(&files, &patch()).unwrap(); + assert_eq!( + text_of(&plan, "settings.gradle.kts"), + format!("{}\n", apply_line(true, "")) + ); + let files = fs(&[("build.gradle", "apply plugin: 'java'\n")]); + let plan = run(&files, &patch()).unwrap(); + assert_eq!( + text_of(&plan, "settings.gradle"), + format!("{}\n", apply_line(false, "")) + ); + assert!(!plan.writes.iter().any(|w| w.rel == "settings.gradle.kts")); + } + + #[test] + fn groovy_settings_wins_over_kotlin_like_gradle() { + let files = fs(&[("settings.gradle", "x\n"), ("settings.gradle.kts", "y\n")]); + let plan = run(&files, &patch()).unwrap(); + assert!(plan.writes.iter().any(|w| w.rel == "settings.gradle")); + assert!(!plan.writes.iter().any(|w| w.rel == "settings.gradle.kts")); + } + + #[test] + fn replan_is_idempotent_and_keeps_a_reformatted_line() { + let files = fs(&[ + ("settings.gradle", "include 'a'\n"), + ("buildSrc/build.gradle", ""), + ]); + assert_idempotent(&files, &patch()); + let files = fs(&[( + "settings.gradle", + "apply from: '.socket/gradle/socket-patch.settings.gradle'\ninclude 'a'\n", + )]); + let plan = run(&files, &patch()).unwrap(); + assert!(!plan.writes.iter().any(|w| w.rel == "settings.gradle")); + } + + #[test] + fn buildsrc_is_wired_with_a_parent_prefix() { + let files = fs(&[ + ("settings.gradle", ""), + ("buildSrc/build.gradle.kts", "plugins { `kotlin-dsl` }\n"), + ]); + let plan = run(&files, &patch()).unwrap(); + assert_eq!( + text_of(&plan, "buildSrc/settings.gradle.kts"), + "apply(from = \"../.socket/gradle/socket-patch.settings.gradle\") // socket-patch\n" + ); + let files = fs(&[ + ("settings.gradle", ""), + ("buildSrc/settings.gradle", "rootProject.name = 'bs'"), + ]); + let plan = run(&files, &patch()).unwrap(); + assert_eq!( + text_of(&plan, "buildSrc/settings.gradle"), + "rootProject.name = 'bs'\napply from: '../.socket/gradle/socket-patch.settings.gradle' // socket-patch\n" + ); + let files = fs(&[("settings.gradle", "")]); + let plan = run(&files, &patch()).unwrap(); + assert!(!plan.writes.iter().any(|w| w.rel.starts_with("buildSrc/"))); + } + + #[test] + fn literal_include_builds_are_wired_recursively() { + let files = fs(&[ + ( + "settings.gradle", + "pluginManagement {\n includeBuild('build-logic')\n}\nincludeBuild \"tools/./gen/\"\n// includeBuild('commented')\n/* includeBuild('block') */\ndef s = \"includeBuild('in-string')\"\n", + ), + ("build-logic/settings.gradle.kts", "includeBuild(\"../nested\")\n"), + ("nested/build.gradle.kts", ""), + ("tools/gen/build.gradle", ""), + ]); + let plan = run(&files, &patch()).unwrap(); + assert_eq!( + text_of(&plan, "build-logic/settings.gradle.kts"), + "includeBuild(\"../nested\")\napply(from = \"../.socket/gradle/socket-patch.settings.gradle\") // socket-patch\n" + ); + assert_eq!( + text_of(&plan, "tools/gen/settings.gradle"), + "apply from: '../../.socket/gradle/socket-patch.settings.gradle' // socket-patch\n" + ); + assert_eq!( + text_of(&plan, "nested/settings.gradle.kts"), + format!("{}\n", apply_line(true, "../")) + ); + for bogus in ["commented", "block", "in-string"] { + assert!( + !plan.writes.iter().any(|w| w.rel.starts_with(bogus)), + "{bogus}" + ); + } + assert!(plan.warnings.is_empty(), "{:?}", plan.warnings); + assert_idempotent(&files, &patch()); + } + + #[test] + fn non_literal_or_escaping_include_builds_warn() { + let files = fs(&[( + "settings.gradle.kts", + "includeBuild(file(\"x\"))\nincludeBuild(\"$dir/y\")\nincludeBuild(\"../outside\")\nincludeBuild(\"/abs\")\nincludeBuild(\".\")\n", + )]); + let plan = run(&files, &patch()).unwrap(); + assert_eq!(reasons(&plan), vec!["unwired_build_logic"; 4]); + assert!(plan.warnings.iter().all(|w| w.code == "vendor_jvm_degraded" + && w.detail.starts_with("reason: unwired_build_logic: "))); + let wired: Vec<&str> = plan + .writes + .iter() + .filter(|w| !w.rel.starts_with(".socket") && w.rel.contains("settings.gradle")) + .map(|w| w.rel.as_str()) + .collect(); + assert_eq!(wired, vec!["settings.gradle.kts"]); + } + + #[test] + fn resolve_dir_normalises() { + assert_eq!(resolve_dir("", "a/./b/"), Some("a/b".into())); + assert_eq!(resolve_dir("x", "../y"), Some("y".into())); + assert_eq!(resolve_dir("x", "../.."), None); + assert_eq!(resolve_dir("", "C:/y"), None); + assert_eq!(resolve_dir("", "a\\b"), None); + assert_eq!(resolve_dir("", "."), Some(String::new())); + } + + #[test] + fn in_block_entry_goes_first_in_plugin_management_repositories() { + let src = "pluginManagement {\n repositories {\n gradlePluginPortal()\n }\n}\nplugins {\n id 'org.gradle.toolchains.foojay-resolver-convention' version '0.8.0'\n}\n"; + let files = fs(&[("settings.gradle", src)]); + let plan = run(&files, &patch()).unwrap(); + let expect = format!( + "pluginManagement {{\n repositories {{\n {}\n gradlePluginPortal()\n }}\n}}\nplugins {{\n id 'org.gradle.toolchains.foojay-resolver-convention' version '0.8.0'\n}}\n{}\n", + in_block_line(false, "", &patch().coords()), + apply_line(false, "") + ); + assert_eq!(text_of(&plan, "settings.gradle"), expect); + assert!(plan.warnings.is_empty()); + assert_idempotent(&files, &patch()); + } + + #[test] + fn in_block_entry_on_a_one_line_repositories_block() { + let src = "pluginManagement {\r\n repositories { gradlePluginPortal(); mavenCentral() }\r\n}\r\nplugins { id(\"x\") version \"1\" }\r\n"; + let files = fs(&[("settings.gradle.kts", src)]); + let plan = run(&files, &patch()).unwrap(); + let out = text_of(&plan, "settings.gradle.kts"); + let entry = in_block_line(true, "", &patch().coords()); + assert!( + out.starts_with(&format!( + "pluginManagement {{\r\n repositories {{\r\n {entry}\r\n gradlePluginPortal(); mavenCentral() }}\r\n}}\r\n" + )), + "{out}" + ); + assert!(entry.contains("includeVersion(\"com.google.code.gson\", \"gson\", \"2.10.1\")")); + assert_idempotent(&files, &patch()); + } + + #[test] + fn in_block_creates_plugin_management_after_imports() { + let src = "import java.io.File\n\nplugins {\n id(\"x\") version \"1\"\n}\nrootProject.name = \"r\"\n"; + let files = fs(&[("settings.gradle.kts", src)]); + let plan = run(&files, &patch()).unwrap(); + let entry = in_block_line(true, "", &patch().coords()); + let expect = format!( + "import java.io.File\n\npluginManagement {{\n repositories {{\n {entry}\n gradlePluginPortal()\n }}\n}}\nplugins {{\n id(\"x\") version \"1\"\n}}\nrootProject.name = \"r\"\n{}\n", + apply_line(true, "") + ); + assert_eq!(text_of(&plan, "settings.gradle.kts"), expect); + assert_idempotent(&files, &patch()); + } + + #[test] + fn in_block_adds_repositories_to_plugin_management_and_keeps_the_portal() { + let src = + "pluginManagement {\n includeBuild('logic')\n}\nplugins { id 'a' version '1' }\n"; + let files = fs(&[("settings.gradle", src), ("logic/settings.gradle", "")]); + let plan = run(&files, &patch()).unwrap(); + let entry = in_block_line(false, "", &patch().coords()); + assert!(text_of(&plan, "settings.gradle").starts_with(&format!( + "pluginManagement {{\n repositories {{\n {entry}\n gradlePluginPortal()\n }}\n includeBuild('logic')\n}}\n" + ))); + let src = "pluginManagement { repositories { } }\nplugins { id 'a' version '1' }\n"; + let files = fs(&[("settings.gradle", src)]); + let out = run(&files, &patch()).unwrap(); + assert!(text_of(&out, "settings.gradle").contains(&format!("{entry}\n"))); + assert!(text_of(&out, "settings.gradle").contains("gradlePluginPortal()")); + assert_idempotent(&files, &patch()); + } + + #[test] + fn in_block_entry_in_an_included_build_uses_its_prefix() { + let files = fs(&[ + ("settings.gradle", "includeBuild('a/b')\n"), + ("a/b/settings.gradle", "plugins { id 'p' version '1' }\n"), + ]); + let plan = run(&files, &patch()).unwrap(); + assert!(text_of(&plan, "a/b/settings.gradle") + .contains("new File(settingsDir, '../../.socket/vendor/gradle')")); + assert!(!text_of(&plan, "settings.gradle").contains("pluginManagement")); + } + + #[test] + fn project_level_or_commented_plugins_need_no_in_block_entry() { + let files = fs(&[( + "settings.gradle", + "// plugins { id 'x' }\ndef s = 'plugins {'\ngradle.beforeProject { plugins { } }\n", + )]); + let plan = run(&files, &patch()).unwrap(); + assert!(!text_of(&plan, "settings.gradle").contains("exclusiveContent")); + } + + #[test] + fn non_literal_plugin_management_repositories_warn() { + let files = fs(&[("settings.gradle", "pluginManagement { repositories.gradlePluginPortal() }\nplugins { id 'a' version '1' }\n")]); + let plan = run(&files, &patch()).unwrap(); + assert_eq!(reasons(&plan), vec!["settings_plugins_unwired"]); + assert!( + text_of(&plan, "settings.gradle").ends_with(&format!("{}\n", apply_line(false, ""))) + ); + } + + #[test] + fn index_rows_merge_sorted_and_replace_the_same_gav() { + let other = format!("org.a:b:1\torg/a/b/1/b-1.jar\t{}\tu1", "a".repeat(64)); + let stale = format!("com.google.code.gson:gson:2.10.1\tcom/google/code/gson/gson/2.10.1/gson-2.10.1.jar\t{}\told", "b".repeat(64)); + let existing = format!("{INDEX_HEADER}\r\n{stale}\r\n\r\n{other}\r\n"); + let files = fs(&[("settings.gradle", ""), (INDEX_REL, &existing)]); + let plan = run(&files, &patch()).unwrap(); + let idx = text_of(&plan, INDEX_REL); + let lines: Vec<&str> = idx.lines().collect(); + assert_eq!(lines[0], INDEX_HEADER); + assert_eq!(lines.len(), 4); + assert!(lines[1].starts_with( + "com.google.code.gson:gson:2.10.1\tcom/google/code/gson/gson/2.10.1/gson-2.10.1.jar\t" + )); + assert!(lines[1].ends_with(&format!("\t{UUID}"))); + assert!(lines[1].contains(&sha256_hex(b"PATCHED-JAR"))); + assert!(lines[2].contains("gson-2.10.1.pom")); + assert_eq!(lines[3], other); + assert!(!idx.contains('\r') && idx.ends_with('\n')); + assert!(!idx.contains("\told")); + } + + #[test] + fn index_rows_include_the_module() { + let p = JvmPatch { + upstream_pom: b"", + upstream_module: Some(b"{\"formatVersion\":\"1.1\"}"), + ..patch() + }; + let files = fs(&[("settings.gradle", "")]); + let plan = run(&files, &p).unwrap(); + let idx = text_of(&plan, INDEX_REL); + assert_eq!(idx.lines().count(), 4); + assert!(idx.contains("gson-2.10.1.module")); + assert!(plan + .writes + .iter() + .any(|w| w.rel.ends_with("gson-2.10.1.module") + && w.tree + && w.bytes == b"{\"formatVersion\":\"1.1\"}")); + } + + #[test] + fn malformed_indexes_are_refused() { + let sha = "c".repeat(64); + for bad in [ + "garbage\n".to_string(), + format!("{INDEX_HEADER}\nonly\ttwo\n"), + format!("{INDEX_HEADER}\ng:a:1+\tg/a/1+/a-1+.jar\t{sha}\tu\n"), + format!("{INDEX_HEADER}\ng:a:1\tg/a/2/a-1.jar\t{sha}\tu\n"), + format!("{INDEX_HEADER}\ng:a:1\tg/a/1/../a-1.jar\t{sha}\tu\n"), + format!( + "{INDEX_HEADER}\ng:a:1\tg/a/1/a-1.jar\t{}\tu\n", + "C".repeat(64) + ), + format!("{INDEX_HEADER}\ng:a:1\tg/a/1/a-1.jar\t{sha}\t\n"), + ] { + let files = fs(&[("settings.gradle", ""), (INDEX_REL, &bad)]); + let err = run(&files, &patch()).unwrap_err(); + assert_eq!(err.code, "vendor_jvm_shape_unsupported", "{bad}"); + assert!( + err.detail.starts_with("reason: build_file_unreadable: "), + "{bad}" + ); + } + } + + #[test] + fn marker_is_sorted_pretty_json() { + let files = fs(&[("settings.gradle", "")]); + let plan = run(&files, &patch()).unwrap(); + let rel = format!("{}/{MARKER_NAME}", plan.tree_dir); + let text = text_of(&plan, &rel); + let v: serde_json::Value = serde_json::from_str(text).unwrap(); + assert_eq!(v["schema"], 1); + assert_eq!(v["tool"], "gradle"); + assert_eq!(v["uuid"], UUID); + assert_eq!(v["purl"], "pkg:maven/com.google.code.gson/gson@2.10.1"); + assert_eq!(v["version"], "2.10.1"); + assert_eq!(v["files"]["gson-2.10.1.jar"]["size"], 11); + assert_eq!( + v["files"]["gson-2.10.1.jar"]["sha256"], + sha256_hex(b"PATCHED-JAR") + ); + let keys: Vec<&String> = v.as_object().unwrap().keys().collect(); + let mut sorted = keys.clone(); + sorted.sort(); + assert_eq!(keys, sorted); + assert_eq!(serde_json::to_string_pretty(&v).unwrap() + "\n", text); + } + + #[test] + fn unsafe_coordinates_are_refused() { + let files = fs(&[("settings.gradle", "")]); + for (g, a, v) in [ + ("com.google", "gson", "2.+"), + ("com.google", "gson", "latest.release"), + ("com.google", "gson", "[1,2)"), + ("com.google", "gson", "1 2"), + ("com.google", "gson", "1\t2"), + ("com.google", "gson", "1:2"), + ("com.google", "gson", ".."), + ("com.google", "..", "1"), + ("com..google", "gson", "1"), + (".com", "gson", "1"), + ("com/google", "gson", "1"), + ("com.google", "gs$on", "1"), + ("", "gson", "1"), + ] { + let p = JvmPatch { + group_id: g, + artifact_id: a, + version: v, + ..patch() + }; + assert_eq!( + run(&files, &p).unwrap_err().code, + "unsafe_coordinates", + "{g}:{a}:{v}" + ); + } + assert!(safe_coordinates( + "org.apache_x-y", + "commons.text-2", + "1.0.0-rc_1+build.2" + )); + let p = JvmPatch { + uuid: "a\tb", + ..patch() + }; + assert_eq!(run(&files, &p).unwrap_err().code, "unsafe_coordinates"); + } + + #[test] + fn module_less_gradle_published_pom_is_refused() { + let files = fs(&[("settings.gradle", "")]); + let p = JvmPatch { + upstream_pom: b"", + ..patch() + }; + let err = run(&files, &p).unwrap_err(); + assert_eq!(err.code, "vendor_jvm_upstream_unavailable"); + assert!(err.detail.starts_with("reason: module_unavailable: ")); + let p = JvmPatch { + upstream_module: Some(b"{\"variants\":[{\"available-at\":{}}]}"), + ..patch() + }; + assert!(run(&files, &p) + .unwrap_err() + .detail + .starts_with("reason: android_or_kmp: ")); + } + + #[test] + fn android_and_kmp_are_refused() { + for (rel, src) in [ + ("build.gradle", "plugins { id 'com.android.application' version '8.0.0' apply false }"), + ("build.gradle.kts", "buildscript { dependencies { classpath(\"com.android.tools.build:gradle:8.0.0\") } }"), + ("settings.gradle.kts", "plugins { kotlin(\"multiplatform\") version \"2.0.0\" apply false }"), + ("settings.gradle", "plugins { id 'org.jetbrains.kotlin.multiplatform' version '2.0.0' }"), + ] { + let files = fs(&[(rel, src)]); + let err = run(&files, &patch()).unwrap_err(); + assert_eq!(err.code, "vendor_jvm_shape_unsupported", "{rel}"); + assert!(err.detail.starts_with("reason: android_or_kmp: "), "{}", err.detail); + } + let files = fs(&[( + "build.gradle", + "// id 'com.android.application'\nplugins { id 'java' }\n", + )]); + assert!(run(&files, &patch()).is_ok()); + } + + #[test] + fn user_exclusive_content_for_the_group_is_refused() { + let files = fs(&[( + "build.gradle", + "repositories { exclusiveContent { forRepository { mavenCentral() }\n filter { includeGroup \"com.google.code.gson\" } } }", + )]); + let err = run(&files, &patch()).unwrap_err(); + assert!( + err.detail + .starts_with("reason: gradle_exclusive_content_conflict: "), + "{}", + err.detail + ); + let files = fs(&[( + "settings.gradle", + "dependencyResolutionManagement { repositories { exclusiveContent { forRepository { maven { url 'x' } }; filter { includeGroup 'org.other' } } } }", + )]); + assert!(run(&files, &patch()).is_ok()); + } + + #[test] + fn settings_buildscript_classpath_on_the_ga_is_wired() { + for src in [ + "buildscript { dependencies { classpath 'com.google.code.gson:gson:2.10.1' } }", + "buildscript { dependencies { classpath(group = \"com.google.code.gson\", name = \"gson\", version = \"2.10.1\") } }", + ] { + let files = fs(&[("settings.gradle", src)]); + let plan = run(&files, &patch()).unwrap(); + assert!(text_of(&plan, "settings.gradle").contains("exclusiveContent")); + assert_idempotent(&files, &patch()); + } + let files = fs(&[( + "settings.gradle", + "buildscript { dependencies { classpath 'org.x:y:1' } }", + )]); + assert!(run(&files, &patch()).is_ok()); + } + + const VM_HEAD: &str = "\n\n \n true\n false\n \n \n \n \n \n"; + const VM_TAIL: &str = " \n\n"; + + fn vm_component(g: &str, a: &str, v: &str, arts: &[(&str, &str)]) -> String { + let mut s = format!(" \n"); + for (n, sha) in arts { + s.push_str(&format!(" \n \n \n")); + } + s.push_str(" \n"); + s + } + + #[test] + fn verification_jar_entry_is_replaced_in_canonical_form() { + let before = format!( + "{VM_HEAD}{}{}{}{VM_TAIL}", + vm_component("com.google.code.gson", "gson", "2.10.1", &[("gson-2.10.1.jar", "old"), ("gson-2.10.1.pom", "pomsha")]), + " \n", + vm_component("org.z", "z", "1", &[("z-1.jar", "zsha")]), + ); + let files = fs(&[("settings.gradle", ""), (VERIFICATION_REL, &before)]); + let p = JvmPatch { + upstream_pom: GSON_POM, + ..patch() + }; + let plan = run(&files, &p).unwrap(); + let after = text_of(&plan, VERIFICATION_REL); + let expect = before.replace( + " \n \n ", + &format!( + " \n \n ", + sha256_hex(b"PATCHED-JAR") + ), + ); + assert_ne!(expect, before); + assert_eq!(after, expect); + assert!(after.contains("")); + assert_eq!(reasons(&plan), vec!["verification_parent_chain_unhandled"]); + assert_idempotent(&files, &p); + } + + #[test] + fn parent_chain_warning_only_when_the_file_may_lack_entries() { + let listed = vm_component( + "com.google.code.gson", + "gson-parent", + "2.10.1", + &[("gson-parent-2.10.1.pom", "x")], + ); + let vm = format!("{VM_HEAD}{listed}{VM_TAIL}"); + assert!(!unverified_parent_chain( + &vm, + std::str::from_utf8(GSON_POM).unwrap(), + None + )); + let bare = format!("{VM_HEAD}{VM_TAIL}"); + assert!(unverified_parent_chain( + &bare, + std::str::from_utf8(GSON_POM).unwrap(), + None + )); + let off = bare.replace("true", "false"); + assert!(!unverified_parent_chain( + &off, + std::str::from_utf8(GSON_POM).unwrap(), + None + )); + assert!(unverified_parent_chain(&vm, "gp${v}", None)); + assert!(unverified_parent_chain( + &vm, + "import", + None + )); + assert!(unverified_parent_chain( + &vm, + "", + Some(b"{\"attributes\":{\"org.gradle.category\":\"platform\"}}") + )); + assert!(!unverified_parent_chain( + &vm, + "", + None + )); + } + + #[test] + fn verification_component_is_inserted_sorted() { + let before = format!( + "{VM_HEAD}{}{}{VM_TAIL}", + vm_component("com.a", "a", "1", &[("a-1.jar", "x")]), + vm_component("org.z", "z", "1", &[("z-1.jar", "zsha")]), + ) + .replace('\n', "\r\n"); + let p = JvmPatch { + upstream_pom: b"", + upstream_module: Some(b"{}"), + ..patch() + }; + let files = fs(&[("settings.gradle", ""), (VERIFICATION_REL, &before)]); + let plan = run(&files, &p).unwrap(); + let after = text_of(&plan, VERIFICATION_REL); + let comp = vm_component( + "com.google.code.gson", + "gson", + "2.10.1", + &[ + ("gson-2.10.1.jar", &sha256_hex(b"PATCHED-JAR")), + ("gson-2.10.1.module", &sha256_hex(b"{}")), + ("gson-2.10.1.pom", &sha256_hex(p.upstream_pom)), + ], + ) + .replace("Generated by Gradle", "socket-patch") + .replace('\n', "\r\n"); + let at = before.find(" \n \n \n")); + assert!(text.ends_with(" \n \n\n")); + + let before = "\n \n\n"; + let files = fs(&[("settings.gradle", ""), (VERIFICATION_REL, before)]); + let after = assert_idempotent(&files, &patch()); + let text = String::from_utf8(after[VERIFICATION_REL].clone()).unwrap(); + assert!(text.starts_with("\n \n \n \n\n")); + } + + #[test] + fn verification_jar_entry_added_to_a_component_without_one() { + let before = format!( + "{VM_HEAD}{}{VM_TAIL}", + vm_component( + "com.google.code.gson", + "gson", + "2.10.1", + &[("gson-2.10.1.pom", "p")] + ) + ); + let files = fs(&[("settings.gradle", ""), (VERIFICATION_REL, &before)]); + let after = assert_idempotent(&files, &patch()); + let text = String::from_utf8(after[VERIFICATION_REL].clone()).unwrap(); + let jar = text.find("gson-2.10.1.jar").unwrap(); + assert!(jar < text.find("gson-2.10.1.pom").unwrap()); + assert!(text + .contains(" \n ", ""] { + let files = fs(&[("settings.gradle", ""), (VERIFICATION_REL, bad)]); + let err = run(&files, &patch()).unwrap_err(); + assert!(err.detail.starts_with("reason: gradle_verification_unparseable: "), "{}", err.detail); + } + } + + #[test] + fn no_verification_file_is_created() { + let files = fs(&[("settings.gradle", "")]); + let plan = run(&files, &patch()).unwrap(); + assert!(!plan.writes.iter().any(|w| w.rel == VERIFICATION_REL)); + } + + #[test] + fn detect_routes_gradle_only_roots_here() { + let files = fs(&[("build.gradle", "")]); + let read = |rel: &str| files.get(rel).cloned(); + assert_eq!(detect(&read), Shape::Gradle); + } + + #[test] + fn lexer_handles_strings_comments_and_escapes() { + let toks = lex("a '''x\n'y''' \"\\\"q\" /* } */ 'it\\'s' \"${b}\" // }\n{"); + let kinds: Vec = toks.into_iter().map(|t| t.tok).collect(); + assert_eq!( + kinds, + vec![ + Tok::Ident("a".into()), + Tok::Str { + value: "x\n'y".into(), + literal: true + }, + Tok::Str { + value: "\"q".into(), + literal: true + }, + Tok::Str { + value: "it's".into(), + literal: true + }, + Tok::Str { + value: "${b}".into(), + literal: false + }, + Tok::Punct(b'{'), + ] + ); + } + + #[tokio::test] + async fn written_plan_reverts_byte_exact() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write(root.join("settings.gradle"), "rootProject.name = 'x'\r\n").unwrap(); + let read = |rel: &str| apply::read_project_file(root, rel); + let p = plan(&read, &patch()).unwrap(); + let records = apply::write_plan(root, &p).await.unwrap(); + assert!(root.join(&p.jar_rel).is_file()); + let again = plan(&read, &patch()).unwrap(); + assert!(again.writes.is_empty()); + let entry = super::super::testing::entry(&patch(), records); + let out = apply::revert(root, &entry, crate::vendor::RevertOpts::new(false)).await; + assert!(out.success && out.warnings.is_empty(), "{:?}", out); + assert_eq!( + std::fs::read(root.join("settings.gradle")).unwrap(), + b"rootProject.name = 'x'\r\n" + ); + assert!(!root.join(".socket").exists()); + } + + // ── revert, peers and patch updates (disk round-trips) ── + + use super::super::testing; + + const UUID_B: &str = "abcdef01-92a3-4b4c-8d5e-6f708192a3b4"; + + fn patch_b() -> JvmPatch<'static> { + JvmPatch { + group_id: "org.example", + artifact_id: "lib", + version: "2.0", + uuid: UUID_B, + jar: b"B-JAR", + upstream_pom: b"\n", + upstream_module: None, + } + } + + fn replan_writes(root: &std::path::Path, p: &JvmPatch<'_>) -> Vec { + let reader = apply::ProjectReader::new(root); + plan(&|rel: &str| reader.read(rel), p) + .unwrap() + .writes + .into_iter() + .map(|w| w.rel) + .collect() + } + + /// Reverting either of two patches leaves the other fully wired (apply + /// line, script, index rows, in-block shell, `.gitattributes`: a re-plan + /// writes nothing), and reverting both restores every byte and + /// directory — for plain, settings-`plugins{}`, one-line + /// `pluginManagement` and verification-file shapes. + #[tokio::test] + async fn two_patches_revert_in_either_order_byte_exact() { + let vm = format!( + "{VM_HEAD}{}{}{VM_TAIL}", + vm_component( + "com.google.code.gson", + "gson", + "2.10.1", + &[("gson-2.10.1.jar", "old"), ("gson-2.10.1.pom", "p")] + ), + vm_component("org.z", "z", "1", &[("z-1.jar", "zsha")]), + ); + let shapes: Vec> = vec![ + vec![("settings.gradle", "rootProject.name = 'x'\r\n".to_string())], + vec![( + "settings.gradle.kts", + "import java.io.File\n\nplugins {\n id(\"org.gradle.toolchains.foojay-resolver-convention\") version \"0.8.0\"\n}\nrootProject.name = \"x\"".to_string(), + )], + vec![( + "settings.gradle", + "pluginManagement { repositories { mavenCentral() } }\nplugins { id 'x' version '1' }\n".to_string(), + )], + vec![ + ("settings.gradle", "plugins {\n\tid 'x' version '1'\n}\n".to_string()), + (VERIFICATION_REL, vm.clone()), + ("buildSrc/build.gradle.kts", String::new()), + ], + ]; + for files in &shapes { + for first_a in [true, false] { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let borrowed: Vec<(&str, &str)> = + files.iter().map(|(r, b)| (*r, b.as_str())).collect(); + testing::populate(root, &borrowed); + let (pristine, pristine_dirs) = (testing::snapshot(root), testing::dirs(root)); + let mut ledger = BTreeMap::new(); + let (pa, pb) = (patch(), patch_b()); + let plan_a = testing::vendor(root, Shape::Gradle, &pa, &mut ledger) + .await + .unwrap(); + let plan_b = testing::vendor(root, Shape::Gradle, &pb, &mut ledger) + .await + .unwrap(); + let (first, second, second_plan) = if first_a { + (&pa, &pb, &plan_b) + } else { + (&pb, &pa, &plan_a) + }; + let out = testing::revert(root, first, &mut ledger).await; + assert!( + out.success && out.warnings.is_empty() && !out.kept_artifact, + "{out:?}" + ); + assert!( + replan_writes(root, second).is_empty(), + "{files:?} first_a={first_a}: the remaining patch lost wiring: {:?}", + replan_writes(root, second) + ); + assert!(root.join(&second_plan.jar_rel).is_file()); + let index = std::fs::read_to_string(root.join(INDEX_REL)).unwrap(); + assert!(!index.contains(first.uuid), "{index}"); + let out = testing::revert(root, second, &mut ledger).await; + assert!(out.success && out.warnings.is_empty(), "{out:?}"); + assert_eq!( + testing::snapshot(root), + pristine, + "{files:?} first_a={first_a}" + ); + assert_eq!( + testing::dirs(root), + pristine_dirs, + "{files:?} first_a={first_a}" + ); + } + } + } + + /// A patch update (same GAV, new uuid) rewrites the tree in place, the + /// index rows and the verification hash, keeps the wiring (the in-block + /// shell and the user's verification element carried from the earlier + /// entry), and reverts to pristine. + #[tokio::test] + async fn patch_update_keeps_the_wiring_and_reverts_pristine() { + let vm = format!( + "{VM_HEAD}{}{VM_TAIL}", + vm_component( + "com.google.code.gson", + "gson", + "2.10.1", + &[("gson-2.10.1.jar", "old")] + ), + ); + let shapes: [&[(&str, &str)]; 3] = [ + &[("settings.gradle", "rootProject.name = 'x'\n")], + &[("settings.gradle", "plugins {\n id 'x' version '1'\n}\n")], + &[("settings.gradle", ""), (VERIFICATION_REL, &vm)], + ]; + for files in shapes { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + testing::populate(root, files); + let pristine = testing::snapshot(root); + let mut ledger = BTreeMap::new(); + testing::vendor(root, Shape::Gradle, &patch(), &mut ledger) + .await + .unwrap(); + let p2 = JvmPatch { + uuid: UUID_B, + jar: b"PATCHED-JAR-2", + ..patch() + }; + let plan2 = testing::vendor(root, Shape::Gradle, &p2, &mut ledger) + .await + .unwrap(); + assert_eq!( + std::fs::read(root.join(&plan2.jar_rel)).unwrap(), + b"PATCHED-JAR-2" + ); + let index = std::fs::read_to_string(root.join(INDEX_REL)).unwrap(); + assert!(index.contains(UUID_B) && !index.contains(UUID), "{index}"); + assert!(replan_writes(root, &p2).is_empty()); + let out = testing::revert(root, &p2, &mut ledger).await; + assert!(out.success && out.warnings.is_empty(), "{files:?}: {out:?}"); + assert_eq!(testing::snapshot(root), pristine, "{files:?}"); + assert!(!root.join(".socket").exists()); + } + } + + #[test] + fn an_apply_line_inside_a_comment_does_not_count() { + let line = apply_line(false, ""); + for commented in [ + format!("rootProject.name = 'x'\n/*\n{line}\n*/\n"), + format!("// {line}\n"), + format!("def s = \"{}\"\n", line.replace('\'', "\\'")), + ] { + let files = fs(&[("settings.gradle", &commented)]); + let plan = run(&files, &patch()).unwrap(); + assert!( + text_of(&plan, "settings.gradle").ends_with(&format!("{line}\n")), + "{commented}" + ); + } + for live in [ + "apply from: '.socket/gradle/socket-patch.settings.gradle'\n", + "apply(from = \".socket/gradle/socket-patch.settings.gradle\")\n", + "apply from : \".socket/gradle/socket-patch.settings.gradle\" // mine\n", + ] { + assert!(has_apply_line(live, ""), "{live}"); + } + assert!(!has_apply_line( + "apply from: '../.socket/gradle/socket-patch.settings.gradle'\n", + "" + )); + } + + #[tokio::test] + async fn an_included_build_without_build_files_is_warned_not_created() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + testing::populate(root, &[("settings.gradle", "includeBuild('missing')\n")]); + let mut ledger = BTreeMap::new(); + let plan = testing::vendor(root, Shape::Gradle, &patch(), &mut ledger) + .await + .unwrap(); + assert_eq!(reasons(&plan), ["unwired_build_logic"]); + assert!(!root.join("missing").exists()); + } + + #[cfg(unix)] + #[test] + fn an_included_build_symlinked_outside_the_checkout_is_reported() { + let dir = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + testing::populate( + dir.path(), + &[("settings.gradle", "includeBuild('build-logic')\n")], + ); + testing::populate( + outside.path(), + &[("settings.gradle", "rootProject.name = 'bl'\n")], + ); + std::os::unix::fs::symlink(outside.path(), dir.path().join("build-logic")).unwrap(); + let reader = apply::ProjectReader::new(dir.path()); + let _ = plan(&|rel: &str| reader.read(rel), &patch()); + assert_eq!(reader.escaped().as_deref(), Some("build-logic")); + } + + #[test] + fn unplan_preserves_a_modified_shared_script() { + let files = fs(&[("settings.gradle", "")]); + let p = patch(); + let plan = run(&files, &p).unwrap(); + let mut after = applied(&files, &plan); + after + .get_mut(SCRIPT_REL) + .unwrap() + .extend_from_slice(b"// user edit\n"); + let undo = unplan(&|rel| after.get(rel).cloned(), &p.coords(), &plan.records); + assert!(!undo.changes.iter().any(|(rel, _)| rel == SCRIPT_REL)); + assert!(undo.drifted.iter().any(|d| d.contains(SCRIPT_REL))); + } + + #[test] + fn unplan_leaves_a_malformed_index_and_keeps_the_tree() { + let files = fs(&[ + ("settings.gradle", ""), + (INDEX_REL, "#socket-patch-gradle-index 1\nnot a row\n"), + ]); + let read = |rel: &str| files.get(rel).cloned(); + let undo = unplan(&read, &patch().coords(), &[]); + assert!(undo.still_wired && !undo.drifted.is_empty()); + assert!(undo.changes.is_empty(), "{:?}", undo.changes); + } +} diff --git a/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs b/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs new file mode 100644 index 000000000..8bc21ecd8 --- /dev/null +++ b/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs @@ -0,0 +1,3949 @@ +//! Multi-module Maven reactor planner. See the module doc of +//! [`super`] and `docs/design/maven-vendoring.md` (the v5 behavior: 2-line `maven.config`, a fallback repository and a pin per local +//! root, declaration rewrites; no build-time checksum pins). +//! +//! Every pom is edited by splicing at byte offsets found on a masked copy +//! (comments, CDATA and processing instructions blanked), so line endings, +//! tabs, comments and unrelated content survive untouched. + +use std::collections::{BTreeMap, BTreeSet}; +use std::ops::Range; + +use serde_json::{json, Value}; + +use super::super::state::{WiringAction, WiringRecord}; +use super::{ + adopt, changes_between, finish_writes, fragment, op_of, op_str, owned_file, replace_op, + safe_coordinates, sha1_hex, sha256_hex, undo_replace, Coords, FileWrite, JvmPatch, JvmPlan, + JvmRefusal, JvmUnplan, JvmWarning, ReadFn, CONFIG_LINE_KIND, OWNED_FILE_KIND, + POM_FRAGMENT_KIND, TREE_GITATTRIBUTES, +}; + +/// The committed maven2 tree. +pub const TREE_ROOT: &str = ".socket/vendor/maven2"; +pub const MAVEN_CONFIG: &str = ".mvn/maven.config"; +/// The tree root's `.gitattributes`, shared by every Maven patch. +pub const GITATTRIBUTES_REL: &str = ".socket/vendor/maven2/.gitattributes"; +const OFFLINE_LINE: &str = "-Daether.offline.protocols=file"; +const OFFLINE_KEY: &str = "-Daether.offline.protocols="; +const TAIL_KEY: &str = "-Dmaven.repo.local.tail="; +const TAIL_DIR: &str = "${session.rootDirectory}/.socket/vendor/maven2"; +pub const REPO_ID: &str = "socket-patch-vendor"; +pub const REPO_URL: &str = "file://${maven.multiModuleProjectDirectory}/.socket/vendor/maven2"; +const BEGIN_MARKER: &str = ""; +const END_MARKER: &str = ""; +/// Prefix of the comment tagging a pin: ``. +const PIN_TAG: &str = "")?]; + let mut parts = gav.split(':'); + let (g, a) = (parts.next()?, parts.next()?); + Some(format!("pin:{g}:{a}")) +} + +/// Splice non-overlapping edits; insertions at one offset keep their order. +fn apply_edits(text: &str, mut edits: Vec) -> String { + edits.sort_by_key(|e| e.start); + let mut out = + String::with_capacity(text.len() + edits.iter().map(|e| e.text.len()).sum::()); + let mut at = 0; + for e in edits { + debug_assert!(e.start >= at, "overlapping pom edits"); + out.push_str(&text[at..e.start]); + out.push_str(&e.text); + at = e.end; + } + out.push_str(&text[at..]); + out +} + +/// Lines of the pin entry, relative to the `` indent. +fn pin_lines(doc: &Doc, patch: &JvmPatch<'_>, sv: &str) -> Vec { + let u = &doc.unit; + vec![ + format!( + "{PIN_TAG}{}: {}:{}:{} -->", + patch.uuid, patch.group_id, patch.artifact_id, patch.version + ), + "".to_string(), + format!("{u}{}", patch.group_id), + format!("{u}{}", patch.artifact_id), + format!("{u}{sv}"), + "".to_string(), + ] +} + +fn nest(unit: &str, open: &str, inner: Vec, close: &str) -> Vec { + let mut lines = vec![open.to_string()]; + lines.extend(inner.into_iter().map(|l| format!("{unit}{l}"))); + lines.push(close.to_string()); + lines +} + +/// The pin as the first child of the top-level +/// ``, creating the sections when +/// absent (before ``, else ``, else ``). +/// `true` when the edit creates (or expands) a section. +fn pin_edit(doc: &Doc, patch: &JvmPatch<'_>, sv: &str) -> (Edit, bool) { + let u = doc.unit.as_str(); + let entry = pin_lines(doc, patch, sv); + let n = entry.len(); + let project = doc.project; + let Some(dm) = doc.child(project, "dependencyManagement") else { + let lines = nest( + u, + "", + nest(u, "", entry, ""), + "", + ); + let edit = match doc + .child(project, "dependencies") + .or_else(|| doc.child(project, "build")) + { + Some(anchor) => doc.insert_before( + doc.nodes[anchor].start, + &doc.indent_of(anchor), + &lines, + Some(2..2 + n), + ), + None => doc.insert_before( + doc.nodes[project].inner_end, + &doc.child_indent(project), + &lines, + Some(2..2 + n), + ), + }; + return (edit, true); + }; + match doc.child(dm, "dependencies") { + Some(deps) if !doc.is_self_closing(deps) => { + (doc.insert_first_child(deps, &entry, None), false) + } + Some(deps) => (doc.insert_first_child(deps, &entry, Some(0..n)), true), + None => ( + doc.insert_first_child( + dm, + &nest(u, "", entry, ""), + Some(1..1 + n), + ), + true, + ), + } +} + +/// An existing pin of g:a:v from another patch uuid, updated in place to +/// this patch's uuid and suffixed version. +fn pin_update(doc: &Doc, pin: &Pin, patch: &JvmPatch<'_>) -> Edit { + let sv = patch.suffixed_version(); + let mut text = doc.text[pin.start..pin.end].to_string(); + if let Some(version) = &pin.version { + text.replace_range(version.start - pin.start..version.end - pin.start, &sv); + } + let text = text.replacen( + &format!("{PIN_TAG}{}:", pin.uuid), + &format!("{PIN_TAG}{}:", patch.uuid), + 1, + ); + Edit { + start: pin.start, + end: pin.end, + text, + role: Role::Pin { mark: None }, + } +} + +/// The marked fallback repository, last in the top-level `` +/// or in a new section before ``. +fn repository_edit(doc: &Doc) -> Edit { + let u = doc.unit.as_str(); + let block = vec![ + BEGIN_MARKER.to_string(), + "".to_string(), + format!("{u}{REPO_ID}"), + format!("{u}{REPO_URL}"), + format!( + "{u}truealways\ + fail" + ), + format!("{u}false"), + "".to_string(), + END_MARKER.to_string(), + ]; + let project = doc.project; + let mut edit = match doc.child(project, "repositories") { + Some(r) if doc.is_self_closing(r) => doc.insert_first_child(r, &block, None), + Some(r) => doc.insert_before(doc.nodes[r].inner_end, &doc.child_indent(r), &block, None), + None => doc.insert_before( + doc.nodes[project].inner_end, + &doc.child_indent(project), + &nest(u, "", block, ""), + None, + ), + }; + edit.role = Role::Repository; + edit +} + +// ── .mvn/maven.config ───────────────────────────────── + +/// `config` with the offline-protocols and tail lines present, and the +/// `config` op recording what changed (`adopt` when nothing did): +/// identical lines are adopted, the last (effective) tail gets our +/// directory appended to its list, the last protocol list gets `file`. +fn merge_maven_config(config: Option<&[u8]>) -> (Vec, Value) { + let text = config.map(String::from_utf8_lossy).unwrap_or_default(); + let nl = if text.contains("\r\n") { "\r\n" } else { "\n" }; + let lines: Vec<&str> = text.split_inclusive('\n').collect(); + let arg_of = |line: &str| line.trim().to_string(); + let last = |key: &str| lines.iter().rposition(|l| arg_of(l).starts_with(key)); + let (last_tail, last_offline) = (last(TAIL_KEY), last(OFFLINE_KEY)); + let mut out = String::new(); + let mut rewritten = Vec::new(); + for (i, line) in lines.iter().enumerate() { + let body = line.trim_end_matches(['\r', '\n']); + let ending = &line[body.len()..]; + let arg = arg_of(line); + let extended = if Some(i) == last_tail { + extend_list(&arg, TAIL_KEY, TAIL_DIR) + } else if Some(i) == last_offline { + extend_list(&arg, OFFLINE_KEY, "file") + } else { + None + }; + match extended { + Some(new_arg) => { + let new_body = body.replacen(&arg, &new_arg, 1); + out.push_str(&new_body); + out.push_str(ending); + rewritten.push(json!({ "from": body, "to": new_body })); + } + None => out.push_str(line), + } + } + let mut appended = String::new(); + if !out.is_empty() && !out.ends_with('\n') && (last_offline.is_none() || last_tail.is_none()) { + appended.push_str(nl); + } + if last_offline.is_none() { + appended.push_str(&format!("{OFFLINE_LINE}{nl}")); + } + if last_tail.is_none() { + appended.push_str(&format!("{TAIL_KEY}{TAIL_DIR}{nl}")); + } + out.push_str(&appended); + let op = if rewritten.is_empty() && appended.is_empty() { + json!({ "op": "adopt" }) + } else { + json!({ + "op": "config", + "created": config.is_none(), + "appended": appended, + "rewritten": rewritten, + }) + }; + (out.into_bytes(), op) +} + +/// `arg` (``) with `item` appended to its comma list, `None` +/// when already listed. +fn extend_list(arg: &str, key: &str, item: &str) -> Option { + let list = arg.strip_prefix(key)?; + if list.split(',').any(|p| p == item) { + return None; + } + let sep = if list.is_empty() { "" } else { "," }; + Some(format!("{key}{list}{sep}{item}")) +} + +// ── vendored tree ─────────────────────────────────────────────────────── + +/// `(tree_dir, jar_rel, writes)` of the version directory. +fn tree_writes( + patch: &JvmPatch<'_>, + sv: &str, + suffixed_pom: String, +) -> (String, String, Vec) { + let a = patch.artifact_id; + let dir = tree_dir(&patch.coords()); + let jar_name = format!("{a}-{sv}.jar"); + let pom_name = format!("{a}-{sv}.pom"); + let pom = suffixed_pom.into_bytes(); + let mut files: BTreeMap> = BTreeMap::new(); + files.insert(format!("{jar_name}.sha1"), sha1_hex(patch.jar).into_bytes()); + files.insert(format!("{pom_name}.sha1"), sha1_hex(&pom).into_bytes()); + files.insert(jar_name.clone(), patch.jar.to_vec()); + files.insert(pom_name, pom); + + let mut listed = serde_json::Map::new(); + for (name, bytes) in &files { + listed.insert( + name.clone(), + serde_json::json!({ "sha256": sha256_hex(bytes), "size": bytes.len() }), + ); + } + // Keys inserted in sorted order: serde_json keeps insertion order here. + let marker = serde_json::json!({ + "files": listed, + "purl": format!("pkg:maven/{}/{a}@{}", patch.group_id, patch.version), + "schema": 1, + "tool": "maven", + "uuid": patch.uuid, + "version": sv, + }); + let mut marker = serde_json::to_string_pretty(&marker).expect("marker serializes"); + marker.push('\n'); + files.insert(MARKER_FILE.to_string(), marker.into_bytes()); + + let writes: Vec = files + .into_iter() + .map(|(name, bytes)| FileWrite { + rel: format!("{dir}/{name}"), + bytes, + tree: true, + }) + .collect(); + let jar_rel = format!("{dir}/{jar_name}"); + (dir, jar_rel, writes) +} + +// ── XML scanning ───────────────────────────────────────────────────────────── + +/// `text` with comments, CDATA sections and processing instructions blanked +/// byte-for-byte (offsets kept); `false` when one is unterminated (it is +/// blanked through EOF). +fn mask(text: &str) -> (String, bool) { + const SPANS: [(&str, &str); 3] = [(""), (""), ("")]; + let mut bytes = text.as_bytes().to_vec(); + let mut complete = true; + let mut from = 0; + while let Some(rel) = text[from..].find('<') { + let lt = from + rel; + let Some((open, close)) = SPANS.iter().find(|(o, _)| text[lt..].starts_with(o)) else { + from = lt + 1; + continue; + }; + let body = lt + open.len(); + let end = match text[body..].find(close) { + Some(r) => body + r + close.len(), + None => { + complete = false; + text.len() + } + }; + bytes[lt..end].fill(b' '); + from = end; + } + let masked = + String::from_utf8(bytes).expect("blanking whole ASCII-delimited spans keeps UTF-8"); + (masked, complete) +} + +/// A real `` open tag (the next byte is a tag boundary). +fn has_open_tag(masked: &str, name: &str) -> bool { + let needle = format!("<{name}"); + masked.match_indices(&needle).any(|(at, _)| { + masked[at + needle.len()..] + .chars() + .next() + .is_none_or(|c| c == '>' || c == '/' || c.is_whitespace()) + }) +} + +#[derive(Debug)] +struct Node { + name: String, + /// The `<` of the open tag. + start: usize, + /// Just past the open tag's `>`. + inner_start: usize, + /// The `<` of the close tag (`== inner_start` for ``). + inner_end: usize, + /// Just past the close tag's `>`. + end: usize, + parent: Option, + children: Vec, +} + +/// A parsed pom: the original text, its masked copy and the element tree. +struct Doc { + text: String, + masked: String, + nodes: Vec, + project: usize, + /// One indentation step, detected from the file. + unit: String, + /// The file's line ending. + nl: &'static str, +} + +impl Doc { + fn parse(text: String) -> Result { + let (masked, complete) = mask(&text); + if !complete { + return Err("unterminated comment, CDATA section or processing instruction".into()); + } + let bytes = masked.as_bytes(); + let mut nodes: Vec = Vec::new(); + let mut stack: Vec = Vec::new(); + let mut tops: Vec = Vec::new(); + let mut from = 0; + while let Some(rel) = masked[from..].find('<') { + let lt = from + rel; + match bytes.get(lt + 1) { + Some(b'/') => { + let gt = masked[lt..] + .find('>') + .map(|r| lt + r) + .ok_or("unterminated close tag")?; + let name = masked[lt + 2..gt].trim(); + let open = stack.pop().ok_or_else(|| format!("unexpected "))?; + if nodes[open].name != name { + return Err(format!("<{}> closed by ", nodes[open].name)); + } + nodes[open].inner_end = lt; + nodes[open].end = gt + 1; + from = gt + 1; + } + Some(b'!') => { + let gt = declaration_end(&masked, lt).ok_or("unterminated { + let gt = tag_end(&masked, lt).ok_or("unterminated open tag")?; + let raw = &masked[lt + 1..gt]; + let self_closing = raw.ends_with('/'); + let name = raw + .split(|c: char| c.is_whitespace() || c == '/') + .next() + .unwrap_or_default(); + if name.is_empty() { + return Err(format!("malformed tag at line {}", line_at(&masked, lt))); + } + let index = nodes.len(); + let parent = stack.last().copied(); + nodes.push(Node { + name: name.to_string(), + start: lt, + inner_start: gt + 1, + inner_end: gt + 1, + end: gt + 1, + parent, + children: Vec::new(), + }); + match parent { + Some(p) => nodes[p].children.push(index), + None => tops.push(index), + } + if !self_closing { + stack.push(index); + } + from = gt + 1; + } + } + } + if let Some(&open) = stack.last() { + return Err(format!("unclosed <{}>", nodes[open].name)); + } + let project = match tops.as_slice() { + [p] if nodes[*p].name == "project" && nodes[*p].inner_start != nodes[*p].end => *p, + _ => return Err("no single element".to_string()), + }; + let unit = indent_unit(&masked); + let nl = if text.contains("\r\n") { "\r\n" } else { "\n" }; + Ok(Doc { + text, + masked, + nodes, + project, + unit, + nl, + }) + } + + fn children<'d>(&'d self, n: usize, name: &'d str) -> impl Iterator + 'd { + self.nodes[n] + .children + .iter() + .copied() + .filter(move |&c| self.nodes[c].name == name) + } + + fn child(&self, n: usize, name: &str) -> Option { + self.children(n, name).next() + } + + fn is_self_closing(&self, n: usize) -> bool { + self.nodes[n].inner_start == self.nodes[n].end + } + + /// Trimmed character data: comments and PIs dropped, CDATA unwrapped. + fn text_of(&self, n: usize) -> String { + let node = &self.nodes[n]; + let mut out = String::new(); + let mut rest = &self.text[node.inner_start..node.inner_end]; + while let Some(lt) = rest.find('<') { + out.push_str(&rest[..lt]); + let tail = &rest[lt..]; + let (skip_to, keep) = if let Some(body) = tail.strip_prefix("").unwrap_or(body.len()); + (9 + end + 3, Some(&body[..end])) + } else if let Some(body) = tail.strip_prefix("").map_or(tail.len(), |r| 4 + r + 3), None) + } else if let Some(body) = tail.strip_prefix("").map_or(tail.len(), |r| 2 + r + 2), None) + } else { + (1, Some("<")) + }; + out.push_str(keep.unwrap_or_default()); + rest = tail.get(skip_to..).unwrap_or_default(); + } + out.push_str(rest); + out.trim().to_string() + } + + fn child_text(&self, n: usize, name: &str) -> Option { + self.child(n, name).map(|c| self.text_of(c)) + } + + /// The span of a text element's value: the trimmed text outside + /// comments, or the whole content when it holds CDATA (whose masked + /// copy is blank). + fn value_span(&self, n: usize) -> Range { + let node = &self.nodes[n]; + if self.text[node.inner_start..node.inner_end].contains(" Option { + let node = &self.nodes[dep]; + let before = self.text[..node.start].trim_end(); + let comment_end = before.len(); + if !before.ends_with("-->") { + return None; + } + let comment_start = before.rfind("")?; + if uuid.contains(char::is_whitespace) || comment_end > node.start { + return None; + } + let (start, end) = line_span(&self.text, comment_start, node.end); + let version = self.child(dep, "version").map(|v| self.value_span(v)); + Some(Pin { + start, + end, + uuid, + gav: gav.to_string(), + version, + }) + } + + /// The top-level managed pin of `patch`'s g:a:v, from any patch uuid. + fn pin_of_gav(&self, gav: &str) -> Option { + let deps = self.child( + self.child(self.project, "dependencyManagement")?, + "dependencies", + )?; + self.children(deps, "dependency") + .filter_map(|d| self.pin_at(d)) + .find(|p| p.gav == gav) + } + + /// The g:a declarations that are not pins, with their record key + /// `version:::
:`. + fn keyed_declarations(&self, g: &str, a: &str) -> Vec<(usize, String)> { + let mut seen: BTreeMap = BTreeMap::new(); + let mut out = Vec::new(); + for dep in self.declarations() { + if self.child_text(dep, "groupId").as_deref() != Some(g) + || self.child_text(dep, "artifactId").as_deref() != Some(a) + || self.pin_at(dep).is_some() + { + continue; + } + let section = self.section_of(dep); + let ordinal = seen.entry(section.clone()).or_default(); + out.push((dep, format!("version:{g}:{a}:{section}:{ordinal}"))); + *ordinal += 1; + } + out + } + + /// `dependencies` / `dependencyManagement`, prefixed with + /// `profile::` inside a profile. + fn section_of(&self, dep: usize) -> String { + let up = |n: usize| self.nodes[n].parent; + let Some(owner) = up(dep).and_then(up) else { + return String::new(); + }; + let (section, model) = if self.nodes[owner].name == "dependencyManagement" { + ("dependencyManagement", up(owner)) + } else { + ("dependencies", Some(owner)) + }; + match model { + Some(m) if self.is_profile(m) => { + let id = self.child_text(m, "id").unwrap_or_default(); + format!("profile:{id}:{section}") + } + _ => section.to_string(), + } + } + + /// `profiles/profile` directly under the project. + fn is_profile(&self, n: usize) -> bool { + self.nodes[n].name == "profile" + && self.nodes[n].parent.is_some_and(|p| { + self.nodes[p].name == "profiles" && self.nodes[p].parent == Some(self.project) + }) + } + + /// The project or one of its profiles: where a model section lives. + fn is_model_root(&self, n: usize) -> bool { + n == self.project || self.is_profile(n) + } + + /// `` elements of the model: `dependencies` and + /// `dependencyManagement` of the project and of each profile. Plugin + /// dependencies and exclusions are not declarations. + fn declarations(&self) -> Vec { + (0..self.nodes.len()) + .filter(|&i| { + let Some(deps) = self.nodes[i].parent else { + return false; + }; + let Some(owner) = self.nodes[deps].parent else { + return false; + }; + self.nodes[i].name == "dependency" + && self.nodes[deps].name == "dependencies" + && (self.is_model_root(owner) + || (self.nodes[owner].name == "dependencyManagement" + && self.nodes[owner] + .parent + .is_some_and(|p| self.is_model_root(p)))) + }) + .collect() + } + + /// `project/dependencyManagement/dependencies/dependency`. + fn is_top_level_managed(&self, dep: usize) -> bool { + let up = |n: usize| self.nodes[n].parent; + up(dep).and_then(up).is_some_and(|dm| { + self.nodes[dm].name == "dependencyManagement" && up(dm) == Some(self.project) + }) + } + + fn line_of(&self, pos: usize) -> usize { + line_at(&self.text, pos) + } + + fn line_start(&self, pos: usize) -> usize { + self.text[..pos].rfind('\n').map_or(0, |n| n + 1) + } + + fn starts_line(&self, pos: usize) -> bool { + self.masked[self.line_start(pos)..pos] + .bytes() + .all(|b| b == b' ' || b == b'\t') + } + + /// The blanks before `n` when it starts its line (a comment before it + /// is not indentation), else one step deeper than its parent. + fn indent_of(&self, n: usize) -> String { + let start = self.nodes[n].start; + if self.starts_line(start) { + return self.text[self.line_start(start)..start] + .chars() + .take_while(|c| *c == ' ' || *c == '\t') + .collect(); + } + match self.nodes[n].parent { + Some(p) => format!("{}{}", self.indent_of(p), self.unit), + None => String::new(), + } + } + + /// The indentation of `n`'s children: that of its first child on a + /// line of its own, else one step deeper than `n`. + fn child_indent(&self, n: usize) -> String { + self.nodes[n] + .children + .iter() + .find(|&&c| self.starts_line(self.nodes[c].start)) + .map(|&c| self.indent_of(c)) + .unwrap_or_else(|| format!("{}{}", self.indent_of(n), self.unit)) + } + + /// Insert `lines` (relative to `indent`) before the element starting at + /// `pos`: as whole lines when `pos` starts its line, else inline. `mark` + /// (a range of `lines`) becomes the pin's byte range in the edit. + fn insert_before( + &self, + pos: usize, + indent: &str, + lines: &[String], + mark: Option>, + ) -> Edit { + let nl = self.nl; + if self.starts_line(pos) { + let pieces: Vec = lines.iter().map(|l| format!("{indent}{l}{nl}")).collect(); + let at = self.line_start(pos); + return Edit { + start: at, + end: at, + role: Role::Pin { + mark: mark.map(|m| byte_range(&pieces, 0, m)), + }, + text: pieces.concat(), + }; + } + let pieces: Vec = lines.iter().map(|l| format!("{nl}{indent}{l}")).collect(); + let mut text = pieces.concat(); + text.push_str(nl); + text.push_str( + &self.text[self.line_start(pos)..pos] + .chars() + .take_while(|c| *c == ' ' || *c == '\t') + .collect::(), + ); + Edit { + start: pos, + end: pos, + text, + role: Role::Pin { + mark: mark.map(|m| byte_range(&pieces, 0, m)), + }, + } + } + + /// Insert `lines` as the first children of `n` (expanding ``); see + /// [`Doc::insert_before`] for `mark`. + fn insert_first_child(&self, n: usize, lines: &[String], mark: Option>) -> Edit { + let nl = self.nl; + let node = &self.nodes[n]; + let indent = self.child_indent(n); + let pieces: Vec = lines.iter().map(|l| format!("{nl}{indent}{l}")).collect(); + let body = pieces.concat(); + if self.is_self_closing(n) { + let open = format!("<{}>", node.name); + let text = format!("{open}{body}{nl}{}", self.indent_of(n), node.name); + return Edit { + start: node.start, + end: node.end, + text, + role: Role::Pin { + mark: mark.map(|m| byte_range(&pieces, open.len(), m)), + }, + }; + } + let after = &self.masked[node.inner_start..]; + let same_line = !after + .trim_start_matches([' ', '\t']) + .starts_with(['\r', '\n']); + let tail = if !same_line { + String::new() + } else if after.trim_start_matches([' ', '\t']).starts_with(") -> Range { + let start = base + pieces[..lines.start].iter().map(String::len).sum::(); + let len: usize = pieces[lines].iter().map(String::len).sum(); + start..start + len +} + +/// A `socket-patch` pin in a pom. +#[derive(Debug)] +struct Pin { + /// The pin comment and its ``, widened to whole lines. + start: usize, + end: usize, + uuid: String, + gav: String, + /// The value span of its ``. + version: Option>, +} + +/// The pin of `patch`'s g:a:v in `doc`, from any patch uuid. +fn pin_of(doc: &Doc, patch: &JvmPatch<'_>) -> Option { + doc.pin_of_gav(&format!( + "{}:{}:{}", + patch.group_id, patch.artifact_id, patch.version + )) +} + +fn line_at(text: &str, pos: usize) -> usize { + text[..pos].matches('\n').count() + 1 +} + +/// Past-the-end `>` of the open tag at `lt`, skipping quoted attributes. +fn tag_end(masked: &str, lt: usize) -> Option { + let mut quote = None; + for (i, b) in masked.bytes().enumerate().skip(lt + 1) { + match (quote, b) { + (None, b'"' | b'\'') => quote = Some(b), + (Some(q), _) if b == q => quote = None, + (None, b'>') => return Some(i), + (None, b'<') => return None, + _ => {} + } + } + None +} + +/// The closing `>` of a `` (with an optional `[…]` subset). +fn declaration_end(masked: &str, lt: usize) -> Option { + let gt = masked[lt..].find('>')? + lt; + match masked[lt..gt].find('[') { + Some(_) => masked[lt..].find("]>").map(|r| lt + r + 1), + None => Some(gt), + } +} + +/// One indentation step: a tab when indented lines start with tabs, else +/// the smallest space indent (default two spaces). +fn indent_unit(masked: &str) -> String { + let (mut tabs, mut spaces, mut min_spaces) = (0, 0, usize::MAX); + for line in masked.split('\n').skip(1) { + let ws: &str = &line[..line.len() - line.trim_start_matches([' ', '\t']).len()]; + if ws.is_empty() || !line[ws.len()..].starts_with('<') { + continue; + } + if ws.starts_with('\t') { + tabs += 1; + } else { + spaces += 1; + let n = ws.bytes().take_while(|b| *b == b' ').count(); + min_spaces = min_spaces.min(n); + } + } + if tabs > spaces { + "\t".to_string() + } else if min_spaces == usize::MAX { + " ".to_string() + } else { + " ".repeat(min_spaces) + } +} + +// ── suffixed pom: a port of depscan's `suffixMavenPom` ───────────────── + +/// An element of the depscan pom scan (`maven-pom-scan.ts`). +#[derive(Debug, Clone)] +struct ScanEl { + name: String, + end_tag_close: usize, + inner_start: usize, + inner_end: usize, + self_closing: bool, +} + +/// `None`: `lt` opens a tag; `Some(None)`: the skipped construct is +/// unterminated; `Some(Some(end))`: resume at `end`. +fn scan_skip_markup(text: &str, lt: usize) -> Option> { + const SKIPPED: [(&str, &str); 3] = [(""), (""), ("")]; + SKIPPED + .iter() + .find(|(open, _)| text[lt..].starts_with(open)) + .map(|(open, close)| { + text[lt + open.len()..] + .find(close) + .map(|r| lt + open.len() + r + close.len()) + }) +} + +fn scan_tag_name(text: &str, from: usize) -> &str { + let rest = &text[from..]; + let end = rest + .find(|c: char| c.is_whitespace() || c == '/' || c == '>') + .unwrap_or(rest.len()); + &rest[..end] +} + +fn find_from(text: &str, needle: char, from: usize) -> Option { + text.get(from..)?.find(needle).map(|r| from + r) +} + +fn byte_at(text: &str, i: usize) -> Option { + text.as_bytes().get(i).copied() +} + +/// `(inner_end, end_tag_close)` of the element whose content starts at +/// `inner_start` (depth counting, names unchecked, as in depscan). +fn scan_subtree_end(text: &str, inner_start: usize) -> Option<(usize, usize)> { + let mut depth = 1usize; + let mut cursor = inner_start; + while cursor < text.len() { + let lt = find_from(text, '<', cursor)?; + match scan_skip_markup(text, lt) { + Some(None) => return None, + Some(Some(end)) => { + cursor = end; + continue; + } + None => {} + } + let gt = find_from(text, '>', lt)?; + if byte_at(text, lt + 1) == Some(b'/') { + depth -= 1; + if depth == 0 { + return Some((lt, gt + 1)); + } + } else if gt == 0 || byte_at(text, gt - 1) != Some(b'/') { + depth += 1; + } + cursor = gt + 1; + } + None +} + +fn scan_children_from(text: &str, content_start: usize) -> Option> { + let mut children = Vec::new(); + let mut cursor = content_start; + while cursor < text.len() { + let lt = find_from(text, '<', cursor)?; + match scan_skip_markup(text, lt) { + Some(None) => return None, + Some(Some(end)) => { + cursor = end; + continue; + } + None => {} + } + if text[lt..].starts_with("', lt)?; + if byte_at(text, tag_end - 1) == Some(b'/') { + children.push(ScanEl { + name: name.to_string(), + end_tag_close: tag_end + 1, + inner_start: tag_end + 1, + inner_end: tag_end + 1, + self_closing: true, + }); + cursor = tag_end + 1; + continue; + } + let (inner_end, end_tag_close) = scan_subtree_end(text, tag_end + 1)?; + children.push(ScanEl { + name: name.to_string(), + end_tag_close, + inner_start: tag_end + 1, + inner_end, + self_closing: false, + }); + cursor = end_tag_close; + } + None +} + +/// The depth-1 children of the single `` element. +fn scan_pom_project(text: &str) -> Option> { + let mut cursor = 0; + while cursor < text.len() { + let lt = find_from(text, '<', cursor)?; + match scan_skip_markup(text, lt) { + Some(None) => return None, + Some(Some(end)) => { + cursor = end; + continue; + } + None => {} + } + if text[lt..].starts_with("', lt + 2)? + 1; + continue; + } + if scan_tag_name(text, lt + 1) != "project" { + return None; + } + let gt = find_from(text, '>', lt)?; + if byte_at(text, gt - 1) == Some(b'/') { + return None; + } + return scan_children_from(text, gt + 1); + } + None +} + +fn scan_find<'e>(children: &'e [ScanEl], name: &str) -> Option<&'e ScanEl> { + children.iter().find(|c| c.name == name) +} + +/// Trimmed inner text of the first direct child `name` of `el`. +fn scan_child_text(text: &str, el: &ScanEl, name: &str) -> Option { + let children = if el.self_closing { + Vec::new() + } else { + scan_children_from(text, el.inner_start)? + }; + scan_find(&children, name).map(|c| text[c.inner_start..c.inner_end].trim().to_string()) +} + +fn literalize_project_version_refs(pom: &str, base: &str) -> String { + let literal = pom + .replace("${project.version}", base) + .replace("${pom.version}", base); + let declares_version_property = scan_pom_project(&literal) + .and_then(|children| { + let properties = scan_find(&children, "properties")?.clone(); + scan_child_text(&literal, &properties, "version") + }) + .is_some(); + if declares_version_property { + literal + } else { + literal.replace("${version}", base) + } +} + +/// The upstream pom rewritten to advertise `suffixed` instead of `base`, or +/// `None` to refuse: byte-for-byte the semantics of depscan's +/// `suffixMavenPom` (`workspaces/app/src/patches/maven-suffix.ts`), so an +/// offline build serves the pom the service would. +fn suffix_maven_pom(pom: &str, base: &str, suffixed: &str) -> Option { + let children = scan_pom_project(pom)?; + if let Some(version) = scan_find(&children, "version") { + let inner = &pom[version.inner_start..version.inner_end]; + if inner.contains("${") || inner.trim() != base { + return None; + } + let spliced = format!( + "{}{suffixed}{}", + &pom[..version.inner_start], + &pom[version.inner_end..] + ); + return Some(literalize_project_version_refs(&spliced, base)); + } + let parent = scan_find(&children, "parent")?; + if scan_child_text(pom, parent, "version").as_deref() != Some(base) { + return None; + } + let at = scan_find(&children, "artifactId")?.end_tag_close; + let spliced = format!( + "{}\n {suffixed}{}", + &pom[..at], + &pom[at..] + ); + Some(literalize_project_version_refs(&spliced, base)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const UUID: &str = "1d3c1fd2-5e6f-4a7b-8c9d-0e1f2a3b4c5d"; + const SV: &str = "1.10.0-socket.1d3c1fd2"; + const TREE: &str = + ".socket/vendor/maven2/org/apache/commons/commons-text/1.10.0-socket.1d3c1fd2"; + const UPSTREAM_POM: &str = "\n\n \n \ + org.apache.commons\n commons-parent\n \ + 54\n \n commons-text\n \ + 1.10.0\n\n"; + + fn patch_with(pom: &'static str) -> JvmPatch<'static> { + JvmPatch { + group_id: "org.apache.commons", + artifact_id: "commons-text", + version: "1.10.0", + uuid: UUID, + jar: b"PK\x03\x04patched", + upstream_pom: pom.as_bytes(), + upstream_module: None, + } + } + + fn patch() -> JvmPatch<'static> { + patch_with(UPSTREAM_POM) + } + + type Fs = BTreeMap>; + + fn fs(files: &[(&str, &str)]) -> Fs { + let mut defaults = BTreeMap::from([( + ".mvn/wrapper/maven-wrapper.properties".to_string(), + b"distributionUrl=https://repo.maven.apache.org/apache-maven-3.9.16-bin.zip\n".to_vec(), + )]); + defaults.extend( + files + .iter() + .map(|(p, c)| (p.to_string(), c.as_bytes().to_vec())), + ); + defaults + } + + fn run(files: &Fs) -> Result { + let read = |p: &str| files.get(p).cloned(); + plan(&read, &patch()) + } + + fn applied(files: &Fs, plan: &JvmPlan) -> Fs { + let mut out = files.clone(); + for w in &plan.writes { + out.insert(w.rel.clone(), w.bytes.clone()); + } + out + } + + /// Plan, check that re-planning the result writes nothing, and return + /// the post-plan files. + fn vendor(files: &Fs) -> (JvmPlan, Fs) { + let plan = run(files).expect("plan"); + let after = applied(files, &plan); + let again = run(&after).expect("re-plan"); + assert!( + again.writes.is_empty(), + "not idempotent: {:?}", + again.writes.iter().map(|w| &w.rel).collect::>() + ); + (plan, after) + } + + fn text(files: &Fs, rel: &str) -> String { + String::from_utf8(files[rel].clone()).unwrap() + } + + fn reasons(plan: &JvmPlan) -> Vec { + plan.warnings + .iter() + .map(|w| { + assert_eq!(w.code, "vendor_jvm_degraded"); + w.detail + .strip_prefix("reason: ") + .unwrap() + .split(':') + .next() + .unwrap() + .to_string() + }) + .collect() + } + + fn refusal_reason(r: &JvmRefusal) -> &str { + r.detail + .strip_prefix("reason: ") + .unwrap() + .split(':') + .next() + .unwrap() + } + + fn pom_rels(plan: &JvmPlan) -> Vec<&str> { + plan.writes + .iter() + .filter(|w| !w.tree && w.rel.ends_with(".xml")) + .map(|w| w.rel.as_str()) + .collect() + } + + fn dep(version: &str) -> String { + format!( + "org.apache.commons\ + commons-text{version}" + ) + } + + const ROOT: &str = r#" + + 4.0.0 + t + root + 1-SNAPSHOT + pom + + a + b + + + + + +"#; + + fn module(name: &str, deps: &str) -> String { + format!( + "\n 4.0.0\n \n \ + t\n root\n \ + 1-SNAPSHOT\n \n {name}\n \ + \n {deps}\n \n\n" + ) + } + + // ── declares_modules ── + + #[test] + fn declares_modules_masks_comments_and_cdata() { + assert!(declares_modules( + "a" + )); + assert!(declares_modules("")); + assert!(declares_modules( + "a" + )); + assert!(declares_modules( + "a" + )); + assert!(!declares_modules( + "" + )); + assert!(!declares_modules( + "]]>" + )); + assert!(!declares_modules("")); + assert!(!declares_modules("\n \ + \n org.apache.commons\n \ + commons-text\n {SV}\n \ + \n \n \n \n" + ), + ) + .replace( + "\n", + &format!( + " \n {BEGIN_MARKER}\n \n \ + socket-patch-vendor\n {REPO_URL}\n \ + truealways\ + fail\n \ + false\n \n \ + {END_MARKER}\n \n\n" + ), + ); + assert_eq!(root, expected_root); + assert_eq!( + text(&after, "a/pom.xml"), + module("a", &dep("1.10.0")).replace("1.10.0", SV) + ); + assert!(!plan.writes.iter().any(|w| w.rel == "b/pom.xml")); + } + + #[test] + fn plan_writes_tree_and_config() { + let files = fs(&[ + ("pom.xml", ROOT), + ("a/pom.xml", &module("a", "")), + ("b/pom.xml", &module("b", "")), + ]); + let (plan, after) = vendor(&files); + let tree: Vec<&str> = plan + .writes + .iter() + .filter(|w| w.tree) + .map(|w| w.rel.as_str()) + .collect(); + assert_eq!( + tree, + [ + format!("{TREE}/commons-text-{SV}.jar"), + format!("{TREE}/commons-text-{SV}.jar.sha1"), + format!("{TREE}/commons-text-{SV}.pom"), + format!("{TREE}/commons-text-{SV}.pom.sha1"), + format!("{TREE}/socket-patch.vendor.json"), + ] + ); + assert_eq!( + text(&after, ".socket/vendor/maven2/.gitattributes"), + "* -text\n" + ); + assert!( + plan.writes + .iter() + .any(|w| w.rel == GITATTRIBUTES_REL && !w.tree), + "the shared .gitattributes is an owned file, not one patch's tree file" + ); + let jar_sha1 = text(&after, &format!("{TREE}/commons-text-{SV}.jar.sha1")); + assert_eq!(jar_sha1, sha1_hex(b"PK\x03\x04patched")); + assert_eq!(jar_sha1.len(), 40); + let pom = text(&after, &format!("{TREE}/commons-text-{SV}.pom")); + assert_eq!( + pom, + UPSTREAM_POM.replace( + "1.10.0", + &format!("{SV}") + ) + ); + assert_eq!( + text(&after, &format!("{TREE}/commons-text-{SV}.pom.sha1")), + sha1_hex(pom.as_bytes()) + ); + + let marker = text(&after, &format!("{TREE}/socket-patch.vendor.json")); + assert!(marker.ends_with("}\n")); + let json: serde_json::Value = serde_json::from_str(&marker).unwrap(); + let keys: Vec<&String> = json.as_object().unwrap().keys().collect(); + assert_eq!(keys, ["files", "purl", "schema", "tool", "uuid", "version"]); + assert_eq!( + json["purl"], + "pkg:maven/org.apache.commons/commons-text@1.10.0" + ); + assert_eq!(json["version"], SV); + assert_eq!(json["schema"], 1); + assert_eq!(json["tool"], "maven"); + assert_eq!(json["uuid"], UUID); + let jar_entry = &json["files"][format!("commons-text-{SV}.jar")]; + assert_eq!(jar_entry["size"], 11); + assert_eq!(jar_entry["sha256"], sha256_hex(b"PK\x03\x04patched")); + assert_eq!(json["files"].as_object().unwrap().len(), 4); + assert!(marker.contains("\n \"files\": {\n \"commons-text-")); + + assert_eq!( + text(&after, ".mvn/maven.config"), + format!("{OFFLINE_LINE}\n{TAIL_KEY}{TAIL_DIR}\n") + ); + } + + #[test] + fn aggregator_with_separate_parent_pins_parent_only() { + let aggregator = "\n t\n agg\n \ + 1\n pom\n \n \ + parent\n a\n \n\n"; + let parent = "\n t\n corp\n \ + 1\n pom\n\n"; + let a = format!( + "\n \n t\n corp\n \ + 1\n ../parent/pom.xml\n \n \ + a\n \n {}\n \n\n", + "org.apache.commonscommons-text" + ); + let files = fs(&[ + ("pom.xml", aggregator), + ("parent/pom.xml", parent), + ("a/pom.xml", &a), + ]); + let (plan, after) = vendor(&files); + assert_eq!(pom_rels(&plan), ["parent/pom.xml"]); + let p = text(&after, "parent/pom.xml"); + assert!(p.contains(&format!("{SV}"))); + assert!(p.contains(BEGIN_MARKER)); + assert!(p.starts_with("\n t")); + } + + #[test] + fn remote_parent_modules_are_their_own_local_roots() { + let root = "\n t\n agg\n 1\n \ + pom\n \n a\n b\n \ + \n\n"; + let boot = |name: &str, deps: &str| { + format!( + "\n \n org.springframework.boot\n \ + spring-boot-starter-parent\n 3.2.0\n \ + \n \n t\n {name}\n \ + \n 1.10.0\n \n \ + \n {deps}\n \n\n" + ) + }; + let files = fs(&[ + ("pom.xml", root), + ("a/pom.xml", &boot("a", &dep("${ct.version}"))), + ("b/pom.xml", &boot("b", "")), + ]); + let (plan, after) = vendor(&files); + assert!(plan.warnings.is_empty(), "{:?}", plan.warnings); + assert_eq!(pom_rels(&plan), ["a/pom.xml", "b/pom.xml"]); + for m in ["a", "b"] { + let t = text(&after, &format!("{m}/pom.xml")); + assert!(t.contains(""), "{m}"); + assert!(t.contains(BEGIN_MARKER), "{m}"); + } + let a = text(&after, "a/pom.xml"); + assert!( + a.contains("1.10.0"), + "property left alone" + ); + assert_eq!(a.matches(&format!("{SV}")).count(), 2); + } + + #[test] + fn middle_local_parent_outside_modules_is_rewritten() { + let root = "\n t\n root\n 1\n \ + pom\n \n x\n \n\n"; + let mid = format!( + "\n \n t\n root\n \ + 1\n \n mid\n pom\n \ + \n \n {}\n \n \ + \n\n", + dep("1.10.0") + ); + let x = "\n \n t\n mid\n \ + 1\n ../mid\n \n \ + x\n\n"; + let files = fs(&[("pom.xml", root), ("mid/pom.xml", &mid), ("x/pom.xml", x)]); + let (plan, after) = vendor(&files); + assert_eq!(pom_rels(&plan), ["mid/pom.xml", "pom.xml"]); + assert_eq!(text(&after, "mid/pom.xml"), mid.replace("1.10.0", SV)); + assert!(text(&after, "pom.xml").contains("")); + } + + #[test] + fn profile_literal_and_management_are_rewritten() { + let a = format!( + "\n troot1-SNAPSHOT\n \ + a\n \n \n p\n \ + {}\n {}\n \ + \n \n\n", + dep("1.10.0"), + dep("1.10.0") + ); + let files = fs(&[ + ("pom.xml", ROOT), + ("a/pom.xml", &a), + ("b/pom.xml", &module("b", "")), + ]); + let (_, after) = vendor(&files); + assert_eq!(text(&after, "a/pom.xml"), a.replace("1.10.0", SV)); + } + + #[test] + fn property_resolved_through_parent_chain() { + let root = ROOT.replace( + " ", + " \n 1.10\n ${ct.major}.0\n \n ", + ); + let a = module("a", &dep("${ct.version}")); + let files = fs(&[ + ("pom.xml", &root), + ("a/pom.xml", &a), + ("b/pom.xml", &module("b", "")), + ]); + let (plan, after) = vendor(&files); + assert!(plan.warnings.is_empty(), "{:?}", plan.warnings); + assert_eq!(text(&after, "a/pom.xml"), a.replace("${ct.version}", SV)); + assert!(text(&after, "pom.xml").contains("${ct.major}.0")); + } + + #[test] + fn maven_config_user_property_wins() { + let root = ROOT.replace( + " ", + " \n 1.9\n \n ", + ); + let a = module("a", &dep("${ct.version}")); + let files = fs(&[ + ("pom.xml", &root), + ("a/pom.xml", &a), + ("b/pom.xml", &module("b", "")), + (".mvn/maven.config", "-Dct.version=1.10.0\n"), + ]); + let (plan, after) = vendor(&files); + assert!(plan.warnings.is_empty(), "{:?}", plan.warnings); + assert_eq!(text(&after, "a/pom.xml"), a.replace("${ct.version}", SV)); + } + + #[test] + fn project_version_expression_resolves() { + let root = ROOT.replace("1-SNAPSHOT", "1.10.0"); + let a = module("a", &dep("${project.version}")).replace("1-SNAPSHOT", "1.10.0"); + let files = fs(&[ + ("pom.xml", &root), + ("a/pom.xml", &a), + ( + "b/pom.xml", + &module("b", "").replace("1-SNAPSHOT", "1.10.0"), + ), + ]); + let (_, after) = vendor(&files); + assert_eq!( + text(&after, "a/pom.xml"), + a.replace("${project.version}", SV) + ); + } + + #[test] + fn unresolvable_property_and_range_warn() { + let a = module( + "a", + &format!("{}\n {}", dep("${remote.prop}"), dep("[1.9,2.0)")), + ); + let b = module("b", &dep("LATEST")); + let files = fs(&[("pom.xml", ROOT), ("a/pom.xml", &a), ("b/pom.xml", &b)]); + let (plan, after) = vendor(&files); + assert_eq!(reasons(&plan), ["property_unresolved", "range", "range"]); + assert!( + plan.warnings[0].detail.contains("a/pom.xml:10:"), + "{}", + plan.warnings[0].detail + ); + assert_eq!(text(&after, "a/pom.xml"), a); + assert!( + text(&after, "pom.xml").contains(""), + "still pinned" + ); + } + + #[test] + fn conflicting_literal_unpins_local_root_but_keeps_other_rewrites() { + let a = module("a", &dep("1.9")); + let b = module("b", &dep("1.10.0")); + let files = fs(&[("pom.xml", ROOT), ("a/pom.xml", &a), ("b/pom.xml", &b)]); + let (plan, after) = vendor(&files); + assert_eq!(reasons(&plan), ["conflicting_literal_version"]); + let root = text(&after, "pom.xml"); + assert!(!root.contains("")); + assert!( + root.contains(BEGIN_MARKER), + "the repository still serves SV" + ); + assert_eq!(text(&after, "a/pom.xml"), a); + assert_eq!(text(&after, "b/pom.xml"), b.replace("1.10.0", SV)); + } + + #[test] + fn plugin_dependencies_exclusions_and_other_types_are_untouched() { + let a = format!( + "\n troot1-SNAPSHOT\n \ + a\n \n \ + xy1\ + org.apache.commonscommons-text\n \ + org.apache.commonscommons-text1.10.0test-jar\n \ + \n p{}\n\n", + dep("1.10.0") + ); + let files = fs(&[ + ("pom.xml", ROOT), + ("a/pom.xml", &a), + ("b/pom.xml", &module("b", "")), + ]); + let (plan, after) = vendor(&files); + assert!(plan.warnings.is_empty()); + assert_eq!(text(&after, "a/pom.xml"), a); + } + + #[test] + fn classifier_declaration_warns() { + let a = module( + "a", + "org.apache.commonscommons-text\ + 1.10.0sources", + ); + let files = fs(&[ + ("pom.xml", ROOT), + ("a/pom.xml", &a), + ("b/pom.xml", &module("b", "")), + ]); + let (plan, after) = vendor(&files); + assert_eq!(reasons(&plan), ["classifier_declared"]); + assert_eq!(text(&after, "a/pom.xml"), a); + } + + #[test] + fn existing_repositories_and_management_are_extended() { + let root = ROOT.replace( + " ", + " \n \n xy1\n \n \n \ + \n \n corp\n https://corp\n \n \n p\n ", + ); + let files = fs(&[ + ("pom.xml", &root), + ("a/pom.xml", &module("a", "")), + ("b/pom.xml", &module("b", "")), + ]); + let (_, after) = vendor(&files); + let out = text(&after, "pom.xml"); + assert!(out.contains(&format!( + " \n \n \n org.apache.commons" + ))); + assert!(out.contains(&format!( + " https://corp\n \n {BEGIN_MARKER}\n \n socket-patch-vendor" + ))); + assert!(out.contains(&format!( + " {END_MARKER}\n \n " + ))); + assert_eq!(out.matches("").count(), 1); + } + + #[test] + fn existing_base_management_is_rewritten_not_duplicated() { + let root = ROOT.replace( + " ", + &format!(" {}\n ", dep("1.10.0")), + ); + let files = fs(&[ + ("pom.xml", &root), + ("a/pom.xml", &module("a", "")), + ("b/pom.xml", &module("b", "")), + ]); + let (_, after) = vendor(&files); + let out = text(&after, "pom.xml"); + assert!(!out.contains("socket-patch 1d3c"), "no second entry"); + assert!(out.contains(&dep(SV))); + } + + #[test] + fn crlf_and_tabs_are_preserved() { + let root = "\r\n\tt\r\n\troot\r\n\t1\r\n\t\ + pom\r\n\t\r\n\t\r\n\t\ta\r\n\t\r\n\t\ + \r\n\t\r\n\r\n"; + let a = "\r\n\t\r\n\t\tt\r\n\t\troot\r\n\t\t1\r\n\t\r\n\t\ + a\r\n\t\r\n\t\t\r\n\t\t\torg.apache.commons\r\n\t\t\t\ + commons-text\r\n\t\t\t1.10.0\r\n\t\t\r\n\t\r\n\r\n"; + let files = fs(&[("pom.xml", root), ("a/pom.xml", a)]); + let (_, after) = vendor(&files); + let out = text(&after, "pom.xml"); + assert!( + !out.replace("\r\n", "").contains('\n'), + "only CRLF: {out:?}" + ); + assert!(out.starts_with("\r\n\tt\r\n\troot\r\n\t1\r\n\tpom\r\n\t")); + assert!(out + .contains("\t\r\n\t\t\r\n\t\t\t\r\n\t\t\r\n\t\t\t")); + assert!(out.ends_with("\t\r\n\r\n")); + assert_eq!(text(&after, "a/pom.xml"), a.replace("1.10.0", SV)); + } + + #[test] + fn single_line_poms_and_self_closing_sections() { + let root = "tr1pom\ + a"; + let a = "tr1a"; + let files = fs(&[("pom.xml", root), ("a/pom.xml", a)]); + let (_, after) = vendor(&files); + let out = text(&after, "pom.xml"); + let doc = Doc::parse(out.clone()).expect("well-formed"); + let dm = doc.child(doc.project, "dependencyManagement").unwrap(); + let deps = doc.child(dm, "dependencies").unwrap(); + assert_eq!(doc.children(deps, "dependency").count(), 1); + let repos = doc.child(doc.project, "repositories").unwrap(); + assert_eq!( + doc.child_text(doc.child(repos, "repository").unwrap(), "id") + .as_deref(), + Some(REPO_ID) + ); + assert_eq!(out.matches("").count(), 1); + } + + #[test] + fn enforcer_repository_ban_omits_fallback() { + let root = ROOT.replace( + "", + "maven-enforcer-plugin", + ); + let files = fs(&[ + ("pom.xml", &root), + ("a/pom.xml", &module("a", "")), + ("b/pom.xml", &module("b", "")), + ]); + let (plan, after) = vendor(&files); + assert_eq!(reasons(&plan), ["maven_fallback_omitted"]); + let out = text(&after, "pom.xml"); + assert!(!out.contains(BEGIN_MARKER)); + assert!(out.contains("")); + assert!(after.contains_key(".mvn/maven.config")); + } + + #[test] + fn commented_enforcer_rule_does_not_ban() { + let root = ROOT.replace("", ""); + let files = fs(&[ + ("pom.xml", &root), + ("a/pom.xml", &module("a", "")), + ("b/pom.xml", &module("b", "")), + ]); + let (plan, _) = vendor(&files); + assert!(plan.warnings.is_empty()); + } + + #[test] + fn deployed_reactor_warns() { + let root = ROOT.replace(" ", " rhttps://r\n "); + let files = fs(&[ + ("pom.xml", &root), + ("a/pom.xml", &module("a", &dep("1.10.0"))), + ("b/pom.xml", &module("b", "")), + ]); + let (plan, after) = vendor(&files); + assert_eq!(reasons(&plan), ["publishes_suffixed_poms"]); + let out = text(&after, "pom.xml"); + let doc = Doc::parse(out).unwrap(); + let dist = doc.child(doc.project, "distributionManagement").unwrap(); + assert!(doc.child(dist, "repository").is_some()); + assert!( + doc.child(doc.project, "repositories").is_some(), + "top-level repositories, not in distributionManagement" + ); + } + + #[test] + fn nested_modules_custom_files_and_normalized_paths() { + let root = ROOT.replace( + "b", + "./b/\n c/custom.xml", + ); + let b = "\n troot1-SNAPSHOT\n \ + b\n pom\n ../b/inner\n\n"; + let inner = "\n tb1-SNAPSHOT\n \ + inner\n DEPS\n\n" + .replace("DEPS", &dep("1.10.0")); + let c = module("c", &dep("1.10.0")).replace( + "", + "../pom.xml", + ); + let files = fs(&[ + ("pom.xml", &root), + ("a/pom.xml", &module("a", "")), + ("b/pom.xml", b), + ("b/inner/pom.xml", &inner), + ("c/custom.xml", &c), + ]); + let (plan, after) = vendor(&files); + assert_eq!( + pom_rels(&plan), + ["b/inner/pom.xml", "c/custom.xml", "pom.xml"] + ); + assert_eq!(text(&after, "b/inner/pom.xml"), inner.replace("1.10.0", SV)); + } + + #[test] + fn mismatched_or_missing_parent_is_remote() { + let root = ROOT.to_string(); + // `a` names a parent that is not the root pom: it is its own local root. + let a = module("a", &dep("1.10.0")).replace( + "root", + "other", + ); + let b = module("b", "").replace( + "1-SNAPSHOT\n ", + "2\n ", + ); + let files = fs(&[("pom.xml", &root), ("a/pom.xml", &a), ("b/pom.xml", &b)]); + let (plan, after) = vendor(&files); + assert_eq!(pom_rels(&plan), ["a/pom.xml", "b/pom.xml"]); + assert!(text(&after, "a/pom.xml").contains("")); + assert!(text(&after, "b/pom.xml").contains(BEGIN_MARKER)); + assert!( + !after["pom.xml"].windows(3).any(|w| w == b"soc"), + "aggregator untouched" + ); + } + + #[test] + fn inherited_group_id_counts_for_parent_match() { + let root = ROOT.replace("b", ""); + let a = "\n troot1-SNAPSHOT\n \ + a\n pom\n x\n\n"; + let x = format!( + "\n ta1-SNAPSHOT\n \ + x\n {}\n\n", + dep("1.10.0") + ); + let files = fs(&[("pom.xml", &root), ("a/pom.xml", a), ("a/x/pom.xml", &x)]); + let (plan, _) = vendor(&files); + assert_eq!(pom_rels(&plan), ["a/x/pom.xml", "pom.xml"]); + } + + // ── refusals ── + + fn refused(files: &[(&str, &str)]) -> JvmRefusal { + run(&fs(files)).expect_err("refused") + } + + #[test] + fn refusals() { + let with = |m: &str| ROOT.replace("b", &format!("{m}")); + let a = module("a", ""); + let r = refused(&[("pom.xml", &with("../elsewhere")), ("a/pom.xml", &a)]); + assert_eq!( + (r.code, refusal_reason(&r)), + (SHAPE_UNSUPPORTED, "module_outside_root") + ); + let r = refused(&[("pom.xml", &with("/abs")), ("a/pom.xml", &a)]); + assert_eq!(refusal_reason(&r), "module_outside_root"); + let r = refused(&[("pom.xml", &with("${m}")), ("a/pom.xml", &a)]); + assert_eq!(refusal_reason(&r), "module_path_unresolvable"); + let r = refused(&[("pom.xml", &with("missing")), ("a/pom.xml", &a)]); + assert_eq!(refusal_reason(&r), "module_path_unresolvable"); + for f in NESTED_MVN_FILES { + let r = refused(&[ + ("pom.xml", &with("a")), + ("a/pom.xml", &a), + (&format!("a/.mvn/{f}"), ""), + ]); + assert_eq!(refusal_reason(&r), "nested_mvn_dir", "{f}"); + } + let mut files = fs(&[("pom.xml", ROOT), ("b/pom.xml", &module("b", ""))]); + files.insert("a/pom.xml".into(), vec![0xff, 0xfe]); + let r = run(&files).unwrap_err(); + assert_eq!(refusal_reason(&r), "build_file_unreadable"); + let r = refused(&[ + ("pom.xml", ROOT), + ("a/pom.xml", ""), + ("b/pom.xml", &a), + ]); + assert_eq!(refusal_reason(&r), "build_file_unreadable"); + let r = refused(&[]); + assert_eq!(refusal_reason(&r), "no_build_file"); + } + + #[test] + fn root_mvn_dir_is_not_nested() { + let files = fs(&[ + ("pom.xml", ROOT), + ("a/pom.xml", &module("a", "")), + ("b/pom.xml", &module("b", "")), + (".mvn/extensions.xml", ""), + ]); + vendor(&files); + } + + #[test] + fn suffix_unavailable_refuses() { + let files = fs(&[ + ("pom.xml", ROOT), + ("a/pom.xml", &module("a", "")), + ("b/pom.xml", &module("b", "")), + ]); + let read = |p: &str| files.get(p).cloned(); + let r = plan( + &read, + &patch_with("${revision}"), + ) + .unwrap_err(); + assert_eq!(r.code, UPSTREAM_UNAVAILABLE); + assert_eq!(refusal_reason(&r), "suffix_unavailable"); + } + + // ── maven.config ── + + fn config(before: &str) -> String { + String::from_utf8(merge_maven_config(Some(before.as_bytes())).0).unwrap() + } + + #[test] + fn maven_config_merges() { + let ours = format!("{OFFLINE_LINE}\n{TAIL_KEY}{TAIL_DIR}\n"); + assert_eq!(String::from_utf8(merge_maven_config(None).0).unwrap(), ours); + assert_eq!(config(""), ours); + assert_eq!(config(&ours), ours); + assert_eq!(config("-T4\n-ntp"), format!("-T4\n-ntp\n{ours}")); + assert_eq!( + config("-T4\r\n"), + format!("-T4\r\n{OFFLINE_LINE}\r\n{TAIL_KEY}{TAIL_DIR}\r\n") + ); + assert_eq!( + config(&format!("{TAIL_KEY}/opt/repo\n-T4\n{OFFLINE_LINE}\n")), + format!("{TAIL_KEY}/opt/repo,{TAIL_DIR}\n-T4\n{OFFLINE_LINE}\n") + ); + assert_eq!( + config(&format!("{TAIL_KEY}/opt/repo,{TAIL_DIR}\n{OFFLINE_LINE}\n")), + format!("{TAIL_KEY}/opt/repo,{TAIL_DIR}\n{OFFLINE_LINE}\n") + ); + assert_eq!( + config("-Daether.offline.protocols=http\n"), + format!("-Daether.offline.protocols=http,file\n{TAIL_KEY}{TAIL_DIR}\n") + ); + assert_eq!(config(OFFLINE_LINE), ours); + } + + // ── suffixMavenPom port (goldens shared with depscan's maven-suffix.test.ts) ── + + const DS_SV: &str = "2.1.0-socket.3fa85f64"; + + #[test] + fn suffix_replaces_literal_version_only() { + let pom = "\n\n \ + 4.0.0\n com.acme\n widget\n \ + 2.1.0\n jar\n \n \n \ + com.google.guava\n guava\n \ + 32.1.3-jre\n \n \n\n"; + let out = suffix_maven_pom(pom, "2.1.0", DS_SV).unwrap(); + assert_eq!(out.replace(DS_SV, "2.1.0"), pom); + assert!(out.contains("32.1.3-jre")); + } + + #[test] + fn suffix_trims_version_whitespace() { + let pom = "\n g\n a\n \n 1.4.2\n \n\n"; + let out = suffix_maven_pom(pom, "1.4.2", "1.4.2-socket.3fa85f64").unwrap(); + assert!(out.contains("1.4.2-socket.3fa85f64")); + } + + #[test] + fn suffix_inserts_inherited_version_after_artifact_id() { + let pom = "\n\n \ + 4.0.0\n \n org.slf4j\n \ + slf4j-parent\n 2.0.9\n \n \ + slf4j-api\n jar\n\n"; + let sv = "2.0.9-socket.3fa85f64"; + let out = suffix_maven_pom(pom, "2.0.9", sv).unwrap(); + assert!(out.contains(&format!( + "slf4j-api\n {sv}" + ))); + assert_eq!( + out.replace(&format!("\n {sv}"), ""), + pom + ); + // The inserted line is LF even in a CRLF pom, exactly as the server does. + let crlf = pom.replace('\n', "\r\n"); + let out = suffix_maven_pom(&crlf, "2.0.9", sv).unwrap(); + assert!(out.contains(&format!("\n {sv}\r\n"))); + } + + #[test] + fn suffix_refusals() { + let sv = "1.0.0-socket.3fa85f64"; + for pom in [ + "\n g\n a\n ${revision}\n\n", + "\n g\n a\n 9.9.9\n\n", + "\n g\n a\n \n \n \ + x\n y\n 1.0.0\n \n \ + \n\n", + "\n \n g\n p\n 1.0.0\n \ + \n jar\n\n", + "\n \n g\n p\n 2.0.0\n \ + \n a\n\n", + "", + "", + "1.0.0\n 2.1.0\n\n"; + let out = suffix_maven_pom(pom, "2.1.0", DS_SV).unwrap(); + assert!(out.contains(&format!("{DS_SV}"))); + assert!(out.contains("")); + } + + #[test] + fn suffix_literalizes_project_version_refs() { + let sv = "1.0.0-socket.3fa85f64"; + let pom = "\n com.example\n widget\n 1.0.0\n \ + \n ${pom.version}\n \n \n \n \ + com.example\n widget-api\n ${project.version}\n \ + \n \n\n"; + assert_eq!( + suffix_maven_pom(pom, "1.0.0", sv).unwrap(), + pom.replace( + "1.0.0", + &format!("{sv}") + ) + .replace("${pom.version}", "1.0.0") + .replace("${project.version}", "1.0.0") + ); + let legacy = "\n c\n w\n 1.0.0\n \ + \n \n ${version}\n \n \n\n"; + assert_eq!( + suffix_maven_pom(legacy, "1.0.0", sv).unwrap(), + legacy + .replacen( + "1.0.0", + &format!("{sv}"), + 1 + ) + .replace("${version}", "1.0.0") + ); + let declared = legacy.replace( + " ", + " \n 2.0.0\n \n ", + ); + assert_eq!( + suffix_maven_pom(&declared, "1.0.0", sv).unwrap(), + declared.replacen( + "1.0.0", + &format!("{sv}"), + 1 + ) + ); + } + + #[test] + fn suffix_multi_module_pom_keeps_modules() { + let pom = "\n com.acme\n parent\n 5.0.0\n \ + pom\n \n core\n \n\n"; + let out = suffix_maven_pom(pom, "5.0.0", "5.0.0-socket.3fa85f64").unwrap(); + assert!(out.contains("5.0.0-socket.3fa85f64")); + assert!(out.contains("core")); + } + + // ── scanner ── + + #[test] + fn doc_parse_handles_prolog_doctype_attributes_and_cdata() { + let doc = Doc::parse( + "\u{feff}\n]>\ny\">\ + c]]> 1 " + .to_string(), + ) + .unwrap(); + assert_eq!( + doc.child_text(doc.project, "name").as_deref(), + Some("a c") + ); + let v = doc.child(doc.project, "v").unwrap(); + assert_eq!(doc.text_of(v), "1"); + assert_eq!(&doc.text[doc.value_span(v)], "1"); + for bad in [ + "", + "", + "", + "\n ", + "-->\n ", + 1, + ); + assert_ne!(broken, pom); + std::fs::write(root.join("pom.xml"), &broken).unwrap(); + let out = testing::revert(root, &patch(), &mut ledger).await; + assert!( + out.success && out.drift_skipped() && out.kept_artifact, + "{out:?}" + ); + assert!(root.join(&plan.jar_rel).is_file()); + assert!( + root.join(MAVEN_CONFIG).is_file(), + "still referenced: shared lines stay" + ); + } + + #[test] + fn cdata_version_is_replaced_whole_and_restored() { + let cdata = "org.apache.commons\ + commons-text"; + let files = fs(&[ + ("pom.xml", ROOT), + ("a/pom.xml", &module("a", cdata)), + ("b/pom.xml", &module("b", "")), + ]); + let (plan, after) = vendor(&files); + assert_eq!( + text(&after, "a/pom.xml"), + module("a", &cdata.replace("", SV)) + ); + let read = |rel: &str| after.get(rel).cloned(); + let undo = unplan(&read, &patch().coords(), &plan.records); + let a = undo.changes.iter().find(|(r, _)| r == "a/pom.xml").unwrap(); + assert_eq!(a.1.as_deref(), Some(module("a", cdata).as_bytes())); + } + + #[test] + fn a_comment_before_the_anchor_is_not_indentation() { + let root = ROOT.replace(" ", " "); + let files = fs(&[ + ("pom.xml", &root), + ("a/pom.xml", &module("a", "")), + ("b/pom.xml", &module("b", "")), + ]); + let (_, after) = vendor(&files); + let out = text(&after, "pom.xml"); + assert_eq!(out.matches("").count(), 1, "{out}"); + assert!( + out.contains("\n \n \n"), + "{out}" + ); + assert!( + out.contains("\n "), + "{out}" + ); + } + + /// Maven interpolates after inheritance: a module overriding the + /// property keeps its own version, so the inherited `${p}` stays and + /// the root is not pinned. + #[test] + fn inherited_property_overridden_by_a_module_is_left_alone() { + let root = ROOT.replace( + " ", + " 1.10.0\n \ + \n \n \ + org.apache.commonscommons-text\ + ${ct.version}\n \n \ + \n ", + ); + let bare = "org.apache.commons\ + commons-text"; + let a = module("a", bare).replace( + " ", + " 1.12.0\n ", + ); + let files = fs(&[ + ("pom.xml", &root), + ("a/pom.xml", &a), + ("b/pom.xml", &module("b", bare)), + ]); + let plan = run(&files).unwrap(); + assert_eq!(reasons(&plan), ["conflicting_literal_version"]); + assert!( + plan.warnings[0].detail.contains("1.12.0 in a/pom.xml"), + "{:?}", + plan.warnings + ); + let after = applied(&files, &plan); + assert!( + !text(&after, "pom.xml").contains(SV), + "{}", + text(&after, "pom.xml") + ); + assert!(text(&after, "pom.xml").contains("${ct.version}")); + // Without the override the inherited declaration is rewritten. + let files = fs(&[ + ("pom.xml", &root), + ("a/pom.xml", &module("a", bare)), + ("b/pom.xml", &module("b", bare)), + ]); + let (plan, after) = vendor(&files); + assert!(plan.warnings.is_empty(), "{:?}", plan.warnings); + assert!(text(&after, "pom.xml").contains(&format!("{SV}"))); + } + + #[test] + fn plugin_configuration_modules_do_not_make_a_reactor() { + let ear = "4.0.0t\ + app1ear\ + maven-ear-plugin\ + xy\ + "; + assert!(!declares_modules(ear)); + let read = |p: &str| (p == "pom.xml").then(|| ear.as_bytes().to_vec()); + assert_eq!(super::super::detect(&read), Shape::Other); + } + + #[test] + fn maven_config_extends_the_last_tail_and_protocol_lines() { + let out = config("-Dmaven.repo.local.tail=/a\n-Daether.offline.protocols=http\n-Dmaven.repo.local.tail=/b\n-Daether.offline.protocols=https\n"); + assert_eq!( + out, + format!( + "-Dmaven.repo.local.tail=/a\n-Daether.offline.protocols=http\n\ + -Dmaven.repo.local.tail=/b,{TAIL_DIR}\n-Daether.offline.protocols=https,file\n" + ) + ); + let (_, op) = merge_maven_config(Some(out.as_bytes())); + assert_eq!(op_of_value(&op), "adopt"); + } + + #[test] + fn maven_config_undo_restores_every_shape() { + for before in [ + None, + Some(""), + Some("-T4"), + Some("-T4\r\n"), + Some(" -Dmaven.repo.local.tail=/a \n-ntp\n"), + Some("-Daether.offline.protocols=http\n-Dmaven.repo.local.tail=\n"), + ] { + let (after, op) = merge_maven_config(before.map(str::as_bytes)); + let w = fragment( + MAVEN_CONFIG, + CONFIG_LINE_KIND, + "config", + WiringAction::Added, + None, + op, + ); + let undone = undo_config(&String::from_utf8(after).unwrap(), &w); + assert_eq!(undone.as_deref(), before, "{before:?}"); + } + } + + #[test] + fn unsafe_coordinates_are_refused_before_any_xml_is_written() { + for (g, a, v) in [ + ("com.ex&le", "a", "1"), + ("g", "a{SV}{BEGIN_MARKER}"))); + assert!(refs("2.0-socket.abcdef01")); + assert!(!refs("")); + assert!(refs(&format!("{PIN_TAG}{UUID_B}: g:a:1 -->"))); + assert!(!refs(&format!("{PIN_TAG}{UUID}: g:a:1 -->"))); + assert!(!refs("1-socket.xyz")); + } + + /// A comment naming the suffixed version is not a reference: revert + /// completes and deletes the tree. + #[tokio::test] + async fn a_comment_naming_the_suffixed_version_does_not_keep_the_tree() { + let dir = disk(&[ + ("pom.xml", ROOT), + ( + "a/pom.xml", + &module( + "a", + &format!("{}", dep("1.10.0")), + ), + ), + ("b/pom.xml", &module("b", "")), + ]); + let root = dir.path(); + let pristine = testing::snapshot(root); + let mut ledger = BTreeMap::new(); + testing::vendor(root, Shape::MavenReactor, &patch(), &mut ledger) + .await + .unwrap(); + let out = testing::revert(root, &patch(), &mut ledger).await; + assert!( + out.success && out.warnings.is_empty() && !out.kept_artifact, + "{out:?}" + ); + assert_eq!(testing::snapshot(root), pristine); + } +} diff --git a/crates/socket-patch-core/src/vendor/jvm/mod.rs b/crates/socket-patch-core/src/vendor/jvm/mod.rs new file mode 100644 index 000000000..7b834e89f --- /dev/null +++ b/crates/socket-patch-core/src/vendor/jvm/mod.rs @@ -0,0 +1,609 @@ +//! The v5 vendored JVM backend (`docs/design/maven-vendoring.md`). +//! +//! Handles multi-module Maven reactors and Gradle builds automatically. +//! Single-POM builds retain the legacy backend. +//! +//! The planners are pure: they read project files through a [`ReadFn`] and +//! return the full post-vendor bytes of every file they touch plus one +//! fragment record per edit (never a whole file). Revert is planned the +//! same way ([`maven_reactor::unplan`], [`gradle::unplan`]): per-patch +//! fragments are cut by their exact text or their `socket-patch` tag, and a +//! shared fragment (repository block, `maven.config` lines, apply lines, +//! owned files) goes only once no other patch's reference is left in the +//! project, so the result does not depend on revert order. [`apply`] +//! does the disk side. + +pub mod apply; +pub(crate) mod archive; +pub mod gradle; +pub mod maven_reactor; + +use serde_json::{json, Value}; + +use super::state::{WiringAction, WiringRecord}; + +/// Fragment of a reactor pom: `pin`, `version:…` (per patch), +/// `pin_section`, `repository` (shared). +pub const POM_FRAGMENT_KIND: &str = "maven_pom_fragment"; +/// The shared `.mvn/maven.config` lines. +pub const CONFIG_LINE_KIND: &str = "maven_config_line"; +/// Fragment of a settings file: `apply`, `in_block_section` (shared), +/// `in_block:` (per patch). +pub const SETTINGS_FRAGMENT_KIND: &str = "gradle_settings_fragment"; +/// The patched hash in an existing `gradle/verification-metadata.xml`. +pub const VERIFICATION_FRAGMENT_KIND: &str = "gradle_verification_fragment"; +/// A file the backend owns outright (script, index, tree `.gitattributes`). +pub const OWNED_FILE_KIND: &str = "jvm_owned_file"; +/// One vendored artifact file; `new` = its sha256. +pub const TREE_KIND: &str = "jvm_vendor_tree"; +/// A directory vendor created; removed on revert once empty. +pub const CREATED_DIR_KIND: &str = "jvm_created_dir"; +/// Whether upstream metadata was verified against registry checksums. +pub const UPSTREAM_KIND: &str = "jvm_upstream_status"; +/// Every kind this backend records. +pub const KINDS: &[&str] = &[ + POM_FRAGMENT_KIND, + CONFIG_LINE_KIND, + SETTINGS_FRAGMENT_KIND, + VERIFICATION_FRAGMENT_KIND, + OWNED_FILE_KIND, + TREE_KIND, + CREATED_DIR_KIND, + UPSTREAM_KIND, +]; + +/// Parse the distribution version from a checked-in wrapper only; never runs the build tool. +pub fn wrapper_version(read: ReadFn<'_>, tool: &str) -> Option<(u32, u32, u32)> { + let path = match tool { + "maven" => ".mvn/wrapper/maven-wrapper.properties", + "gradle" => "gradle/wrapper/gradle-wrapper.properties", + _ => return None, + }; + let bytes = read(path)?; + let text = std::str::from_utf8(&bytes).ok()?; + let url = text + .lines() + .find_map(|line| line.trim().strip_prefix("distributionUrl="))?; + let re = regex::Regex::new(&format!(r"{tool}-(\d+)\.(\d+)(?:\.(\d+))?")).ok()?; + let caps = re.captures(url)?; + Some(( + caps[1].parse().ok()?, + caps[2].parse().ok()?, + caps.get(3).map_or(Some(0), |m| m.as_str().parse().ok())?, + )) +} + +/// Reads a project-relative, forward-slash path. `None` = missing or +/// unreadable. +pub type ReadFn<'a> = &'a dyn Fn(&str) -> Option>; + +/// The identity of one patch: enough to find (and revert) its wiring. +#[derive(Debug, Clone, Copy)] +pub struct Coords<'a> { + pub group_id: &'a str, + pub artifact_id: &'a str, + /// The upstream (base) version. + pub version: &'a str, + pub uuid: &'a str, +} + +impl Coords<'_> { + /// `org.apache.commons` → `org/apache/commons`. + pub fn group_path(&self) -> String { + self.group_id.replace('.', "/") + } + + /// First 8 lowercase hex of the uuid. + pub fn hex8(&self) -> String { + self.uuid + .chars() + .filter(|c| *c != '-') + .take(8) + .collect::() + .to_ascii_lowercase() + } + + /// The Maven suffixed version: `-socket.`, + /// the same rule as the patch server's `mavenSuffixedVersion`. + pub fn suffixed_version(&self) -> String { + format!("{}-socket.{}", self.version, self.hex8()) + } +} + +/// One patched artifact, fully materialised. +#[derive(Debug, Clone)] +pub struct JvmPatch<'a> { + pub group_id: &'a str, + pub artifact_id: &'a str, + /// The upstream (base) version. + pub version: &'a str, + pub uuid: &'a str, + /// The patched jar bytes. + pub jar: &'a [u8], + /// The upstream pom, verbatim. + pub upstream_pom: &'a [u8], + /// The upstream Gradle module metadata, verbatim, when published. + pub upstream_module: Option<&'a [u8]>, +} + +impl<'a> JvmPatch<'a> { + pub fn coords(&self) -> Coords<'a> { + Coords { + group_id: self.group_id, + artifact_id: self.artifact_id, + version: self.version, + uuid: self.uuid, + } + } + + pub fn group_path(&self) -> String { + self.coords().group_path() + } + + pub fn suffixed_version(&self) -> String { + self.coords().suffixed_version() + } +} + +/// Which JVM build the project root holds. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Shape { + /// A root `pom.xml` that declares ``. + MavenReactor, + /// No root `pom.xml`, and a Gradle settings or build script. + Gradle, + /// Anything else (including a single-module pom, which stays legacy). + Other, +} + +/// A planned file: project-relative forward-slash path and its full new +/// bytes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileWrite { + pub rel: String, + pub bytes: Vec, + /// Vendored artifact tree file (jar, pom, sidecar, marker): recorded by + /// hash and deleted on revert; every other write is described by the + /// plan's fragment records. + pub tree: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JvmWarning { + pub code: &'static str, + pub detail: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JvmRefusal { + pub code: &'static str, + pub detail: String, +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct JvmPlan { + /// Sorted by `rel`, no duplicates. Files whose new bytes equal their + /// current bytes are omitted. + pub writes: Vec, + /// One record per text fragment the patch relies on, including shared + /// fragments another patch already wrote (`op: adopt`, which the caller + /// replaces with that patch's creation record). + pub records: Vec, + /// Every file of the patch's tree with its sha256, whether this plan + /// writes it or it is already in place (a patch update recording only + /// what changed would let the stale sweep delete the rest). + pub tree_files: Vec<(String, String)>, + pub warnings: Vec, + /// The tree directory holding the vendored artifact (project-relative). + pub tree_dir: String, + /// The vendored jar (project-relative). + pub jar_rel: String, +} + +/// A committed tree as planner input: `(jar, upstream pom, module)`. +pub type CommittedTree = (Vec, Vec, Option>); + +/// A planned revert. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct JvmUnplan { + /// Sorted by path: the new bytes, or `None` to delete the file. + pub changes: Vec<(String, Option>)>, + /// `vendor_lock_entry_drifted` details: a fragment that is still there + /// but no longer has the shape vendor wrote. + pub drifted: Vec, + /// The project still references this patch's tree (a drifted fragment), + /// so the tree must stay. + pub still_wired: bool, +} + +/// Classify the project root. +pub fn detect(read: ReadFn<'_>) -> Shape { + if let Some(pom) = read("pom.xml") { + let text = String::from_utf8_lossy(&pom); + // Single-pom projects stay on the legacy path until Phase 4. + return if maven_reactor::declares_modules(&text) { + Shape::MavenReactor + } else { + Shape::Other + }; + } + const GRADLE_FILES: &[&str] = &[ + "settings.gradle", + "settings.gradle.kts", + "build.gradle", + "build.gradle.kts", + ]; + if GRADLE_FILES.iter().any(|f| read(f).is_some()) { + Shape::Gradle + } else { + Shape::Other + } +} + +/// Plan the vendoring of `patch` for a project of `shape`. +pub fn plan(shape: Shape, read: ReadFn<'_>, patch: &JvmPatch<'_>) -> Result { + match shape { + Shape::MavenReactor => maven_reactor::plan(read, patch), + Shape::Gradle => gradle::plan(read, patch), + Shape::Other => Err(JvmRefusal { + code: "vendor_jvm_shape_unsupported", + detail: "reason: no_build_file: not a multi-module Maven reactor or a Gradle build" + .to_string(), + }), + } +} + +/// Safe coordinates that every written file accepts unescaped: g is +/// dot-separated `[A-Za-z0-9_-]` segments, a is `[A-Za-z0-9_.-]`, v is +/// `[A-Za-z0-9_.+-]` not ending in `+` nor starting with `latest.`; neither a +/// nor v is all dots, and none holds `--` (it ends an XML comment). +pub fn safe_coordinates(g: &str, a: &str, v: &str) -> bool { + let seg = |s: &str, extra: &str| { + !s.is_empty() + && s.chars() + .all(|c| c.is_ascii_alphanumeric() || "_-".contains(c) || extra.contains(c)) + }; + g.split('.').all(|s| seg(s, "")) + && seg(a, ".") + && seg(v, ".+") + && !a.chars().all(|c| c == '.') + && !v.chars().all(|c| c == '.') + && !v.ends_with('+') + && !v.starts_with("latest.") + && ![g, a, v].iter().any(|s| s.contains("--")) +} + +/// A fragment record. `op` (a JSON object with an `"op"` field) goes in +/// `new`; `original` is the text a rewrite replaced, when known. +pub(crate) fn fragment( + file: &str, + kind: &str, + key: &str, + action: WiringAction, + original: Option, + op: Value, +) -> WiringRecord { + WiringRecord { + file: file.to_string(), + kind: kind.to_string(), + action, + key: Some(key.to_string()), + original: original.map(Value::String), + new: Some(op), + } +} + +/// A tree root's owned `.gitattributes`: created when absent, +/// adopted (never overwritten) when present. +pub(crate) fn owned_file(read: ReadFn<'_>, rel: &str, writes: &mut Vec) -> WiringRecord { + if read(rel).is_some() { + return adopt(rel, OWNED_FILE_KIND, "owned"); + } + writes.push(FileWrite { + rel: rel.to_string(), + bytes: TREE_GITATTRIBUTES.as_bytes().to_vec(), + tree: false, + }); + fragment( + rel, + OWNED_FILE_KIND, + "owned", + WiringAction::Added, + None, + json!({ "op": "create" }), + ) +} + +/// A shared fragment another patch (or the user) already put in place. +pub(crate) fn adopt(file: &str, kind: &str, key: &str) -> WiringRecord { + fragment( + file, + kind, + key, + WiringAction::Added, + None, + json!({ "op": "adopt" }), + ) +} + +/// A `replace` op: revert swaps `to` back to `from` where `to` occurs once. +pub(crate) fn replace_op(from: &str, to: &str) -> Value { + json!({ "op": "replace", "from": from, "to": to }) +} + +/// The `op` of a record, `""` when malformed. +pub(crate) fn op_of(w: &WiringRecord) -> &str { + w.new + .as_ref() + .and_then(|n| n.get("op")) + .and_then(Value::as_str) + .unwrap_or_default() +} + +/// String field `name` of a record's op. +pub(crate) fn op_str<'w>(w: &'w WiringRecord, name: &str) -> Option<&'w str> { + w.new.as_ref()?.get(name)?.as_str() +} + +/// `text` with the only occurrence of `to` replaced by `from`; `None` when +/// `to` is empty, absent or ambiguous. +pub(crate) fn undo_replace(text: &str, from: &str, to: &str) -> Option { + if to.is_empty() { + return None; + } + let mut hits = text.match_indices(to); + let (at, _) = hits.next()?; + if hits.next().is_some() { + return None; + } + Some(format!("{}{from}{}", &text[..at], &text[at + to.len()..])) +} + +/// The changed files between `before` and `after` (both path → text, `None` +/// = absent), in [`JvmUnplan::changes`] form. +pub(crate) fn changes_between( + before: &std::collections::BTreeMap>, + after: &std::collections::BTreeMap>, +) -> Vec<(String, Option>)> { + after + .iter() + .filter(|(rel, text)| before.get(*rel) != Some(*text)) + .map(|(rel, text)| (rel.clone(), text.clone().map(String::into_bytes))) + .collect() +} + +/// `(rel, sha256)` of every tree write in `writes`. +pub(crate) fn tree_files(writes: &[FileWrite]) -> Vec<(String, String)> { + let mut out: Vec<(String, String)> = writes + .iter() + .filter(|w| w.tree) + .map(|w| (w.rel.clone(), sha256_hex(&w.bytes))) + .collect(); + out.sort(); + out.dedup(); + out +} + +/// Keep only writes that change a file, sorted and de-duplicated by path +/// (the last write for a path wins). +pub(crate) fn finish_writes(read: ReadFn<'_>, writes: Vec) -> Vec { + let mut by_rel = std::collections::BTreeMap::new(); + for w in writes { + by_rel.insert(w.rel.clone(), w); + } + by_rel + .into_values() + .filter(|w| read(&w.rel).as_deref() != Some(w.bytes.as_slice())) + .collect() +} + +/// `sha1` hex of `bytes`, the bare-40-hex sidecar body Maven reads. +pub(crate) fn sha1_hex(bytes: &[u8]) -> String { + use sha1::{Digest as _, Sha1}; + hex::encode(Sha1::digest(bytes)) +} + +/// `sha256` hex of `bytes`. +pub(crate) fn sha256_hex(bytes: &[u8]) -> String { + use sha2::{Digest as _, Sha256}; + hex::encode(Sha256::digest(bytes)) +} + +/// `.gitattributes` for an owned tree root: keeps git from rewriting line +/// endings in vendored poms and module files (layer-1 hashes are exact). +pub(crate) const TREE_GITATTRIBUTES: &str = "* -text\n"; + +/// A disk round-trip harness for the planners' tests: vendor and revert +/// the way the CLI does (ledger peers, carry-forward, stale sweep). +#[cfg(test)] +pub(crate) mod testing { + use std::collections::BTreeMap; + use std::path::Path; + + use super::super::state::{carry_forward_wiring, VendorEntry}; + use super::super::{RevertOpts, RevertOutcome}; + use super::{apply, JvmPatch, JvmPlan, JvmRefusal, Shape}; + + /// A ledger entry for `patch` holding `wiring`. + pub fn entry(patch: &JvmPatch<'_>, wiring: Vec) -> VendorEntry { + serde_json::from_value(serde_json::json!({ + "ecosystem": "maven", + "basePurl": format!("pkg:maven/{}/{}@{}", patch.group_id, patch.artifact_id, patch.version), + "uuid": patch.uuid, + "artifact": { "path": "", "sha256": "" }, + "wiring": serde_json::to_value(wiring).unwrap(), + })) + .unwrap() + } + + /// Vendor `patch` under `root` and record it in `ledger` (keyed by purl). + pub async fn vendor( + root: &Path, + shape: Shape, + patch: &JvmPatch<'_>, + ledger: &mut BTreeMap, + ) -> Result { + let reader = apply::ProjectReader::new(root); + let plan = super::plan(shape, &|rel: &str| reader.read(rel), patch)?; + assert_eq!(reader.escaped(), None); + if plan.writes.is_empty() { + return Ok(plan); + } + let mut wiring = apply::write_plan(root, &plan).await.expect("write plan"); + apply::inherit_peer_records(&mut wiring, ledger.values()); + let mut fresh = entry(patch, wiring); + let key = fresh.base_purl.clone(); + let prev = ledger.get(&key).cloned(); + if let Some(prev) = &prev { + carry_forward_wiring(prev, &mut fresh); + } + ledger.insert(key, fresh); + if let Some(prev) = prev.filter(|p| p.uuid != patch.uuid) { + apply::sweep_replaced_tree(root, &prev, ledger.values()) + .await + .expect("sweep"); + } + Ok(plan) + } + + /// Revert `patch`'s ledger entry, dropping it unless kept. + pub async fn revert( + root: &Path, + patch: &JvmPatch<'_>, + ledger: &mut BTreeMap, + ) -> RevertOutcome { + let key = format!( + "pkg:maven/{}/{}@{}", + patch.group_id, patch.artifact_id, patch.version + ); + let e = ledger.get(&key).expect("ledger entry").clone(); + let out = apply::revert(root, &e, RevertOpts::new(false)).await; + if out.success && !out.kept_artifact { + ledger.remove(&key); + } + out + } + + /// Every file under `root` (relative path → bytes). + pub fn snapshot(root: &Path) -> BTreeMap> { + fn walk(root: &Path, dir: &Path, out: &mut BTreeMap>) { + for e in std::fs::read_dir(dir).unwrap() { + let p = e.unwrap().path(); + let meta = std::fs::symlink_metadata(&p).unwrap(); + if meta.is_dir() { + walk(root, &p, out); + } else { + let rel = p + .strip_prefix(root) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + out.insert(rel, std::fs::read(&p).unwrap()); + } + } + } + let mut out = BTreeMap::new(); + walk(root, root, &mut out); + out + } + + /// Every directory under `root`, relative. + pub fn dirs(root: &Path) -> Vec { + fn walk(root: &Path, dir: &Path, out: &mut Vec) { + for e in std::fs::read_dir(dir).unwrap() { + let p = e.unwrap().path(); + if std::fs::symlink_metadata(&p).unwrap().is_dir() { + out.push(p.strip_prefix(root).unwrap().to_string_lossy().into_owned()); + walk(root, &p, out); + } + } + } + let mut out = Vec::new(); + walk(root, root, &mut out); + out + } + + /// Write `files` under `root`. + pub fn populate(root: &Path, files: &[(&str, &str)]) { + for (rel, body) in files { + let path = root.join(rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, body).unwrap(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn patch() -> JvmPatch<'static> { + JvmPatch { + group_id: "org.apache.commons", + artifact_id: "commons-text", + version: "1.10.0", + uuid: "5E6F7081-92a3-4b4c-8d5e-6f708192a3b4", + jar: b"jar", + upstream_pom: b"pom", + upstream_module: None, + } + } + + #[test] + fn suffixed_version_uses_first_eight_lowercase_hex() { + assert_eq!(patch().suffixed_version(), "1.10.0-socket.5e6f7081"); + assert_eq!(patch().coords().hex8(), "5e6f7081"); + } + + #[test] + fn safe_coordinates_follow_d15_and_forbid_comment_dashes() { + assert!(safe_coordinates( + "org.apache.commons", + "commons-text", + "1.10.0" + )); + assert!(safe_coordinates("g", "a.b_c", "1.0+build.2-rc_1")); + for (g, a, v) in [ + ("com.ex&le", "a", "1"), + ("g", "aa".to_vec()) + }; + assert_eq!(detect(&reactor), Shape::MavenReactor); + let single = |p: &str| (p == "pom.xml").then(|| b"".to_vec()); + assert_eq!(detect(&single), Shape::Other); + let gradle = |p: &str| (p == "settings.gradle.kts").then(Vec::new); + assert_eq!(detect(&gradle), Shape::Gradle); + let empty = |_: &str| None; + assert_eq!(detect(&empty), Shape::Other); + } +} diff --git a/crates/socket-patch-core/src/vendor/jvm/socket-patch.settings.gradle b/crates/socket-patch-core/src/vendor/jvm/socket-patch.settings.gradle new file mode 100644 index 000000000..b7c0203dc --- /dev/null +++ b/crates/socket-patch-core/src/vendor/jvm/socket-patch.settings.gradle @@ -0,0 +1,66 @@ +// Generated by socket-patch (vendored mode). Do not edit. +// Data: .socket/vendor/gradle-index.tsv. Remove with `socket-patch vendor --revert`. +import java.security.MessageDigest +if (org.gradle.util.GradleVersion.current() < org.gradle.util.GradleVersion.version('6.8')) { + throw new GradleException('socket-patch: vendored dependencies need Gradle 6.8+') +} +def socketDir = buildscript.sourceFile.parentFile.parentFile +def socketRepoDir = new File(socketDir, 'vendor/gradle') +def socketIndex = new File(socketDir, 'vendor/gradle-index.tsv') +def socketFail = { String m -> + throw new GradleException("socket-patch: ${m}. Restore it from git or re-run `socket-patch vendor`.") +} +if (!socketIndex.isFile()) { socketFail("${socketIndex} missing") } +def socketRows = socketIndex.readLines('UTF-8') +if (socketRows.isEmpty() || socketRows[0] != '#socket-patch-gradle-index 1') { socketFail("${socketIndex} has an unknown header") } +def socketModules = [:] as LinkedHashMap +def socketListed = [:] +socketRows.drop(1).each { String row -> + if (row.isEmpty()) { return } + def c = row.split('\t', -1) + def gav = c.length == 4 ? c[0].split(':', -1) : [] as String[] + if (gav.length != 3 || !(gav[0] ==~ /[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*/) || !(gav[1] ==~ /[A-Za-z0-9_.-]+/) || + !(gav[2] ==~ /[A-Za-z0-9_.+-]+/) || gav[2].endsWith('+') || gav[2].startsWith('latest.') || + gav[1] ==~ /\.+/ || gav[2] ==~ /\.+/ || !(c[2] ==~ /[0-9a-f]{64}/)) { + socketFail("malformed row in ${socketIndex}: ${row}") + } + def dir = "${gav[0].replace('.', '/')}/${gav[1]}/${gav[2]}".toString() + def name = c[1].startsWith(dir + '/') ? c[1].substring(dir.length() + 1) : '' + if (!name.startsWith("${gav[1]}-${gav[2]}".toString()) || name.contains('/')) { socketFail("row path ${c[1]} does not match ${c[0]}") } + def f = new File(socketRepoDir, c[1]) + if (!f.isFile()) { socketFail("vendored file missing: ${f}") } + def md = MessageDigest.getInstance('SHA-256') + f.withInputStream { s -> byte[] b = new byte[65536]; int n; while ((n = s.read(b)) > 0) { md.update(b, 0, n) } } + def got = md.digest().encodeHex().toString() + if (got != c[2]) { socketFail("${f} has sha256 ${got}, pinned ${c[2]}") } + socketModules[c[0]] = gav + socketListed.get(dir, [] as Set) << name +} +socketListed.each { String dir, Set names -> + def (a, v) = dir.split('/')[-2..-1] + if (!names.contains("${a}-${v}.jar".toString()) || !names.contains("${a}-${v}.pom".toString())) { socketFail("jar or pom row missing for ${dir}") } + def extra = new File(socketRepoDir, dir).list().findAll { it != 'socket-patch.vendor.json' && !names.contains(it) } + if (extra) { socketFail("unindexed files in ${dir}: ${extra}") } +} +def socketName = 'socketPatchVendor' +def socketOwned = { ArtifactRepository r -> r.name == socketName || r.name.startsWith(socketName + '_') } +def socketWire = { RepositoryHandler repos -> + if (repos.any(socketOwned)) { return } + repos.exclusiveContent { + forRepository { repos.maven { name = socketName; url = socketRepoDir.toURI() } } + filter { socketModules.values().each { m -> includeVersion(m[0], m[1], m[2]) } } + } +} +def socketFollow = { RepositoryHandler repos -> + if (repos.any { !socketOwned(it) }) { socketWire(repos) } + repos.whenObjectAdded { ArtifactRepository r -> if (!socketOwned(r)) { socketWire(repos) } } +} +def socketDrm = settings.dependencyResolutionManagement +socketWire(socketDrm.repositories) +socketFollow(settings.pluginManagement.repositories) +settings.gradle.beforeProject { Project p -> + socketFollow(p.buildscript.repositories) + if (socketDrm.repositoriesMode.getOrElse(RepositoriesMode.PREFER_PROJECT) == RepositoriesMode.PREFER_PROJECT) { + socketFollow(p.repositories) + } +} diff --git a/crates/socket-patch-core/src/vendor/maven_repo.rs b/crates/socket-patch-core/src/vendor/maven_repo.rs index 2c98c6646..2e09f183d 100644 --- a/crates/socket-patch-core/src/vendor/maven_repo.rs +++ b/crates/socket-patch-core/src/vendor/maven_repo.rs @@ -301,10 +301,17 @@ pub(crate) async fn service_preflight( project_root: &Path, record: &PatchRecord, ) -> Option { - maven_prelude(purl, project_root, record) - .await - .ok() - .filter(|p| !p.in_sync)?; + if let Some(shape) = jvm_shape(project_root).await { + jvm_committed_patch(shape, purl, project_root, record) + .await + .is_none() + .then_some(())?; + } else { + maven_prelude(purl, project_root, record) + .await + .ok() + .filter(|p| !p.in_sync)?; + } // `service_archive_copy` checks the archive's members against the // afterHashes before writing it verbatim. Some(crate::api::client::PlannedDownload { @@ -333,6 +340,43 @@ pub async fn vendor_maven( force: bool, service: Option<&VendorServiceConfig>, ) -> VendorOutcome { + for ancestor in project_root.ancestors().skip(1) { + let reader = super::jvm::apply::ProjectReader::new(ancestor); + let rel = project_root + .join("pom.xml") + .strip_prefix(ancestor) + .ok() + .map(|p| p.to_string_lossy().replace('\\', "/")); + if rel + .as_ref() + .is_some_and(|rel| super::jvm::maven_reactor::contains_module(&|p| reader.read(p), rel)) + { + return refused( + "vendor_jvm_shape_unsupported", + format!( + "reason: not_build_root: run vendor from reactor root {}", + ancestor.display() + ), + ); + } + if ancestor.join(".git").exists() { + break; + } + } + if let Some(shape) = jvm_shape(project_root).await { + return vendor_maven_jvm( + shape, + purl, + installed_dir, + project_root, + record, + sources, + dry_run, + force, + service, + ) + .await; + } let MavenPrelude { group_id, artifact_id, @@ -599,6 +643,12 @@ pub async fn revert_maven_opts( dry_run, keep_artifact, } = opts; + // Routed only when EVERY record is a JVM kind; the JVM revert validates + // the uuid, the coordinates and each recorded path before any disk + // access (state.json is tamper-able). + if super::jvm::apply::is_jvm_entry(entry) { + return super::jvm::apply::revert(project_root, entry, opts).await; + } // SECURITY: state.json is committed and tamper-able; the uuid keys the // directory we are about to delete. Anything but the canonical uuid grammar // is rejected fail-closed before any disk access. @@ -681,6 +731,679 @@ pub async fn revert_maven_opts( outcome } +// ── v5 JVM backend (reactors, Gradle) ───────────────────────────────── + +/// Route reactors and Gradle builds to the JVM backend. +async fn jvm_shape(project_root: &Path) -> Option { + let reader = super::jvm::apply::ProjectReader::new(project_root); + let shape = super::jvm::detect(&|rel: &str| reader.read(rel)); + (shape != super::jvm::Shape::Other).then_some(shape) +} + +/// The committed tree bytes for `record` (jar, upstream pom, module) when +/// the jar's patched members hash to the record's `afterHash`es: a re-run +/// then needs no jar source at all (the in-sync hot path). +async fn jvm_committed_patch( + shape: super::jvm::Shape, + purl: &str, + project_root: &Path, + record: &PatchRecord, +) -> Option { + let (g, a, v) = parse_maven_purl(purl)?; + let coords = super::jvm::Coords { + group_id: &g, + artifact_id: &a, + version: &v, + uuid: &record.uuid, + }; + let reader = super::jvm::apply::ProjectReader::new(project_root); + let read = |rel: &str| reader.read(rel); + let dir = match shape { + super::jvm::Shape::MavenReactor => super::jvm::maven_reactor::tree_dir(&coords), + super::jvm::Shape::Gradle => super::jvm::gradle::tree_dir(&coords), + super::jvm::Shape::Other => return None, + }; + let marker: serde_json::Value = + serde_json::from_slice(&read(&format!("{dir}/socket-patch.vendor.json"))?).ok()?; + if marker.get("uuid")?.as_str()? != record.uuid { + return None; + } + let files = marker.get("files")?.as_object()?; + let tree_version = match shape { + super::jvm::Shape::MavenReactor => coords.suffixed_version(), + _ => v.to_string(), + }; + for ext in ["jar", "pom"] { + files.get(&format!("{a}-{tree_version}.{ext}"))?; + } + for (name, file) in files { + if name.contains('/') || name.contains('\\') || name == ".." { + return None; + } + let bytes = read(&format!("{dir}/{name}"))?; + if file.get("sha256")?.as_str()? != super::jvm::sha256_hex(&bytes) { + return None; + } + } + let committed = match shape { + super::jvm::Shape::MavenReactor => { + super::jvm::maven_reactor::committed(&read, &coords).map(|(jar, pom)| (jar, pom, None)) + } + super::jvm::Shape::Gradle => super::jvm::gradle::committed(&read, &coords), + super::jvm::Shape::Other => None, + }?; + (!record.files.is_empty() && zip_bytes_match_after_hashes(&committed.0, &record.files)) + .then_some(committed) +} + +/// Vendor into a multi-module reactor or a Gradle build through the +/// [`super::jvm`] backend. The jar and pom come from the committed +/// tree when it already holds this patch, else from the same service / +/// local-rebuild rungs as the legacy path; nothing is written for a +/// refused plan. +#[allow(clippy::too_many_arguments)] +async fn vendor_maven_jvm( + shape: super::jvm::Shape, + purl: &str, + installed_dir: &Path, + project_root: &Path, + record: &PatchRecord, + sources: &PatchSources<'_>, + dry_run: bool, + force: bool, + service: Option<&VendorServiceConfig>, +) -> VendorOutcome { + let Some((group_id, artifact_id, version)) = parse_maven_purl(purl) else { + return refused("unsafe_coordinates", format!("not a maven purl: {purl}")); + }; + let (group_id, artifact_id, version) = ( + group_id.to_string(), + artifact_id.to_string(), + version.to_string(), + ); + if vendor_uuid_dir_rel("maven", &record.uuid).is_none() { + return refused( + "unsafe_coordinates", + format!("non-canonical patch uuid {:?}", record.uuid), + ); + } + if !super::jvm::safe_coordinates(&group_id, &artifact_id, &version) { + return refused( + "unsafe_coordinates", + format!("unsafe maven coordinates `{group_id}:{artifact_id}` @ `{version}`"), + ); + } + let state = match super::state::load_state(project_root).await { + Ok(state) => Some(state), + Err(e) => return refused("vendor_state_unreadable", e.to_string()), + }; + let display_path = project_root.join(".socket/vendor"); + if record.files.is_empty() { + let reader = super::jvm::apply::ProjectReader::new(project_root); + let probe_pom = format!("{group_id}{artifact_id}{version}"); + let patch = super::jvm::JvmPatch { + group_id: &group_id, + artifact_id: &artifact_id, + version: &version, + uuid: &record.uuid, + jar: &[], + upstream_pom: probe_pom.as_bytes(), + upstream_module: None, + }; + let plan = super::jvm::plan(shape, &|rel| reader.read(rel), &patch); + if let Some(rel) = reader.escaped() { + return refused( + "vendor_jvm_shape_unsupported", + super::jvm::apply::outside_root_detail(&rel), + ); + } + if let Err(e) = plan { + return refused(e.code, e.detail); + } + return done( + synthesized_result(purl, &display_path, Vec::new(), true, None), + None, + Vec::new(), + ); + } + + let mut warnings: Vec = Vec::new(); + let committed = jvm_committed_patch(shape, purl, project_root, record).await; + let was_committed = committed.is_some(); + let (jar_bytes, mut pom_bytes, mut module_bytes, mut result) = match committed { + Some((jar, pom, module)) => ( + jar, + pom, + module, + already_patched_result(purl, &display_path, &record.files), + ), + None => { + let (jar, result) = + match service_archive_copy(service, record, &artifact_id, ".jar", &mut warnings) + .await + { + ServiceCopy::Used(bytes) => ( + bytes, + already_patched_result(purl, &display_path, &record.files), + ), + ServiceCopy::HardFail(outcome) => return *outcome, + ServiceCopy::FallBack => { + match local_rebuild_jar( + purl, + installed_dir, + &display_path, + &artifact_id, + &version, + record, + sources, + force, + &mut warnings, + ) + .await + { + Ok((bytes, result)) if result.success => { + let upstream = match read_regular_to_bytes( + &installed_dir.join(format!("{artifact_id}-{version}.jar")), + ) + .await + { + Ok(bytes) => bytes, + Err(e) => { + return refused( + "vendor_jvm_upstream_unavailable", + e.to_string(), + ) + } + }; + if let Err(e) = verify_jvm_upstream( + &upstream, + &group_id, + &artifact_id, + &version, + "jar", + service, + ) + .await + { + return refused("vendor_prebuilt_integrity_mismatch", e); + } + match super::jvm::archive::canonical_jar(&upstream, &bytes) { + Ok(bytes) => (bytes, result), + Err(e) => return refused("vendor_jvm_upstream_unavailable", e), + } + } + Ok(pair) => pair, + Err(outcome) => return jvm_refusal(*outcome), + } + } + }; + if !result.success { + return done(result, None, warnings); + } + let pom = match acquire_jvm_metadata( + installed_dir, + &group_id, + &artifact_id, + &version, + "pom", + service, + ) + .await + { + Ok(bytes) => bytes, + Err(detail) => { + return refused( + "vendor_jvm_upstream_unavailable", + format!("reason: pom_unavailable: {detail}"), + ) + } + }; + let module = read_regular_to_bytes( + &installed_dir.join(format!("{artifact_id}-{version}.module")), + ) + .await + .ok(); + (jar, pom, module, result) + } + }; + + let online = service.is_some_and(|s| !s.offline); + if online && was_committed { + let upstream = match acquire_jvm_metadata( + installed_dir, + &group_id, + &artifact_id, + &version, + "jar", + service, + ) + .await + { + Ok(bytes) => bytes, + Err(e) => return refused("vendor_jvm_upstream_unavailable", e), + }; + if let Err(e) = verify_unpatched_jar_members(&upstream, &jar_bytes, record) { + return refused("vendor_prebuilt_integrity_mismatch", e); + } + pom_bytes = match acquire_jvm_metadata( + installed_dir, + &group_id, + &artifact_id, + &version, + "pom", + service, + ) + .await + { + Ok(bytes) => bytes, + Err(e) => return refused("vendor_jvm_upstream_unavailable", e), + }; + } + if shape == super::jvm::Shape::Gradle + && (module_bytes.is_some() + || String::from_utf8_lossy(&pom_bytes).contains("published-with-gradle-metadata")) + && (module_bytes.is_none() || online) + { + module_bytes = match acquire_jvm_metadata( + installed_dir, + &group_id, + &artifact_id, + &version, + "module", + service, + ) + .await + { + Ok(bytes) => Some(bytes), + Err(e) => return refused("vendor_jvm_upstream_unavailable", e), + }; + } + let patch = super::jvm::JvmPatch { + group_id: &group_id, + artifact_id: &artifact_id, + version: &version, + uuid: &record.uuid, + jar: &jar_bytes, + upstream_pom: &pom_bytes, + upstream_module: module_bytes.as_deref(), + }; + let reader = super::jvm::apply::ProjectReader::new(project_root); + let prior_disabled = state.as_ref().is_some_and(|s| { + s.entries.values().any(|e| { + e.wiring + .iter() + .any(|w| super::jvm::op_of(w) == "config_none") + }) + }); + let config_enabled = service + .and_then(|s| s.maven_config) + .unwrap_or(!prior_disabled); + if !config_enabled + && state.as_ref().is_some_and(|s| { + s.entries.values().any(|e| { + e.wiring.iter().any(|w| { + w.kind == super::jvm::CONFIG_LINE_KIND && super::jvm::op_of(w) == "config" + }) + }) + }) + { + return refused("vendor_jvm_shape_unsupported", "reason: maven_config_changed: revert the existing Maven wiring before selecting --maven-config=none"); + } + let read = |rel: &str| reader.read(rel); + let planned = match shape { + super::jvm::Shape::MavenReactor => { + super::jvm::maven_reactor::plan_with_config(&read, &patch, config_enabled) + } + _ => super::jvm::plan(shape, &read, &patch), + }; + if let Some(rel) = reader.escaped() { + return refused( + "vendor_jvm_shape_unsupported", + super::jvm::apply::outside_root_detail(&rel), + ); + } + let mut plan = match planned { + Ok(plan) => plan, + Err(refusal) => return refused(refusal.code, refusal.detail), + }; + if shape == super::jvm::Shape::Gradle + && reader + .read(super::jvm::gradle::VERIFICATION_REL) + .is_some_and(|b| super::jvm::gradle::verifies_metadata(&String::from_utf8_lossy(&b))) + { + // An in-sync run can reuse the already recorded metadata edits offline. + let previous = state.as_ref().and_then(|s| { + s.entries.values().find(|e| { + e.uuid == record.uuid + && e.base_purl == build_maven_purl(&group_id, &artifact_id, &version) + }) + }); + let metadata_records: Vec<_> = previous + .into_iter() + .flat_map(|e| &e.wiring) + .filter(|w| { + w.kind == super::jvm::VERIFICATION_FRAGMENT_KIND + && w.key.as_deref().is_some_and(|k| k.starts_with("metadata:")) + }) + .cloned() + .collect(); + let text = reader + .read(super::jvm::gradle::VERIFICATION_REL) + .unwrap_or_default(); + let reusable = !online + && !metadata_records.is_empty() + && metadata_records.iter().all(|w| { + super::jvm::gradle::metadata_record_present(&String::from_utf8_lossy(&text), w) + }); + if reusable { + plan.records.extend(metadata_records); + plan.warnings.retain(|w| { + !w.detail + .starts_with("reason: verification_parent_chain_unhandled:") + }); + } else { + let mut repo = installed_dir.to_path_buf(); + for _ in 0..group_id.split('.').count() + 2 { + repo.pop(); + } + let mut metadata = vec![super::jvm::gradle::MetadataArtifact { + group: group_id.clone(), + artifact: artifact_id.clone(), + version: version.clone(), + extension: "pom", + bytes: pom_bytes.clone(), + }]; + if let Some(module) = &module_bytes { + metadata.push(super::jvm::gradle::MetadataArtifact { + group: group_id.clone(), + artifact: artifact_id.clone(), + version: version.clone(), + extension: "module", + bytes: module.clone(), + }); + } + // Gradle verifies both the parent's standalone model and the + // child's effective imports, which can select different BOM versions. + for propagate_properties in [false, true] { + if let Err(e) = collect_gradle_metadata( + &pom_bytes, + &repo, + service, + &mut metadata, + 0, + &std::collections::BTreeMap::new(), + propagate_properties, + ) + .await + { + return refused( + "vendor_jvm_upstream_unavailable", + format!("reason: verification_metadata_unavailable: {e}"), + ); + } + } + if let Err(e) = + super::jvm::gradle::add_verification_metadata(&read, &mut plan, &metadata) + { + return refused(e.code, e.detail); + } + } + } + let previous = state.as_ref().and_then(|s| { + s.entries.values().find(|e| { + e.uuid == record.uuid + && e.base_purl == build_maven_purl(&group_id, &artifact_id, &version) + }) + }); + let prior_verified = previous.is_some_and(|e| !super::jvm::apply::upstream_unverified(e)); + let registry_verified = online || (was_committed && prior_verified); + plan.records.push(WiringRecord {file: plan.jar_rel.clone(), kind: super::jvm::UPSTREAM_KIND.into(), action: WiringAction::Added, key: Some("upstream".into()), original: None, new: Some(serde_json::json!({"op": if registry_verified {"registry_verified"} else {"local_unverified"}}))}); + warnings.extend( + plan.warnings + .iter() + .map(|w| VendorWarning::new(w.code, w.detail.clone())), + ); + let jar_path = project_root.join(&plan.jar_rel); + result.package_path = jar_path.display().to_string(); + if plan.writes.is_empty() && (previous.is_none() || registry_verified == prior_verified) { + return done( + already_patched_result(purl, &jar_path, &record.files), + None, + warnings, + ); + } + if dry_run { + return done(result, None, warnings); + } + let mut wiring = match super::jvm::apply::write_plan(project_root, &plan).await { + Ok(records) => records, + Err(e) => return done(failed_result(purl, &jar_path, e), None, warnings), + }; + // Shared fragments another JVM entry wrote, and the pristine originals + // of a patch update, come from the ledger. + if let Some(state) = &state { + super::jvm::apply::inherit_peer_records(&mut wiring, state.entries.values()); + } + let mut entry = maven_entry( + build_maven_purl(&group_id, &artifact_id, &version), + record, + plan.jar_rel.clone(), + &jar_bytes, + wiring, + ); + entry.ecosystem = "jvm".to_string(); + done(result, Some(entry), warnings) +} + +/// Verify upstream cache/registry bytes against checksums fetched independently over TLS. +fn verify_unpatched_jar_members( + upstream: &[u8], + patched: &[u8], + record: &PatchRecord, +) -> Result<(), String> { + let mut original = super::verify::read_zip_bytes_to_map(upstream)?; + let mut committed = super::verify::read_zip_bytes_to_map(patched)?; + let retain = |name: &String, _: &mut Vec| { + !record.files.contains_key(name) && !super::jvm::archive::is_signature(name) + }; + original.retain(retain); + committed.retain(retain); + if original != committed { + return Err( + "committed jar's unpatched members differ from the verified upstream jar".into(), + ); + } + Ok(()) +} + +/// Check registry sidecars independently of any checksum in the local cache. +async fn verify_jvm_upstream( + bytes: &[u8], + g: &str, + a: &str, + v: &str, + ext: &str, + service: Option<&VendorServiceConfig>, +) -> Result<(), String> { + if service.is_none_or(|s| s.offline) { + return Ok(()); + } + use sha2::Digest; + let url = format!( + "{}/{gpath}/{a}/{v}/{a}-{v}.{ext}", + maven_registry_base(), + gpath = group_id_to_path(g) + ); + let (checksum, actual) = match fetch_pom_bytes(&format!("{url}.sha512")).await { + Ok(sum) => (sum, hex::encode(sha2::Sha512::digest(bytes))), + Err(_) => ( + fetch_pom_bytes(&format!("{url}.sha1")).await?, + sha1_hex(bytes), + ), + }; + let expected = std::str::from_utf8(&checksum) + .map_err(|_| "upstream checksum is not UTF-8")? + .split_whitespace() + .next() + .ok_or("upstream checksum is empty")?; + if !expected.eq_ignore_ascii_case(&actual) { + return Err(format!("upstream checksum mismatch for {g}:{a}:{v}.{ext}")); + } + Ok(()) +} + +async fn acquire_jvm_metadata( + dir: &Path, + g: &str, + a: &str, + v: &str, + ext: &str, + service: Option<&VendorServiceConfig>, +) -> Result, String> { + let path = dir.join(format!("{a}-{v}.{ext}")); + let bytes = match read_regular_to_bytes(&path).await { + Ok(bytes) => bytes, + Err(e) + if e.kind() == std::io::ErrorKind::NotFound && service.is_some_and(|s| !s.offline) => + { + fetch_registry_bytes( + &format!( + "{}/{}/{a}/{v}/{a}-{v}.{ext}", + maven_registry_base(), + group_id_to_path(g) + ), + if ext == "jar" { + super::registry_fetch::MAX_DOWNLOAD_BYTES + } else { + MAX_POM_BYTES as u64 + }, + ) + .await? + } + Err(e) => return Err(format!("upstream {g}:{a}:{v}.{ext} unavailable: {e}")), + }; + verify_jvm_upstream(&bytes, g, a, v, ext, service).await?; + Ok(bytes) +} + +/// Collect effective parent/BOM metadata. Descendant properties override parent +/// import versions, but do not leak into a separately imported BOM's own model. +async fn collect_gradle_metadata( + bytes: &[u8], + repo: &Path, + service: Option<&VendorServiceConfig>, + out: &mut Vec, + depth: usize, + descendant: &std::collections::BTreeMap, + propagate_properties: bool, +) -> Result, String> { + use super::jvm::maven_reactor::metadata_model; + if depth > 32 || out.len() > 128 { + return Err("upstream metadata graph exceeds the depth or size limit".into()); + } + let empty = std::collections::BTreeMap::new(); + let model = metadata_model(bytes, &empty, descendant, false)?; + let mut properties = empty; + if let Some((g, a, v)) = model.parent { + let path = repo.join(group_id_to_path(&g)).join(&a).join(&v); + let parent = acquire_jvm_metadata(&path, &g, &a, &v, "pom", service).await?; + let child_properties = model + .properties + .into_iter() + .filter(|(key, _)| { + propagate_properties + && !matches!( + key.as_str(), + "project.groupId" | "project.artifactId" | "project.version" + ) + }) + .collect(); + properties = Box::pin(collect_gradle_metadata( + &parent, + repo, + service, + out, + depth + 1, + &child_properties, + propagate_properties, + )) + .await?; + collect_metadata_artifacts(repo, &g, &a, &v, parent, service, out).await?; + } + let model = metadata_model(bytes, &properties, descendant, true)?; + for (g, a, v) in model.imports { + if out + .iter() + .any(|m| m.group == g && m.artifact == a && m.version == v && m.extension == "pom") + { + continue; + } + let path = repo.join(group_id_to_path(&g)).join(&a).join(&v); + let bom = acquire_jvm_metadata(&path, &g, &a, &v, "pom", service).await?; + Box::pin(collect_gradle_metadata( + &bom, + repo, + service, + out, + depth + 1, + &std::collections::BTreeMap::new(), + propagate_properties, + )) + .await?; + collect_metadata_artifacts(repo, &g, &a, &v, bom, service, out).await?; + } + Ok(model.properties) +} + +async fn collect_metadata_artifacts( + repo: &Path, + g: &str, + a: &str, + v: &str, + pom: Vec, + service: Option<&VendorServiceConfig>, + out: &mut Vec, +) -> Result<(), String> { + if out + .iter() + .any(|m| m.group == g && m.artifact == a && m.version == v) + { + return Ok(()); + } + let module = if String::from_utf8_lossy(&pom).contains("published-with-gradle-metadata") { + let path = repo.join(group_id_to_path(g)).join(a).join(v); + Some(acquire_jvm_metadata(&path, g, a, v, "module", service).await?) + } else { + None + }; + for (extension, bytes) in [("pom", Some(pom)), ("module", module)] { + if let Some(bytes) = bytes { + out.push(super::jvm::gradle::MetadataArtifact { + group: g.into(), + artifact: a.into(), + version: v.into(), + extension, + bytes, + }); + } + } + Ok(()) +} + +/// A legacy jar refusal in the JVM backend's codes. +fn jvm_refusal(outcome: VendorOutcome) -> VendorOutcome { + match outcome { + VendorOutcome::Refused { + code: "vendor_maven_jar_not_found", + detail, + } => refused( + "vendor_jvm_upstream_unavailable", + format!("reason: no_base_jar: {detail}"), + ), + other => other, + } +} + // ── materialisation (service download / local rebuild) ────────────────────────── /// Produce the patched jar bytes + the real upstream pom, then write both (with @@ -908,6 +1631,10 @@ async fn acquire_upstream_pom( /// Bounded HTTP GET of a pom from the maven2 registry. async fn fetch_pom_bytes(url: &str) -> Result, String> { + fetch_registry_bytes(url, MAX_POM_BYTES as u64).await +} + +async fn fetch_registry_bytes(url: &str, cap: u64) -> Result, String> { let client = reqwest::Client::builder() .user_agent(MAVEN_USER_AGENT) .timeout(Duration::from_secs(60)) @@ -925,7 +1652,7 @@ async fn fetch_pom_bytes(url: &str) -> Result, String> { // bytes (the shared reader every other registry download uses): a // mirror serving a huge body is refused mid-stream instead of being // buffered whole before the size check. - crate::utils::http::read_capped(resp, MAX_POM_BYTES as u64, "pom") + crate::utils::http::read_capped(resp, cap, "Maven artifact") .await .map_err(|e| format!("{url}: {e}")) } @@ -1950,7 +2677,7 @@ mod tests { } #[tokio::test] - async fn refuses_multimodule_root() { + async fn missing_reactor_module_is_refused_without_writes() { let multimodule = "\n\ \x20 4.0.0\n\ \x20 com.example\n\ @@ -1964,7 +2691,7 @@ mod tests { let (dir, blobs, installed, record) = fixture(Some(multimodule), true, true).await; let root = dir.path(); let (code, _d) = unwrap_refused(run_vendor(root, &blobs, &installed, &record, false).await); - assert_eq!(code, "vendor_maven_multimodule_unsupported"); + assert_eq!(code, "vendor_jvm_shape_unsupported"); assert!(!root.join(".socket").exists(), "refusal writes nothing"); } @@ -1990,16 +2717,18 @@ mod tests { } #[tokio::test] - async fn refuses_gradle_only_project() { + async fn vendors_gradle_only_project_by_default() { // build.gradle but no pom.xml → gradle-only. let (dir, blobs, installed, record) = fixture(None, true, true).await; let root = dir.path(); tokio::fs::write(root.join("build.gradle"), b"plugins { id 'java' }\n") .await .unwrap(); - let (code, _d) = unwrap_refused(run_vendor(root, &blobs, &installed, &record, false).await); - assert_eq!(code, "vendor_gradle_unsupported"); - assert!(!root.join(".socket").exists()); + let (result, entry, _) = + unwrap_done(run_vendor(root, &blobs, &installed, &record, false).await); + assert!(result.success, "{:?}", result.error); + assert_eq!(entry.unwrap().ecosystem, "jvm"); + assert!(root.join(".socket/vendor/gradle-index.tsv").is_file()); } #[tokio::test] @@ -2833,6 +3562,7 @@ mod tests { ) -> VendorServiceConfig { use crate::api::client::{ApiClient, ApiClientOptions}; VendorServiceConfig { + maven_config: None, source, client: api_url.map(|uri| { ApiClient::new(ApiClientOptions { @@ -4266,4 +4996,306 @@ mod tests { assert_eq!(code, "vendor_prebuilt_required"); assert!(!root.join(".socket").exists(), "nothing written"); } + + #[tokio::test] + #[serial_test::serial] + async fn jvm_upstream_checksums_are_independent_of_cache_sidecars() { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + let server = MockServer::start().await; + let _reg = EnvGuard::set("SOCKET_MAVEN_REGISTRY", &server.uri()); + let cfg = service_cfg(None, crate::vendor::VendorSource::Build, false); + let bytes = b"upstream"; + let route = "/org/example/foo/1/foo-1.jar"; + Mock::given(method("GET")) + .and(path(format!("{route}.sha512"))) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(hex::encode(sha2::Sha512::digest(bytes))), + ) + .mount(&server) + .await; + assert!( + verify_jvm_upstream(bytes, "org.example", "foo", "1", "jar", Some(&cfg)) + .await + .is_ok() + ); + assert!( + verify_jvm_upstream(b"corrupt", "org.example", "foo", "1", "jar", Some(&cfg)) + .await + .unwrap_err() + .contains("checksum mismatch") + ); + server.reset().await; + Mock::given(method("GET")) + .and(path(format!("{route}.sha1"))) + .respond_with(ResponseTemplate::new(200).set_body_string(sha1_hex(bytes))) + .mount(&server) + .await; + assert!( + verify_jvm_upstream(bytes, "org.example", "foo", "1", "jar", Some(&cfg)) + .await + .is_ok() + ); + server.reset().await; + assert!( + verify_jvm_upstream(bytes, "org.example", "foo", "1", "jar", Some(&cfg)) + .await + .is_err() + ); + let mut offline = cfg; + offline.offline = true; + assert!( + verify_jvm_upstream(bytes, "org.example", "foo", "1", "jar", Some(&offline)) + .await + .is_ok() + ); + } + + // ── JVM backend glue ── + + const REACTOR_ROOT: &str = "\n 4.0.0\n \ + t\n root\n 1\n \ + pom\n \n a\n \n\n"; + + fn reactor_module() -> String { + "\n \n t\n root\n \ + 1\n \n a\n \n \ + org.apache.commonscommons-text\ + 1.10.0\n \n\n" + .to_string() + } + + async fn reactor_fixture( + with_local_jar: bool, + ) -> (tempfile::TempDir, PathBuf, PathBuf, PatchRecord) { + let fx = fixture(Some(REACTOR_ROOT), with_local_jar, true).await; + let a = fx.0.path().join("a"); + tokio::fs::create_dir_all(&a).await.unwrap(); + tokio::fs::write(a.join("pom.xml"), reactor_module()) + .await + .unwrap(); + fx + } + + #[tokio::test] + async fn corrupt_jvm_ledger_refuses_without_losing_revert_records() { + let (dir, blobs, installed, record) = reactor_fixture(true).await; + let root = dir.path(); + std::fs::create_dir_all(root.join(".socket/vendor")).unwrap(); + std::fs::write(root.join(".socket/vendor/state.json"), "{corrupt").unwrap(); + let (code, _) = unwrap_refused(run_jvm(root, &blobs, &installed, &record, None).await); + assert_eq!(code, "vendor_state_unreadable"); + assert_eq!( + std::fs::read_to_string(root.join("pom.xml")).unwrap(), + REACTOR_ROOT + ); + assert_eq!( + std::fs::read_to_string(root.join(".socket/vendor/state.json")).unwrap(), + "{corrupt" + ); + assert!(!root.join(".socket/vendor/maven2").exists()); + } + + #[tokio::test] + async fn empty_jvm_patch_still_validates_the_reactor_layout() { + let (dir, blobs, installed, mut record) = reactor_fixture(true).await; + record.files.clear(); + std::fs::remove_file(dir.path().join("a/pom.xml")).unwrap(); + let (code, detail) = + unwrap_refused(run_jvm(dir.path(), &blobs, &installed, &record, None).await); + assert_eq!(code, "vendor_jvm_shape_unsupported"); + assert!(detail.contains("module"), "{detail}"); + assert!(!dir.path().join(".socket").exists()); + } + + async fn run_jvm( + root: &Path, + blobs: &Path, + installed: &Path, + record: &PatchRecord, + service: Option<&VendorServiceConfig>, + ) -> VendorOutcome { + let sources = PatchSources::blobs_only(blobs); + vendor_maven_jvm( + super::super::jvm::Shape::MavenReactor, + PURL, + installed, + root, + record, + &sources, + false, + false, + service, + ) + .await + } + + /// A re-run over a committed tree that holds this patch needs no jar + /// source: no cached jar, and `--vendor-source=service --offline`. + #[tokio::test] + async fn jvm_rerun_is_in_sync_without_any_jar_source() { + let (dir, blobs, installed, record) = reactor_fixture(true).await; + let root = dir.path(); + let (result, entry, _) = + unwrap_done(run_jvm(root, &blobs, &installed, &record, None).await); + assert!(result.success, "{result:?}"); + let entry = entry.expect("ledger entry"); + assert!(super::super::jvm::apply::is_jvm_entry(&entry)); + tokio::fs::remove_file(installed.join("commons-text-1.10.0.jar")) + .await + .unwrap(); + tokio::fs::remove_file(installed.join("commons-text-1.10.0.pom")) + .await + .unwrap(); + let cfg = crate::vendor::test_support::service_cfg( + "http://127.0.0.1:9", + crate::vendor::VendorSource::Service, + true, + ); + let (result, again, _) = + unwrap_done(run_jvm(root, &blobs, &installed, &record, Some(&cfg)).await); + assert!(result.success && again.is_none(), "{result:?}"); + assert!( + jvm_committed_patch(super::super::jvm::Shape::MavenReactor, PURL, root, &record) + .await + .is_some(), + "the service prefetch plan skips an in-sync JVM entry" + ); + // A tampered committed jar is not in sync: the jar source is needed. + let jar = root.join(&entry.artifact.path); + tokio::fs::write(&jar, make_jar(PRISTINE)).await.unwrap(); + let (code, detail) = unwrap_refused(run_jvm(root, &blobs, &installed, &record, None).await); + assert_eq!(code, "vendor_jvm_upstream_unavailable"); + assert!(detail.starts_with("reason: no_base_jar: "), "{detail}"); + } + + #[tokio::test] + async fn jvm_revert_honours_keep_artifact_and_restores_the_poms() { + let (dir, blobs, installed, record) = reactor_fixture(true).await; + let root = dir.path(); + let (_, entry, _) = unwrap_done(run_jvm(root, &blobs, &installed, &record, None).await); + let entry = entry.unwrap(); + let out = revert_maven_opts( + &entry, + root, + RevertOpts { + dry_run: false, + keep_artifact: true, + }, + ) + .await; + assert!( + out.success && !out.kept_artifact && out.warnings.is_empty(), + "{out:?}" + ); + assert!( + root.join(&entry.artifact.path).is_file(), + "--preserve-state keeps the jar" + ); + assert_eq!( + std::fs::read_to_string(root.join("pom.xml")).unwrap(), + REACTOR_ROOT + ); + assert_eq!( + std::fs::read_to_string(root.join("a/pom.xml")).unwrap(), + reactor_module() + ); + assert!(!root.join(".mvn").exists()); + } + + /// A forged JVM entry (tamper-able state.json) is refused before any + /// disk access. + #[tokio::test] + async fn jvm_revert_refuses_forged_entries() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + tokio::fs::create_dir_all(root.join("src")).await.unwrap(); + tokio::fs::write(root.join("src/Main.java"), "class Main {}\n") + .await + .unwrap(); + let tree = |file: &str| WiringRecord { + file: file.to_string(), + kind: super::super::jvm::TREE_KIND.to_string(), + action: WiringAction::Added, + key: None, + original: None, + new: Some(Value::String(hex::encode(Sha256::digest( + b"class Main {}\n", + )))), + }; + let forged = |uuid: &str, file: &str| VendorEntry { + uuid: uuid.to_string(), + wiring: vec![tree(file)], + ..maven_entry( + PURL.to_string(), + &PatchRecord { + uuid: UUID.to_string(), + ..fixture_record() + }, + String::new(), + b"", + Vec::new(), + ) + }; + for entry in [ + forged("../../../NOT-A-UUID", "src/Main.java"), + forged(UUID, "src/Main.java"), + ] { + let out = revert_maven_opts(&entry, root, RevertOpts::new(false)).await; + assert!(!out.success, "{out:?}"); + } + assert!(root.join("src/Main.java").is_file()); + } + + fn fixture_record() -> PatchRecord { + PatchRecord { + uuid: UUID.to_string(), + exported_at: String::new(), + files: HashMap::new(), + vulnerabilities: HashMap::new(), + description: String::new(), + license: String::new(), + tier: String::new(), + } + } + + #[cfg(unix)] + #[tokio::test] + async fn jvm_refuses_a_module_symlinked_outside_the_checkout() { + let (dir, blobs, installed, record) = fixture(Some(REACTOR_ROOT), true, true).await; + let root = dir.path(); + let outside = tempfile::tempdir().unwrap(); + tokio::fs::write(outside.path().join("pom.xml"), reactor_module()) + .await + .unwrap(); + std::os::unix::fs::symlink(outside.path(), root.join("a")).unwrap(); + let (code, detail) = unwrap_refused(run_jvm(root, &blobs, &installed, &record, None).await); + assert_eq!(code, "vendor_jvm_shape_unsupported"); + assert!( + detail.starts_with("reason: build_file_outside_root: a "), + "{detail}" + ); + assert_eq!( + std::fs::read_to_string(outside.path().join("pom.xml")).unwrap(), + reactor_module() + ); + } + + /// A previously vendored project keeps routing to the JVM backend when the + /// switch is unset (its ledger names a JVM entry). + #[tokio::test] + async fn jvm_routing_follows_the_ledger() { + let (dir, blobs, installed, record) = reactor_fixture(true).await; + let root = dir.path(); + assert!(jvm_shape(root).await.is_some()); + let (_, entry, _) = unwrap_done(run_jvm(root, &blobs, &installed, &record, None).await); + let mut state = super::super::state::VendorState::new(); + state.entries.insert(PURL.to_string(), entry.unwrap()); + super::super::state::save_state(root, &state).await.unwrap(); + assert_eq!( + jvm_shape(root).await, + Some(super::super::jvm::Shape::MavenReactor) + ); + } } diff --git a/crates/socket-patch-core/src/vendor/mod.rs b/crates/socket-patch-core/src/vendor/mod.rs index 0ab48d9fa..1fc2f9797 100644 --- a/crates/socket-patch-core/src/vendor/mod.rs +++ b/crates/socket-patch-core/src/vendor/mod.rs @@ -65,6 +65,7 @@ pub mod gem; pub mod go_mod_edit; pub mod go_sum_edit; pub mod golang; +pub mod jvm; pub(crate) mod ledger_snapshots; pub mod lock_inventory; pub mod maven_repo; @@ -264,6 +265,8 @@ impl VendorSource { /// "build-only". #[derive(Debug, Clone)] pub struct VendorServiceConfig { + /// Override Maven config wiring; None preserves the recorded choice (auto for new projects). + pub maven_config: Option, /// The `auto` / `service` / `build` policy. pub source: VendorSource, /// The run-level API client (reused from the CLI). `None` disables the diff --git a/crates/socket-patch-core/src/vendor/npm_common.rs b/crates/socket-patch-core/src/vendor/npm_common.rs index 0fd4315e5..479302edf 100644 --- a/crates/socket-patch-core/src/vendor/npm_common.rs +++ b/crates/socket-patch-core/src/vendor/npm_common.rs @@ -1286,6 +1286,7 @@ mod tests { fn service_cfg(server_uri: &str, source: VendorSource) -> VendorServiceConfig { VendorServiceConfig { + maven_config: None, source, client: Some( ApiClient::new(ApiClientOptions { diff --git a/crates/socket-patch-core/src/vendor/npm_lock.rs b/crates/socket-patch-core/src/vendor/npm_lock.rs index c518e9c17..6f32ff943 100644 --- a/crates/socket-patch-core/src/vendor/npm_lock.rs +++ b/crates/socket-patch-core/src/vendor/npm_lock.rs @@ -3707,6 +3707,7 @@ mod tests { fn service_cfg(server_uri: &str, source: VendorSource, offline: bool) -> VendorServiceConfig { VendorServiceConfig { + maven_config: None, source, client: Some( ApiClient::new(ApiClientOptions { diff --git a/crates/socket-patch-core/src/vendor/nuget_feed.rs b/crates/socket-patch-core/src/vendor/nuget_feed.rs index ba8a4b252..371482ecc 100644 --- a/crates/socket-patch-core/src/vendor/nuget_feed.rs +++ b/crates/socket-patch-core/src/vendor/nuget_feed.rs @@ -4437,6 +4437,7 @@ mod tests { .mount(&server) .await; let cfg = VendorServiceConfig { + maven_config: None, source: VendorSource::Service, client: Some( ApiClient::new(ApiClientOptions { @@ -4518,6 +4519,7 @@ mod tests { .mount(&server) .await; let cfg = VendorServiceConfig { + maven_config: None, source: VendorSource::Service, client: Some( ApiClient::new(ApiClientOptions { @@ -5008,6 +5010,7 @@ mod tests { .mount(&server) .await; let cfg = VendorServiceConfig { + maven_config: None, source: VendorSource::Service, client: Some( ApiClient::new(ApiClientOptions { @@ -5224,6 +5227,7 @@ mod tests { ) -> VendorServiceConfig { use crate::api::client::{ApiClient, ApiClientOptions}; VendorServiceConfig { + maven_config: None, source, client: server.map(|s| { ApiClient::new(ApiClientOptions { diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index 5b3cb18f1..54bc00b04 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -2909,6 +2909,7 @@ wheels = [ offline: bool, ) -> VendorServiceConfig { VendorServiceConfig { + maven_config: None, source, client: Some( ApiClient::new(ApiClientOptions { diff --git a/crates/socket-patch-core/src/vendor/service_fetch.rs b/crates/socket-patch-core/src/vendor/service_fetch.rs index cbde546a9..3975db269 100644 --- a/crates/socket-patch-core/src/vendor/service_fetch.rs +++ b/crates/socket-patch-core/src/vendor/service_fetch.rs @@ -412,6 +412,7 @@ mod tests { fn cfg_for(server: &MockServer) -> VendorServiceConfig { VendorServiceConfig { + maven_config: None, source: VendorSource::Service, client: Some( ApiClient::new(ApiClientOptions { @@ -577,6 +578,7 @@ mod tests { #[tokio::test] async fn unavailable_when_client_absent() { let cfg = VendorServiceConfig { + maven_config: None, source: VendorSource::Auto, client: None, use_public_proxy: false, diff --git a/crates/socket-patch-core/src/vendor/test_support.rs b/crates/socket-patch-core/src/vendor/test_support.rs index 66c1f8e5b..95f11389d 100644 --- a/crates/socket-patch-core/src/vendor/test_support.rs +++ b/crates/socket-patch-core/src/vendor/test_support.rs @@ -49,6 +49,7 @@ pub(crate) fn service_cfg( offline: bool, ) -> VendorServiceConfig { VendorServiceConfig { + maven_config: None, source, client: Some( ApiClient::new(ApiClientOptions { diff --git a/crates/socket-patch-core/src/vendor/verify.rs b/crates/socket-patch-core/src/vendor/verify.rs index f9080b617..cf5d7e488 100644 --- a/crates/socket-patch-core/src/vendor/verify.rs +++ b/crates/socket-patch-core/src/vendor/verify.rs @@ -50,6 +50,13 @@ pub(crate) fn checked_artifact_path( entry: &VendorEntry, record: &PatchRecord, ) -> Result { + // The JVM trees are not `/` dirs: the jar must be the + // entry's own tree jar for this uuid (checked against the layout and + // the marker). + if super::jvm::apply::is_jvm_entry(entry) { + return super::jvm::apply::checked_tree_jar(project_root, entry, &record.uuid) + .map(|rel| project_root.join(rel)); + } let rel = &entry.artifact.path; let parts = parse_vendor_path(rel).ok_or_else(|| "vendor_path_unsafe".to_string())?; let norm = rel.replace('\\', "/"); @@ -391,7 +398,6 @@ fn read_wheel_to_map(whl: &Path) -> Result>, String> { /// [`read_wheel_to_map`] over in-memory zip bytes — the same entry and /// decompressed-size caps — for callers that hash and decode the SAME /// buffer (a committed wheel read exactly once). -#[cfg(test)] pub(crate) fn read_zip_bytes_to_map(bytes: &[u8]) -> Result>, String> { read_zip_to_map(std::io::Cursor::new(bytes), false) } diff --git a/crates/socket-patch-core/src/vex/discover/maven.rs b/crates/socket-patch-core/src/vex/discover/maven.rs index d90720e74..734f3e61d 100644 --- a/crates/socket-patch-core/src/vex/discover/maven.rs +++ b/crates/socket-patch-core/src/vex/discover/maven.rs @@ -245,6 +245,15 @@ async fn extract_hosted( .map(String::as_str) .filter(|uuid| uuid.starts_with(hex8)) .collect(); + // A JVM-backend pin: its committed maven2 tree serves it. + let jvm_tree = candidates.is_empty() + && ctx + .exists(&format!( + "{}/{}/{artifact}/{pinned}/{artifact}-{pinned}.jar", + crate::vendor::jvm::maven_reactor::TREE_ROOT, + group.replace('.', "/") + )) + .await; match candidates.as_slice() { [uuid] => { ties.entry(*uuid).or_default().insert(( @@ -254,6 +263,7 @@ async fn extract_hosted( pinned.clone(), )); } + [] if jvm_tree => {} [] => out.diag( DIAG_REF_UNATTRIBUTABLE, POM, @@ -488,6 +498,13 @@ fn classify_repo(ctx: &DiscoverCtx<'_>, repo: &PomRepo, out: &mut Discovery) -> if !id_socket && url_hosted.is_none() && !url_vendor_text { return RepoKind::NotOurs; } + // The JVM backend's one shared fallback repository names no patch; its + // ledger entries prove their own liveness. + if id == crate::vendor::jvm::maven_reactor::REPO_ID + && url == crate::vendor::jvm::maven_reactor::REPO_URL + { + return RepoKind::NotOurs; + } let invalid = |out: &mut Discovery, why: &str| { out.diag( DIAG_REF_INVALID, diff --git a/crates/socket-patch-core/src/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index 2f36169f9..11a585f0b 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -1513,6 +1513,11 @@ impl Discovery { /// files still naming the uuid dir (or, with none recorded, the /// ecosystem's root locks — [`vendored_wiring_live`]). pub async fn vendor_entry_live(&self, root: &Path, entry: &VendorEntry) -> bool { + // The JVM backend's trees are not `/` dirs its refs could + // name: its own layout decides. + if crate::vendor::jvm::apply::is_jvm_entry(entry) { + return crate::vendor::jvm::apply::entry_wired(root, entry); + } if let Some(live) = self.vendored_claim(&entry.base_purl, &entry.uuid, &entry.artifact.path) { return live; diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-mirrors-before-url/restored/composer.lock b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-mirrors-before-url/restored/composer.lock new file mode 100644 index 000000000..6f1b07d32 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-mirrors-before-url/restored/composer.lock @@ -0,0 +1,39 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state" + ], + "content-hash": "abc123def456abc123def456abc1", + "packages": [ + { + "name": "monolog/monolog", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "abc123def456" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/abc123", + "reference": "abc123def456", + "shasum": "" + } + }, + { + "name": "psr/log", + "version": "1.1.4", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "shasum": "" + } + } + ], + "packages-dev": [] +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-mirrors/restored/composer.lock b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-mirrors/restored/composer.lock new file mode 100644 index 000000000..6f1b07d32 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-mirrors/restored/composer.lock @@ -0,0 +1,39 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state" + ], + "content-hash": "abc123def456abc123def456abc1", + "packages": [ + { + "name": "monolog/monolog", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "abc123def456" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/abc123", + "reference": "abc123def456", + "shasum": "" + } + }, + { + "name": "psr/log", + "version": "1.1.4", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "shasum": "" + } + } + ], + "packages-dev": [] +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-after-dist/restored/composer.lock b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-after-dist/restored/composer.lock new file mode 100644 index 000000000..ab77a76a6 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-after-dist/restored/composer.lock @@ -0,0 +1,42 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state" + ], + "content-hash": "abc123def456abc123def456abc1", + "packages": [ + { + "name": "monolog/monolog", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "abc123def456" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/abc123", + "reference": "abc123def456", + "shasum": "" + }, + "require": { + "php": ">=7.2" + } + }, + { + "name": "psr/log", + "version": "1.1.4", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "shasum": "" + } + } + ], + "packages-dev": [] +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-before-name/restored/composer.lock b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-before-name/restored/composer.lock new file mode 100644 index 000000000..6f1b07d32 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-before-name/restored/composer.lock @@ -0,0 +1,39 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state" + ], + "content-hash": "abc123def456abc123def456abc1", + "packages": [ + { + "name": "monolog/monolog", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "abc123def456" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/abc123", + "reference": "abc123def456", + "shasum": "" + } + }, + { + "name": "psr/log", + "version": "1.1.4", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "shasum": "" + } + } + ], + "packages-dev": [] +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-last-key/restored/composer.lock b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-last-key/restored/composer.lock new file mode 100644 index 000000000..6f1b07d32 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-last-key/restored/composer.lock @@ -0,0 +1,39 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state" + ], + "content-hash": "abc123def456abc123def456abc1", + "packages": [ + { + "name": "monolog/monolog", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "abc123def456" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/abc123", + "reference": "abc123def456", + "shasum": "" + } + }, + { + "name": "psr/log", + "version": "1.1.4", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "shasum": "" + } + } + ], + "packages-dev": [] +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-not-adjacent/restored/composer.lock b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-not-adjacent/restored/composer.lock new file mode 100644 index 000000000..05f654496 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-not-adjacent/restored/composer.lock @@ -0,0 +1,42 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state" + ], + "content-hash": "abc123def456abc123def456abc1", + "packages": [ + { + "name": "monolog/monolog", + "version": "2.0.0", + "require": { + "php": ">=7.2" + }, + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "abc123def456" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/abc123", + "reference": "abc123def456", + "shasum": "" + } + }, + { + "name": "psr/log", + "version": "1.1.4", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "shasum": "" + } + } + ], + "packages-dev": [] +} diff --git a/crates/socket-patch-core/tests/upstream_restore_golden.rs b/crates/socket-patch-core/tests/upstream_restore_golden.rs index 4cf525004..373e0bc04 100644 --- a/crates/socket-patch-core/tests/upstream_restore_golden.rs +++ b/crates/socket-patch-core/tests/upstream_restore_golden.rs @@ -749,10 +749,14 @@ async fn composer_run( #[tokio::test] #[serial] async fn composer_goldens_round_trip() { - // Every composer golden with a rewrite is invertible. + // Restore registry fields byte for byte. A hosted rewrite removes local + // mirror settings and the source block's original position, neither of + // which Packagist can reconstruct without a ledger. Those cases record + // the canonical upstream result in `restored/`. let mut ran = 0; - for case in load("composer/composer-lock") { + for mut case in load("composer/composer-lock") { let (after, statuses) = composer_run(&case, |_| {}).await; + case.input.extend(walk(&case.dir.join("restored"))); assert_round_trip(&case, &after, &statuses); ran += 1; } diff --git a/docs/design/maven-vendoring.md b/docs/design/maven-vendoring.md new file mode 100644 index 000000000..8b77abf5b --- /dev/null +++ b/docs/design/maven-vendoring.md @@ -0,0 +1,176 @@ +# Vendored Maven reactors and Gradle in v5 + +The JVM backend in `crates/socket-patch-core/src/vendor/jvm/` is enabled by +build shape. No experimental environment variable is required. Maven reactors +use suffixed coordinates; Gradle keeps the original coordinates. Both commit a +local repository so another checkout can build without socket-patch or the +Socket service. + +The existing single-POM backend remains supported. This change adds reactor and +Gradle support without automatically migrating existing single-POM repositories. +Hosted mode keeps its existing behavior. + +## Commands + +```sh +socket-patch vendor +socket-patch vendor --offline +socket-patch vendor --check +socket-patch vendor --check --local-repo /path/to/maven/repository +socket-patch vendor --maven-config=none +socket-patch vendor --revert +``` + +`scan --mode vendored`, `get --mode vendored`, `vendor`, `repair`, `remove` and +`rollback` share the v5 vendored backend. Maven discovery still uses the Maven +local repository; a Gradle-only cache is not a Maven discovery source. An online +service-backed vendoring request can obtain the upstream POM and Gradle module +metadata from the registry when they are not cached locally. + +`vendor --check` is read-only and offline even without `--offline`. It checks +artifact hashes, recorded tree files, wiring, Gradle's index and script, and +unindexed files. `--local-repo` also detects a suffixed Maven jar or POM whose +bytes conflict with the committed copy. Failures produce per-package +`vendor_check_failed` events and exit 1. A patch without a ledger entry fails +with `vendor_ledger_missing`. Offline upstream metadata is identified by the +`vendor_jvm_upstream_unverified` warning; run vendor online to authenticate it +against registry checksums. + +## Maven + +Supported reactors have an explicit root `pom.xml` and `` or +`` declarations, including declarations in profiles. Run vendoring +from the reactor root. A discovered ancestor reactor produces `not_build_root` +instead of allowing a partial submodule edit. + +The patched version is `-socket.`, matching the patch +service. A warm cache of the original version cannot shadow that coordinate. +The backend writes: + +- `.socket/vendor/maven2////`: patched + jar, suffixed POM, SHA-1 sidecars, and an ownership marker. +- `.socket/vendor/maven2/.gitattributes`: disables line-ending conversion of + committed repository bytes. +- A shared file repository and dependency-management pins in each local root. +- Rewrites of conflicting base-version literals and resolved local properties, + including profiles and local parent POMs outside the module list. +- Two lines in `.mvn/maven.config`: offline file protocol access and the local + repository tail at `${session.rootDirectory}/.socket/vendor/maven2`. + +Parent chains must remain within the checkout. Local parent coordinates are +checked before using their properties. Range selectors, unresolved properties, +classifier declarations, conflicting explicit versions and publishing POMs +produce specific warnings; the backend does not silently claim those +unsupported declarations are patched. An enforcer repository ban omits the +fallback repository and warns that the tail requires Maven 3.9.2 or newer. + +Maven 3.9.2+ can read the repository tail without copying jars into `~/.m2` and +without routing through mirrors. Older Maven versions use the fallback file +repository, which copies the suffixed artifact into the local cache. A +`mirrorOf=*` configuration must exclude `socket-patch-vendor` on that fallback +path. + +Maven 3.9.2–3.9.8 has a known interpolation limitation when `-f` is invoked from +outside the root. Use Maven from the root or select `--maven-config=none` on the +first vendoring run. That option uses the fallback file repository only, is +recorded in the ledger, and remains in effect on later runs and repair. Revert +existing auto-config wiring before changing to `none`. Combining `none` with a +repository ban is refused. Version detection reads wrapper properties only; +it never executes Maven. + +## Gradle + +Gradle 6.8+ is supported, including Groovy and Kotlin settings, multi-project +builds, buildSrc, and literal `includeBuild` paths inside the checkout. A +wrapper proving a version below 6.8 is refused before writes; the generated +script also checks the running Gradle version. + +The original GAV is retained under +`.socket/vendor/gradle////`. Lockfiles, version +catalogs and project build scripts stay unchanged. Settings files receive an +apply line for `.socket/gradle/socket-patch.settings.gradle`. Settings plugin +and buildscript classpaths receive an in-block exclusive repository entry +because those classpaths resolve before the apply line runs. + +The static script: + +- Reads `.socket/vendor/gradle-index.tsv`, with sorted GAV/path/SHA-256/UUID rows. +- Hashes files as streams and requires jar and POM rows for every GAV. +- Rejects unsafe coordinates, selectors, mismatched paths and unindexed files. +- Adds an `exclusiveContent` file repository for the patched coordinates to + the relevant repository handlers while respecting repository mode. +- Fails configuration if a vendored artifact no longer matches its index. + +When `gradle/verification-metadata.xml` already exists, vendoring updates the +patched jar's checksum and, when metadata verification is enabled, adds missing +POM and module entries for the artifact, its parents and imported BOMs. +Metadata traversal is bounded and covers parent defaults and child property overrides. The backend +records supplementary entries as shared fragments so either patch can be +reverted first. Existing verification policy and unrelated checksums are kept. +A verification file is never created automatically. + +Android, Kotlin Multiplatform, `available-at` module redirects, conflicting +exclusive-content rules and paths leaving the checkout are refused. Nonliteral +included builds are warned about rather than evaluated. The backend never +executes user build code to discover settings or metadata. + +## Artifact identity and integrity + +Service artifacts pass the existing download-integrity and patched-member +checks. Local jar rebuilds reproduce the server's stored ZIP encoding: upstream +entry order, executable bits, fixed 1980 timestamps, no directory entries or +extra fields, sorted additions, and stripped signature metadata. A checked-in +fixture generated by archiver 7.0.1 tests byte identity, including Unicode paths +and executable entries. The generator is beside the fixture under +`src/vendor/jvm/fixtures/repack/`. + +Online JVM vendoring checks upstream jar and metadata bytes against registry +SHA-512 sidecars, falling back to SHA-1, independently of local cache sidecars. +Offline metadata is accepted with its trust status recorded in the ledger. +Registry metadata fetches use a separate HTTP client and never send Socket API +credentials. `SOCKET_MAVEN_REGISTRY` supports a private mirror. + +Maven v5 does not enable Resolver trusted-checksum processors at build time. +Those processors crash some release reactors and system-scope dependencies, +and do not reliably enforce pins on all supported Resolver versions. +`vendor --check` supplies the offline integrity audit. Gradle always performs +its index check at configuration time; this is separate from Gradle's own +optional dependency-verification policy. + +## Transactions, state and reversal + +New entries use ecosystem `jvm` with Maven PURLs. Old binaries refuse their +revert rather than interpreting them as legacy whole-POM edits. Existing +prototype entries identified by their JVM wiring kinds remain readable. + +The planners return complete file writes and fragment records. The v5 group +commit captures build-file edits, the Gradle index, the owned settings script, +repository `.gitattributes`, and the vendor ledger. Artifact bytes are made +durable before those commit points. All vendor entry points use the same +transaction and recovery mechanism. Ordinary vendoring retains v5's per-patch +success/failure contract; hosted ejection retains its all-or-nothing contract. + +Revert restores per-patch fragments, keeps shared wiring while other patches +still consume it, and preserves user edits that no longer match recorded +fragments. `--preserve-state` restores wiring while retaining artifacts and the +ledger. Patch updates remove superseded Maven trees after the new wiring is +committed. Gradle updates keep the same artifact paths. Unrecognized or forged +paths cannot direct writes outside the backend's allowed files. + +`repair` reconstructs missing/corrupt artifacts using the same backend. The +ledger is required for exact reversal; restore a deleted ledger from version +control. In-place ledger reconstruction remains outside v5's repair contract. + +## Validation and remaining scope + +The implementation is covered by planner, disk-safety, lifecycle and command +integration tests. Real-tool capstones exercise a Maven reactor from fresh +checkouts, root and module invocations, Gradle strict locking and repository +mode, existing verification metadata, offline builds, tamper detection and +byte-exact revert. CI pins Maven 3.6.3, 3.8.9, 3.9.2, 3.9.16 and 4.0.0-rc-6, +and Gradle 6.9.4, 7.6.4, 8.14.3 and 9.8.0, with macOS and Windows coverage. + +Automatic single-POM migration, Maven 4 implicit subproject discovery, +build-time Maven strict pins, dynamic-version repository metadata, classifier +artifacts, creating Gradle verification policy, and online dependency-graph +resolution checks remain separate work. They are not enabled by this release. diff --git a/docs/ecosystems.md b/docs/ecosystems.md index a90488668..8ab95c894 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -19,7 +19,7 @@ The backticked slug in each row is the value `-e`/`--ecosystems` accepts (e.g. | Cargo (`cargo`) | ✅ in-place + `.cargo-checksum.json` rewrite (shared registry-cache caveat — see [Cargo: shared registry cache](#cargo-shared-registry-cache)) | ✅ `[patch.crates-io]` path entry in the root `Cargo.toml` (v5; per-version Socket keys; pre-v5 `.cargo/config*` wiring migrates on re-run) | ✅ per-patch sparse registry (`[registries.socket-patch-]` + Cargo.lock source/checksum); direct dependencies only — a crate another dependency also pulls in is refused, use `--mode vendored`; with no `Cargo.lock` the graph is unknown, so only a project whose sole dependency is the patched crate is redirected | | RubyGems (`gem`) | ✅ in place | ✅ Gemfile + Gemfile.lock path pair (`Gemfile` spelling only — a `gems.rb` project cannot vendor yet) | ✅ per-dep `source` block — edits `gems.rb` + `gems.locked` when present (bundler prefers them over `Gemfile`; spellings that diverge beyond Socket's own edits fail closed with `redirect_gem_gemfile_spellings_diverge`); the `CHECKSUMS` pin needs bundler ≥ 2.6 (older locks get a `redirect_gem_no_checksums_section` warning); a stale pre-redirect materialization that `bundle install` would reuse instead of refetching is flagged `redirect_gem_stale_install` with a prescriptive remedy (see CLI_CONTRACT.md's "Gem stale-install guard") | | Go (`golang`) | ✅ `go.mod` `replace` → `.socket/go-patches/` — see [Go: directory replaces and go.sum](#go-directory-replaces-and-gosum) | ✅ `replace` → the committed vendor tree | ✅ (free tier) fork-style `replace` → `patch.socket.dev/gopatch/` + committed `go.sum` pin; see [Go notes](#go-directory-replaces-and-gosum). Paid hosted patches are unsupported; `redirect_golang_unsupported` names the vendored remedy | -| Maven (`maven`) | ✅ in-place jar patching leaves the `~/.m2` checksum sidecars stale — prefer vendored / hosted, see [Maven & NuGet caveats](#maven--nuget-caveats) | ✅ committed maven2 `file://` repository. A root pom declaring `` (multi-module aggregator) is refused (`vendor_maven_multimodule_unsupported`), and a gradle-only project is refused (`vendor_gradle_unsupported`) | ✅ **pom projects only, fail-closed** — the patched jar is pinned at a Socket-only `-socket.` suffix; `${property}` versions are refused; Gradle gets a manual `exclusiveContent` snippet — see [Maven & NuGet caveats](#maven--nuget-caveats) | +| Maven (`maven`) | ✅ in-place jar patching leaves the `~/.m2` checksum sidecars stale — prefer vendored / hosted, see [Maven & NuGet caveats](#maven--nuget-caveats) | ✅ single-POM repository, suffixed Maven reactor repository, or Gradle 6.8+ same-GAV repository with settings wiring and SHA-256 checks; see [JVM vendoring](design/maven-vendoring.md) | ✅ **pom projects only, fail-closed** — the patched jar is pinned at a Socket-only `-socket.` suffix; `${property}` versions are refused; Gradle gets a manual `exclusiveContent` snippet — see [Maven & NuGet caveats](#maven--nuget-caveats) | | NuGet (`nuget`) | ✅ in-place patching deletes `.nupkg.metadata` and advises on the `.nupkg.sha512` tamper-evidence sidecar — prefer vendored / hosted, see [Maven & NuGet caveats](#maven--nuget-caveats) | ✅ committed folder feed + `packageSourceMapping` + `packages.lock.json` contentHash pin | ✅ `nuget.config` source + source-mapping, `packages.lock.json` contentHash rewrite. See the locked-mode note in [Maven & NuGet caveats](#maven--nuget-caveats) | | Composer (`composer`) | ✅ in place (`vendor/`) | ✅ `composer.lock` `dist: path` rewrite | ✅ `composer.lock` dist url + shasum rewrite; the entry's `source` and `dist.mirrors` are removed. See [composer-compatibility.md](testing/composer-compatibility.md) | | Deno (`deno`) | ✅ in place (the only mode for Deno) | ❌ refused (`vendor_unsupported_ecosystem`) | ❌ not supported | @@ -355,14 +355,15 @@ Honest limits of the Maven and NuGet flows — documented behavior, not bugs: confirmed directory, a POM at a canonical path whose contents disagree with its directory (hand-placed, or a legacy upstream POM with mismatched coordinates) reports the directory's coordinates. -* **Warm `~/.m2` shadowing (vendored Maven only).** Maven consults the *local repository* +* **Warm `~/.m2` shadowing (legacy single-POM vendoring).** Maven consults the *local repository* before any configured ``, so with vendored mode a warm `~/.m2` copy of the same GAV silently wins over the committed `file://` repository — the build succeeds with **unpatched** bytes. Purge it with: `mvn dependency:purge-local-repository -DmanualInclude=:` (the always-on `vendor_maven_local_cache_shadow` warning carries the same one-liner). - Hosted mode is **not** affected: the patched jar lives at the suffixed version, which - no warm `~/.m2` entry can hold. + Reactor vendoring and hosted mode use a suffixed version, so a cached original + version cannot shadow it. Audit conflicting copies of that suffix with + `vendor --check --local-repo `. * **`mirrorOf` mirrors (hosted Maven).** A `settings.xml` `` with `*` (common in corporate environments) reroutes *all* repositories — including the injected `socket-patch-` repository — through the mirror. Because diff --git a/docs/testing/README.md b/docs/testing/README.md index c6970a50a..43438c44d 100644 --- a/docs/testing/README.md +++ b/docs/testing/README.md @@ -27,6 +27,7 @@ setting when that toolchain is required for the check. | npm family | [npm](npm-compatibility.md), [pnpm](pnpm-compatibility.md), [Yarn Berry](yarn-berry-compatibility.md), [Bun](bun-compatibility.md), [vlt](vlt-compatibility.md) | | Python | [uv](uv-compatibility.md), [Poetry](poetry-compatibility.md), [PDM](pdm-compatibility.md), [Pipenv](pipenv-compatibility.md), [Hatch](hatch.md) | | PHP | [Composer](composer-compatibility.md) | +| JVM | [Maven reactor and Gradle vendoring](../design/maven-vendoring.md#validation-and-remaining-scope) | Other ecosystems have Rust and container suites listed in [ecosystem support](../ecosystems.md) and the Docker guide. diff --git a/docs/usage.md b/docs/usage.md index f9d92256a..260123714 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -105,6 +105,24 @@ operation never fetches missing inputs. `repair` can restore missing or corrupt artifacts from a valid ledger when sufficient inputs are available; it cannot reconstruct a lost vendor ledger. Restore a lost ledger from version control. +### Maven reactors and Gradle + +Maven reactors use suffixed versions in `.socket/vendor/maven2`; Gradle 6.8+ +keeps its coordinates and lockfiles, with settings wiring and a configuration-time +SHA-256 check. Existing Gradle verification files are updated. Single-POM Maven +projects retain their existing vendoring behavior. + +```sh +socket-patch vendor --check # read-only offline artifact and wiring audit +socket-patch vendor --check --local-repo ~/.m2/repository # also check Maven cache conflicts +socket-patch scan --mode vendored --maven-config=none # use only the fallback file repository +``` + +`--maven-config=auto` (the default) writes a Maven repository tail; `none` disables +that tail. The choice persists in the ledger. See [JVM vendoring](design/maven-vendoring.md) +for supported shapes, committed files, Maven mirror and `-f` limitations, and +offline operation. + ## OpenVEX Generate an attestation after installing the patched dependencies: