From 8462e7f9c23009b8ad516237cc324a69ce4f5e52 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 05:18:59 +0000 Subject: [PATCH 1/5] Add the vendored Maven and Gradle design Vendored Maven today works for a single pom.xml only. It refuses multi-module reactors and Gradle builds, which covers most enterprise Java. This design says how vendored mode should handle both without dropping any Maven or Gradle version that works today. - Maven uses the server's suffixed version (-socket.), found through a committed maven2 tree. Maven 3.9.2+ reads it through maven.repo.local.tail; every version also gets a file:// fallback repository and dependencyManagement pins in each local root pom. - Gradle keeps the same coordinates. One owned settings script, applied by one line, adds an exclusiveContent file repository and checks the vendored files' sha256 on every configuration. Lockfiles, catalogs and build.gradle files are never edited. - Build-time Maven pins (trusted checksums, deny lines) are opt-in for later. The adversarial review found crashes and silent no-ops in them on common Maven versions. The doc includes the supported-shapes matrix, the failure modes, the refusal-code mapping, the migration plan, the server/CLI split, the test plan, the panel scores and the adversarial review findings. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019ZLLAZfofQZ2ywkgrHkdEZ --- docs/design/maven-vendoring.md | 1138 ++++++++++++++++++++++++++++++++ 1 file changed, 1138 insertions(+) create mode 100644 docs/design/maven-vendoring.md diff --git a/docs/design/maven-vendoring.md b/docs/design/maven-vendoring.md new file mode 100644 index 00000000..e67b4778 --- /dev/null +++ b/docs/design/maven-vendoring.md @@ -0,0 +1,1138 @@ +# socket-patch v5: vendored mode for Maven and Gradle + +> Target path: `docs/design/maven-vendoring.md`. Status: **REVISED after adversarial review.** This document merges four candidate designs (A–D), three judges and five adversarial reviews. +> +> Evidence labels: +> - **V**: verified with real tools. The source is a design (A–D), a judge (J1–J3), the research corpus, or a reviewer: **M** (Maven empirical), **G** (Gradle empirical), **S** (security), **C** (state/revert/code) or **P** (scope/cost). +> - **R**: verified by the earlier research. +> - **I**: inferred, not yet run. Every I that matters is a Phase-0 or capstone item (§12). +> - **X**: refuted by review. The design has been changed to match. + +--- + +## 1. Status and context + +**What ships today** (branch `v5/maven-vendoring`, HEAD 8ae7dc3): +- Vendored Maven works for one single-module `pom.xml`. +- The patched jar keeps its original GAV under `.socket/vendor/maven//`. +- The root pom gets `` at `file://${project.basedir}/…`. +- **No `.mvn/maven.config` is written.** +- CI runs 3.6.3, 3.8.9, 3.9.16 and 4.0.0-rc-6 on Ubuntu, and 3.9.16 on macOS. +- Gradle builds and multi-module reactors are refused. + +**Known defects in the current code:** + +| # | Defect | Severity | Evidence | +|---|---|---|---| +| 1 | When `state.json` is missing, the orphan sweep deletes a vendored directory that `pom.xml` still references. NuGet has the same bug: `nuget.config` is not in `WIRING_FILES`. | high | V (C); NuGet INFERRED | +| 2 | `repair` does not rebuild Maven ledger entries. This is by design under v5-plan WS5: repair re-downloads, then `vendor` re-wires. | – | V (C) | +| 3 | A cold-cache re-vendor trips `debug_assert!` (`vendor.rs:170-175`). It panics **in debug builds only**; a release build gives `vendor_maven_jar_not_found` for a stale artifact. | low | V (C) | +| 4 | Vendoring from a submodule silently loses to its siblings. | medium | V (C) | +| 5 | A patch update (new uuid) followed by `--revert` leaves the old `socket-patch-vendor-` block pointing at a deleted directory. Cause: `carry_forward_wiring` takes the whole-file `original` from the already-wired pom. | medium | V (C) | +| – | Shadowing: a warm `~/.m2` copy of the same GAV beats the vendored jar. Maven 3 then poisons other projects; Maven 4 overwrites the copy. | high | R ×3 | + +**What depscan provides:** +- `POST /patches/package` returns `artifacts[0].integrity{sha256,sha512}` and `registryOverride.identifiers{mavenSuffixedVersion, mavenPomSha256}`. +- A hosted maven2 slice (six files at `-socket.`) and a direct jar download at the base filename. +- Server jars come from archiver 7.0.1 `repackDirToZip`: stored entries, 1980-01-01 dates, upstream entry order, no directory entries, no extra fields, and `META-INF/*.SF|RSA|DSA|EC` stripped. +- `build_failed` rows, classifier and non-jar artifacts, and mixed-case coordinates are never built. + +**Owner constraints:** + +| # | Constraint | Where it is met | +|---|---|---| +| 1 | No regression of supported Maven versions or shapes | §2.1, §4, §9. One exposure remains: `-f` from outside the Maven root on 3.9.2–3.9.8 (§4.2). | +| 2 | Reactors and Gradle | §4, §5 | +| 3 | Strong pinning | §6. Gradle layer 1 always runs. Maven relies on `vendor --check` in v5.0; build-time pins are opt-in in v5.x. | +| 4 | Small diff, byte-exact revert | §3, §7 | +| 5 | Low CI friction | §2.1 (pins opt-in), §5.3 | +| 6 | depscan read-only | §11: v5 needs no depscan change | +| 7 | Fewer refusal codes | §8, counted honestly | + +**Scope:** jar artifacts without a classifier. Android and Kotlin Multiplatform are refused. Hosted mode is unchanged, except that `vendor` ejects it (WS2). + +**Plan conflict.** `docs/design/v5-plan.md` lists "a better vendored Maven story" as "Future work (not v5)… do not invest further now". This document proposes changing that scope. **Owner sign-off (Q0) gates Phase 0.** + +--- + +## 2. Decisions + +### 2.1 v5 scope vs later + +Review showed that trusted checksums, if on by default, cause new failures: +- the D6 compile crash, including on `${revision}` reactors; +- an NPE on `system`-scope dependencies; +- an NPE that hides every "artifact not found" message on 3.9.0–3.9.9; +- deny failures on common plugin classpaths. + +They also enforce nothing on 3.9.2–3.9.3, and only a narrow subset of builds gets a working pin. **v5.0 therefore ships wiring plus offline checks; build-time Maven pins become opt-in in v5.x.** + +| Area | **v5.0 (this release)** | **v5.x (opt-in or deferred)** | +|---|---|---| +| Maven identity | SV = `-socket.`; `maven2/` tree; byte-identical local rebuild (D7) | — | +| How Maven finds the tree | 2-line `maven.config` (offline protocols and tail); fallback repository per local root; `--maven-config=none` | Q11: carry the tail in `jvm.config` | +| Maven declarations | Local-root pins; rewrites of literals and local `${p}`; un-pin on a conflicting literal; `property_unresolved` and `range` are **warnings** | Maven ranges through GA metadata (Q3) | +| Maven build-time pin | None. `vendor --check` (offline) is the pin on every version. | `--maven-pin=auto\|strict`: trusted checksums (7 lines), deny lines, the D6 unsafe-reactor rule, `--maven-allow-unpatched` | +| Maven 4 implicit subprojects | Not detected (I; release candidate only) | Detected | +| Gradle | Same-GAV tree; static script with the layer-1 self-check; Gradle-only index; apply lines in the root, buildSrc and literal `includeBuild` settings; in-block `pluginManagement` entry for settings plugins | — | +| Gradle verification file | **If one already exists:** replace the hash and add parent-chain components. This is required, because without it the build breaks. | Create a scoped file, behind `--gradle-verification=create`, in canonical form | +| Gradle extras | — | GA-level `maven-metadata.xml` for dynamic versions; vendoring `-sources`/`-javadoc` | +| Checks | `vendor --check`, offline, including `--local-repo` | `--check --online`, `--check --resolve` | +| Version detection | Wrapper `distributionUrl` only | — | +| State | Fragment-only records; peer-aware revert of shared fragments; ecosystem `jvm`; repair = re-download and re-wire | — | +| Migration | Reactors and Gradle go to the new backend. Single-pom projects migrate only once the capstone matrix is green (Phase 4). | — | + +### 2.2 Decision record + +**D1. Each tool gets its own identity.** Maven uses `SV = -socket.`; Gradle keeps the same GAV. +- **Maven:** the same GAV shadows, poisons and overwrites (R ×3). SV sorts above the base and below the next release on 3.6.3–4-rc-7 under both version schemes (V, critic), and it is exactly what the server serves. +- **Gradle:** file repositories are never copied into `modules-2` (R), so the same GAV leaves lockfiles, catalogs, `strictly` and platforms untouched. A `-socket.x` version would also sort **below** the base in Gradle. +- **Rejected:** + - **B** (`-0.socket` for both): equals the base on 3.6.3, and Gradle needs rewrites. + - **D** (same GAV on Maven): a warm cache breaks the build again and again. + - **C** (WorkspaceReader): fails open on 3.6, and is fatal on 3.8–3.9.1 unless the extension is published to Central. Kept as Q7. +- **Correction (M, P):** "SV is unique, so nothing is shadowed" was **X**. SV encodes the patch uuid, not the jar bytes. When one SV has two byte sets, 3.9.x strict fails and 3.8.8 silently uses the other project's bytes (V). D7 now guarantees one SV = one byte sequence. + +**D2. Maven finds the tree in two ways that coexist.** +- **3.9.2+ and 4:** `-Dmaven.repo.local.tail=${session.rootDirectory}/.socket/vendor/maven2` in `.mvn/maven.config`. The tail copies nothing into `~/.m2` (V, M, every run), and mirrors do not apply to it. +- **Every version:** a fallback `` at `file://${maven.multiModuleProjectDirectory}/.socket/vendor/maven2` in each local root. On 3.6.3, 3.8.8 and 3.9.0 it copies SV into `~/.m2` (V, M). +- **Correction (M):** with `${session.rootDirectory}` in `maven.config`, **3.9.2–3.9.8 cannot run `mvn -f /pom.xml` from outside the Maven root.** V: rc=1 on 3.9.2 and 3.9.6–3.9.8; rc=0 on 3.9.1, 3.9.9, 3.9.11 and 4-rc-7. No expression works on both 3.9.2–3.9.8 and 4, because 4 does not interpolate `${maven.multiModuleProjectDirectory}` (R). +- **Decision: keep the tail.** It is the only mechanism that survives `mirrorOf *` and enforcer repository bans on 3.9.2+. In addition: + - warn `maven_f_outside_root` when the Maven root is not the VCS root and the wrapper does not prove a version outside 3.9.2–3.9.8; + - offer `--maven-config=none`, recorded in the ledger, which relies on the fallback repository only and is safe because of D7. +- Hosted mode already ships the same lines (§11 #1). + +**D3. Pins go into local roots; conflicting declarations are rewritten in place.** +- **Local root:** walk each reactor module's `` chain while the parent file is in the checkout and its GAV matches. The topmost pom reached is the local root. A `relativePath` that names a directory gets `/pom.xml` appended (V, M). +- **Rewrite scope:** every reactor pom **and every in-checkout pom on any reactor module's parent chain**, profiles included. + - Correction (M): a middle local parent outside `` kept its literal management. That beat the root pin: silently on 3.6.3, and as a deny failure on 3.9.11 (V). +- **Pin:** each local root gets a `` entry for SV. Literal and locally resolved `${p}` declarations of the base become the literal SV. +- **Verified on six versions (M):** the local-parent pin beats a module's own imported BOM and covers transitive use, and a remote-parent module's own pin wins. + +**D4. Gradle is wired by one owned static settings script.** +- One apply line goes into the root settings file, `buildSrc`, and the settings file of each literal `includeBuild`. +- **When a settings file has settings-level `plugins{}`, it also gets one in-block `pluginManagement.repositories` entry per patched GAV** (§5.1). +- **Correction (G):** a settings plugin whose dependencies include the patched GAV (foojay 0.8.0 → gson 2.10.1) silently unpatched buildSrc, build-logic and root-script classes on 7.6.4 (V). The in-block entry fixed it (V, 7.6.4). +- The main matrix passed 24/24: 2 DSLs × 3 `repositoriesMode` values × 6.9.4, 7.6.4, 8.14.3 and 9.8.0 (V, G). + +**D5. The deny line moves to v5.x strict mode, 3.9.4+ only.** +- **What it catches:** missed declarations, and relocations on 3.9.11 and 4 (V, S). +- **Where it breaks builds:** plugin classpaths that project dependency management cannot reach, for example `spotbugs-maven-plugin:4.7.3.6:check` and `dependency:go-offline` (V, M). The only escape, `--maven-allow-unpatched`, drops the guard for the whole purl. +- **What it misses:** graph-only tools such as `dependency:tree` (V, M). +- **In strict mode:** vendor warns about the known plugin failures, and `vendor_jvm_upstream_unavailable` recommends `mvn dependency:resolve`. +- The resolver has no opt-out scoped to a plugin. + +**D6. The pin-unsafe reactor rule (v5.x strict only).** The trusted-checksum post-processor has two crashes: +- It fails `compile` and `test` with `IOException: Is a directory` when it hashes a sibling module's `target/classes`. V: D, J1, J2, M, P; it also happens on 3.9.0, which closes Q4. +- It throws `NullPointerException: artifactRepository is null` on any `system`-scope dependency, direct or transitive. V (S): 3.9.6, 3.9.9, 3.9.11 and 4-rc-7. + +A reactor is **pin-unsafe** when either condition holds: +- **(a)** both of these are true: + - a module's effective version does not end in `-SNAPSHOT`, **or contains `${`**. Correction (M, P): `-Drevision=1.2.3 test` crashes a reactor whose version looks like `1-SNAPSHOT` (V). + - some module has a sibling dependency of type `jar` or `test-jar`, or one with a classifier. Dependencies with `type=pom` or `scope=import`, and plugin ``, do not crash (V, M). +- **(b)** any reactor pom, profiles included, has a `system`-scope dependency. + +**Modes:** +- `--maven-pin=auto` falls back to the v5.0 wiring and warns `reactor_release_compile` or `system_scope`. +- `--maven-pin=strict` pins anyway, for teams that only run `package` and later phases. + +A transitive `system` dependency cannot be detected statically. It fails closed, and this is documented. + +**D7. One SV = one byte sequence.** +- **Server jar first:** the server jar is used whenever the service has built it, and `mavenPomSha256` verifies the pom. +- **Same recipe locally:** the local rebuild reproduces the server recipe from §1: stored entries, 1980-01-01 dates, upstream order, no directory entries, no extra fields. The old Deflate rebuild made byte identity **X** (C, M). A conformance test against server jars (§12.4) keeps this at I until it is green. +- **Which bytes are kept:** + - `source:"service"` bytes are kept for the life of the uuid. + - `source:"local"` bytes are **replaced by service bytes** on the first online run where they differ. This one-time change closes the cross-project collision (M, P) and the `--check --online` evasion (S). +- **Rejected:** a per-project repository id (P). It fixes 3.9.11, but 3.8.8 still keeps the other project's bytes (V, P). + +**D8. `.socket/vendor/gradle-index.tsv` is a derived index, for Gradle only.** +- **Format:** columns GAV, path, sha256 and uuid; header `#socket-patch-gradle-index 1`; rows sorted; LF line endings. +- **Regeneration:** rebuilt on every run from the ledger and the markers. +- **Maven rows were dropped (P):** the script ignored them, and Maven liveness comes from the ledger, the markers and SV references. + +**D9. Gradle integrity.** +- **Layer 1 (always on).** At configuration time the script: + - streams a sha256 of every index row. Correction (G): `f.bytes` ran out of heap on a 388 MB jar at the default 512 MB heap (V). + - requires a jar row and a pom row per GAV. + - fails on any file in a version directory that is not in the index. Correction (S): a deleted row let a foreign jar through (V). + - rejects version-selector syntax. Correction (S): `includeVersion` treats `+` and `[1,3)` as selectors (V). + + Layer 1 still runs with lenient or off verification (V, G, S) and with `GRADLE_USER_HOME` overrides. A tamper invalidates the configuration cache (V, G: 7.6.4, 8.14.3, 9.8.0). +- **Layer 2, v5.0: update an existing `gradle/verification-metadata.xml`.** Replace the jar hash, and add `` entries for the vendored pom's parent chain and import-scope BOMs. + - Correction (G): without these entries, every `.module`-publishing dependency failed on all four versions (V). With them, it passed on 8.14.3 (V). +- **Creating a scoped file moves to v5.x** (`--gradle-verification=create`), for three reasons: + - Renovate rewrites it on every Gradle PR (P, V); + - it silently cancels out a later adoption of verification (P, S, V); + - when a settings plugin is missed, it breaks the build in a way that re-vendoring cannot fix (G, V). +- **Rejected:** B's `afterResolve` guard, because `exclusiveContent` already blocks upstream in wired scopes. A conflict that picks a newer vulnerable version (S-F10) belongs to `--check --resolve`. + +**D10. Lockfiles, catalogs and `build.gradle*` are never edited.** Lockfiles stay byte-unchanged on all four Gradle versions, including after configuration-cache and isolated-projects runs (V, G). A Maven range on the patched GA is **warned, not refused** (C, P): the declaration is left alone and is probably unpatched. + +**D11. Keep the CLI port of `suffixMavenPom`.** The offline capstone and the Docker `--network none` leg depend on it. It shares golden test files with depscan's `maven-suffix.ts`. + +**D12. Maven version detection reads only the wrapper** (`.mvn/wrapper/maven-wrapper.properties`, `distributionUrl`), and only to produce warnings. The `mvn -v` probe is dropped (P). The wiring is the same on every version. + +**D13. Migration is automatic but phased by shape (§7.6). Revert is byte-exact and independent of order (§7.3).** + +**D14. Every owned tree root gets a `.gitattributes` containing `* -text`.** +- **Where:** `.socket/vendor/maven2/`, `.socket/vendor/gradle/`, and `.mvn/checksums/` in v5.x. +- **Correction (G):** a `core.autocrlf=true` clone (the Git for Windows default) put CRLF line endings into `.pom` and `.module` files, and layer 1 failed. With `* -text` it passed (V). +- This mirrors npm's `UUID_GITATTRIBUTES` (`npm_dir.rs:47`). Index parsers strip a trailing `\r`. + +**D15. `unsafe_coordinates` gets a JVM grammar.** +- g and a must match `^[A-Za-z0-9_.-]+$`. +- v must contain no whitespace, no control characters and none of `"<>|?*[](),\`. It must not end in `+` or start with `latest.`. +- Before this, `is_safe_single_segment` accepted `+$(|"<&`, tab and newline, which allowed row injection and regex escapes (S). + +--- + +## 3. On-disk layout + +Everything below is committed. Files are created only for the tool that is detected. + +``` +.socket/ + vendor/ + state.json # existing ledger; new entries use ecosystem "jvm" (§7.1) + gradle-index.tsv # Gradle only; derived (D8) + maven2/ # Maven only; RESERVED name for the sweep + .gitattributes # "* -text\n" (D14) + org/apache/commons/commons-text/1.10.0-socket.abcd1234/ + commons-text-1.10.0-socket.abcd1234.jar # server bytes, or local rebuild with the same recipe (D7) + commons-text-1.10.0-socket.abcd1234.jar.sha1 # bare 40-hex, no newline (fallback repository; Maven 4 requires it) + commons-text-1.10.0-socket.abcd1234.pom # server suffixed pom, or CLI port output + commons-text-1.10.0-socket.abcd1234.pom.sha1 + socket-patch.vendor.json # marker + gradle/ # Gradle only; RESERVED name + .gitattributes + com/google/code/gson/gson/2.10.1/ + gson-2.10.1.jar gson-2.10.1.pom [gson-2.10.1.module] socket-patch.vendor.json + gradle/socket-patch.settings.gradle # static owned script (§5.2) +.mvn/maven.config # +2 lines (v5.0); +7 more in v5.x strict +/pom.xml # 1 shared + 1 depMgmt entry per patch +/pom.xml # in-place rewrites, only where needed +settings.gradle(.kts), buildSrc/…, /… # +1 apply line; +1 in-block entry per patched GAV when the file has plugins{} +gradle/verification-metadata.xml # only if it already exists: hash replaced, parent-chain components added +``` + +- **The two trees are never shared.** If the Maven fallback repository could see a same-GAV jar, it would copy it into `~/.m2` and poison other projects. +- **No other sidecars are written:** no `.md5`, `.sha256`, `.asc` or `_remote.repositories`. +- **Marker `socket-patch.vendor.json`:** sorted keys, 2-space indent, trailing newline. Fields: + - `schema`, `uuid`, `purl`, `tool`, `version`; + - `source`: `service`, `local` or `local-unverified`; + - `files{name:{sha256,size}}`; + - `replaced`: the verbatim original verification `` element, used for revert without a ledger. +- **Index row:** `com.google.code.gson:gson:2.10.1com/google/code/gson/gson/2.10.1/gson-2.10.1.jar`. + +**Committed diff for one patch in v5.0** (P, V): +- **20-module reactor with parent = root:** about 30 lines to review by hand (root pom +15, 1 line per literal module, 2 `maven.config` lines) plus a 5-file tree. The worst case is a remote parent in every module: 20 local roots × about 15 lines. +- **10-project Gradle build:** about 65 lines in 7 files (1 settings line, the 60-line static script, 2–3 index rows) plus a 4-file tree. + +--- + +## 4. Maven wiring + +### 4.1 Discovery (static; never runs Maven) + +- **Build root:** the cwd, which must hold `pom.xml`. If the cwd pom's `` resolves to a pom above it whose `` lists the cwd, refuse with `not_build_root` and the root path. This fixes bug #4. +- **Reactor:** the recursive union of `` and Maven 4 ``, including those inside every ``. Taking a superset is safe. +- **Parser:** the existing comment and profile masking (`maven_repo.rs:1138-1216`) plus CDATA masking (`maven_pom.rs:204-240`), extended to record byte spans. +- **Parents:** `relativePath` defaults to `../pom.xml`. A directory gets `/pom.xml` appended. `` means the parent is remote. A parent is local only if its file is in the checkout and its GAV matches. +- **Refused with `vendor_jvm_shape_unsupported`:** + - `module_outside_root`; + - `module_path_unresolvable` (a `${` in ``, or a missing pom); + - `nested_mvn_dir`; + - `build_file_unreadable`; + - `build_file_outside_root`: a symlink that leaves the checkout. Symlinks inside the checkout are followed, as today. +- **Also computed:** + - pin-unsafe (D6, v5.x); + - enforcer `requireNoRepositories` / `bannedRepositories`; + - ``; + - classifier declarations of a patched GA. These warn `classifier_declared`, because the classifier variant bypasses the pin (S). + +### 4.2 `.mvn/maven.config` + +**v5.0: 2 lines.** V on 3.6.3, 3.8.8, 3.9.0, 3.9.2, 3.9.11 and 4-rc-7 (A, M), except `-f` from outside the root (see the table below). +``` +-Daether.offline.protocols=file +-Dmaven.repo.local.tail=${session.rootDirectory}/.socket/vendor/maven2 +``` + +**v5.x strict: 7 more lines.** The `record=false` line is new (S): +- Without it, a `settings.xml` profile, `MAVEN_OPTS` or Maven 4's `~/.m2/maven-user.properties` can set `record=true`. A tampered jar then passes, and the committed pins are rewritten to the attacker's hash (V on 3.9.11 and 4-rc-7). +- User properties from `maven.config` take precedence over all three channels (V, S). +``` +-Daether.trustedChecksumsSource.summaryFile=true +-Daether.trustedChecksumsSource.summaryFile.basedir=${session.rootDirectory}/.mvn/checksums +-Daether.trustedChecksumsSource.summaryFile.originAware=false +-Daether.artifactResolver.postProcessor.trustedChecksums=true +-Daether.artifactResolver.postProcessor.trustedChecksums.checksumAlgorithms=SHA-256 +-Daether.artifactResolver.postProcessor.trustedChecksums.failIfMissing=false +-Daether.artifactResolver.postProcessor.trustedChecksums.record=false +``` + +**Behaviour by Maven version:** + +| Version | Tail | `-f /pom.xml` from outside the root | Strict pins | +|---|---|---|---| +| 3.6.3, 3.8.x | ignored; the fallback repository is used | OK (V) | ignored | +| 3.9.0–3.9.1 | the literal string is not interpolated, so it has no effect; the fallback is used | OK (V) | not enforced (the basedir stays literal), but the D6 crash and the §10 #9 NPE still happen (V, 3.9.0) | +| 3.9.2–3.9.3 | used | **fails**: `Illegal use of undefined property: session.rootDirectory` (V 3.9.2; I 3.9.3) | **not enforced**: tamper and deny both pass (V, M and S). The change is between resolver 1.9.13 and 1.9.14. | +| 3.9.4–3.9.8 | used | **fails** (V: 3.9.6–3.9.8) | enforced (V: 3.9.4–3.9.6) | +| 3.9.9+ | used | OK (V: 3.9.9, 3.9.11) | enforced (V) | +| 4.0.0-rc-7 | used | OK (V) | enforced (V) | + +**Merge rules.** `maven.config` has no comment syntax (3.9.0 and 3.9.1 reject `#` lines), so our lines are identified by their exact text. +- **An identical line is already present** (from hosted mode or another JVM entry): record it as a shared fragment this entry depends on (`adopt`, §7.3). Never duplicate it. +- **An existing `-Dmaven.repo.local.tail=X`:** rewrite it to `X,${session.rootDirectory}/.socket/vendor/maven2` and record the original. The list form is V on 3.9.11 and 4. +- **An existing trusted-checksum configuration (strict):** + - with `originAware=false` and a basedir we can resolve: reuse it; + - with `originAware=true`: add nothing and warn `trusted_checksums_origin_aware`. +- **No trailing newline:** add one and record `addedLeadingNewline`. +- **User lines** such as `-T4`, `-ntp` and `--fail-at-end` coexist on every version (V, M). + +### 4.3 `.mvn/checksums/checksums.sha256` (v5.x strict only) + +- **Per patch:** a jar pin, a pom pin and a deny line (the deny line is omitted with `--maven-allow-unpatched`). +- **Format:** lines are sorted by path, with exactly **two spaces** between hash and path. With one space, 3.9.11 silently stops enforcing (V, M). Golden tests enforce the format. +- **No header comment:** hosted `merge_checksums` (`redirect/mod.rs:7068-7090`) deletes it (C), and its text blamed tampering when the real cause was an SV collision (M). This makes Q5 moot. + +``` +2ec2d4b2…25f6 org/apache/commons/commons-text/1.10.0-socket.abcd1234/commons-text-1.10.0-socket.abcd1234.jar + org/apache/commons/commons-text/1.10.0-socket.abcd1234/commons-text-1.10.0-socket.abcd1234.pom +socket-patch-refuses-unpatched-org.apache.commons:commons-text:1.10.0 org/apache/commons/commons-text/1.10.0/commons-text-1.10.0.jar +``` + +### 4.4 Fallback repository + +There is one block per local root, shared by all patches, with id `socket-patch-vendor`. +- **Placement:** inside an existing top-level ``, or in a new one before ``. The existing `build_repo_edit` anchor logic does the insertion, with indentation detected from the file. +- **Markers:** the begin and end comment lines let revert cut the block without a ledger. + +```xml + + + socket-patch-vendor + file://${maven.multiModuleProjectDirectory}/.socket/vendor/maven2 + truealwaysfail + false + + +``` + +- `updatePolicy=always` defeats negative caching left by runs before vendoring (R). +- **Enforcer bans repositories:** the block is omitted, with warning `maven_fallback_omitted`. These projects need 3.9.2+. +- **`mirrorOf *`:** vendor reads `~/.m2/settings.xml` and `$MAVEN_HOME/conf/settings.xml` (read-only). It warns `maven_mirror_of_all` for a `mirrorOf *` mirror (but not `external:*`): + - before 3.9.2 the build fails with "Could not find …-socket…"; + - the fix is `external:*` or `*,!socket-patch-vendor`; + - today such builds are green but silently unpatched, so this loud change is deliberate and goes in the CHANGELOG (P). + +### 4.5 Pin and declaration rewrites + +**Pin.** The pin goes into each local root's top-level ``. +- If an entry for g:a already exists (type jar, no classifier) and its version is the literal base or a local `${p}` equal to the base, rewrite its `` to SV. +- Otherwise insert the entry as the first child. If the section is missing, create it before ``, `` or ``, in that order of preference. + +```xml + + + org.apache.commons + commons-text + 1.10.0-socket.abcd1234 + +``` + +**Declaration rewrites.** The scope is D3's: every reactor pom and every in-checkout pom on any reactor parent chain, profiles included. `` is excluded. + +| Declared version of g:a (type jar, no classifier) | Action | +|---|---| +| Literal base | Rewrite to SV (V). | +| `${p}` that resolves to the base (own properties, then local parents, then `-D` values in `.mvn/maven.config`) | Rewrite that `` to the literal SV. The property itself is left alone (V). | +| `${p}` that cannot be resolved in the checkout | Leave it, and warn `property_unresolved{file,line}`. This used to be a refusal (C, P). | +| Range, `LATEST` or `RELEASE` | Leave it, and warn `range{file,line}`. This used to be a refusal. | +| A different literal | Leave it, **do not pin that module's local root**, and warn `conflicting_literal_version{file,line}`. | + +A rewritten SV contains the base (`-socket.`), so revert can restore it even without a ledger. + +### 4.6 Per shape + +| Shape | Edits | +|---|---| +| Single pom | 2 `maven.config` lines, 1 repository, 1 pin (+1 rewrite if the version is a literal) | +| Reactor, parent = root | 1 local root | +| Aggregator separate from the parent | The pin goes in `parent/pom.xml`; the aggregator is untouched (V) | +| Middle local parent not in `` | Its literals are rewritten (D3) | +| Remote parent (Boot-style) | Each module is its own local root (V) | +| Wrapper | Uses the same `.mvn/` and behaves like the Maven version it pins (V, M: 3.9.11, including `mvnw -f` from outside the root) | + +--- + +## 5. Gradle wiring + +### 5.1 Apply lines and the in-block entry + +| File | Line (appended as the last line) | +|---|---| +| Root `settings.gradle` | `apply from: '.socket/gradle/socket-patch.settings.gradle'` | +| Root `settings.gradle.kts` | `apply(from = ".socket/gradle/socket-patch.settings.gradle")` | +| `buildSrc/settings.gradle(.kts)`, created if missing, with the DSL taken from `buildSrc/build.gradle*` | Same form, with a `../` prefix | +| Each literal `includeBuild('

')`, including inside `pluginManagement{}`, found recursively, with a target inside the root | Same form, with the computed relative prefix | + +**In-block entry.** When a settings file has settings-level `plugins{}`, one line per patched GAV becomes the **first** statement of `pluginManagement { repositories { … } }`. +- If the file had no `pluginManagement` repositories, the block is created with our entry followed by `gradlePluginPortal()`. That keeps Gradle's default of an implicit Plugin Portal. +- The static script later finds `socketPatchVendor` in that handler and skips it. +- V on 7.6.4 with Groovy (G). The Kotlin form and 6.9.4, 8.14.3 and 9.8.0 are I (P2). + +```groovy + exclusiveContent { forRepository { maven { name = 'socketPatchVendor'; url = new File(settingsDir, '.socket/vendor/gradle').toURI() } }; filter { includeVersion('com.google.code.gson', 'gson', '2.10.1') } } // socket-patch +``` + +**Other cases:** +- **Settings `buildscript{}`:** a literal patched GA is refused (`gradle_settings_classpath`). Otherwise the same line goes into its `repositories{}`, which is non-empty whenever a classpath is declared (I). +- **Non-literal `includeBuild`, or a target outside the root:** warn `unwired_build_logic`. That scope is silently unpatched in v5.0; the v5.x created verification file makes it fail closed. +- **No settings file:** create one that contains only the apply line. +- **An ancestor settings file includes this directory:** refuse `not_build_root`. +- **Project-level `plugins{}` with the default Plugin Portal only:** patched with no extra wiring, because `socketFollow(p.buildscript.repositories)` sees the repositories Gradle adds for plugin resolution (V, G and P: all four versions). + +### 5.2 Owned script `.socket/gradle/socket-patch.settings.gradle` + +The script is static: its bytes change only with a CLI release. The earlier version was V (24/24, G). The streaming digest, the path checks and the unindexed-file check are new, so this revision stays **I until P2**. + +```groovy +// 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_.-]+/) || !(gav[1] ==~ /[A-Za-z0-9_.-]+/) || + !(gav[2] ==~ /[A-Za-z0-9_.+-]+/) || gav[2].endsWith('+') || gav[2].startsWith('latest.') || + !(c[2] ==~ /[0-9a-f]{64}/)) { + socketFail("malformed row in ${socketIndex}: ${row}") + } + def dir = "${gav[0].replace('.', '/')}/${gav[1]}/${gav[2]}" + def name = c[1].startsWith(dir + '/') ? c[1].substring(dir.length() + 1) : '' + if (!name.startsWith("${gav[1]}-${gav[2]}") || 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 socketWire = { RepositoryHandler repos -> + if (repos.findByName(socketName) != null) { 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 { it.name != socketName }) { socketWire(repos) } + repos.whenObjectAdded { ArtifactRepository r -> if (r.name != socketName) { 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) + } +} +``` + +**Rules and their evidence:** +- **`includeVersion`, not `includeModule`**, so upgrades still come from the user's own repositories. +- **Never add our repository to an empty handler.** Doing so would switch off the settings repositories, or drop the implicit Plugin Portal. +- **`File.toURI()`** avoids the Kotlin per-project `uri()` trap and Windows drive-letter URLs. +- **Only `name =` assignment syntax is used**, so there are no deprecations on 9.8.0 (V, G: the warning set is identical to the baseline). +- **`repositoriesMode` is never set.** +- **Isolated projects:** no problems reported (V, G: 8.14.3 and 9.8.0). +- **Cost:** the script hashes every row once per settings evaluation (root, buildSrc and each included build). That is negligible at normal artifact sizes (V, G). Q12 covers hashing only in the root build. + +### 5.3 Existing `gradle/verification-metadata.xml` (v5.0) + +- **Jar entry:** in the `` for g:a:v, the whole `` element is replaced by a multi-line element in Gradle's canonical format with the patched sha256. The original element is recorded verbatim, in the ledger and in the marker's `replaced` field. +- **Parent chain and BOMs** (when `true`): + - insert any missing `` for the vendored pom's parents and import-scope BOMs, each with a `.pom` entry and, when published, a `.module` entry; + - hashes come from `modules-2`, `~/.m2` or Central, verified as in §6.1; + - each insert is recorded in the ledger. +- **Missing component for the patched GAV:** insert a sorted `` with jar, pom and (if present) `.module` entries. +- **Never touched:** `` and keys. A PGP-trusted setup still verifies the sha256 (V, S). +- **Identifying our entries:** by component GAV, artifact name and a sha256 equal to the marker's, **not** by the `origin` attribute. `--write-verification-metadata` and Renovate rewrite `origin` (P). +- **Warnings:** a user trust rule matching g:a:v gives `gradle_trusted_by_user_rule`, and `org.gradle.dependency.verification=lenient|off` gives `gradle_verification_lenient`. Layer 1 still pins in both cases. +- **The v5.x created file** will: + - use Gradle's canonical order and formatting, with a golden test that asserts `--write-verification-metadata sha256` changes nothing; + - quote regexes with `\Q…\E`, use one alternation per g:a (`^(?!\Q1.0\E$|\Q2.0\E$).*$`) and XML-escape attributes. S, V: escaping only `.` as `[.]` trusted `1.0+1`, and separate version rules trusted each other; + - have no `file=` sources rule. + +### 5.4 Per shape + +- **Supported (V, §9):** Groovy and Kotlin DSL, every `repositoriesMode`, catalogs, locking, configuration cache, isolated projects, buildSrc, included build-logic and the root buildscript. +- **Nothing to edit (I):** `platform`, `enforcedPlatform`, `strictly` and Spring dependency-management. +- **Classifier artifacts of a patched GAV** (`-sources`, `-javadoc`, native classifiers) are routed to our repository and are not found there (V, G): + - IDE "download sources" returns nothing; + - a native classifier fails loudly with "Could not find". + + This is documented. Vendoring the upstream classifiers verbatim is v5.x. +- **A user `exclusiveContent` that includes the patched GA:** refused when a literal scan finds it; otherwise "Could not find" (R). +- **Android (`com.android.*`), KMP, or a `.module` with `available-at`:** refused with `android_or_kmp`. + +--- + +## 6. Integrity + +### 6.1 Install time + +| Path | Checks | +|---|---| +| Maven, server | GET `////{a-SV.jar, .jar.sha1, a-SV.pom, .pom.sha1}`. The jar must match `integrity.sha256` and `integrity.sha512`, the pom must match `mavenPomSha256`, and each sidecar must match the recomputed sha1. Any mismatch fails with `vendor_prebuilt_integrity_mismatch` and nothing is written. | +| Gradle, server | The jar comes from the direct base-filename URL and is checked the same way. The upstream `.pom`, `.module`, parent-chain poms and BOM poms come from `modules-2`, `~/.m2` or Central. **When online, each is verified against Central's `.sha512` (falling back to `.sha1`) over TLS, whatever its source** (S: cache directory names and sidecars vouch only for themselves). Offline, the file is accepted and marked `source:"local-unverified"`; `--check` reports it, and the next online run re-verifies. The `.module` is copied when the pom carries `published-with-gradle-metadata`. If that `.module` cannot be obtained, refuse `vendor_jvm_upstream_unavailable`, because the dependency graph would change (R). | +| Local rebuild (service off, offline, `pending_build`, or no SV) | When online, the **base jar** is verified against Central's `.sha512`/`.sha1` (today there is no check at all: `maven_repo.rs:776-805`). Patched members must match the manifest `afterHash`. Signatures are stripped, and the server recipe is reproduced (D7). For Maven, the pom comes from the CLI port. | + +### 6.2 Build time + +| Tool / version | v5.0 default | v5.x strict | +|---|---|---| +| Maven 3.6.3–3.9.1 | The fallback `.sha1` with `checksumPolicy=fail` checks the first copy only; `~/.m2` copies are not re-checked | not enforced; on 3.9.0–3.9.1 the D6 crash and the NPE still happen (V) | +| Maven 3.9.2–3.9.3 | Tail, with no build-time check. A poisoned SV copy in the `~/.m2` head wins (V, S). | **not enforced** (V); vendor warns `maven_version_lt_3_9_4` | +| Maven 3.9.4+, 4-rc | Same as 3.9.2–3.9.3 | sha256 of the SV jar and pom on every resolution, including hits in the `~/.m2` head (V); the deny line fails on the unpatched base (V) | +| Gradle 6.8–9.x | Layer 1 on every configuration (V); layer 2 when the user already has a verification file (V) | plus the created scoped file | + +On every Maven version, `vendor --check` is the content pin in v5.0 (§6.3). + +### 6.3 `vendor --check` (v5.0, offline) + +- **Consistency:** the index, ledger, markers, file hashes, `maven.config` lines, pom fragments, apply lines, in-block entries, `.gitattributes` files and verification entries all agree, and no `` sits under a component we own. +- **`--local-repo`:** every SV copy in the effective local repository (`settings.xml `, `-Dmaven.repo.local` or `~/.m2`) must hash to the committed bytes. This detects poisoned or colliding copies (M, P, S). +- **Degraded options are reported:** `--maven-config=none`, `--maven-allow-unpatched`, `--no-gradle-verification`, auto-degrade and `local-unverified`. `--check --strict` exits non-zero on any of them, because each is a one-line ledger edit (S). +- **v5.x `--online`:** for every entry the server has built, compare against `/patches/package` integrity **whatever the recorded `source`**. Fail when the server has bytes but the marker claims `local` (S). +- **v5.x `--resolve`:** run `dependency:list` / `dependencies` to catch: + - transitive-only mismatches; + - graph-only reports of the unpatched version; + - Gradle conflicts that pick a newer vulnerable version. + +### 6.4 Threats + +**Scope.** The threat model covers content poisoning of committed files, of `~/.m2/repository` and of `modules-2`. Code execution through `~/.m2/extensions.xml` (Maven 4), `GRADLE_USER_HOME/init.d` or `gradle.properties` is **out of scope**. Gradle layer 1 is the control that still works when `GRADLE_USER_HOME` overrides are present. + +| # | Threat | Defence | +|---|---|---| +| 1 | Jar replaced and every pin updated in the same PR | No pin inside the repo can catch this. Use CODEOWNERS on `.socket/**`, `.mvn/**` and `gradle/verification-metadata.xml` (vendor output suggests it), plus `vendor --check --strict` in CI. v5.x adds `--online`. | +| 2 | Poisoned `~/.m2` or CI cache holding the SV | Undetected at build time in v5.0 on every version; detected by `vendor --check --local-repo`. Advice: exclude `**/*-socket.*` from CI cache paths. In v5.x, strict mode on 3.9.4+ fails the build (V). | +| 3 | Another project on the same machine plants the SV (the SV is public and deterministic) | Same as #2. D7 removes the harmless version of this, where two projects hold different bytes for one SV. | +| 4 | Poisoned cache at vendor time | §6.1 verifies against Central over TLS; offline results are marked `local-unverified`. | +| 5 | Bypass switches (`record=true`, `trustedChecksums=false`, `--dependency-verification=off`) | Strict mode pins `record=false` in `maven.config`. The others cannot be prevented. Gradle layer 1 still runs. `--check` flags committed forms, including a `record` setting in `.mvn/jvm.config`. | +| 6 | Stale patch | A new uuid gives a new SV and a new directory. The old one is swept once nothing live references it. | + +--- + +## 7. State, ledger, revert, repair and migration + +### 7.1 Ledger entries and records + +- **New entries use ecosystem `jvm` (C).** + - Older binaries then fail closed at `vendor.rs:247` ("no vendor backend"). Without this they treat unknown kinds as drift and silently drop the entry (the drop is V, C). + - `--ecosystems maven|gradle` maps to `jvm`, filtered by `tool`; `ecosystem_in_scope` follows. +- **Records keep the `WiringRecord{file, kind, action, key, original, new}` struct.** + - `action` stays `Added` or `Rewritten`. There is no new enum variant, so v4 can still parse the ledger. + - **Fragments only:** `original` and `new` never hold a whole file, and no JVM kind is in `WHOLE_FILE_KINDS`. P, C, V: whole-file records gave a 42 KB ledger for a 10 KB pom with 2 patches. + - Ops are stored as JSON in `new`. + +| Kind | File | Shared or per patch | Ops in `new` | +|---|---|---|---| +| `maven_pom_fragment` | each edited pom | repository block and created sections: shared; pin and rewrites: per patch | `insert_block{anchorKey,text}`, `rewrite_version{depKey,from,to}`, `create_section{name}` | +| `maven_config_line` | `.mvn/maven.config` | shared | `add{line}`, `rewrite{from,to}`, `adopt{line}` (line already present; revert does nothing) | +| `maven_checksums_line` (v5.x) | `.mvn/checksums/checksums.sha256` | per patch | `add{line}` | +| `gradle_settings_fragment` | each settings file | shared | `add{line}`, `create_file`, `create_block{text}` | +| `jvm_owned_file` | script, index, `.gitattributes` | shared | `create` (the index is regenerated) | +| `gradle_verification_fragment` | verification file | jar: per patch; parent-chain components: shared | `replace{original}`, `insert{text}` | +| `jvm_vendor_tree` | tree files and markers | per patch | — | +| `created_dir` | `.mvn`, `.mvn/checksums`, `.socket/gradle` | shared | — | +| `jvm_option` | — | per patch or shared | `maven_config_none`, `maven_pin`, `maven_allow_unpatched`, `no_gradle_verification` | + +### 7.2 Write path + +Writes go through the existing **GroupCommit** journal (`utils/group_commit.rs`), not ad-hoc temp-file-and-rename (C). +- **Captured set:** extended to `.socket/vendor/gradle-index.tsv`, `.socket/gradle/socket-patch.settings.gradle` and the trees' `.gitattributes`, with the journal-replay path filter to match. Before this, these files were written before the commit point. +- **Planning:** all JVM entries of a run are planned **before** the vendor loop. One JVM refusal refuses every JVM entry, so a pom never ends up with legacy and v5 wiring side by side. +- **Finalize hook:** after the loop, and at every revert site, derived files are regenerated from the live entries in memory. Derived files are the index, the script's presence and, in v5.x, the trust rules. +- **Legacy directory deletion:** runs after `group.commit()`, through a new post-commit cleanup reason, `migrated_legacy_uuid_dir`. The existing `stale_artifacts` deferral only fires when the uuid changes. + +### 7.3 Byte-exact revert, independent of order + +The previously cited "liveness contract" (`mod.rs:801-898`) covers only one entry. Under that model, reverting in the order the entries were vendored left 2 lines behind (C, V by model). It is replaced by: +1. **Each JVM entry records every shared fragment it relies on:** the repository block, created sections, `maven.config` lines, apply lines, in-block entries, owned files and created directories. Each record is a full copy of the insertion record from the entry that created it. +2. **`RevertOpts` gains `live_peers: &[VendorEntry]`:** the in-memory state after the entries being reverted have been removed. A shared fragment is removed only when no peer lists it, so the result does not depend on revert order. +3. **Every caller passes peers:** + - `run_revert`, `reconcile_dropped` and both legs of `run_vendor_gc`; + - the vendored leg of rollback (`rollback.rs:879`); + - both `remove` paths, and repair. + + Entries reverted in one invocation are never live, with or without `--preserve-state`. +4. **Undoing an op:** + - an insertion (always whole lines) is cut by its exact text; + - `rewrite_version` restores `from` only where the live text is exactly SV; + - a created section is removed when only our children remain in it; + - directories are pruned only if we created them and they are empty. +5. **Missing or edited fragment:** emit `vendor_lock_entry_drifted{file,line}` and keep the artifacts while any live file references them. +6. **Verification file reformatted by Gradle:** our entries are removed by meaning rather than by exact text (§5.3). +7. **Order:** references first, then derived files, then trees. + +**Revert with no ledger** (the bug #1 case) cuts self-delimiting fragments and never reconstructs a ledger: +- `socket-patch:begin/end` blocks and `socket-patch ` comment tags; +- exact `maven.config` lines; +- `-socket.` stripped from rewritten versions; +- settings lines ending in `// socket-patch`; +- the marker's `replaced` element. + +### 7.4 References, sweep and repair + +- **A separate JVM reference scanner** reads the index, markers, pom comment tags and SV strings. `scan_vendor_references` and `parse_vendor_path` stay path-based for the other ecosystems (C). **This is a stated CLI_CONTRACT change:** a JVM directory's uuid cannot be recovered from its path. +- **Liveness:** a version directory is live if any of these references it: + - the ledger; + - an index row; + - an SV string in a pom or checksum file; + - a verification component whose hash equals the marker's. +- **Reserved names:** `maven2/` and `gradle/` are reserved, and the existing sweeps already skip them because `ECOSYSTEM_DIRS` lists only `maven` (V, C). `sweep_stale_artifact` gains the new trees. +- **Phase-1 legacy fix (bug #1):** + - add `pom.xml` and `nuget.config` to `WIRING_FILES`; + - add `<` to the scan terminators; + - accept `/` references without a leaf for maven and nuget. +- **`repair`** follows WS5: re-download, then a normal `vendor` re-wire. **There is no ledger reconstruction** (P). +- **Cold-cache re-vendor (bug #3):** the backend accepts `PackageSource::Deferred`. + - committed files that match: `already_vendored`, with no network; + - stale and online: re-fetch; + - stale and offline: `vendor_artifact_kept`. +- **Drift on re-runs:** + - pins are re-derived on every run; + - a fragment the user edited is warned about and treated as not wired; + - a declaration the user moved to a new literal gets the §4.5 un-pin. + +### 7.5 Patch update (bug #5) + +- **Same uuid:** `carry_forward_wiring` drops the legacy `maven_pom_repository` records during migration, the same way the cargo special case does (`state.rs:486-488`). +- **New uuid:** fragments are per patch, so there is no whole-file original to carry forward. +- **Orphans:** migration always cuts orphan `socket-patch-vendor-` blocks, whether or not a ledger exists. + +### 7.6 Migration from the legacy layout + +The legacy state is: +- `.socket/vendor/maven/////…`; +- `` using `${project.basedir}`; +- ledger kind `maven_pom_repository`. + +| Phase | Behaviour | +|---|---| +| 2–3 | The `jvm` backend takes **only shapes refused today**: Gradle and multi-module reactors (branch points `maven_repo.rs:251` and `:263`). Single-pom projects stay on the legacy path, so **nothing accepted today changes** (C). | +| 4 | Single-pom migration, gated on the full capstone matrix and the byte-identity conformance test. If those are not green, it waits for v5.x. | + +Phase-4 steps: +1. **Plan in memory:** undo the legacy wiring with `revert_repo_record` (fixed per §7.5), then compute the v5 plan on the post-undo text. If the plan refuses, write nothing and report `migration: blocked`. +2. **Get the SV slice:** use the server slice when online. Offline, use the legacy committed jar if it matches the ledger sha256, re-spell its pom with the CLI port, and mark it `source:"local"`. It is replaced on the next online run (D7). +3. **Commit** through GroupCommit, then delete the legacy directory as post-commit cleanup. +4. **No ledger:** cut blocks matching `socket-patch-vendor-[0-9a-f-]{36}` and `/.socket/vendor/maven/`. The uuid and purl come from the marker. +5. **Local cache:** emit `vendor_legacy_layout_migrated{localCacheHint}` when `_remote.repositories` names a legacy id. The CLI never touches the user's cache. +6. **Hosted-mode projects:** `vendor` ejects them per WS2, removing the hosted Maven wiring through the WS1 restore, then applies the vendored wiring. Identical lines are adopted, not duplicated (P). +7. **Downgrade:** a v4 CLI fails closed on `jvm` entries. Documented: run `vendor --revert` with v5 first. + +--- + +## 8. Refusal and warning codes + +### 8.1 New list + +**JVM refusals (3 codes).** Each is decided before any write and carries `file`/`line` plus a fix. + +| Code | Reasons (and fix) | +|---|---| +| `unsafe_coordinates` (kept) | Coordinate grammar tightened (D15). | +| `vendor_jvm_shape_unsupported{reason}` | `no_build_file` (run where `pom.xml` or `settings.gradle*` lives) · `build_file_unreadable` · `not_build_root` (run from ``) · `nested_mvn_dir` (remove or merge `/.mvn`) · `build_file_outside_root` · `module_outside_root` · `module_path_unresolvable` (make `` a literal) · `gradle_below_6_8` (`gradle wrapper --gradle-version 8.14.3`) · `gradle_settings_classpath` · `gradle_exclusive_content_conflict` (drop g:a from your rule) · `gradle_verification_unparseable` · `android_or_kmp` (use hosted mode) | +| `vendor_jvm_upstream_unavailable{reason}` | `no_base_jar` · `pom_unavailable` · `module_unavailable` · `upstream_checksum_mismatch` · `suffix_unavailable` (the upstream pom computes its own version). Fix: run one online build (`mvn dependency:resolve` or `./gradlew dependencies`), or enable the service. | + +**JVM warnings (3 codes):** + +| Code | Reasons | +|---|---| +| `vendor_jvm_degraded{reason}` | **v5.0:** `maven_f_outside_root` · `maven_config_omitted` · `maven_fallback_omitted` · `maven_mirror_of_all` · `property_unresolved` · `range` · `conflicting_literal_version` · `classifier_declared` · `publishes_suffixed_poms` · `gradle_trusted_by_user_rule` · `gradle_verification_lenient` · `unwired_build_logic` · `upstream_unverified`. **v5.x:** `reactor_release_compile` · `system_scope` · `trusted_checksums_origin_aware` · `maven_version_lt_3_9_4` · `deny_disabled` · `deny_plugin_classpath` · `gradle_verification_skipped` | +| `vendor_artifact_rebuilt` (kept) | Also emitted with `detail:migrated`. | +| `vendor_lock_entry_drifted` (kept) | Now also emitted by `maven.config`, settings and verification revert. | + +**Shared (10, unchanged):** +- `vendor_artifact_kept` +- `vendor_marker_write_failed` +- `vendor_content_mismatch_overwritten` +- `vendor_service_offline_conflict` +- `vendor_prebuilt_required` +- `vendor_prebuilt_integrity_mismatch` +- `vendor_prebuilt_layout_mismatch` +- `vendor_prebuilt_pending` +- `vendor_prebuilt_unavailable` +- `vendor_prebuilt_downloaded` + +**Events (not codes):** `pom_fetched`, `vendor_legacy_layout_migrated{localCacheHint}`. + +**Honest totals (P).** The number of codes falls, but the number of conditions rises, because more shapes are now handled: + +| | Today | v5.0 | +|---|---|---| +| Codes | 21 | **16** | +| JVM refusal conditions | 7 | 1 + 12 + 5 = 18 | +| JVM warning conditions | 4 | 13 + 2 kept | +| All user-visible conditions | 21 | about 43 | + +CLI_CONTRACT will report reasons as the metric. + +**Inputs accepted today that v5.0 refuses** (intentional; listed in the CHANGELOG): +- `not_build_root` when run from a submodule. Today this case is silently broken (bug #4). +- `build_file_outside_root` for a symlink that leaves the checkout. +- Coordinates that fail the D15 grammar. +- `suffix_unavailable`, for single-pom projects in Phase 4 only. It is rare, and depscan proposal 5 removes it. + +`property_unresolved` and `range` were downgraded to warnings to keep this list short. + +### 8.2 Mapping from the current 21 + +| # | Current | Fate | +|---|---|---| +| 1 | `unsafe_coordinates` | kept (grammar tightened) | +| 2 | `vendor_maven_pom_unreadable` | → `vendor_jvm_shape_unsupported{build_file_unreadable}` | +| 3 | `vendor_gradle_unsupported` | removed; the remaining cases are `android_or_kmp` and `gradle_below_6_8` | +| 4 | `vendor_maven_pom_project_missing` | → `vendor_jvm_shape_unsupported{no_build_file}` | +| 5 | `vendor_maven_multimodule_unsupported` | removed | +| 6 | `vendor_maven_jar_not_found` | → `vendor_jvm_upstream_unavailable{no_base_jar}` | +| 7 | `vendor_maven_pom_unavailable` | → `vendor_jvm_upstream_unavailable{pom_unavailable}` | +| 8 | `vendor_maven_local_cache_shadow` | removed: SV is never the base GAV, and D7 makes one SV one set of bytes. A foreign copy is caught by `--check --local-repo`. | +| 9 | `vendor_maven_pom_downloaded` | → the `pom_fetched` event | +| 10 | `vendor_artifact_rebuilt` | kept | +| 11 | `vendor_lock_entry_drifted` | kept | +| 12–21 | shared | kept | +| new | `vendor_jvm_shape_unsupported`, `vendor_jvm_upstream_unavailable`, `vendor_jvm_degraded` | added | + +Entries on the legacy path (Phases 2–3) keep emitting codes 2–9 until Phase 4. Hosted `redirect_*` codes are untouched. + +--- + +## 9. Supported shapes matrix + +Legend: +- **OK**: patched, with no build-time Maven pin (the v5.0 default; `vendor --check` is the pin). +- **P**: patched and pinned at build time. +- **Ps**: pinned at build time only with v5.x strict mode. +- **W**: works, with a warning. +- **L**: fails loudly. +- **S**: silently unpatched. +- **R(x)**: refused with reason x. +- The V/R/I evidence label follows each cell. + +### Maven + +| Shape | 3.6.3 | 3.8.x | 3.9.0–3.9.1 | 3.9.2–3.9.3 | 3.9.4+ | 4.0.0-rc | +|---|---|---|---|---|---|---| +| Single pom | OK V | OK V | OK V | OK V | OK/Ps V | OK/Ps V | +| Reactor, parent = root, SNAPSHOT | OK V | OK V | OK V | OK V | OK/Ps V | OK/Ps V | +| Aggregator separate from parent | OK V | OK V | OK V | OK V | OK/Ps V | OK/Ps V | +| Middle local parent not in `` | OK I (X before the fix) | OK I | OK I | OK I | OK I | OK I | +| Remote parent, module-level pin | OK V | OK V | OK V | OK V | OK/Ps V | OK/Ps V | +| Module imports its own BOM | OK V | OK V | OK V | OK V | OK/Ps V | OK/Ps V | +| Transitive only | OK V | OK V | OK V | OK V | OK/Ps V | OK/Ps V | +| `${prop}` resolved locally | OK V | OK V | OK V | OK V | OK/Ps V | OK/Ps V | +| Literal inside a profile | OK V | OK V | OK V | OK V | OK/Ps V | OK/Ps V | +| `${prop}` not resolvable locally | W(property_unresolved) S I | same | same | same | same; strict deny gives L | same | +| Range, LATEST, RELEASE | W(range) S I | same | same | same | same | same | +| Release reactor + sibling dependency, `compile`/`test` | OK V | OK V | OK V | OK V | OK V; strict auto-degrades (crash V) | OK V; same | +| CI-friendly `${revision}` reactor, `-Drevision=` | OK I | OK I | OK I | OK I | OK I; strict auto-degrades (crash V) | same | +| `system`-scope dependency | OK I | OK I | OK I | OK V (control run) | OK V; strict auto-degrades (NPE V) | OK V; same | +| Root, `cd module`, `-pl x -am`, `-o` | V | V | V | V | V | V | +| `-f /pom.xml` from outside the root | OK V | OK V | OK V | **L V** (W: maven_f_outside_root) | **L V on 3.9.4–3.9.8**; OK V on 3.9.9+ | OK V | +| … the same, with `--maven-config=none` | OK I | OK I | OK I | OK I | OK I | OK I | +| Wrapper | behaves as the Maven version it pins (V, 3.9.11) | | | | | | +| `mirrorOf *` | L V (W) | L I | L I | OK V | OK V | OK V | +| Enforcer bans repositories | L (W) | L | L | OK R | OK R | OK R | +| Tampered SV jar and sidecar | `--check` | `--check` | `--check` | `--check`; strict **not enforced** V | `--check`; strict L V | `--check`; strict L V | +| Poisoned SV copy in `~/.m2` | used V; `--check --local-repo` | same | same | used V; same | used V; strict L V | used V; strict L V | +| Missed declaration resolves the base | S | S | S | S (strict too, V) | S; strict L V | S; strict L V | +| Relocation to the base | S | S V | S | S | S; strict L V | S; strict L V | +| Nested `.mvn`, symlink outside, module outside root, run from submodule | R | R | R | R | R | R | +| Library reactor that deploys | W(publishes_suffixed_poms) | same | same | same | same | same | +| Classifier or non-jar | refused server-side | same | same | same | same | same | + +### Gradle + +| Shape | 6.9.4 | 7.6.4 | 8.14.3 | 9.8.0 | +|---|---|---|---|---| +| Groovy and Kotlin, 3 `repositoriesMode` values, multi-project | P V | P V | P V | P V | +| buildSrc, `pluginManagement{includeBuild}` build-logic, root `buildscript{}` | P V | P V | P V | P V | +| Settings `plugins{}` whose dependencies include the patched GAV | P I (S before the fix) | P V (S V before the fix) | P I | P I | +| Patched GA declared literally on the settings classpath | R | R | R | R | +| Project `plugins{}`, Plugin Portal only | P V | P V | P V | P V | +| Non-literal `includeBuild` | S, W | S, W | S, W | S, W | +| Version catalog | n/a | P V | P V | P V | +| Locking STRICT, lockfiles byte-unchanged | P V | P V | P V | P V | +| Existing verification file, pom-only dependency | P V | P V | P V | P V | +| Existing verification file, `.module` dependency | P I (L V before the fix) | P I (L V) | P V (L V) | P I (L V) | +| Verification lenient or off | P (layer 1) I | P V | P V | P V | +| `--offline`, including a CI cache warmed before vendoring | V | V | V (both) | V | +| Configuration cache store, reuse, tamper | n/a | P V | P V | P V | +| Isolated projects | n/a | n/a | P V | P V | +| `core.autocrlf=true` clone | P I | P I | P V (L V before) | P I | +| Jar over 300 MB with the default 512 MB heap | P I | P I | P I | P I (L V before the streaming fix) | +| Classifier of a patched GAV (`sources`, natives) | L V | L I | L V | L V | +| Range or dynamic version resolving to the patched version | v5.x | v5.x | v5.x (V with GA metadata) | v5.x | +| `platform`, `enforcedPlatform`, `strictly`, Spring dependency-management | P I | P I | P I | P I | +| User `exclusiveContent` for the GA | R when detected, else L | same | same | same | +| Android / KMP | R | R | R | R | +| Gradle below 6.8 | R, and the script throws | – | – | – | +| Mixed pom.xml and Gradle in one root | both wired, separate trees (I) | | | | + +--- + +## 10. Failure modes + +| # | Situation | Surfaces as | User action | +|---|---|---|---| +| 1 | `mvn -f /pom.xml` from outside the Maven root on 3.9.2–3.9.8 | `Illegal use of undefined property: session.rootDirectory` (warned at vendor time) | Run from inside the root, use 3.9.9+, or re-vendor with `--maven-config=none` | +| 2 | Missed declaration | silently unpatched in v5.0; deny failure in strict mode on 3.9.4+ | `vendor --check`; make the declaration discoverable | +| 3 | Unresolvable `${p}` or a range on the patched GA | warned at vendor time; probably unpatched | Make it a literal, or define it locally | +| 4 | Stale manifest while a module declares another literal | its local root is not pinned (`conflicting_literal_version`) | Update the patch or the declaration | +| 5 | Stale manifest, transitive-only use of another version | a silently forced version (hosted mode has the same flaw) | v5.x `--check --resolve` | +| 6 | `mirrorOf *` before 3.9.2 | `Could not find …-socket…` (warned) | `external:*` or `*,!socket-patch-vendor` | +| 7 | Enforcer bans repositories before 3.9.2 | loud (warned) | Use 3.9.2+ | +| 8 | Poisoned or colliding SV in `~/.m2` | the foreign bytes are used | `vendor --check --local-repo`; delete the copy; exclude `**/*-socket.*` from CI caches | +| 9 | Strict mode on 3.9.0–3.9.9, with any dependency that cannot be resolved | `Cannot invoke "…Artifact.isSnapshot()" … null` (V, M) | Vendor output and docs say: rerun with `-Daether.artifactResolver.postProcessor.trustedChecksums=false` to see the real missing artifact | +| 10 | Strict mode, and a plugin needs the base (spotbugs `check`, `dependency:go-offline`) | deny failure | `--maven-allow-unpatched ` (covers the whole purl; no plugin-scoped opt-out exists) | +| 11 | Strict mode on 3.9.2–3.9.3 | nothing is enforced (warned) | Use 3.9.4+ | +| 12 | Strict mode, transitive `system`-scope dependency | NPE `artifactRepository is null` | `--maven-pin=auto`, or drop strict mode | +| 13 | Library reactor deploys | consumers cannot resolve SV | Warned; do not vendor into published libraries (Q6) | +| 14 | IDE import ignores `maven.config` | the fallback repository resolves SV (I) | none | +| 15 | Gradle vendored file tampered, missing or not in the index | `socket-patch: …` at configuration time | Restore it from git, or re-vendor | +| 16 | Gradle non-literal `includeBuild` | silently unpatched in that build (warned) | Make it literal, or use v5.x `--gradle-verification=create` | +| 17 | Gradle `.module` dependency with an existing verification file whose parent entries were removed by the user | `N artifacts failed verification … jackson-base…pom` | Re-run `vendor` | +| 18 | Gradle classifier of a patched GAV | `Could not find a-v-classifier.jar`; the IDE shows no sources | Documented; v5.x vendors the classifiers | +| 19 | Renovate or `--write-verification-metadata` rewrites the verification file | `--check` compares by meaning and passes; the `origin` attribute is lost, which is harmless | none | +| 20 | CRLF checkout after our `.gitattributes` was deleted | layer-1 hash mismatch | Restore `.gitattributes` (`--check` flags it) | +| 21 | Crash mid-vendor | GroupCommit replays or rolls back | Re-run `vendor` | +| 22 | Windows, Maven `file://${maven.multiModuleProjectDirectory}` URL | untested (I) | Q8 | +| 23 | A bot bumps the version to 1.10.1 | Maven: the SV literal is edited, which shows as drift, and the tree is swept later. Gradle: resolves upstream, as it should. | Normal upgrade flow | +| 24 | v4 CLI run on a v5 checkout | fails closed: "no vendor backend for ecosystem jvm" | Use v5 | + +--- + +## 11. Server/CLI split + +**v5.0 needs no depscan changes.** +- The CLI does discovery, fetch and verification (against Central's `.sha512`/`.sha1` over TLS for upstream files), writes the trees, makes all edits, and handles the ledger, revert, VEX discovery and `vendor --check`. +- Maven uses the hosted SV slice as it is. +- Gradle uses the direct jar plus upstream metadata. + +**Degraded without server changes:** +- Trust in Gradle's upstream metadata rests on Central over TLS. +- Byte identity of the local rebuild rests on reproducing the server recipe, guarded by the conformance test (D7). +- `suffix_unavailable` remains. +- Mixed-case coordinates only get a local rebuild. + +**Proposed depscan changes**, ordered by value (none is required): + +| # | Change | Where | Why | +|---|---|---|---| +| 1 | Hosted mode has the same Maven hazards. (a) Add `-Daether.artifactResolver.postProcessor.trustedChecksums.record=false` to `MVN_CONFIG_ARGS`. (b) Skip, or document, the six trusted-checksum lines when the reactor is pin-unsafe: a release or `${` version with a jar sibling, or `system` scope. (c) Document that nothing is enforced on 3.9.2–3.9.3, that the NPE hides missing-artifact messages on 3.9.0–3.9.9, and that `-f` from outside the root fails on 3.9.2–3.9.8. | `workspaces/app/src/patches/registry-rewrite/maven-pom.ts:61-70`; detection next to `maven-pom-scan.ts` | S-F2, S-F3, M-F1, M-F2, M-F3 (all V) | +| 2 | Publish the repack recipe as a versioned contract with a conformance fixture: archiver version, options, and a sample input with its jar sha256. | `patches/src/repack/repackers/maven.ts:138-206`, `patches-shared/src/archive/repack-utils.ts:696-724` | Locks D7 byte identity against server drift | +| 3 | Attest upstream metadata (`upstreamPomSha256`, `upstreamModuleSha256\|null`, `gradleMetadataMarker`, and the parent-chain and BOM pom hashes), and serve the verbatim upstream pom and `.module` on the direct route. | `api-v0/src/endpoints/orgs/patches/package.ts`; `package_maven_pom` storage | Removes Central from the Gradle trust chain (S-F6) | +| 4 | Serve `.sha256` and `.sha512` sidecars on the hosted route. | hosted maven2 route | The values are already stored | +| 5 | Suffix `${…}`-versioned poms by pinning the literal. | `workspaces/app/src/patches/maven-suffix.ts` (`suffixMavenPom`) | Removes `suffix_unavailable` | +| 6 | Replace the hosted Gradle snippet's "bump the version" advice (`-socket.x` sorts below the base in Gradle) with the same-GAV settings-script approach. | `registry-rewrite/maven-pom.ts:362-383` | Not fail-closed today | +| 7 | Stop lowercasing Maven purls. | `patches/src/repack/upstream/maven.ts:44-50` | Mixed-case coordinates are never built | + +--- + +## 12. Test plan + +All tests follow the repository rules: real files, real tools wherever behaviour depends on the tool, no mocking of owned modules, and the external API only through the existing wiremock harness. + +### 12.1 Phase-0 prototypes: exactly two shapes (shell fixtures, before the Rust work) + +The review harnesses already cover most of both shapes. Phase 0 turns them into two committed fixture scripts and re-runs them as the gate. Rows marked "already V" become regression assertions; the rest must pass before Phase 2. + +**P1: multi-module Maven reactor.** + +- **Fixture:** + - an aggregator with a separate corp parent; + - a **middle local parent that is not a module**, reached through a directory `relativePath`, that manages the base literally; + - a module importing its own BOM that manages 1.9; + - a module that uses the library only transitively; + - a standalone module that is its own local root; + - a remote-parent module with a property version; + - a sibling dependency, plus a `type=pom` sibling and a plugin-dependency sibling; + - a literal inside a profile; + - a planted base literal; + - a `system`-scope dependency. +- **Variants:** SNAPSHOT, release, and `${revision}`. +- **Maven versions:** 3.6.3, 3.8.8, 3.9.0, 3.9.3, 3.9.4, 3.9.8, 3.9.9, 3.9.11 and 4.0.0-rc-7 (plus 4 GA when it is released). +- **Invocations:** root, `cd a`, `-pl e -am`, `-pl a,e`, `-f` from outside the root, `cd c`, and `./mvnw`. +- **Each invocation runs** `compile`, `test` and `package`, offline with a warm upstream `~/.m2`, and online under `mirrorOf *`. + +| Assertion | State | +|---|---| +| 2-line config: patched in every invocation and variant, except `-f` from outside on 3.9.2–3.9.8 | already V (M), except `${revision}` and `system` (I) | +| The middle local parent is rewritten, so the patched class is on the classpath | I (the fix) | +| `--maven-config=none`: patched on every version and invocation, including `-f` | I | +| No SV copy in `~/.m2` on 3.9.2+ with the tail | already V | +| `mirrorOf *`: OK on 3.9.2+; loud on 3.6.3 and 3.9.0 with the 2-line config | V; I for 3.9.0 with 2 lines | +| Strict: tamper, deny, relocation and `record=true` attacks fail on 3.9.4+ and pass on 3.9.3 | already V (M, S) | +| Strict: the D6 rule flags release+sibling, `${revision}` and `system`, but not `type=pom` or plugin siblings | the classifier logic is I; the crash shapes are already V | +| Revert is byte-exact (`cmp`) after vendoring 2 patches, in both revert orders, and with the ledger deleted | I | + +**P2: Gradle multi-project with dependency locking.** + +- **Fixture:** + - `app` and `lib`, in Groovy and Kotlin DSL; + - `repositoriesMode` set to FAIL_ON_PROJECT_REPOS, PREFER_PROJECT and PREFER_SETTINGS; + - `lockAllConfigurations()` STRICT, with existing `gradle.lockfile`, `buildscript-gradle.lockfile` and `settings-gradle.lockfile`; + - a version catalog; + - buildSrc; + - `pluginManagement{includeBuild('build-logic')}`; + - settings `plugins{ foojay-resolver-convention }` (0.8.0 on 7.6.4, whose dependencies include the patched gson); + - root `buildscript{ classpath gson }`; + - patched gson (pom only) and jackson-databind (with `.module`); + - a variant with an existing verification file. +- **Gradle versions:** 6.9.4 (JDK 11), 7.6.4 (JDK 17), and 8.14.3 and 9.8.0 (JDK 21). +- **Runs:** online and `--offline`, with configuration-cache store, reuse and tamper, and isolated projects on 8 and 9. + +| Assertion | State | +|---|---| +| Every scope is PATCHED; lockfiles are byte-unchanged (md5) | already V (G) | +| The revised §5.2 script (streaming, path derivation, unindexed-file check) behaves like the old one; a deleted row plus a foreign jar fails; `+` and range versions are rejected | I | +| In-block `pluginManagement` entry: foojay's dependencies are PATCHED in buildSrc, build-logic and root, in both DSLs | V on 7.6.4 Groovy; I elsewhere | +| Existing verification file with parent-chain and BOM components: the `.module` dependency passes | V on 8.14.3; I on 6.9.4, 7.6.4 and 9.8.0 | +| A `core.autocrlf=true` clone passes with the trees' `.gitattributes` | V on 8.14.3 (G); I elsewhere | +| Layer 1 fails a tamper under `--dependency-verification=lenient`, and the configuration cache is invalidated | already V | +| No new deprecations on 9.8.0 with `--warning-mode all` | already V | +| An index row for a 400 MB jar passes with `-Xmx512m` | I | +| Revert is byte-exact (`cmp`), including the in-block entry and the verification inserts | I | + +### 12.2 Unit tests (pure, I/O-free) + +- **Pom span parser:** comments, CDATA, profiles, model 4.1.0, namespaces, CRLF, tabs and directory `relativePath`. +- **Classifiers:** local-root and parent-chain scope, the property chain including `maven.config` `-D` values, and the D6 pin-unsafe rule. +- **Planners:** pin and rewrite planning, including the conflicting-literal un-pin and the warnings. +- **`suffixMavenPom` port:** golden files shared with depscan's `maven-suffix.ts`. +- **`maven.config` merge:** tail list, `adopt`, originAware, no trailing newline. **Checksum format:** exactly two spaces. +- **Gradle edits:** apply-line and in-block placement in both DSLs, settings-file creation, and verification XML replace, insert, parent-chain insert and semantic revert. Also v5.x `\Q…\E` regex generation. +- **Parsing:** the D15 grammar; index writer and parser rejections, including a trailing `\r`. +- **Peer-aware revert:** a property test over N entries and every permutation of revert order, asserting byte-exact results with user edits in between. + +### 12.3 In-process tests (CLI over real temp directories) + +- **Core flows:** vendor, repair, sweep and revert across the fixtures. +- **Bug regressions:** + - #1 for Maven and NuGet (sweep with `state.json` deleted); + - #3 (cold-cache `already_vendored`); + - #4 (`not_build_root`); + - #5 (uuid update, then byte-exact revert). +- **Ledger schema:** + - `legacy_ledgers_revert_byte_for_byte` stays green; + - `new_ledgers_compact…` is updated for fragment records and passes in both revert orders; + - a v4 binary fails closed on a `jvm` ledger. +- **GroupCommit:** kill the process after each commit point; replay covers the index and the script. +- **Phase-4 migration** from `fixtures/legacy-ledgers/maven/wired`, with and without a ledger, and the `migration: blocked` path. +- **Service suites** (existing wiremock harness): integrity mismatch, `pending_build`, null suffix, local→service byte upgrade, and offline `local-unverified`. + +### 12.4 Real-tool capstones (`#[ignore]`, CI matrix) + +- **`e2e_vendor_maven_build.rs`:** + - keep the single-pom chain on 3.6.3, 3.8.9, 3.9.16, 4.0.0-rc-6 and macOS 3.9.16; + - **add legs** for 3.9.0 (fallback), 3.9.3 (tail with no enforcement), 3.9.8 (the `-f` warning path) and 3.9.9; + - add the P1 reactor, with `cmp` revert. +- **New `e2e_vendor_gradle_build.rs`:** gated by `SOCKET_PATCH_GRADLE_E2E_{GRADLE,VERSION,REQUIRED}`; runs the P2 matrix in both DSLs, with `cmp` revert. +- **Byte-identity conformance** (online, gated): for a set of published patches, a local rebuild from Central's base jar plus the manifest must equal the server jar's sha256. Until this is green, D7 stays I and Phase 4 is blocked. +- **Docker `--network none`:** add a reactor case to `docker_e2e_vendor_maven.rs`, and add a Gradle `--offline` twin. +- **Must stay green, unchanged:** + - the hosted redirect goldens; + - `e2e_redirect_maven_build.rs`, `e2e_maven.rs` and `crawler_maven_e2e.rs`; + - `e2e_vex_lockfile/maven.rs` and `vex-discover-golden`; + - until Phase 4, every legacy-layout test: 74 in `maven_repo.rs`, 31 in `vex/discover/maven.rs`, the capstones, and `setup_matrix_maven.rs`. + +--- + +## 13. Implementation plan and phases + +Paths are under `crates/`. LOC figures are production code only. They are revised upward per review C-F7, and reduced by moving work to v5.x. + +| # | Module | v5.0 LOC | Contents | +|---|---|---|---| +| 1 | `socket-patch-core/src/vendor/jvm/mod.rs` (new) | ~450 | Backend for ecosystem `jvm`. Plans all entries before the loop, runs refusals before any write, runs the finalize hook, accepts `PackageSource::Deferred`. | +| 2 | `jvm/acquire.rs` (new) | ~650 | SV slice and direct jar with integrity checks; upstream metadata verified against Central's `.sha512`/`.sha1`; local rebuild with the server recipe; the local→service upgrade. | +| 3 | `jvm/maven_suffix.rs` (new) | ~150 | Port of `suffixMavenPom`. | +| 4 | `jvm/maven_reactor.rs` (new) | ~850 | Span-preserving model of the reactor, parent chain, properties and declarations; local roots; enforcer, `distributionManagement` and classifier detection. | +| 5 | `jvm/maven_wiring.rs` (new) | ~650 | Pins, rewrites, repository block, `maven.config` merge, `settings.xml` mirror check, fragment ops and cuts. | +| 6 | `jvm/gradle_settings.rs` (new) | ~650 | Settings discovery with a lexer that understands strings and comments; apply lines; the in-block entry; settings creation; the Android/KMP, settings-classpath and `exclusiveContent` scans. | +| 7 | `jvm/gradle_verification.rs` (new) | ~450 | Replace in an existing file, parent-chain and BOM inserts, semantic revert. | +| 8 | `jvm/gradle_index.rs` + `assets/socket-patch.settings.gradle` | ~150 | Deterministic index; the script embedded with `include_str!`. | +| 9 | `jvm/legacy.rs` | ~350 | Legacy undo and migration (Phase 4). Legacy revert and legacy VEX stay. | +| 10 | `state.rs`, `ledger_snapshots.rs`, the revert API | ~600 | Fragment records; `live_peers` on `RevertOpts` at every caller; the `carry_forward_wiring` fix (bug #5). | +| 11 | `path.rs`, `repair_vendor.rs`, the JVM reference scanner | ~500 | Reserved names; JVM liveness; the Phase-1 `WIRING_FILES` fix for Maven and NuGet. | +| 12 | `utils/group_commit.rs`, `commands/vendor.rs` post-commit | ~150 | Captured set, replay filter, `migrated_legacy_uuid_dir`. | +| 13 | `verify.rs`, `vex/discover/maven.rs` | ~350 | New trees; Gradle through the index. | +| 14 | CLI `commands/vendor.rs` | ~600 | Dispatch; `--maven-config=none`; `vendor --check [--local-repo] [--strict]`; new codes; `--ecosystems` mapping. | +| 15 | `crawlers/maven_crawler.rs` | ~250 | modules-2 as a source; honour `settings.xml ` and `-Dmaven.repo.local`; stop treating `M2_HOME` as a repository. | + +**Totals:** +- **v5.0:** about 6.9k LOC of new or changed production code, 12–15k LOC of tests (a 1:2 ratio, like the repo's existing backends), and about 1k LOC deleted (Phase 4 only). +- **v5.x:** about 1.6k LOC more: strict pins ~450, the created verification file ~350, `--check --online/--resolve` ~500, classifier vendoring and GA metadata ~300. + +**Docs:** +- `ecosystems.md`; +- `CLI_CONTRACT.md`: the vendored row, codes and reasons, flags, the JVM path-recovery contract change, and VEX scope; +- CHANGELOG: the intentional refusals, and `mirrorOf *` now failing loudly; +- CODEOWNERS guidance; +- the CI snippet `socket-patch vendor --check --strict --local-repo`. + +**Phases** (each is its own reviewable PR): + +| Phase | Content | Gate | +|---|---|---| +| 0 | P1 and P2 fixture scripts (§12.1) | Owner sign-off (Q0). Every I row in §12.1 passes, or its decision is reopened. | +| 1 | Shared infrastructure on the legacy path: bugs #1 (Maven and NuGet), #3, #4 and #5; fragment records; the peer-aware revert API; the GroupCommit captured set; ecosystem `jvm`; D15 | Mergeable on its own; every existing test green | +| 2 | Gradle backend (items 6–8, and the Gradle parts of 2 and 13–15) | P2 capstone matrix and the Docker twin | +| 3 | Maven reactors on the `jvm` backend (items 3–5, and the Maven parts of 2 and 13); single-pom stays on the legacy path | P1 capstone and the new CI legs | +| 4 | Single-pom migration (item 9) | Full capstone matrix and byte-identity conformance green; otherwise this moves to v5.x | +| 5 (v5.x) | Strict pins, the created verification file, `--check --online/--resolve`, classifier vendoring, GA metadata, Maven 4 implicit subprojects | Evidence for Q1 and Q13 | + +--- + +## 14. Panel scoring + +Each judge weighted the criteria differently, so compare scores within a column, not across columns. + +| Design | Judge 1 (/100) | Judge 2 (integrity ×3, /100) | Judge 3 (CI, cost, revert, diff ×2, /120) | Rank | +|---|---|---|---|---| +| **A: per-tool native** (SV for Maven, same GAV for Gradle) | **85** | **69** | **77** | **1st, unanimous** | +| D: config only, same GAV, Maven tail | 76 | 67 | 76 | 2nd | +| B: unified `-0.socket` suffix | 76 | 62.5 | 70 | 3rd / 3rd / 4th | +| C: resolver core extension | 65 | 57 | 71 | 4th / 4th / 3rd | + +- **Why A won:** it is the only design that meets constraint 1 (no regression across 3.6.3–4-rc) together with constraint 5 (no recurring cache problem), and it fails closed on both tools. +- **Ideas taken from the other designs:** + - from D: crash detection, the tail, and the reserved-name sweep rules; + - from B: the Gradle configuration-time self-check and anchoring with `sourceFile`; + - from C: a derived, line-oriented index. +- **After the adversarial review:** A's identity and wiring held up, and were verified on 6 Maven and 4 Gradle versions. A's default integrity layer (the deny line and trusted checksums on by default) did not hold up, and moved to v5.x. Judge 2's integrity weighting therefore overstated what A delivers by default. D's "warn only" position on the crash is closer to what v5.0 ships. + +--- + +## 15. Adversarial review + +There were five reviewers: +- **M:** Maven empirical, 3.6.3–4-rc-7, 14 versions; +- **G:** Gradle empirical, 6.9.4–9.8.0; +- **S:** security; +- **C:** state, revert and code; +- **P:** scope and cost. + +| ID | Sev | Finding | Evidence | Disposition | +|---|---|---|---|---| +| M-F1 | blocker | `-f /pom.xml` from outside the root fails on 3.9.2–3.9.8 when `maven.config` contains `${session.rootDirectory}` | V | **Partly accepted.** No expression works on both 3.9.2–3.9.8 and 4, so the tail stays for `mirrorOf *` and enforcer bans. Added the `maven_f_outside_root` warning, `--maven-config=none`, CI legs for 3.9.8 and 3.9.9, a corrected matrix, and depscan #1. Writing no `maven.config` by default was rejected, because it loses `mirrorOf *` on 3.9.2+. | +| M-F2, S-F1 | major, blocker | Trusted checksums enforce nothing on 3.9.2–3.9.3 | V | Accepted. The threshold is 3.9.4 everywhere; new warning `maven_version_lt_3_9_4`; a 3.9.3 CI leg. | +| M-F3 | major | 3.9.0 crashes too (Q4). With the post-processor on, 3.9.0–3.9.9 turn every missing artifact into an `isSnapshot()` NPE. | V | Accepted. Pins are opt-in (v5.x), so default users are unaffected; failure mode #9 carries the hint; depscan #1. Q4 is closed. | +| M-F4, P-F1 | major | One SV can name two byte sets (local rebuild vs server), giving false mismatches or silently foreign bytes | V | Accepted. D7: byte-identical rebuild plus the local→service upgrade, and `--check --local-repo`. A per-project repository id was rejected because it does not fix 3.8.8 (V). | +| M-F5 | major | The deny line fires on the spotbugs and `go-offline` plugin classpaths | V | Accepted. Deny lines only in v5.x strict mode, with a warning and docs; the remediation text now says `dependency:resolve`. Running `resolve-plugins` at vendor time was rejected (it runs the user's Maven, over the network). | +| M-F6, P-F2 | major | `${revision}` with `-Drevision=1.2.3 test` crashes a reactor that D6 classed as safe | V | Accepted. A version containing `${` counts as unsafe; `-D` values in `maven.config` are read. | +| M-F7 | major | A middle local parent outside the rewrite scope defeats the pin | V | Accepted. The scope is every in-checkout pom on a reactor parent chain; a directory `relativePath` is handled. | +| M-F8 | minor | The matrix said the wrapper is refused | V | Fixed. | +| M-F9 | minor | D6 false positives on `type=pom` and plugin-dependency siblings | V | Accepted. | +| M-F10 | minor | The deny line does not cover graph-only tools | V | Documented; `--check --resolve` in v5.x. | +| G-F1 | major | A settings plugin's dependencies silently unpatch build logic, and a created verification file then breaks the build in a way re-vendoring cannot fix | V | Accepted. In-block `pluginManagement` entry (V on 7.6.4); the created file moved to v5.x. | +| G-F2 | major | An existing verification file fails for `.module`-publishing dependencies, because the parent and BOM poms are missing | V | Accepted. Parent-chain and BOM components are inserted (V on 8.14.3). | +| G-F3 | major | `core.autocrlf=true` breaks the vendored `.pom` and `.module` | V | Accepted. D14. | +| G-F4 | major | `f.bytes` runs out of heap on large jars | V | Accepted. Streaming digest. | +| G-F5 | minor | Classifier artifacts of a patched GAV become unresolvable, and the `file=` rule does nothing | V | Documented; classifier vendoring in v5.x; the `file=` rule is dropped. | +| G-F6, P-F9 | minor | Project-level `plugins{}` with only the Plugin Portal is patched, not a loud failure | V | Matrix now says P V. | +| S-F2 | major | Leaving `record` unset lets `settings.xml`, `MAVEN_OPTS` or `maven-user.properties` switch off pins and rewrite them | V | Accepted. `record=false` in strict mode; `--check` flags `jvm.config`; depscan #1. | +| S-F3 | major | Trusted checksums throw an NPE on `system`-scope dependencies | V | Accepted. The default is unaffected (V control run); strict auto-degrades (`system_scope`); the transitive case is documented. | +| S-F4 | major | A poisoned `~/.m2` beats the tail; other projects can plant the SV | V | Accepted. Claims corrected; `--check --local-repo`; cache-exclusion advice; D7. | +| S-F5 | major | The trust regex escapes only `.`, and separate version rules trust each other | V | Accepted. `\Q…\E`, one alternation per g:a and XML escaping (v5.x); the D15 grammar (v5.0). | +| S-F6 | major | The trust roots at vendor time are caches that vouch only for themselves | INFERRED | Accepted. Every upstream file and the base jar are verified against Central's `.sha512`/`.sha1` over TLS; offline results are `local-unverified`. Making depscan #3 required was rejected (constraint 6). | +| S-F7 | major | `--check --online` can be evaded by marking an entry `source:local`, and degraded options are one-line edits | INFERRED | Accepted. D7 makes local bytes equal the service's; `--online` compares whatever the recorded source; `--check --strict` fails on degraded options. | +| S-F8 | minor/major | Layer 1 checks only the rows it is given, and `includeVersion` accepts selectors | V | Accepted. Path derivation, required jar and pom rows, the unindexed-file check and selector rejection. | +| S-F9 | minor | Relocations and classifier variants bypass the pin | V (3.8.8) | Documented; `classifier_declared` warning. | +| S-F10 | minor | A newer vulnerable version wins through Gradle conflict resolution | INFERRED | `--check --resolve` in v5.x. | +| S-F11 | minor | The threat model overstated the `~/.m2` and `GRADLE_USER_HOME` defences | documented | Accepted. §6.4 scope statement. | +| S-F12, P-F5 | minor | A created scoped file silently cancels out later adoption of verification | V | Creation moved to v5.x, where `--check` warns about it. | +| C-F1 | blocker | Shared fragments cannot be reverted through the single-entry API; reverting in vendor order leaves lines behind | V (model) | Accepted. Peer-aware revert (§7.3) at every caller; a property test over every revert order. | +| C-F2 | major | An older binary silently drops v5 entries | V | Accepted. Ecosystem `jvm`, which makes old binaries fail closed. | +| C-F3 | major | The rewrite of references and liveness is bigger than claimed, and breaks the path-recovery contract | code | Accepted. A separate JVM scanner; a stated contract change; the Phase-1 legacy fix. | +| C-F4 | major | The design ignored GroupCommit, and migration runs per package | code | Accepted. §7.2. | +| C-F5 | major | `carry_forward_wiring` works against migration, and today a legacy revert after a uuid update is not byte-exact | V | Accepted. New bug #5; §7.5. | +| C-F6, P-F7 | major | "No accepted input becomes refused" is false, and counting codes hides conditions | code, V | Accepted. `property_unresolved` and `range` are warnings; a symlink is refused only when it leaves the checkout; the remaining new refusals are listed; totals are honest. | +| C-F7 | major | LOC was underestimated (tests at 1:0.64) | code | Accepted. Re-estimated in §13. | +| C-F8, P-F8 | minor | Whole-file originals bloat the ledger, and `pre_existing` is not a valid action | V | Accepted. Fragment-only records; an `adopt` op inside `Added`. | +| C-F9 | minor | Wrong CDATA citation | code | Fixed (`maven_pom.rs:204-240`). | +| C-F10 | minor | Bug #3 panics only in debug builds | V | Severity corrected. | +| C-F11 | minor | Bug #1 also affects NuGet | INFERRED | Fixed in Phase 1. | +| C-F12 | minor | Hosted `merge_checksums` drops the header | code | Header removed; Q5 is moot. | +| C-F13, P-F6 | major | Conflicts with the v5 plan (vendored Maven is future work; repair re-synthesis was cut) | doc | Repair re-synthesis dropped. Owner sign-off is Q0, and it gates Phase 0. | +| P-F3 | major | Pins on by default protect a small subset of builds at a high cost | V | Accepted. Strict pins are opt-in in v5.x (§2.1). The cheaper "source lines only" alternative does not trigger the deny line (V, P). | +| P-F4 | major | A created verification file makes Renovate rewrite it on every Gradle PR | V (source and simulation) | Accepted. Creation is v5.x, in canonical form, with a golden test that asserts no change; our entries are identified by hash, not `origin`. | +| P-F10 | minor | The `mvn -v` probe is gold-plating | — | Accepted. Wrapper only. | +| P-F11 | minor | Before 3.9.2, migration turns green (silently unpatched) `mirrorOf *` builds red | INFERRED | Accepted. `maven_mirror_of_all` warning and a CHANGELOG entry; the loud failure is intended. | +| P-F12 | minor | Leaving the hosted repository in place (old §11.8) conflicted with the WS2 eject | doc | Accepted. Follow WS2 (§7.6 step 6). | +| P-F13 | minor | The same facts were stored in three places | — | Accepted. The index is Gradle-only. | + +**Claims the reviewers tried and failed to refute** (these are now stronger; all V): + +| Area | Claim | +|---|---| +| Maven wiring | The 2-line and 8-line configs patch root, `cd module`, `-pl -am`, `-f` from outside and `-o` builds on 3.6.3, 3.8.8, 3.9.0, 3.9.11 and 4-rc-7, including literals inside profiles. The only exception is `-f` on 3.9.2–3.9.8. | +| D3 precedence | On six versions: the local-parent pin beats an imported BOM, it covers transitive use, and a remote-parent module's own pin wins. | +| Tail | No SV copy lands in `~/.m2` on 3.9.2+ and 4, and the tail survives `mirrorOf *` there. | +| `maven.config` | No warnings on 3.6.3, 3.8.8 or 3.9.0. It coexists with user lines. Its user properties take precedence over settings profiles, `MAVEN_OPTS` and `maven-user.properties`. | +| Strict mode on 3.9.4+ and 4 | Tamper, deny and relocation are all detected, including under `-T4`, and the deny message names the label. Unrelated bumps and new dependencies still pass (`failIfMissing=false`). The D6 crash and the degrade both reproduce, and `package` and `verify` pass even with pins on. Dependabot ignores `file://` repositories. | +| Gradle | 24/24 matrix rows. Lockfiles stay byte-unchanged on four versions. The end-of-file apply line works with FAIL_ON_PROJECT_REPOS and with settings `plugins{}`. `sourceFile` anchoring works from Kotlin settings. Isolated projects work. Configuration-cache store, reuse and tamper work. Layer 1 works under lenient verification. No deprecations on 9.8.0. Offline works against a cache warmed before vendoring. Unrelated additions and bumps pass. PGP-trusted files still verify the sha256. Index fields cannot inject Groovy. Diffs do not grow with project count. | +| State | Existing sweeps ignore `maven2/` and `gradle/`. Old binaries parse the new kind strings. Legacy revert of the two-entry fixture stays byte-exact. | + +--- + +## 16. Open questions + +| # | Question | +|---|---| +| Q0 | **Owner re-scope:** approve moving vendored Maven and Gradle from "future work" in the v5 plan into v5, with the §2.1 cut. This gates Phase 0. | +| Q1 | The resolver's `Is a directory` crash and `system`-scope NPE: file maven-resolver issues using the P1 fixture. If a 3.9.x or 4.0 GA resolver fixes them, gate D6 on the version read from the wrapper. | +| Q2 | `vendor --check --resolve` (v5.x): should it run `mvn -q dependency:list` / `./gradlew dependencies`, opt-in and over the network, to catch transitive and graph-only mismatches and Gradle conflict upgrades? | +| Q3 | Maven ranges: does a GA-level `maven-metadata.xml` in `maven2/` let ranges resolve SV offline on every version? If so, drop the `range` warning. | +| Q6 | Publishing reactors: should they be refused rather than warned? The current answer is to warn, because internal deploy-to-Nexus flows exist. | +| Q7 | Is there demand for C's WorkspaceReader as an opt-in layer for 3.9.2+ users who want no pom edits at all? | +| Q8 | Add a Windows CI leg for the Maven `file://${maven.multiModuleProjectDirectory}` URL and the Gradle `toURI()` form. | +| Q9 | Gradle reports the base version (same GAV). Should VEX emission be mandatory for Gradle-vendored projects? | +| Q10 | Re-run P1 on Maven 4 GA: the tail, `${session.rootDirectory}`, deny behaviour and implicit subprojects. | +| Q11 | Can `.mvn/jvm.config`, with the launcher substituting the project base directory, carry the tail on 3.9.2–3.9.8 without the `-f` failure, and still work on 4? This is unverified. If it works, it replaces `maven_f_outside_root`. | +| Q12 | Gradle layer 1 in included builds and buildSrc: should it hash only in the root build (`gradle.parent == null`), once the order in which 6.x and 7.x evaluate buildSrc has been checked? | +| Q13 | What is the root cause of the missing enforcement on 3.9.2–3.9.3 (resolver 1.9.13 → 1.9.14)? Knowing it would let the 3.9.4 threshold rest on a documented change. | + +Q4 (does 3.9.0 crash? yes, V) and Q5 (the header was dropped) are closed. \ No newline at end of file From da6f9b5dff0702709c49484a5bfbd1ea7face2b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 08:50:52 +0000 Subject: [PATCH 2/5] Drop unused rollback fixture hash fields Workspace clippy with -D warnings failed on two fields of the covgap_commands_rollback fixture that nothing reads. The hashes are still computed and used to stage the manifest and blobs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019ZLLAZfofQZ2ywkgrHkdEZ --- crates/socket-patch-cli/tests/covgap_commands_rollback.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/crates/socket-patch-cli/tests/covgap_commands_rollback.rs b/crates/socket-patch-cli/tests/covgap_commands_rollback.rs index bfa15851..3f48f26f 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_rollback.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_rollback.rs @@ -149,8 +149,6 @@ struct PatchedFixture { purl: &'static str, before: &'static [u8], after: &'static [u8], - before_hash: String, - after_hash: String, } fn patched_fixture() -> PatchedFixture { @@ -179,8 +177,6 @@ fn patched_fixture() -> PatchedFixture { purl, before, after, - before_hash, - after_hash, } } From 251b7a3796341de9b07c55b63401273d190349a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 08:50:52 +0000 Subject: [PATCH 3/5] Prototype vendored Maven reactors and Gradle Vendored Maven refuses multi-module reactors and Gradle builds today. With SOCKET_PATCH_EXPERIMENTAL_JVM_VENDOR=1 set, those two shapes now go to a new backend (vendor/jvm) that implements the v5.0 cut of docs/design/maven-vendoring.md. With the variable unset, nothing that vendors today behaves differently. - Maven reactor: pins the server's suffixed version in every local root pom and rewrites literal and property declarations (including in profiles and middle parents). Adds one shared file:// fallback repository, two .mvn/maven.config lines (offline file protocol and maven.repo.local.tail) and a committed maven2 tree. - Gradle: keeps the same coordinates. One apply line per settings file (root, buildSrc, literal includeBuild) loads an owned script. It adds an exclusiveContent file repository and fails configuration if a vendored file's sha256 no longer matches the index. Lockfiles, catalogs and build scripts are never edited; an existing verification-metadata.xml gets the patched hash. - Revert is per fragment and byte-exact in any order. Shared pieces stay until no other patch uses them. Re-runs, patch updates, --preserve-state, the cold-cache fast path, VEX liveness and repair all handle the new trees. The real-tool capstones (e2e_vendor_jvm_build, ignored by default) build a reactor and a locked Kotlin DSL Gradle build offline from a fresh checkout. They pass on Maven 3.8.8 to 4.0.0-rc-7 and Gradle 6.9.4 to 9.8.0. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019ZLLAZfofQZ2ywkgrHkdEZ --- .../socket-patch-cli/src/commands/vendor.rs | 32 +- .../tests/e2e_vendor_jvm_build.rs | 965 +++++ .../socket-patch-cli/tests/vendor_jvm_cli.rs | 465 ++ .../socket-patch-core/src/vendor/jvm/apply.rs | 1078 +++++ .../src/vendor/jvm/gradle.rs | 2689 ++++++++++++ .../src/vendor/jvm/maven_reactor.rs | 3814 +++++++++++++++++ .../socket-patch-core/src/vendor/jvm/mod.rs | 594 +++ .../vendor/jvm/socket-patch.settings.gradle | 65 + .../src/vendor/maven_repo.rs | 486 ++- crates/socket-patch-core/src/vendor/mod.rs | 1 + crates/socket-patch-core/src/vendor/verify.rs | 7 + .../src/vex/discover/maven.rs | 17 + .../socket-patch-core/src/vex/discover/mod.rs | 5 + docs/design/maven-vendoring.md | 35 +- 14 files changed, 10246 insertions(+), 7 deletions(-) create mode 100644 crates/socket-patch-cli/tests/e2e_vendor_jvm_build.rs create mode 100644 crates/socket-patch-cli/tests/vendor_jvm_cli.rs create mode 100644 crates/socket-patch-core/src/vendor/jvm/apply.rs create mode 100644 crates/socket-patch-core/src/vendor/jvm/gradle.rs create mode 100644 crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs create mode 100644 crates/socket-patch-core/src/vendor/jvm/mod.rs create mode 100644 crates/socket-patch-core/src/vendor/jvm/socket-patch.settings.gradle diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 5da61dd9..54e47a25 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -167,11 +167,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( @@ -1077,6 +1079,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() 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 00000000..1b81e434 --- /dev/null +++ b/crates/socket-patch-cli/tests/e2e_vendor_jvm_build.rs @@ -0,0 +1,965 @@ +//! Real-tool capstones for the prototype v5 JVM vendored backend +//! (`SOCKET_PATCH_EXPERIMENTAL_JVM_VENDOR=1`, `docs/design/maven-vendoring.md` +//! §12.1 P1/P2): the two shapes the legacy `maven_repo` backend refuses. +//! +//! 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 EXPERIMENTAL_ENV: &str = "SOCKET_PATCH_EXPERIMENTAL_JVM_VENDOR"; + +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() +} + +fn git_sha256(bytes: &[u8]) -> String { + socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes(bytes) +} + +/// `socket-patch ` with ambient `SOCKET_*` scrubbed, the prototype +/// backend switched on, and `m2` as the crawler's 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(EXPERIMENTAL_ENV, "1") + .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: PathBuf = tmp.path().canonicalize().unwrap(); + 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#"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)); + 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: PathBuf = tmp.path().canonicalize().unwrap(); + 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; + } + 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"]); + 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) + ); + + 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!["settings.gradle.kts".to_string()], + "only the settings file is edited; lockfiles and build scripts byte-unchanged" + ); + assert_eq!( + text(&vendored["settings.gradle.kts"]), + format!("{GRADLE_SETTINGS}{APPLY_LINE}\n") + ); + 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(&format!( + "socket-patch: {} has sha256 {}, pinned {}", + jar_path.display(), + 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/vendor_jvm_cli.rs b/crates/socket-patch-cli/tests/vendor_jvm_cli.rs new file mode 100644 index 00000000..b46fc52e --- /dev/null +++ b/crates/socket-patch-cli/tests/vendor_jvm_cli.rs @@ -0,0 +1,465 @@ +//! CLI flows of the prototype v5 JVM vendored backend +//! (`SOCKET_PATCH_EXPERIMENTAL_JVM_VENDOR=1`) 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 EXPERIMENTAL_ENV: &str = "SOCKET_PATCH_EXPERIMENTAL_JVM_VENDOR"; +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 => { + 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, experimental: bool, 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); + } + } + if experimental { + cmd.env(EXPERIMENTAL_ENV, "1"); + } + 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, experimental: bool, args: &[&str]) -> serde_json::Value { + let (code, env) = socket(root, experimental, 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, true, &["vendor"]); + assert_eq!(env["summary"]["applied"], 2, "{env}"); + ok(root, true, &["rollback", &purl(first)]); + let other = if first == "foo" { "bar" } else { "foo" }; + let env = ok(root, true, &["vendor"]); + assert_eq!( + events(&env), + [( + purl(other), + "skipped".to_string(), + "already_vendored".to_string() + )], + "{env}" + ); + ok(root, false, &["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, true, &["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, true, &["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, true, &["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, true, &["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, false, args); + assert_eq!( + events(&env), + [( + purl("foo"), + "skipped".to_string(), + "already_vendored".to_string() + )], + "{args:?}: {env}" + ); + } + let vex = root.join("vex.json"); + ok( + root, + false, + &[ + "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, false, &["repair"]); + assert_eq!(env["events"][0]["action"], "rebuilt", "{env}"); + assert!(root.join(tree_jar).is_file()); + ok(root, false, &["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, true, &["vendor"]); + ok(root, true, &["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, with or without the switch. +#[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(); + for experimental in [false, true] { + let (code, env) = socket(root, experimental, &["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 == "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, true, &["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"); + } +} 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 00000000..f1c46d64 --- /dev/null +++ b/crates/socket-patch-core/src/vendor/jvm/apply.rs @@ -0,0 +1,1078 @@ +//! Disk side of the prototype JVM backend: write a [`JvmPlan`], record it, +//! and revert it. +//! +//! Records (§7.1): 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())) +} + +/// The validated `(group, artifact, version)` of a JVM entry: a maven purl +/// in the D15 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 => 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>, +} + +impl ProjectReader { + pub fn new(root: &Path) -> Self { + Self { + root: root.to_path_buf(), + canonical: std::fs::canonicalize(root).ok(), + escaped: 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 = self.resolve(rel).ok()?; + if let Some(captured) = group_commit::read(&path) { + return captured.ok(); + } + read_regular_to_bytes_sync(&path).ok() + } + + /// 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 Ok(rel.split('/').fold(self.root.clone(), |p, s| p.join(s))); + }; + 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(_) => { + return Ok(segments[i + 1..].iter().fold(next, |p, s| p.join(s))); + } + 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 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 => { + if !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 (§7.3): 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` (§7.3): 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 unplan: JvmUnplan = if is_gradle(&entry.wiring) { + gradle::unplan(&read, &c, &entry.wiring) + } 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; + if opts.dry_run { + return RevertOutcome { + success: true, + warnings, + error: None, + kept_artifact: false, + }; + } + 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. + let mut present = Vec::new(); + 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); + } + 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 { + let Ok((g, a, v)) = entry_gav(entry) else { + return false; + }; + 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(&read, &c) + } else { + maven_reactor::wired(&read, &c) + }; + wired && reader.escaped().is_none() +} + +/// 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(); + 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 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/gradle.rs b/crates/socket-patch-core/src/vendor/jvm/gradle.rs new file mode 100644 index 00000000..b93780ea --- /dev/null +++ b/crates/socket-patch-core/src/vendor/jvm/gradle.rs @@ -0,0 +1,2689 @@ +//! Gradle planner (prototype). See the module doc of [`super`] and +//! `docs/design/maven-vendoring.md` §5. +//! +//! The patched artifact keeps its GAV (D1) in `.socket/vendor/gradle/`, +//! listed with its sha256 in `.socket/vendor/gradle-index.tsv` (D8). The +//! owned static script [`SCRIPT`] reads the index, checks every hash at +//! configuration time (D9 layer 1) 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` (D14), 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, D1). +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 { + 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)?; + check_settings_classpath(&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() { + match in_block_entry(&text, t.kotlin, &prefix, &coords) { + InBlock::Unneeded => {} + InBlock::Present => { + let key = format!("in_block:{g}:{a}:{v}"); + records.push(adopt(&t.rel, SETTINGS_FRAGMENT_KIND, &key)); + records.push(adopt(&t.rel, SETTINGS_FRAGMENT_KIND, "in_block_section")); + } + InBlock::Edited { + start, + end, + text: inserted, + } => { + records.extend(in_block_records( + &t.rel, &text, start, end, &inserted, t.kotlin, &prefix, &coords, + )); + 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..]); + 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 from = Some(&text[start..end]).filter(|f| !f.contains("origin=\"socket-patch\"")); + 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 (§7.3): 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()) { + 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 w.key.as_deref() == Some("in_block_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() + .filter(|w| w.kind == VERIFICATION_FRAGMENT_KIND) + { + 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 ours = rel == SCRIPT_REL + || current.as_deref() == Some(super::TREE_GITATTRIBUTES.as_bytes()); + if created && ours && current.is_some() { + before.insert(rel.to_string(), Some(String::new())); + after.insert(rel.to_string(), None); + } + } + } 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 +} + +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) { + 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(()) +} + +/// A settings `buildscript{}` classpath is resolved before any apply line. +fn check_settings_classpath(rel: &str, text: &str, patch: &JvmPatch<'_>) -> Result<(), JvmRefusal> { + let toks = lex(text); + let Some((_, open, close)) = find_block(&toks, 0, toks.len(), "buildscript") else { + return Ok(()); + }; + let body = &toks[open..close]; + let ga = format!("{}:{}", patch.group_id, patch.artifact_id); + let literal = strings(body).any(|s| s == ga || s.starts_with(&format!("{ga}:"))) + || (strings(body).any(|s| s == patch.group_id) + && strings(body).any(|s| s == patch.artifact_id)); + if literal { + return Err(shape_refusal( + "gradle_settings_classpath", + format!("{rel} puts {ga} on the settings buildscript classpath, which resolves before vendoring applies"), + )); + } + 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 (§5.1) ────────────────────────────────────── + +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); + if kotlin { + format!( + "exclusiveContent {{ forRepository {{ maven {{ name = \"{REPO_NAME}\"; url = File(settingsDir, \"{prefix}{TREE_ROOT}\").toURI() }} }}; filter {{ includeVersion(\"{g}\", \"{a}\", \"{v}\") }} }} // socket-patch" + ) + } else { + format!( + "exclusiveContent {{ forRepository {{ maven {{ name = '{REPO_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 in_block_entry(text: &str, kotlin: bool, prefix: &str, patch: &Coords<'_>) -> InBlock { + let toks = lex(text); + if find_block(&toks, 0, toks.len(), "plugins").is_none() { + return InBlock::Unneeded; + } + let entry = in_block_line(kotlin, prefix, patch); + if text.lines().any(|l| l.trim() == entry) { + return InBlock::Present; + } + let nl = newline_of(text); + let unit = indent_unit(text); + let Some((pm_name, pm_open, pm_close)) = find_block(&toks, 0, toks.len(), "pluginManagement") + else { + // 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, + }; + }; + 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 { + 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 body = format!( + "{inner}repositories {{{nl}{inner}{unit}{entry}{nl}{inner}{unit}gradlePluginPortal(){nl}{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 (§5.3) ───────────────────────────────────── + +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 (§5.3: 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. +fn unverified_parent_chain(verification: &str, pom: &str, module: Option<&[u8]>) -> bool { + let vm = mask_xml_comments(verification); + if vm.contains("false") { + 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> { + 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}.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()) { + 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 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)) +} + +/// 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_refused() { + 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 err = run(&files, &patch()).unwrap_err(); + assert!(err.detail.starts_with("reason: gradle_settings_classpath: "), "{}", err.detail); + } + 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_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 00000000..568ca8f6 --- /dev/null +++ b/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs @@ -0,0 +1,3814 @@ +//! Multi-module Maven reactor planner (prototype). See the module doc of +//! [`super`] and `docs/design/maven-vendoring.md` §3 and §4 (the v5.0 +//! column: 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 (D2, §3). +pub const TREE_ROOT: &str = ".socket/vendor/maven2"; +pub const MAVEN_CONFIG: &str = ".mvn/maven.config"; +/// The tree root's `.gitattributes` (D14), 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 (§4.2, v5.0 two lines) ───────────────────────────────── + +/// `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 (§3) ─────────────────────────────────────────────────────── + +/// `(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 (D11): 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 { + files + .iter() + .map(|(p, c)| (p.to_string(), c.as_bytes().to_vec())) + .collect() + } + + 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 (§4.5 "a different literal"). + #[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 00000000..d7222258 --- /dev/null +++ b/crates/socket-patch-core/src/vendor/jvm/mod.rs @@ -0,0 +1,594 @@ +//! Prototype of the v5 vendored JVM backend (`docs/design/maven-vendoring.md`). +//! +//! Handles only the shapes the legacy `maven_repo` backend refuses — a +//! multi-module Maven reactor and a Gradle build — and only when +//! [`EXPERIMENTAL_ENV`] is set (or the ledger already holds an entry this +//! backend wrote), so every shape vendored today keeps its current behavior. +//! +//! 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 (§7.1: 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 (§7.3). [`apply`] +//! does the disk side. + +pub mod apply; +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"; +/// 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, +]; + +/// Opt-in switch for the prototype backend. +pub const EXPERIMENTAL_ENV: &str = "SOCKET_PATCH_EXPERIMENTAL_JVM_VENDOR"; + +/// Whether [`EXPERIMENTAL_ENV`] is set to a non-empty value other than `0`. +pub fn experimental_enabled() -> bool { + std::env::var(EXPERIMENTAL_ENV).is_ok_and(|v| !v.is_empty() && v != "0") +} + +/// 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, §7.3). + 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 (§7.6). + 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(), + }), + } +} + +/// D15 narrowed to what 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` (D14): 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 00000000..24d91e93 --- /dev/null +++ b/crates/socket-patch-core/src/vendor/jvm/socket-patch.settings.gradle @@ -0,0 +1,65 @@ +// 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 socketWire = { RepositoryHandler repos -> + if (repos.findByName(socketName) != null) { 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 { it.name != socketName }) { socketWire(repos) } + repos.whenObjectAdded { ArtifactRepository r -> if (r.name != socketName) { 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 2c98c664..b2ed6d9a 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,20 @@ pub async fn vendor_maven( force: bool, service: Option<&VendorServiceConfig>, ) -> VendorOutcome { + 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 +620,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 +708,239 @@ pub async fn revert_maven_opts( outcome } +// ── prototype v5 JVM backend (reactors, Gradle) ───────────────────────────────── + +/// The JVM shape this project routes to the prototype backend: only with +/// [`super::jvm::EXPERIMENTAL_ENV`] set, or once the ledger holds an entry +/// that backend wrote (so a vendored project keeps working without the +/// flag), and only for a reactor or a Gradle build. +async fn jvm_shape(project_root: &Path) -> Option { + if !super::jvm::experimental_enabled() { + let state = super::state::load_state(project_root).await.ok()?; + if !state.entries.values().any(super::jvm::apply::is_jvm_entry) { + return None; + } + } + 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 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 +/// prototype [`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 display_path = project_root.join(".socket/vendor"); + if record.files.is_empty() { + 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 (jar_bytes, pom_bytes, 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(pair) => pair, + Err(outcome) => return jvm_refusal(*outcome), + } + } + }; + if !result.success { + return done(result, None, warnings); + } + let pom = match acquire_upstream_pom( + installed_dir, + &group_id, + &artifact_id, + &version, + &group_id_to_path(&group_id), + service, + &mut warnings, + ) + .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 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 planned = super::jvm::plan(shape, &|rel: &str| reader.read(rel), &patch); + if let Some(rel) = reader.escaped() { + return refused( + "vendor_jvm_shape_unsupported", + super::jvm::apply::outside_root_detail(&rel), + ); + } + let plan = match planned { + Ok(plan) => plan, + Err(refusal) => return refused(refusal.code, refusal.detail), + }; + 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() { + 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 (§7.3). + if let Ok(state) = super::state::load_state(project_root).await { + super::jvm::apply::inherit_peer_records(&mut wiring, state.entries.values()); + } + let entry = maven_entry( + build_maven_purl(&group_id, &artifact_id, &version), + record, + plan.jar_rel.clone(), + &jar_bytes, + wiring, + ); + done(result, Some(entry), warnings) +} + +/// A legacy jar refusal in the JVM backend's codes (§8.2). +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 @@ -4266,4 +4526,222 @@ mod tests { assert_eq!(code, "vendor_prebuilt_required"); assert!(!root.join(".socket").exists(), "nothing written"); } + + // ── prototype 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 + } + + 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, whatever the experimental switch says. + #[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 project the prototype vendored keeps routing to it once 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(); + if !super::super::jvm::experimental_enabled() { + assert!(jvm_shape(root).await.is_none()); + } + 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 9dae4b77..76e53904 100644 --- a/crates/socket-patch-core/src/vendor/mod.rs +++ b/crates/socket-patch-core/src/vendor/mod.rs @@ -66,6 +66,7 @@ pub(crate) mod gemfile_lock; 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(crate) mod maven_pom; diff --git a/crates/socket-patch-core/src/vendor/verify.rs b/crates/socket-patch-core/src/vendor/verify.rs index f9080b61..3f00cb59 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('\\', "/"); diff --git a/crates/socket-patch-core/src/vex/discover/maven.rs b/crates/socket-patch-core/src/vex/discover/maven.rs index 9908e52c..9b94db5c 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 34b3c3ef..6c739270 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -1471,6 +1471,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/docs/design/maven-vendoring.md b/docs/design/maven-vendoring.md index e67b4778..d2e00e3d 100644 --- a/docs/design/maven-vendoring.md +++ b/docs/design/maven-vendoring.md @@ -1135,4 +1135,37 @@ There were five reviewers: | Q12 | Gradle layer 1 in included builds and buildSrc: should it hash only in the root build (`gradle.parent == null`), once the order in which 6.x and 7.x evaluate buildSrc has been checked? | | Q13 | What is the root cause of the missing enforcement on 3.9.2–3.9.3 (resolver 1.9.13 → 1.9.14)? Knowing it would let the 3.9.4 threshold rest on a documented change. | -Q4 (does 3.9.0 crash? yes, V) and Q5 (the header was dropped) are closed. \ No newline at end of file +Q4 (does 3.9.0 crash? yes, V) and Q5 (the header was dropped) are closed. +--- + +## 17. Prototype status (this branch) + +The prototype lives in `crates/socket-patch-core/src/vendor/jvm/`. It runs only when `SOCKET_PATCH_EXPERIMENTAL_JVM_VENDOR=1` is set, and only for shapes the legacy backend refuses: a root `pom.xml` with ``, or a Gradle-only root. With the variable unset, every shape behaves exactly as before. After an entry exists, its re-runs and reverts follow the ledger even without the variable. + +| Area | Covered | +|---|---| +| Maven reactor (§4) | Discovery; parent chains, including a middle local parent reached by a directory `relativePath`; local roots; pins; literal and `${p}` rewrites, including profiles and `.mvn/maven.config` `-D` values; the conflicting-literal un-pin; the shared fallback repository; the 2-line `maven.config`; the SV tree with a port of `suffixMavenPom`; enforcer and classifier warnings | +| Gradle (§5) | Static script with layer-1 hashing; index; apply lines for root, buildSrc and literal `includeBuild` settings in both DSLs; the in-block `pluginManagement` entry; hash replace or insert in an existing verification file; the Android/KMP, `exclusiveContent` and settings-classpath refusals | +| State (§7) | Per-fragment records. Shared fragments are removed only when no other patch still references them, so revert order does not matter. Also covered: same-uuid re-runs; patch updates with the stale-tree sweep; `--preserve-state`; the cold-cache in-sync fast path; VEX liveness and repair of a deleted tree jar | +| Safety | Every path resolves inside the checkout (a symlink that leaves it is refused); recorded paths are whitelisted per record kind; the D15 coordinate grammar is enforced | + +**Evidence:** +- **Unit tests:** 113 in `vendor::jvm`. +- **Subprocess tests:** 6 in `crates/socket-patch-cli/tests/vendor_jvm_cli.rs`: two patches reverted in either order, patch update, cold cache plus VEX plus repair, `--preserve-state`, forged ledgers, and escaping symlinks. +- **Real-tool capstones:** 2 in `crates/socket-patch-cli/tests/e2e_vendor_jvm_build.rs`. The reactor test is a fresh checkout built offline from the root and from `cd module`, followed by a byte-exact revert. The Gradle test covers Kotlin DSL, FAIL_ON_PROJECT_REPOS and STRICT locking: lockfiles stay byte-unchanged, `--offline` works, a tamper fails, and revert is byte-exact. +- **Versions:** the capstones pass on Maven 3.8.8, 3.9.2, 3.9.11 and 4.0.0-rc-7, and on Gradle 6.9.4, 7.6.4, 8.14.3 and 9.8.0. A wider reactor fixture built patched in all 42 cells: Maven 3.6.3, 3.8.8, 3.9.0, 3.9.2, 3.9.11 and 4.0.0-rc-7, each with 7 invocations. + +**Not yet implemented** (differences from this design): +- Ledger entries still use ecosystem `maven`, not `jvm`. +- Shared-fragment liveness comes from what the project files still reference, not from `RevertOpts.live_peers`. +- There is no revert without a ledger. +- GroupCommit does not capture the index, the script or `.gitattributes`. +- Entries are not all planned before the vendor loop. +- Maven: + - `not_build_root`; + - the `maven_f_outside_root` and `maven_mirror_of_all` warnings; + - wrapper detection; + - `--maven-config=none`; + - Maven 4 implicit subprojects. +- Gradle: the settings `buildscript{}` in-block entry, parent-chain and BOM components in the verification file (only warned), and `gradle_below_6_8`. +- `vendor --check`, the D7 byte-identity conformance test, and install-time checks against Central checksums. From 66126fd8016776a28dbe11f523ee0bd9a3e1ec0a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 09:04:59 +0000 Subject: [PATCH 4/5] Keep rollback fixture hashes for the macOS test The previous commit dropped two fixture fields to quiet clippy on Linux. A macOS-only test reads them, so the macOS test build broke. Restore the fields and allow dead code on other platforms instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019ZLLAZfofQZ2ywkgrHkdEZ --- crates/socket-patch-cli/tests/covgap_commands_rollback.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/socket-patch-cli/tests/covgap_commands_rollback.rs b/crates/socket-patch-cli/tests/covgap_commands_rollback.rs index 3f48f26f..1e8c03c9 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_rollback.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_rollback.rs @@ -149,6 +149,11 @@ struct PatchedFixture { purl: &'static str, before: &'static [u8], after: &'static [u8], + // Read only by the macOS-gated manifest-write-failure test. + #[cfg_attr(not(target_os = "macos"), allow(dead_code))] + before_hash: String, + #[cfg_attr(not(target_os = "macos"), allow(dead_code))] + after_hash: String, } fn patched_fixture() -> PatchedFixture { @@ -177,6 +182,8 @@ fn patched_fixture() -> PatchedFixture { purl, before, after, + before_hash, + after_hash, } } From e3695a27fb0326276badbcb2e6cdfbb9e73b488d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 09:42:24 +0000 Subject: [PATCH 5/5] Check out the Gradle settings script byte for byte The owned Gradle script is embedded with include_str! and written into user repos as is. A Windows checkout with autocrlf turned it into CRLF, so Windows builds would write different script bytes (and the script sanity test failed there). Mark it -text like the other byte-exact files. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019ZLLAZfofQZ2ywkgrHkdEZ --- .gitattributes | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitattributes b/.gitattributes index 4b3f63f0..ce7e35cd 100644 --- a/.gitattributes +++ b/.gitattributes @@ -22,3 +22,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