From aca8b1c8384bfcdafa0b60e91af78d6c16d06b32 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 2 Oct 2026 10:03:06 -0400 Subject: [PATCH 01/63] Add a shared JVM cache-root and project-dependency seam Introduce crawlers/jvm_cache: one list of JVM project markers, a layout-tagged cache root (Maven2 / GradleModules2 / Coursier / Ivy) that MavenCrawler crawls and resolves PURLs through, and a per-build-tool project_dependency_set provider list. Behavior is unchanged: only the Maven2 root is populated and no provider is registered yet. Gradle and sbt support each plug into this seam from their own modules. Also counts build.gradle.kts and settings.gradle(.kts) as manifest markers in scan policy, which previously listed only build.gradle. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/commands/scan/policy.rs | 8 +- .../src/crawlers/jvm_cache.rs | 138 ++++++++++++++++++ .../src/crawlers/maven_crawler.rs | 106 ++++++++------ crates/socket-patch-core/src/crawlers/mod.rs | 1 + .../src/hosted/memory/roots.rs | 11 +- 5 files changed, 202 insertions(+), 62 deletions(-) create mode 100644 crates/socket-patch-core/src/crawlers/jvm_cache.rs diff --git a/crates/socket-patch-cli/src/commands/scan/policy.rs b/crates/socket-patch-cli/src/commands/scan/policy.rs index 0cd466bf5..98b1ecc45 100644 --- a/crates/socket-patch-cli/src/commands/scan/policy.rs +++ b/crates/socket-patch-cli/src/commands/scan/policy.rs @@ -90,6 +90,7 @@ pub(crate) fn dir_markers(dir: &Path) -> Vec { // No lockfile: the manifests say what the project is. markers = MANIFEST_MARKERS .iter() + .chain(socket_patch_core::crawlers::jvm_cache::JVM_PROJECT_MARKERS) .filter(|name| dir.join(name).is_file()) .map(|name| name.to_string()) .collect(); @@ -98,16 +99,15 @@ pub(crate) fn dir_markers(dir: &Path) -> Vec { markers } -/// Manifests that stand in as markers for a root with no lockfile. -const MANIFEST_MARKERS: [&str; 8] = [ +/// Manifests that stand in as markers for a root with no lockfile (plus +/// every JVM build file, `jvm_cache::JVM_PROJECT_MARKERS`). +const MANIFEST_MARKERS: [&str; 6] = [ "package.json", "pyproject.toml", "setup.py", "Cargo.toml", "composer.json", "Gemfile", - "pom.xml", - "build.gradle", ]; #[derive(Default)] diff --git a/crates/socket-patch-core/src/crawlers/jvm_cache.rs b/crates/socket-patch-core/src/crawlers/jvm_cache.rs new file mode 100644 index 000000000..cc6f57125 --- /dev/null +++ b/crates/socket-patch-core/src/crawlers/jvm_cache.rs @@ -0,0 +1,138 @@ +//! Shared seam for JVM build tools whose artifacts land in different +//! caches (Maven's `~/.m2/repository`, Gradle's `modules-2`, Coursier, +//! Ivy). Every Maven-PURL discovery path goes through here: +//! +//! - [`JVM_PROJECT_MARKERS`]: the files that make a directory a JVM +//! project root (each build tool contributes its own). +//! - [`JvmCacheLayout`] / [`JvmCacheRoot`]: an installed-artifact cache +//! and how its directories spell coordinates. [`MavenCrawler`] crawls +//! and resolves PURLs per root, dispatching on the layout. +//! - [`project_dependency_set`]: the coordinates a project actually +//! resolves, from one provider per build tool (Gradle lock state, an sbt +//! lock, …). `None` means no provider could tell, so callers fall back to +//! the whole-cache crawl. +//! +//! Each build tool keeps its implementation in its own module and only +//! adds a variant / list entry / match arm here. +//! +//! [`MavenCrawler`]: super::MavenCrawler + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +/// Files whose presence makes a directory a JVM project root. +pub const JVM_PROJECT_MARKERS: &[&str] = &[ + // Maven + "pom.xml", + // Gradle + "build.gradle", + "build.gradle.kts", + "settings.gradle", + "settings.gradle.kts", +]; + +/// Whether `dir` holds any [`JVM_PROJECT_MARKERS`] file. +pub async fn is_jvm_project(dir: &Path) -> bool { + for marker in JVM_PROJECT_MARKERS { + if tokio::fs::metadata(dir.join(marker)).await.is_ok() { + return true; + } + } + false +} + +/// How a cache root's directories spell an artifact's coordinates. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum JvmCacheLayout { + /// `///-.{pom,jar}` + /// (Maven local repository). + Maven2, + /// `////` (Gradle's + /// `caches/modules-2/files-2.1`). + GradleModules2, + /// Coursier's per-repository-URL cache. + Coursier, + /// Ivy's `~/.ivy2` cache. + Ivy, +} + +impl JvmCacheLayout { + /// The layout of the cache rooted at `path`, from the root's own + /// spelling (each cache's root directory has a distinctive name). + /// Anything unrecognized is a Maven local repository. + pub fn classify(path: &Path) -> Self { + match path.file_name().and_then(|n| n.to_str()) { + Some("files-2.1") => Self::GradleModules2, + _ => Self::Maven2, + } + } +} + +/// One installed-artifact cache to crawl. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct JvmCacheRoot { + pub path: PathBuf, + pub layout: JvmCacheLayout, +} + +impl JvmCacheRoot { + pub fn new(path: PathBuf, layout: JvmCacheLayout) -> Self { + Self { path, layout } + } +} + +/// Maven coordinates `(group_id, artifact_id, version)`. +pub type Gav = (String, String, String); + +/// What a project resolves, as far as one build tool's provider can tell. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ProjectDependencySet { + /// The provider that answered (`"gradle"`, `"sbt"`, …). + pub provider: &'static str, + pub gavs: BTreeSet, +} + +/// The coordinates the project rooted at `root` resolves, from the first +/// build-tool provider that recognizes it. `None` = no provider knows +/// (callers fall back to every cached artifact). +pub fn project_dependency_set(root: &Path) -> Option { + // One entry per build tool; each returns `None` for a root it does not + // own or cannot read. + let providers: &[fn(&Path) -> Option] = &[]; + providers.iter().find_map(|provider| provider(root)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classify_recognizes_gradle_files_root_and_defaults_to_maven2() { + let gradle = Path::new("/h/.gradle/caches/modules-2/files-2.1"); + assert_eq!( + JvmCacheLayout::classify(gradle), + JvmCacheLayout::GradleModules2 + ); + let m2 = Path::new("/h/.m2/repository"); + assert_eq!(JvmCacheLayout::classify(m2), JvmCacheLayout::Maven2); + assert_eq!( + JvmCacheLayout::classify(Path::new("")), + JvmCacheLayout::Maven2 + ); + } + + #[test] + fn no_provider_means_whole_cache_fallback() { + assert_eq!(project_dependency_set(Path::new("/nonexistent")), None); + } + + #[tokio::test] + async fn every_marker_makes_a_jvm_project() { + for marker in JVM_PROJECT_MARKERS { + let dir = tempfile::tempdir().unwrap(); + assert!(!is_jvm_project(dir.path()).await); + std::fs::write(dir.path().join(marker), "").unwrap(); + assert!(is_jvm_project(dir.path()).await, "{marker}"); + } + } +} diff --git a/crates/socket-patch-core/src/crawlers/maven_crawler.rs b/crates/socket-patch-core/src/crawlers/maven_crawler.rs index da2a15846..479429261 100644 --- a/crates/socket-patch-core/src/crawlers/maven_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/maven_crawler.rs @@ -2,6 +2,7 @@ use std::borrow::Cow; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; +use super::jvm_cache::{self, JvmCacheLayout, JvmCacheRoot}; use super::types::{CrawledPackage, CrawlerOptions}; use super::walk_pool::{par_map, run_walk}; use crate::patch::path_safety; @@ -554,56 +555,45 @@ impl MavenCrawler { // Public API // ------------------------------------------------------------------ - /// Get Maven repository paths based on options. + /// Every JVM artifact cache to crawl, tagged with its layout. /// - /// In global mode, returns `~/.m2/repository/` (respects `$M2_HOME`, - /// `$MAVEN_REPO_LOCAL`, `--global-prefix`). - /// - /// In local mode, only returns the Maven repo if the cwd contains - /// `pom.xml`, `build.gradle`, `build.gradle.kts`, `settings.gradle`, - /// or `settings.gradle.kts` (prevents scanning for non-Java projects). - pub async fn get_maven_repo_paths( - &self, - options: &CrawlerOptions, - ) -> Result, std::io::Error> { - if options.global || options.global_prefix.is_some() { - if let Some(ref custom) = options.global_prefix { - return Ok(vec![custom.clone()]); - } - let repo = Self::m2_repo_path(); - if is_dir(&repo).await { - return Ok(vec![repo]); - } - return Ok(Vec::new()); + /// In global mode (or with `--global-prefix`) returns the caches + /// regardless of the cwd; in local mode only when the cwd is a JVM + /// project ([`jvm_cache::is_jvm_project`]), so non-Java projects are + /// never scanned against a shared cache. A `--global-prefix` names one + /// root whose layout is [`JvmCacheLayout::classify`]'d from its path. + pub async fn get_jvm_cache_roots(&self, options: &CrawlerOptions) -> Vec { + if let Some(ref custom) = options.global_prefix { + return vec![JvmCacheRoot::new( + custom.clone(), + JvmCacheLayout::classify(custom), + )]; } - - // Local mode: only return Maven repo if this looks like a Java/Maven/Gradle project - let java_markers = [ - "pom.xml", - "build.gradle", - "build.gradle.kts", - "settings.gradle", - "settings.gradle.kts", - ]; - - let mut is_java_project = false; - for marker in &java_markers { - if tokio::fs::metadata(options.cwd.join(marker)).await.is_ok() { - is_java_project = true; - break; - } - } - - if !is_java_project { - return Ok(Vec::new()); + if !options.global && !jvm_cache::is_jvm_project(&options.cwd).await { + return Vec::new(); } - + let mut roots = Vec::new(); let repo = Self::m2_repo_path(); if is_dir(&repo).await { - Ok(vec![repo]) - } else { - Ok(Vec::new()) + roots.push(JvmCacheRoot::new(repo, JvmCacheLayout::Maven2)); } + roots + } + + /// Get the paths of [`Self::get_jvm_cache_roots`] (`~/.m2/repository/` + /// respects `$M2_HOME`, `$MAVEN_REPO_LOCAL`, `--global-prefix`). Each + /// path's layout is recovered by [`JvmCacheLayout::classify`] in + /// [`Self::find_by_purls`]. + pub async fn get_maven_repo_paths( + &self, + options: &CrawlerOptions, + ) -> Result, std::io::Error> { + Ok(self + .get_jvm_cache_roots(options) + .await + .into_iter() + .map(|root| root.path) + .collect()) } /// Crawl all discovered Maven repository paths and return every @@ -612,14 +602,12 @@ impl MavenCrawler { let mut packages = Vec::new(); let mut seen = HashSet::new(); - let repo_paths = self.get_maven_repo_paths(options).await.unwrap_or_default(); - - for repo_path in repo_paths { + for root in self.get_jvm_cache_roots(options).await { // The walkdir walk and POM reads are blocking: run each repo // on the walk pool so concurrently crawled ecosystems keep // making progress (the dedup set rides along and comes back). let (found, returned_seen) = run_walk(move || { - let found = MavenCrawler.scan_maven_repo(&repo_path, &mut seen); + let found = MavenCrawler.scan_cache_root(&root, &mut seen); (found, seen) }) .await; @@ -640,6 +628,13 @@ impl MavenCrawler { src_path: &Path, purls: &[String], ) -> Result, std::io::Error> { + match JvmCacheLayout::classify(src_path) { + JvmCacheLayout::Maven2 => {} + // Other layouts plug in here; until then they resolve nothing. + JvmCacheLayout::GradleModules2 | JvmCacheLayout::Coursier | JvmCacheLayout::Ivy => { + return Ok(HashMap::new()) + } + } let mut result: HashMap = HashMap::new(); for purl in purls { @@ -742,6 +737,21 @@ impl MavenCrawler { self.scan_maven_repo_chunked(repo_path, seen, POM_PARSE_CHUNK) } + /// Crawl one cache root according to its layout. + fn scan_cache_root( + &self, + root: &JvmCacheRoot, + seen: &mut HashSet, + ) -> Vec { + match root.layout { + JvmCacheLayout::Maven2 => self.scan_maven_repo(&root.path, seen), + // Other layouts plug in here; until then they crawl nothing. + JvmCacheLayout::GradleModules2 | JvmCacheLayout::Coursier | JvmCacheLayout::Ivy => { + Vec::new() + } + } + } + /// [`Self::scan_maven_repo`] over an explicit chunk size, so tests can /// cross the chunk boundary on a small fixture. fn scan_maven_repo_chunked( diff --git a/crates/socket-patch-core/src/crawlers/mod.rs b/crates/socket-patch-core/src/crawlers/mod.rs index b0c257f50..c3bd77844 100644 --- a/crates/socket-patch-core/src/crawlers/mod.rs +++ b/crates/socket-patch-core/src/crawlers/mod.rs @@ -3,6 +3,7 @@ pub mod composer_crawler; pub mod deno_crawler; pub mod fuzzy_match; pub mod go_crawler; +pub mod jvm_cache; mod listing; pub mod maven_crawler; #[cfg(test)] diff --git a/crates/socket-patch-core/src/hosted/memory/roots.rs b/crates/socket-patch-core/src/hosted/memory/roots.rs index 84e0dd8d7..35f39be1a 100644 --- a/crates/socket-patch-core/src/hosted/memory/roots.rs +++ b/crates/socket-patch-core/src/hosted/memory/roots.rs @@ -15,16 +15,7 @@ use super::types::IgnoredPath; /// Marker files of the ecosystems the in-memory engine cannot inventory /// (disk discovers them only through installed-tree crawlers). pub const UNSUPPORTED_MARKERS: [(&str, &[&str]); 2] = [ - ( - "maven", - &[ - "pom.xml", - "build.gradle", - "build.gradle.kts", - "settings.gradle", - "settings.gradle.kts", - ], - ), + ("maven", crate::crawlers::jvm_cache::JVM_PROJECT_MARKERS), ( "nuget", &[ From 601c6b43d5709a1147ce68cfe11b1e43304ee5e5 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 2 Oct 2026 11:00:19 -0400 Subject: [PATCH 02/63] Extract the real-Gradle test harness into gradle_build_common The Gradle detect/run/skip helpers, the classpath and lockfile readers, the project writer and the Windows verbatim-path strip lived inside e2e_vendor_jvm_build.rs, so every new Gradle suite would have had to copy them. They move to tests/gradle_build_common/ unchanged in behaviour, and the module gains what the agent/hosted/vendored suites need: the Gradle major/minor and JDK banner, Isolated Projects runs, per-DSL project writers, a configuration-cache-safe printRuntimeClasspath task plus an assertion on the bytes Gradle actually consumed, a test-only mirror init script for the fake origins, an autocrlf clone and per-cell probe reports. The launcher scrub now also drops GRADLE_RO_DEP_CACHE and GRADLE_HOME. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tests/e2e_vendor_jvm_build.rs | 198 +---- .../tests/gradle_build_common/mod.rs | 692 ++++++++++++++++++ 2 files changed, 707 insertions(+), 183 deletions(-) create mode 100644 crates/socket-patch-cli/tests/gradle_build_common/mod.rs diff --git a/crates/socket-patch-cli/tests/e2e_vendor_jvm_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_jvm_build.rs index d4e7f1963..1a42cb08c 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_jvm_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_jvm_build.rs @@ -27,7 +27,7 @@ //! (`maven_build_common`), Gradle via `SOCKET_PATCH_GRADLE_E2E_GRADLE` (the //! launcher; default `gradle` on `PATH`), `SOCKET_PATCH_GRADLE_E2E_VERSION` //! (the version it must report) and `SOCKET_PATCH_GRADLE_E2E_REQUIRED` (no -//! SKIP). Scratch trees go under `TMPDIR`. +//! SKIP) (`gradle_build_common`). Scratch trees go under `TMPDIR`. #[path = "maven_build_common/mod.rs"] mod maven_build_common; @@ -35,49 +35,30 @@ mod maven_build_common; #[path = "prebuilt_common/mod.rs"] mod prebuilt_common; +#[path = "gradle_build_common/mod.rs"] +mod gradle_build_common; + use std::collections::BTreeMap; -use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; +use gradle_build_common::{ + fixture_root, gradle_classpath, gradle_skip, lockfiles, snapshot, write_project, Gradle, +}; use maven_build_common::*; const UUID: &str = "1d3c1fd2-7b4e-4c1a-9f0e-2a3b4c5d6e7f"; const SV: &str = "1.10.0-socket.1d3c1fd2"; -const GRADLE_ENV: &str = "SOCKET_PATCH_GRADLE_E2E_GRADLE"; -const GRADLE_VERSION_ENV: &str = "SOCKET_PATCH_GRADLE_E2E_VERSION"; -const GRADLE_REQUIRED_ENV: &str = "SOCKET_PATCH_GRADLE_E2E_REQUIRED"; - /// The classpath probe. Not [`DEPENDENCY_PLUGIN`]: 3.6.x itself depends on /// `commons-text:1.10.0` (3.5.0 on 1.3), so purging the fixture version from /// the local repository would break the plugin realm, not the project. const CLASSPATH_PLUGIN: &str = "org.apache.maven.plugins:maven-dependency-plugin:3.5.0"; -/// Directories a build writes that a checkout never carries. -const BUILD_OUTPUT_DIRS: &[&str] = &["target", "build", ".gradle", ".kotlin"]; - fn binary() -> PathBuf { env!("CARGO_BIN_EXE_socket-patch").into() } -/// Java rejects the extended Windows paths returned by canonicalize. -/// Keep a canonical root for symlinked macOS temp directories, but use the -/// ordinary drive/UNC spelling when handing paths to Maven and Gradle. -fn fixture_root(tmp: &tempfile::TempDir) -> PathBuf { - let root = tmp.path().canonicalize().unwrap(); - #[cfg(windows)] - if let Some(path) = root.to_str() { - if let Some(rest) = path.strip_prefix(r"\\?\UNC\") { - return format!(r"\\{rest}").into(); - } - if let Some(rest) = path.strip_prefix(r"\\?\") { - return rest.into(); - } - } - root -} - fn git_sha256(bytes: &[u8]) -> String { socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes(bytes) } @@ -176,33 +157,6 @@ fn stage_manifest(proj: &Path, member_before: &[u8], member_after: &[u8]) { .unwrap(); } -/// Every committable file under `root` (build output skipped), keyed by its -/// forward-slash relative path. -fn snapshot(root: &Path) -> BTreeMap> { - fn walk(root: &Path, dir: &Path, out: &mut BTreeMap>) { - for entry in std::fs::read_dir(dir).unwrap() { - let entry = entry.unwrap(); - let name = entry.file_name().to_string_lossy().into_owned(); - let path = entry.path(); - if entry.file_type().unwrap().is_dir() { - if !BUILD_OUTPUT_DIRS.contains(&name.as_str()) { - walk(root, &path, out); - } - continue; - } - let rel = path - .strip_prefix(root) - .unwrap() - .to_string_lossy() - .replace('\\', "/"); - out.insert(rel, std::fs::read(&path).unwrap()); - } - } - let mut out = BTreeMap::new(); - walk(root, root, &mut out); - out -} - /// `(changed, added, removed)` paths from `before` to `after`. fn diff( before: &BTreeMap>, @@ -578,109 +532,6 @@ fn maven_reactor_vendor_fresh_checkout_offline_build_and_byte_exact_revert() { // ── P2: Gradle multi-project with dependency locking ──────────────────── -fn gradle_flag(name: &str) -> bool { - std::env::var_os(name).is_some_and(|v| !v.is_empty()) -} - -fn gradle_skip(suite: &str, why: &str) { - assert!( - !gradle_flag(GRADLE_REQUIRED_ENV), - "{suite}: {GRADLE_REQUIRED_ENV} is set but the Gradle capstone cannot run: {why}" - ); - println!("SKIP {suite}: {why}"); -} - -/// The selected Gradle launcher, run hermetically: a per-test -/// `GRADLE_USER_HOME`, no daemon, plain console, ambient options scrubbed. -struct Gradle { - program: OsString, - version: String, -} - -impl Gradle { - fn command(program: &OsString, home: Option<&Path>) -> Command { - let mut cmd = Command::new(program); - for key in ["GRADLE_OPTS", "JAVA_OPTS", "GRADLE_USER_HOME"] { - cmd.env_remove(key); - } - for key in CI_DETECTOR_ENV { - cmd.env_remove(key); - } - if let Some(home) = home { - cmd.env("GRADLE_USER_HOME", home); - } - cmd - } - - fn detect(suite: &str, home: &Path) -> Option { - let program: OsString = std::env::var_os(GRADLE_ENV) - .filter(|v| !v.is_empty()) - .unwrap_or_else(|| { - if cfg!(windows) { - "gradle.bat" - } else { - "gradle" - } - .into() - }); - let out = match Self::command(&program, Some(home)) - .args(["--version", "--no-daemon"]) - .output() - { - Ok(out) => out, - Err(e) => { - gradle_skip( - suite, - &format!("`{}` did not run: {e}", program.to_string_lossy()), - ); - return None; - } - }; - let banner = String::from_utf8_lossy(&out.stdout).into_owned(); - let Some(version) = banner.lines().find_map(|l| { - l.trim() - .strip_prefix("Gradle ") - .map(|v| v.trim().to_string()) - }) else { - gradle_skip( - suite, - &format!( - "`{} --version` printed no `Gradle ` banner:\n{}{}", - program.to_string_lossy(), - banner, - String::from_utf8_lossy(&out.stderr) - ), - ); - return None; - }; - if let Some(pin) = std::env::var(GRADLE_VERSION_ENV) - .ok() - .filter(|v| !v.is_empty()) - { - assert_eq!( - version, - pin, - "{GRADLE_VERSION_ENV} pins Gradle {pin} but `{}` is Gradle {version}", - program.to_string_lossy() - ); - } - println!( - "{suite}: driving Gradle {version} ({})", - program.to_string_lossy() - ); - Some(Gradle { program, version }) - } - - fn run(&self, cwd: &Path, home: &Path, args: &[&str]) -> Output { - Self::command(&self.program, Some(home)) - .current_dir(cwd) - .args(["--no-daemon", "--console=plain", "--stacktrace"]) - .args(args) - .output() - .expect("spawn gradle") - } -} - const GRADLE_SETTINGS: &str = r#"buildscript { repositories { mavenCentral() } dependencies { classpath("org.apache.commons:commons-text:1.10.0") } @@ -737,33 +588,14 @@ 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() + write_project( + proj, + &[ + ("settings.gradle.kts", GRADLE_SETTINGS), + ("lib/build.gradle.kts", GRADLE_LIB), + ("app/build.gradle.kts", GRADLE_APP), + ], + ); } fn gradle_tree_rel() -> String { diff --git a/crates/socket-patch-cli/tests/gradle_build_common/mod.rs b/crates/socket-patch-cli/tests/gradle_build_common/mod.rs new file mode 100644 index 000000000..9f59f442d --- /dev/null +++ b/crates/socket-patch-cli/tests/gradle_build_common/mod.rs @@ -0,0 +1,692 @@ +//! Real-Gradle plumbing shared by the Gradle capstones +//! (`e2e_vendor_jvm_build`'s multi-project leg and the `e2e_gradle_*` / +//! `e2e_*_gradle_build` suites). +//! +//! Toolchain selection (the version-matrix lever, mirroring +//! `maven_build_common`'s `SOCKET_PATCH_MAVEN_E2E_*` trio): +//! +//! * `SOCKET_PATCH_GRADLE_E2E_GRADLE` — the launcher to drive (an unpacked +//! `gradle-/bin/gradle`); unset/empty = `gradle` on `PATH`. +//! * `SOCKET_PATCH_GRADLE_E2E_VERSION` — when set and non-empty, the +//! launcher's `--version` banner MUST report exactly this version. +//! * `SOCKET_PATCH_GRADLE_E2E_REQUIRED` — when set and non-empty, a missing +//! toolchain or an unreachable origin is a hard failure, not a SKIP. +//! * `SOCKET_PATCH_GRADLE_E2E_PROBE_DIR` — where [`probe_report`] writes the +//! per-cell JSON (default `/gradle-probe`). +//! +//! Every Gradle run is hermetic: a per-test `GRADLE_USER_HOME`, no daemon, +//! plain console, and the ambient JVM / Gradle options ([`AMBIENT_ENV`]) and +//! CI markers ([`CI_DETECTOR_ENV`]) scrubbed. Repositories are redirected to +//! the fake origins by a test-only init script ([`mirror_init_script`]); +//! production code has no test override. + +#![allow(dead_code)] + +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::io::Read as _; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +pub const GRADLE_ENV: &str = "SOCKET_PATCH_GRADLE_E2E_GRADLE"; +pub const GRADLE_VERSION_ENV: &str = "SOCKET_PATCH_GRADLE_E2E_VERSION"; +pub const GRADLE_REQUIRED_ENV: &str = "SOCKET_PATCH_GRADLE_E2E_REQUIRED"; +pub const GRADLE_PROBE_DIR_ENV: &str = "SOCKET_PATCH_GRADLE_E2E_PROBE_DIR"; + +/// Ambient settings that would change what a Gradle child resolves or where +/// it caches: JVM options (a `-Dgradle.user.home` there beats the per-test +/// home), the user home itself, the read-only dependency cache and an +/// installation's `init.d`. +pub const AMBIENT_ENV: &[&str] = &[ + "GRADLE_OPTS", + "JAVA_OPTS", + "GRADLE_USER_HOME", + "GRADLE_RO_DEP_CACHE", + "GRADLE_HOME", +]; + +/// The CI markers `maven_build_common` scrubs, scrubbed here too so a leg +/// logs what a developer's terminal run logs. +pub const CI_DETECTOR_ENV: &[&str] = &[ + "CI", + "GITHUB_ACTIONS", + "CIRCLECI", + "WORKSPACE", + "TEAMCITY_VERSION", + "TRAVIS", +]; + +/// Directories a build writes that a checkout never carries. +pub const BUILD_OUTPUT_DIRS: &[&str] = &["target", "build", ".gradle", ".kotlin"]; + +/// The line prefix [`print_cp_task`] prints before each classpath entry. +pub const CP_MARKER: &str = "SOCKET-CP "; + +pub fn gradle_flag(name: &str) -> bool { + std::env::var_os(name).is_some_and(|v| !v.is_empty()) +} + +/// CI legs set `SOCKET_PATCH_GRADLE_E2E_REQUIRED`: never skip there. +pub fn gradle_required() -> bool { + gradle_flag(GRADLE_REQUIRED_ENV) +} + +/// Skip (println) locally; fail when the leg is required. +pub fn gradle_skip(suite: &str, why: &str) { + assert!( + !gradle_required(), + "{suite}: {GRADLE_REQUIRED_ENV} is set but the Gradle capstone cannot run: {why}" + ); + println!("SKIP {suite}: {why}"); +} + +/// Java rejects the extended Windows paths returned by canonicalize. +/// Keep a canonical root for symlinked macOS temp directories, but use the +/// ordinary drive/UNC spelling when handing paths to Maven and Gradle. +pub fn fixture_root(tmp: &tempfile::TempDir) -> PathBuf { + strip_verbatim(tmp.path().canonicalize().unwrap()) +} + +/// `\\?\C:\x` → `C:\x` and `\\?\UNC\h\s` → `\\h\s`; identity elsewhere. +pub fn strip_verbatim(path: PathBuf) -> PathBuf { + #[cfg(windows)] + if let Some(text) = path.to_str() { + if let Some(rest) = text.strip_prefix(r"\\?\UNC\") { + return format!(r"\\{rest}").into(); + } + if let Some(rest) = text.strip_prefix(r"\\?\") { + return rest.into(); + } + } + path +} + +pub fn ok(out: &Output) -> bool { + out.status.success() +} + +pub fn dump(out: &Output) -> String { + format!( + "exit {:?}\n--- stdout\n{}\n--- stderr\n{}", + out.status.code(), + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ) +} + +// ── the launcher ──────────────────────────────────────────────────────── + +/// The selected Gradle launcher, run hermetically: a per-test +/// `GRADLE_USER_HOME`, no daemon, plain console, ambient options scrubbed. +pub struct Gradle { + pub program: OsString, + /// What the `Gradle ` banner line reports, e.g. `8.14.3`. + pub version: String, + /// What the `JVM:` banner line reports, e.g. `21.0.8 (Eclipse Adoptium 21.0.8+9-LTS)`. + pub jvm: String, + /// Arguments every [`Gradle::run`] passes first (see + /// [`Gradle::with_isolated_projects`]). + pub extra_args: Vec, +} + +impl Gradle { + pub fn command(program: &OsString, home: Option<&Path>) -> Command { + let mut cmd = Command::new(program); + for key in AMBIENT_ENV.iter().chain(CI_DETECTOR_ENV) { + cmd.env_remove(key); + } + if let Some(home) = home { + cmd.env("GRADLE_USER_HOME", home); + } + cmd + } + + /// Probe the launcher (`gradle --version`). `None` = skipped (message + /// printed; a hard failure on required legs). + pub fn detect(suite: &str, home: &Path) -> Option { + let program: OsString = std::env::var_os(GRADLE_ENV) + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| { + if cfg!(windows) { + "gradle.bat" + } else { + "gradle" + } + .into() + }); + let out = match Self::command(&program, Some(home)) + .args(["--version", "--no-daemon"]) + .output() + { + Ok(out) => out, + Err(e) => { + gradle_skip( + suite, + &format!("`{}` did not run: {e}", program.to_string_lossy()), + ); + return None; + } + }; + let banner = String::from_utf8_lossy(&out.stdout).into_owned(); + let Some(version) = banner.lines().find_map(|l| { + l.trim() + .strip_prefix("Gradle ") + .map(|v| v.trim().to_string()) + }) else { + gradle_skip( + suite, + &format!( + "`{} --version` printed no `Gradle ` banner:\n{}{}", + program.to_string_lossy(), + banner, + String::from_utf8_lossy(&out.stderr) + ), + ); + return None; + }; + if let Some(pin) = std::env::var(GRADLE_VERSION_ENV) + .ok() + .filter(|v| !v.is_empty()) + { + assert_eq!( + version, + pin, + "{GRADLE_VERSION_ENV} pins Gradle {pin} but `{}` is Gradle {version}", + program.to_string_lossy() + ); + } + let jvm = jvm_banner(&banner).unwrap_or_default(); + println!( + "{suite}: driving Gradle {version} on JVM {jvm} ({})", + program.to_string_lossy() + ); + Some(Gradle { + program, + version, + jvm, + extra_args: Vec::new(), + }) + } + + /// The major version (`6` for `6.9.4`). + pub fn major(&self) -> u32 { + version_part(&self.version, 0) + } + + pub fn minor(&self) -> u32 { + version_part(&self.version, 1) + } + + /// `self.version >= major.minor`. + pub fn at_least(&self, major: u32, minor: u32) -> bool { + (self.major(), self.minor()) >= (major, minor) + } + + /// The JVM's feature release (`21` for `21.0.8`, `8` for `1.8.0_412`). + pub fn jdk_major(&self) -> Option { + jdk_feature(&self.jvm) + } + + /// Every later [`Gradle::run`] enables Isolated Projects. Meaningful on + /// 9.x (and late 8.x); older releases ignore the unknown property. + pub fn with_isolated_projects(mut self) -> Self { + self.extra_args + .push("-Dorg.gradle.unsafe.isolated-projects=true".into()); + self + } + + pub fn run(&self, cwd: &Path, home: &Path, args: &[&str]) -> Output { + self.run_env(cwd, home, args, &[]) + } + + /// [`Gradle::run`] plus child-only environment (applied last). + pub fn run_env( + &self, + cwd: &Path, + home: &Path, + args: &[&str], + env: &[(&str, &std::ffi::OsStr)], + ) -> Output { + let mut cmd = Self::command(&self.program, Some(home)); + cmd.current_dir(cwd) + .args(["--no-daemon", "--console=plain", "--stacktrace"]) + .args(&self.extra_args) + .args(args); + for (k, v) in env { + cmd.env(k, v); + } + cmd.output().expect("spawn gradle") + } +} + +fn version_part(version: &str, index: usize) -> u32 { + version + .split(['.', '-']) + .nth(index) + .and_then(|p| p.parse().ok()) + .unwrap_or(0) +} + +/// The `JVM:` line of a `gradle --version` banner. +pub fn jvm_banner(banner: &str) -> Option { + banner + .lines() + .find_map(|l| l.trim().strip_prefix("JVM:").map(|v| v.trim().to_string())) +} + +/// `21.0.8 (…)` → 21; `1.8.0_412 (…)` → 8; `11 (…)` → 11. +pub fn jdk_feature(jvm: &str) -> Option { + let version = jvm.split_whitespace().next()?; + let mut parts = version.split(['.', '_', '+', '-']); + let first: u32 = parts.next()?.parse().ok()?; + if first == 1 { + parts.next()?.parse().ok() + } else { + Some(first) + } +} + +// ── build scripts ─────────────────────────────────────────────────────── + +/// The two build-script dialects. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Dsl { + Groovy, + Kotlin, +} + +impl Dsl { + pub const ALL: [Dsl; 2] = [Dsl::Groovy, Dsl::Kotlin]; + + pub fn name(self) -> &'static str { + match self { + Dsl::Groovy => "groovy", + Dsl::Kotlin => "kotlin", + } + } + + /// `.gradle` / `.gradle.kts`. + pub fn ext(self) -> &'static str { + match self { + Dsl::Groovy => ".gradle", + Dsl::Kotlin => ".gradle.kts", + } + } + + pub fn settings_file(self) -> String { + format!("settings{}", self.ext()) + } + + pub fn build_file(self) -> String { + format!("build{}", self.ext()) + } +} + +/// Run `f` once per DSL, labelling the output so a failure names its DSL. +pub fn for_each_dsl(mut f: impl FnMut(Dsl)) { + for dsl in Dsl::ALL { + println!("── {} DSL ──", dsl.name()); + f(dsl); + } +} + +/// Write `files` (`(relative path, body)`) under `proj`. +pub fn write_project(proj: &Path, files: &[(&str, &str)]) { + for (rel, body) in files { + let path = proj.join(rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, body).unwrap(); + } +} + +/// The same project in both DSLs, under `root/groovy` and `root/kotlin`. +/// `files(dsl)` returns that DSL's `(relative path, body)` list. +pub fn write_both_dsls( + root: &Path, + files: impl Fn(Dsl) -> Vec<(String, String)>, +) -> [(Dsl, PathBuf); 2] { + Dsl::ALL.map(|dsl| { + let proj = root.join(dsl.name()); + let owned = files(dsl); + let borrowed: Vec<(&str, &str)> = owned + .iter() + .map(|(rel, body)| (rel.as_str(), body.as_str())) + .collect(); + write_project(&proj, &borrowed); + (dsl, proj) + }) +} + +/// A `printRuntimeClasspath` task (configuration-cache safe: it captures a +/// file collection, not the project) printing one [`CP_MARKER`] line per +/// resolved file of `configuration`. +pub fn print_cp_task(dsl: Dsl, configuration: &str) -> String { + match dsl { + Dsl::Groovy => format!( + "def socketCp = files(configurations.named('{configuration}'))\n\ + tasks.register('printRuntimeClasspath') {{\n \ + doLast {{ socketCp.files.each {{ println('{CP_MARKER}' + it.absolutePath) }} }}\n\ + }}\n" + ), + Dsl::Kotlin => format!( + "val socketCp = files(configurations.named(\"{configuration}\"))\n\ + tasks.register(\"printRuntimeClasspath\") {{\n \ + doLast {{ socketCp.files.forEach {{ println(\"{CP_MARKER}\" + it.absolutePath) }} }}\n\ + }}\n" + ), + } +} + +/// The [`CP_MARKER`] lines a `printRuntimeClasspath` run printed. +pub fn gradle_classpath(out: &Output) -> Vec { + String::from_utf8_lossy(&out.stdout) + .lines() + .filter_map(|l| l.strip_prefix(CP_MARKER)) + .map(PathBuf::from) + .collect() +} + +/// [`gradle_classpath`] with the build's own success asserted. +pub fn print_cp(out: &Output, what: &str) -> Vec { + assert!(ok(out), "{what}:\n{}", dump(out)); + gradle_classpath(out) +} + +/// The one classpath entry named `-…`; its `member` must be +/// exactly `want`. Returns the entry: what Gradle actually consumed. +pub fn assert_patched( + out: &Output, + artifact: &str, + member: &str, + want: &[u8], + what: &str, +) -> PathBuf { + let cp = print_cp(out, what); + let prefix = format!("{artifact}-"); + let hits: Vec<&PathBuf> = cp + .iter() + .filter(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with(&prefix) && n.ends_with(".jar")) + }) + .collect(); + assert_eq!( + hits.len(), + 1, + "{what}: exactly one `{artifact}` jar on the classpath:\n{cp:?}" + ); + let jar = std::fs::read(hits[0]).unwrap(); + let got = jar_member(&jar, member) + .unwrap_or_else(|| panic!("{what}: {} has no {member}", hits[0].display())); + assert_eq!( + String::from_utf8_lossy(&got), + String::from_utf8_lossy(want), + "{what}: {member} of the consumed {} is not the expected bytes", + hits[0].display() + ); + hits[0].clone() +} + +/// One member's bytes from a jar. +pub fn jar_member(jar: &[u8], name: &str) -> Option> { + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(jar)).ok()?; + let mut entry = archive.by_name(name).ok()?; + let mut buf = Vec::new(); + entry.read_to_end(&mut buf).ok()?; + Some(buf) +} + +// ── init scripts ──────────────────────────────────────────────────────── + +/// Write an init script into `dir` and return the `--init-script` args. +pub fn init_script(dir: &Path, name: &str, body: &str) -> [String; 2] { + std::fs::create_dir_all(dir).unwrap(); + let path = dir.join(name); + std::fs::write(&path, body).unwrap(); + ["--init-script".into(), path.to_string_lossy().into_owned()] +} + +/// A Groovy init script that points every Maven repository of the build — +/// settings `pluginManagement` / `buildscript` / `dependencyResolutionManagement` +/// and each project's (+ buildscript) repositories, including ones declared +/// later — at the fake origins: `https://patch.socket.dev/` at +/// `hosted` + `` when given, everything else except `file:` at +/// `central`. Plain-http origins need `allowInsecureProtocol`. Projects are +/// reached through `gradle.lifecycle.beforeProject` on Gradle ≥ 8.8 +/// (Isolated Projects compatible), `gradle.beforeProject` below that. +pub fn mirror_init_script(central: &str, hosted: Option<&str>) -> String { + let central = central.trim_end_matches('/'); + let hosted = hosted.unwrap_or("").trim_end_matches('/'); + format!( + r#"// socket-patch e2e harness: route every repository to the fake origins. +def socketCentral = '{central}/' +def socketHosted = '{hosted}' +def socketMirror = {{ repos -> + repos.configureEach {{ repo -> + if (repo instanceof org.gradle.api.artifacts.repositories.MavenArtifactRepository) {{ + def url = repo.url.toString() + if (url.startsWith('file:')) return + if (socketHosted && url.startsWith('https://patch.socket.dev/')) {{ + repo.url = socketHosted + url.substring('https://patch.socket.dev'.length()) + }} else {{ + repo.url = socketCentral + }} + repo.allowInsecureProtocol = true + }} + }} +}} +gradle.beforeSettings {{ settings -> + socketMirror(settings.pluginManagement.repositories) + socketMirror(settings.buildscript.repositories) + socketMirror(settings.dependencyResolutionManagement.repositories) +}} +def socketProject = {{ project -> + socketMirror(project.buildscript.repositories) + socketMirror(project.repositories) +}} +if (org.gradle.util.GradleVersion.current() >= org.gradle.util.GradleVersion.version('8.8')) {{ + gradle.lifecycle.beforeProject(socketProject) +}} else {{ + gradle.beforeProject(socketProject) +}} +"# + ) +} + +// ── files ─────────────────────────────────────────────────────────────── + +/// Every committable file under `root` (build output skipped), keyed by its +/// forward-slash relative path. +pub fn snapshot(root: &Path) -> BTreeMap> { + fn walk(root: &Path, dir: &Path, out: &mut BTreeMap>) { + for entry in std::fs::read_dir(dir).unwrap() { + let entry = entry.unwrap(); + let name = entry.file_name().to_string_lossy().into_owned(); + let path = entry.path(); + if entry.file_type().unwrap().is_dir() { + if !BUILD_OUTPUT_DIRS.contains(&name.as_str()) && name != ".git" { + walk(root, &path, out); + } + continue; + } + let rel = path + .strip_prefix(root) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + out.insert(rel, std::fs::read(&path).unwrap()); + } + } + let mut out = BTreeMap::new(); + walk(root, root, &mut out); + out +} + +/// Every Gradle lockfile under `root`: `/gradle.lockfile` on 7+, +/// `/gradle/dependency-locks/.lockfile` on 6.x. +pub fn lockfiles(root: &Path) -> BTreeMap> { + snapshot(root) + .into_iter() + .filter(|(rel, _)| rel.ends_with(".lockfile")) + .collect() +} + +/// `git` with the ambient repository / config environment scrubbed. +fn git(cwd: &Path) -> Command { + let mut cmd = Command::new("git"); + for (key, _) in std::env::vars_os() { + if key.to_string_lossy().starts_with("GIT_") { + cmd.env_remove(&key); + } + } + cmd.current_dir(cwd) + .env("GIT_CONFIG_NOSYSTEM", "1") + .args(["-c", "user.name=socket-patch-e2e"]) + .args(["-c", "user.email=e2e@socket.invalid"]) + .args(["-c", "init.defaultBranch=main"]); + cmd +} + +fn git_ok(cmd: &mut Command, what: &str) { + let out = cmd.output().unwrap_or_else(|e| panic!("{what}: git: {e}")); + assert!(ok(&out), "{what}:\n{}", dump(&out)); +} + +/// Commit `src`'s tree (LF, as written) and clone it to `dst` with +/// `core.autocrlf=true`: the working tree a Windows developer checks out, +/// on every OS. Text files arrive with CRLF unless `.gitattributes` says +/// otherwise. Returns `dst`. +pub fn git_autocrlf_clone(src: &Path, dst: &Path) -> PathBuf { + if !src.join(".git").exists() { + git_ok(git(src).args(["init", "-q"]), "git init"); + } + git_ok( + git(src).args(["-c", "core.autocrlf=false", "add", "-A"]), + "git add", + ); + git_ok( + git(src).args([ + "-c", + "core.autocrlf=false", + "commit", + "-q", + "--allow-empty", + "-m", + "fixture", + ]), + "git commit", + ); + let parent = dst.parent().unwrap(); + std::fs::create_dir_all(parent).unwrap(); + git_ok( + git(parent).args([ + "-c", + "core.autocrlf=true", + "clone", + "-q", + "--config", + "core.autocrlf=true", + &src.to_string_lossy(), + &dst.to_string_lossy(), + ]), + "git clone", + ); + dst.to_path_buf() +} + +// ── probe reports ─────────────────────────────────────────────────────── + +/// Where [`probe_report`] writes. +pub fn probe_dir() -> PathBuf { + std::env::var_os(GRADLE_PROBE_DIR_ENV) + .filter(|v| !v.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("gradle-probe")) +} + +/// Write one cell's JSON probe report (`/.json`, the cell +/// name reduced to `[A-Za-z0-9._-]`) and return its path. The CI grid +/// uploads the directory, so a cell's measured Gradle behaviour survives +/// the run. +pub fn probe_report(cell: &str, json: &serde_json::Value) -> PathBuf { + let dir = probe_dir(); + std::fs::create_dir_all(&dir).unwrap(); + let name: String = cell + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') { + c + } else { + '_' + } + }) + .collect(); + let path = dir.join(format!("{name}.json")); + std::fs::write(&path, serde_json::to_vec_pretty(json).unwrap()).unwrap(); + println!("probe report: {}", path.display()); + path +} + +// ── self-tests (pure; integration crates get no cfg(test)) ────────────── + +mod gradle_build_common_selftests { + use super::*; + + #[test] + fn jdk_feature_reads_both_version_schemes() { + assert_eq!( + jdk_feature("21.0.8 (Eclipse Adoptium 21.0.8+9-LTS)"), + Some(21) + ); + assert_eq!(jdk_feature("11.0.24 (Homebrew 11.0.24+0)"), Some(11)); + assert_eq!(jdk_feature("1.8.0_412 (Temurin 25.412-b08)"), Some(8)); + assert_eq!(jdk_feature("17 (x)"), Some(17)); + assert_eq!(jdk_feature(""), None); + let banner = "\n------\nGradle 8.14.3\n------\n\nKotlin: 2.0.21\nJVM: 21.0.8 (Eclipse Adoptium 21.0.8+9-LTS)\nOS: Linux\n"; + assert_eq!( + jvm_banner(banner).as_deref(), + Some("21.0.8 (Eclipse Adoptium 21.0.8+9-LTS)") + ); + } + + #[test] + fn version_parts_and_ordering() { + let g = |v: &str| Gradle { + program: "gradle".into(), + version: v.into(), + jvm: String::new(), + extra_args: Vec::new(), + }; + assert_eq!((g("6.9.4").major(), g("6.9.4").minor()), (6, 9)); + assert_eq!((g("9.0-rc-1").major(), g("9.0-rc-1").minor()), (9, 0)); + assert!(g("8.14.3").at_least(8, 8) && !g("8.7").at_least(8, 8)); + assert!(g("9.8.0").at_least(8, 8) && !g("7.6.6").at_least(8, 0)); + let ip = g("9.8.0").with_isolated_projects(); + assert_eq!( + ip.extra_args, + vec!["-Dorg.gradle.unsafe.isolated-projects=true".to_string()] + ); + } + + #[test] + fn mirror_init_script_redirects_both_origins() { + let script = mirror_init_script("http://127.0.0.1:1/", Some("http://127.0.0.1:2")); + assert!(script.contains("def socketCentral = 'http://127.0.0.1:1/'")); + assert!(script.contains("def socketHosted = 'http://127.0.0.1:2'")); + assert!(script.contains("gradle.beforeSettings")); + assert!(script.contains("gradle.lifecycle.beforeProject")); + assert!(script.contains("repo.allowInsecureProtocol = true")); + assert!(mirror_init_script("http://h", None).contains("def socketHosted = ''")); + } + + #[test] + fn print_cp_task_per_dsl() { + assert!(print_cp_task(Dsl::Groovy, "runtimeClasspath") + .contains("files(configurations.named('runtimeClasspath'))")); + assert!(print_cp_task(Dsl::Kotlin, "compileClasspath") + .contains("files(configurations.named(\"compileClasspath\"))")); + assert_eq!(Dsl::Kotlin.settings_file(), "settings.gradle.kts"); + assert_eq!(Dsl::Groovy.build_file(), "build.gradle"); + } +} From 49a2be332d19963c6724b6ea23a681a5a4bb1bb7 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 2 Oct 2026 11:01:43 -0400 Subject: [PATCH 03/63] Extract the hosted Maven API fake into hosted_maven_common The Gradle hosted suites drive the same Socket API and suffixed maven2 repository as the real-Maven hosted capstone. The wiremock Server, the API mounts and the suffixed-pom rewrite move to tests/hosted_maven_common/ behind a Hosted descriptor of the patched GAV and grant, so they can be reused for other coordinates. e2e_redirect_maven_build keeps its constants and thin wrappers and behaves exactly as before. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tests/e2e_redirect_maven_build.rs | 173 +++---------- .../tests/hosted_maven_common/mod.rs | 229 ++++++++++++++++++ 2 files changed, 258 insertions(+), 144 deletions(-) create mode 100644 crates/socket-patch-cli/tests/hosted_maven_common/mod.rs diff --git a/crates/socket-patch-cli/tests/e2e_redirect_maven_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_maven_build.rs index e30f3c04d..153f4d3f1 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_maven_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_maven_build.rs @@ -52,6 +52,8 @@ //! `SOCKET_PATCH_MAVEN_E2E_{MVN,VERSION,REQUIRED}` gates in //! `maven_build_common`. +#[path = "hosted_maven_common/mod.rs"] +mod hosted_maven_common; #[path = "maven_build_common/mod.rs"] mod maven_build_common; #[path = "vex_e2e_common/mod.rs"] @@ -60,10 +62,9 @@ mod vex_e2e_common; use std::path::{Path, PathBuf}; use std::process::Command; +use hosted_maven_common::{Hosted, Server}; use maven_build_common::*; use vex_e2e_common::*; -use wiremock::matchers::{method, path, path_regex}; -use wiremock::{Mock, MockServer, ResponseTemplate}; const SUITE: &str = "e2e_redirect_maven_build"; const ORG: &str = "test-org"; @@ -76,26 +77,35 @@ const GHSA: &str = "GHSA-redirect-maven-real"; const CVE: &str = "CVE-2026-7201"; const PRODUCT: &str = "pkg:maven/com.example/app@1.0.0"; +const HOSTED: Hosted = Hosted { + org: ORG, + uuid: UUID, + hex8: HEX8, + token: TOKEN, + ghsa: GHSA, + cve: CVE, + group: GROUP, + artifact: ARTIFACT, + version: VERSION, + title: "maven hosted capstone", +}; + fn suffixed() -> String { - format!("{VERSION}-socket.{HEX8}") + HOSTED.suffixed() } /// The Socket repository path (mirror target and index url path). fn repo_path() -> String { - format!("/patch-registry/maven/{TOKEN}/{UUID}/maven2") + HOSTED.repo_path() } fn prod_index_url() -> String { - format!("https://patch.socket.dev{}", repo_path()) + HOSTED.prod_index_url() } /// `////-.` under the Socket repository. fn served_path(ext: &str) -> String { - let sfx = suffixed(); - format!( - "{}/{GROUP_PATH}/{ARTIFACT}/{sfx}/{ARTIFACT}-{sfx}.{ext}", - repo_path() - ) + HOSTED.served_path(ext) } /// The record's file key: the version-dir jar name (the consumed copy is @@ -108,141 +118,16 @@ fn jar_key() -> String { /// own `` right after ``; the parent and the /// dependencies — the transitive — are untouched). fn served_pom(upstream: &[u8]) -> Vec { - let text = String::from_utf8(upstream.to_vec()).expect("utf-8 pom"); - let after_parent = text.find("").expect("commons-text has a parent") + 9; - let needle = format!("{VERSION}"); - let at = after_parent + text[after_parent..].find(&needle).expect("project version"); - let mut out = text.clone(); - out.replace_range( - at..at + needle.len(), - &format!("{}", suffixed()), + let out = HOSTED.served_pom(upstream); + assert!( + String::from_utf8_lossy(&out).contains("commons-lang3"), + "transitive kept" ); - assert!(out.contains("commons-lang3"), "transitive kept"); - out.into_bytes() -} - -/// A wiremock server with its own runtime (the CLI and Maven run as -/// blocking child processes on the test thread). -struct Server { - server: MockServer, - rt: tokio::runtime::Runtime, + out } -impl Server { - fn start() -> Self { - let rt = tokio::runtime::Builder::new_multi_thread() - .worker_threads(1) - .enable_all() - .build() - .unwrap(); - let server = rt.block_on(MockServer::start()); - Server { server, rt } - } - - fn uri(&self) -> String { - self.server.uri() - } - - fn get(&self, route: &str, status: u16, body: Vec) { - self.rt.block_on( - Mock::given(method("GET")) - .and(path(route.to_string())) - .respond_with(ResponseTemplate::new(status).set_body_bytes(body)) - .mount(&self.server), - ); - } - - /// Serve `jar` + `pom` (and `.sha1` sidecars: `jar_sha1` overrides the - /// jar's) as the Socket repository's suffixed GAV. - fn serve_repo(&self, jar: &[u8], pom: &[u8], jar_sha1: Option) { - self.get(&served_path("jar"), 200, jar.to_vec()); - self.get( - &format!("{}.sha1", served_path("jar")), - 200, - jar_sha1.unwrap_or_else(|| sha1_hex(jar)).into_bytes(), - ); - self.get(&served_path("pom"), 200, pom.to_vec()); - self.get( - &format!("{}.sha1", served_path("pom")), - 200, - sha1_hex(pom).into_bytes(), - ); - } - - fn paths(&self) -> Vec { - self.rt - .block_on(self.server.received_requests()) - .unwrap_or_default() - .iter() - .map(|r| r.url.path().to_string()) - .collect() - } -} - -/// The API `scan --mode hosted` drives: batch discovery, the by-package -/// listing, the reference grant (maven2 override, production-shaped urls) -/// and the patch view. fn mount_api(s: &Server, jar: &[u8], pom: &[u8], view: &serde_json::Value) { - let purl = purl(); - let artifact_url = format!( - "https://patch.socket.dev/patch/maven/{GROUP}/{ARTIFACT}/{VERSION}/{TOKEN}/{UUID}/{ARTIFACT}-{}.jar", - suffixed() - ); - let mounts = [ - Mock::given(method("POST")) - .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "packages": [{ "purl": purl, "patches": [{ - "uuid": UUID, "purl": purl, "tier": "free", "cveIds": [CVE], - "ghsaIds": [GHSA], "severity": "high", "title": "maven hosted capstone" - }] }], - "canAccessPaidPatches": false, - }))), - Mock::given(method("GET")) - .and(path_regex(format!( - "^/v0/orgs/{ORG}/patches/by-package/.+$" - ))) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "patches": [{ - "uuid": UUID, "purl": purl, "publishedAt": "2026-01-01T00:00:00Z", - "description": "d", "license": "MIT", "tier": "free", - "vulnerabilities": view["vulnerabilities"].clone() - }], - "canAccessPaidPatches": false, - }))), - Mock::given(method("POST")) - .and(path(format!("/v0/orgs/{ORG}/patches/package"))) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "results": { UUID: { - "status": "granted", - "url": artifact_url, - "purl": purl, - "artifacts": [{ - "kind": "tarball", - "url": artifact_url, - "integrity": { "sha1": sha1_hex(jar), "sha256": sha256_hex(jar) } - }], - "registryOverride": { - "kind": "maven2", - "indexUrl": prod_index_url(), - "identifiers": { - "name": format!("{GROUP}/{ARTIFACT}"), - "version": VERSION, - "mavenGroupId": GROUP, - "mavenArtifactId": ARTIFACT, - "mavenSuffixedVersion": suffixed(), - "mavenPomSha256": sha256_hex(pom), - } - } - } } - }))), - Mock::given(method("GET")) - .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(view.clone())), - ]; - for m in mounts { - s.rt.block_on(m.mount(&s.server)); - } + hosted_maven_common::mount_api(s, &HOSTED, jar, pom, view); } /// `socket-patch ` with ambient `SOCKET_*` scrubbed and the per-test @@ -336,7 +221,7 @@ fn maven_scan_hosted_fresh_checkout_install_and_manifestless_vex() { ); let server = Server::start(); mount_api(&server, &patched_jar, &sfx_pom, &view); - server.serve_repo(&patched_jar, &sfx_pom, None); + server.serve_repo(&HOSTED, &patched_jar, &sfx_pom, None); // 3. The real writer: three-file rewrite + in-run VEX, no ledger. let (code, env, stderr) = socket( @@ -415,7 +300,7 @@ fn maven_scan_hosted_fresh_checkout_install_and_manifestless_vex() { let bad = Server::start(); let mut tampered = patched_jar.clone(); tampered.extend_from_slice(b"TAMPER"); - bad.serve_repo(&tampered, &sfx_pom, Some(sha1_hex(&patched_jar))); + bad.serve_repo(&HOSTED, &tampered, &sfx_pom, Some(sha1_hex(&patched_jar))); let bad_settings = root.join("settings-bad.xml"); let bad_url = format!("{}{}", bad.uri(), repo_path()); write_settings( @@ -435,7 +320,7 @@ fn maven_scan_hosted_fresh_checkout_install_and_manifestless_vex() { // MATCHING `.sha1` passes transport validation; only the committed // sha256 summary can catch it. let resigned = Server::start(); - resigned.serve_repo(&tampered, &sfx_pom, None); + resigned.serve_repo(&HOSTED, &tampered, &sfx_pom, None); let resigned_settings = root.join("settings-resigned.xml"); let resigned_url = format!("{}{}", resigned.uri(), repo_path()); write_settings( diff --git a/crates/socket-patch-cli/tests/hosted_maven_common/mod.rs b/crates/socket-patch-cli/tests/hosted_maven_common/mod.rs new file mode 100644 index 000000000..c5456a2ed --- /dev/null +++ b/crates/socket-patch-cli/tests/hosted_maven_common/mod.rs @@ -0,0 +1,229 @@ +//! The hosted (`scan --mode hosted`) Socket API + `maven2` repository fake +//! shared by the real-build hosted capstones (`e2e_redirect_maven_build` +//! and the Gradle hosted suites). +//! +//! A [`Hosted`] names one patched GAV and its grant coordinates; the +//! served repository is the production-shaped +//! `…/patch-registry/maven///maven2` path carrying the +//! SUFFIXED version (`-socket.`). + +#![allow(dead_code)] + +use wiremock::matchers::{method, path, path_regex}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +/// One hosted patch: the GAV it patches and the grant around it. +pub struct Hosted { + pub org: &'static str, + /// Canonical lowercase patch uuid; its first 8 hex are the suffix. + pub uuid: &'static str, + pub hex8: &'static str, + /// Grant-token path level of the hosted urls (uuid-shaped, like prod). + pub token: &'static str, + pub ghsa: &'static str, + pub cve: &'static str, + pub group: &'static str, + pub artifact: &'static str, + pub version: &'static str, + /// The batch listing's `title`. + pub title: &'static str, +} + +impl Hosted { + pub fn purl(&self) -> String { + format!( + "pkg:maven/{}/{}@{}", + self.group, self.artifact, self.version + ) + } + + pub fn group_path(&self) -> String { + self.group.replace('.', "/") + } + + pub fn suffixed(&self) -> String { + format!("{}-socket.{}", self.version, self.hex8) + } + + /// The Socket repository path (mirror target and index url path). + pub fn repo_path(&self) -> String { + format!("/patch-registry/maven/{}/{}/maven2", self.token, self.uuid) + } + + pub fn prod_index_url(&self) -> String { + format!("https://patch.socket.dev{}", self.repo_path()) + } + + /// `////-.` under the Socket repository. + pub fn served_path(&self, ext: &str) -> String { + let sfx = self.suffixed(); + format!( + "{}/{}/{}/{sfx}/{}-{sfx}.{ext}", + self.repo_path(), + self.group_path(), + self.artifact, + self.artifact + ) + } + + /// The upstream pom re-versioned to the suffixed version (the project's + /// own `` right after ``; the parent and the + /// dependencies — the transitive — are untouched). + pub fn served_pom(&self, upstream: &[u8]) -> Vec { + let text = String::from_utf8(upstream.to_vec()).expect("utf-8 pom"); + let after_parent = text + .find("") + .expect("the fixture pom has a parent") + + 9; + let needle = format!("{}", self.version); + let at = after_parent + text[after_parent..].find(&needle).expect("project version"); + let mut out = text.clone(); + out.replace_range( + at..at + needle.len(), + &format!("{}", self.suffixed()), + ); + out.into_bytes() + } +} + +pub fn sha1_hex(bytes: &[u8]) -> String { + use sha1::{Digest, Sha1}; + hex::encode(Sha1::digest(bytes)) +} + +pub fn sha256_hex(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + hex::encode(Sha256::digest(bytes)) +} + +/// A wiremock server with its own runtime (the CLI and the build tool run +/// as blocking child processes on the test thread). +pub struct Server { + pub server: MockServer, + pub rt: tokio::runtime::Runtime, +} + +impl Server { + pub fn start() -> Self { + let rt = tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .enable_all() + .build() + .unwrap(); + let server = rt.block_on(MockServer::start()); + Server { server, rt } + } + + pub fn uri(&self) -> String { + self.server.uri() + } + + pub fn get(&self, route: &str, status: u16, body: Vec) { + self.rt.block_on( + Mock::given(method("GET")) + .and(path(route.to_string())) + .respond_with(ResponseTemplate::new(status).set_body_bytes(body)) + .mount(&self.server), + ); + } + + /// Serve `jar` + `pom` (and `.sha1` sidecars: `jar_sha1` overrides the + /// jar's) as the Socket repository's suffixed GAV. + pub fn serve_repo(&self, h: &Hosted, jar: &[u8], pom: &[u8], jar_sha1: Option) { + self.get(&h.served_path("jar"), 200, jar.to_vec()); + self.get( + &format!("{}.sha1", h.served_path("jar")), + 200, + jar_sha1.unwrap_or_else(|| sha1_hex(jar)).into_bytes(), + ); + self.get(&h.served_path("pom"), 200, pom.to_vec()); + self.get( + &format!("{}.sha1", h.served_path("pom")), + 200, + sha1_hex(pom).into_bytes(), + ); + } + + /// Every request path the server has seen, in order. + pub fn paths(&self) -> Vec { + self.rt + .block_on(self.server.received_requests()) + .unwrap_or_default() + .iter() + .map(|r| r.url.path().to_string()) + .collect() + } +} + +/// The API `scan --mode hosted` drives: batch discovery, the by-package +/// listing, the reference grant (maven2 override, production-shaped urls) +/// and the patch view. +pub fn mount_api(s: &Server, h: &Hosted, jar: &[u8], pom: &[u8], view: &serde_json::Value) { + let purl = h.purl(); + let org = h.org; + let uuid = h.uuid; + let artifact_url = format!( + "https://patch.socket.dev/patch/maven/{}/{}/{}/{}/{uuid}/{}-{}.jar", + h.group, + h.artifact, + h.version, + h.token, + h.artifact, + h.suffixed() + ); + let mounts = [ + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{org}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [{ "purl": purl, "patches": [{ + "uuid": uuid, "purl": purl, "tier": "free", "cveIds": [h.cve], + "ghsaIds": [h.ghsa], "severity": "high", "title": h.title + }] }], + "canAccessPaidPatches": false, + }))), + Mock::given(method("GET")) + .and(path_regex(format!( + "^/v0/orgs/{org}/patches/by-package/.+$" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "patches": [{ + "uuid": uuid, "purl": purl, "publishedAt": "2026-01-01T00:00:00Z", + "description": "d", "license": "MIT", "tier": "free", + "vulnerabilities": view["vulnerabilities"].clone() + }], + "canAccessPaidPatches": false, + }))), + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{org}/patches/package"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "results": { uuid: { + "status": "granted", + "url": artifact_url, + "purl": purl, + "artifacts": [{ + "kind": "tarball", + "url": artifact_url, + "integrity": { "sha1": sha1_hex(jar), "sha256": sha256_hex(jar) } + }], + "registryOverride": { + "kind": "maven2", + "indexUrl": h.prod_index_url(), + "identifiers": { + "name": format!("{}/{}", h.group, h.artifact), + "version": h.version, + "mavenGroupId": h.group, + "mavenArtifactId": h.artifact, + "mavenSuffixedVersion": h.suffixed(), + "mavenPomSha256": sha256_hex(pom), + } + } + } } + }))), + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{org}/patches/view/{uuid}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(view.clone())), + ]; + for m in mounts { + s.rt.block_on(m.mount(&s.server)); + } +} From 78f10d46fd6ef5d7ba4ae4e837a69dfab7c1f171 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 2 Oct 2026 11:05:02 -0400 Subject: [PATCH 04/63] Isolate CLI test children from ambient Gradle and JVM caches Once the crawlers learn Gradle's user home they resolve it from GRADLE_OPTS / JAVA_OPTS (-Dgradle.user.home), GRADLE_USER_HOME and ~/.gradle, plus the read-only GRADLE_RO_DEP_CACHE, and m2 from ~/.m2. Inherited as-is, a developer's warm caches would leak into every test that does not pin them. The common and prebuilt harnesses now scrub those variables by default and pin HOME / USERPROFILE to an empty stand-in (carrying version-manager roots over); a test passes a cache explicitly when it wants one, and prebuilt_common's fixture server serves explicit GRADLE_USER_HOME / GRADLE_RO_DEP_CACHE trees as maven2 repositories (with a slot for sbt's COURSIER_CACHE). Install detection learns the files-2.1// layout (the jar and the pom live in different hash dirs), and fabricate_files21 lays out a Gradle cache under the real sha1 names, padded or with leading zeros dropped. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../socket-patch-cli/tests/common/jvm_env.rs | 141 +++++++++ crates/socket-patch-cli/tests/common/mod.rs | 8 + .../tests/prebuilt_common/mod.rs | 279 ++++++++++++++++-- 3 files changed, 402 insertions(+), 26 deletions(-) create mode 100644 crates/socket-patch-cli/tests/common/jvm_env.rs diff --git a/crates/socket-patch-cli/tests/common/jvm_env.rs b/crates/socket-patch-cli/tests/common/jvm_env.rs new file mode 100644 index 000000000..247809d11 --- /dev/null +++ b/crates/socket-patch-cli/tests/common/jvm_env.rs @@ -0,0 +1,141 @@ +//! JVM build-tool isolation for the `socket-patch` children the tests spawn. +//! +//! The JVM crawlers resolve their caches from the environment: Gradle's user +//! home from `-Dgradle.user.home` in `GRADLE_OPTS` / `JAVA_OPTS`, then +//! `GRADLE_USER_HOME`, then `~/.gradle`; the read-only cache from +//! `GRADLE_RO_DEP_CACHE`; Maven's local repository from `~/.m2`. Inherited +//! as-is, a developer's real caches leak into every test that does not pin +//! them, and a machine with a warm `~/.gradle` sees packages a CI runner +//! does not. +//! +//! [`isolate_cli`] scrubs [`AMBIENT`] and points `HOME` / `USERPROFILE` at +//! [`stand_in_home`], an empty directory nothing writes to. A test that +//! wants a cache passes it as explicit env AFTER this call (the caller's +//! env lands last; see [`EXPLICIT`]). +//! +//! Shared by `common/mod.rs` and `prebuilt_common/mod.rs` (the latter pulls +//! it in with `#[path]`), so the two harnesses cannot drift. + +#![allow(dead_code)] + +use std::path::PathBuf; +use std::process::Command; + +/// Scrubbed from every CLI child by default. +pub const AMBIENT: &[&str] = &[ + "GRADLE_OPTS", + "JAVA_OPTS", + "GRADLE_USER_HOME", + "GRADLE_RO_DEP_CACHE", + "GRADLE_HOME", +]; + +/// The cache roots a test may hand the CLI explicitly (they survive +/// `prebuilt_common::prepare_command`'s scrub and are served by its fixture +/// server). +pub const EXPLICIT: &[&str] = &[ + "GRADLE_USER_HOME", + "GRADLE_RO_DEP_CACHE", + // sbt (Coursier / Ivy) registers COURSIER_CACHE here. +]; + +/// Toolchain locations that default to a path under the real home (the +/// list `cache_env::TOOLCHAIN_ROOTS` carries for package-manager children). +/// A CLI child that spawns `node` / `git` through a version-manager shim +/// still needs them once `HOME` moves. +const TOOLCHAIN_ROOTS: &[(&str, &str)] = &[ + ("RUSTUP_HOME", ".rustup"), + ("RBENV_ROOT", ".rbenv"), + ("PYENV_ROOT", ".pyenv"), + ("NVM_DIR", ".nvm"), + ("FNM_DIR", ".fnm"), + ("VOLTA_HOME", ".volta"), + ("ASDF_DIR", ".asdf"), + ("ASDF_DATA_DIR", ".asdf"), + ("SDKMAN_DIR", ".sdkman"), + ("MISE_DATA_DIR", ".local/share/mise"), + ("MISE_CONFIG_DIR", ".config/mise"), +]; + +/// The empty stand-in home every isolated CLI child gets. Per account +/// (`/tmp` is shared on Linux) and outside every test's scratch tree, so +/// the policy lookup's stop-at-home rule never fires inside a fixture. +pub fn stand_in_home() -> PathBuf { + let account: String = std::env::var("USER") + .or_else(|_| std::env::var("USERNAME")) + .unwrap_or_default() + .chars() + .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_') + .collect(); + let home = std::env::temp_dir().join(format!("socket-patch-cli-home-{account}")); + let _ = std::fs::create_dir_all(&home); + home +} + +/// Scrub [`AMBIENT`] from `cmd` and pin `HOME` / `USERPROFILE` to +/// [`stand_in_home`], carrying the version-manager roots over. Call it +/// before applying a test's own env. +pub fn isolate_cli(cmd: &mut Command) -> &mut Command { + for key in AMBIENT { + cmd.env_remove(key); + } + let real = std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) + .filter(|p| !p.as_os_str().is_empty()); + if let Some(real) = real { + for (var, relative) in TOOLCHAIN_ROOTS { + if std::env::var_os(var).is_some() { + continue; + } + let path = real.join(relative); + if path.is_dir() { + cmd.env(var, path); + } + } + } + let home = stand_in_home(); + cmd.env("HOME", &home).env("USERPROFILE", &home) +} + +mod jvm_env_selftests { + use super::*; + + /// A stray GRADLE_OPTS (and every other ambient JVM knob) never reaches + /// the child, the home is the stand-in, and an explicit cache applied + /// afterwards wins. + #[test] + fn isolate_cli_scrubs_ambient_jvm_env_and_pins_home() { + let mut cmd = Command::new("socket-patch"); + cmd.env("GRADLE_OPTS", "-Dgradle.user.home=/real/.gradle") + .env("JAVA_OPTS", "-Dgradle.user.home=/real/.gradle") + .env("GRADLE_RO_DEP_CACHE", "/real/ro"); + isolate_cli(&mut cmd); + cmd.env("GRADLE_USER_HOME", "/explicit/gradle-home"); + let envs: std::collections::HashMap> = cmd + .get_envs() + .map(|(k, v)| { + ( + k.to_string_lossy().into_owned(), + v.map(|v| v.to_string_lossy().into_owned()), + ) + }) + .collect(); + for key in [ + "GRADLE_OPTS", + "JAVA_OPTS", + "GRADLE_RO_DEP_CACHE", + "GRADLE_HOME", + ] { + assert_eq!(envs.get(key), Some(&None), "{key} must be scrubbed"); + } + let home = stand_in_home().to_string_lossy().into_owned(); + assert_eq!(envs["HOME"].as_deref(), Some(home.as_str())); + assert_eq!(envs["USERPROFILE"].as_deref(), Some(home.as_str())); + assert_eq!( + envs["GRADLE_USER_HOME"].as_deref(), + Some("/explicit/gradle-home") + ); + assert!(std::path::Path::new(&home).is_dir()); + } +} diff --git a/crates/socket-patch-cli/tests/common/mod.rs b/crates/socket-patch-cli/tests/common/mod.rs index 7199ee0e6..b658642c2 100644 --- a/crates/socket-patch-cli/tests/common/mod.rs +++ b/crates/socket-patch-cli/tests/common/mod.rs @@ -27,6 +27,10 @@ use sha2::{Digest, Sha256}; /// `#[path = "common/cache_env.rs"] mod cache_env;`. pub mod cache_env; +/// JVM build-tool env scrub + stand-in home for the CLI children (shared +/// with `prebuilt_common`). +pub mod jvm_env; + // ── Binary discovery + invocation ───────────────────────────────────── /// Absolute path to the built `socket-patch` binary that cargo @@ -135,6 +139,10 @@ pub fn run_bin_with_env( // this force-set is the layer that holds there. Notifier tests opt back // in via caller env (which lands last). cmd.env("SOCKET_NO_UPDATE_CHECK", "1"); + // No ambient Gradle / JVM options and no real home: the JVM crawlers + // would otherwise read the developer's `~/.gradle` / `~/.m2` (and any + // `GRADLE_USER_HOME` / `GRADLE_RO_DEP_CACHE`) into every test. + jvm_env::isolate_cli(&mut cmd); // Caller-supplied env lands last so explicit injections (runtime // gates, discovery roots) survive the scrub. for (k, v) in env { diff --git a/crates/socket-patch-cli/tests/prebuilt_common/mod.rs b/crates/socket-patch-cli/tests/prebuilt_common/mod.rs index 6ef93fa0d..df936c795 100644 --- a/crates/socket-patch-cli/tests/prebuilt_common/mod.rs +++ b/crates/socket-patch-cli/tests/prebuilt_common/mod.rs @@ -15,6 +15,9 @@ use socket_patch_core::vendor::test_support::service_fixture::{ use wiremock::matchers::{method, path, path_regex}; use wiremock::{Mock, MockServer, ResponseTemplate}; +#[path = "../common/jvm_env.rs"] +pub mod jvm_env; + type Package = (String, Vec, Secondary); static PUBLISHED: OnceLock>> = OnceLock::new(); static MAVEN_METADATA: OnceLock>>>> = @@ -81,19 +84,16 @@ impl Server { let root = root.to_path_buf(); let extra: Vec<_> = env .iter() - .filter(|(k, _)| { - matches!( - *k, - "CARGO_HOME" - | "GOMODCACHE" - | "MAVEN_REPO_LOCAL" - | "NUGET_PACKAGES" - | "GEM_HOME" - | "VIRTUAL_ENV" - | "BUNDLE_PATH" - ) + .filter_map(|(k, v)| match *k { + "CARGO_HOME" | "GOMODCACHE" | "MAVEN_REPO_LOCAL" | "NUGET_PACKAGES" + | "GEM_HOME" | "VIRTUAL_ENV" | "BUNDLE_PATH" => Some(PathBuf::from(v)), + // The explicit JVM caches (`jvm_env::EXPLICIT`) are served + // as maven2 repositories from their `files-2.1` trees. + "GRADLE_USER_HOME" => Some(PathBuf::from(v).join(GRADLE_FILES21)), + "GRADLE_RO_DEP_CACHE" => Some(PathBuf::from(v).join(RO_FILES21)), + // sbt: "COURSIER_CACHE" => … + _ => None, }) - .map(|(_, v)| PathBuf::from(v)) .collect(); let (ready_tx, ready_rx) = mpsc::channel(); let (stop, stopped) = mpsc::channel(); @@ -184,12 +184,17 @@ async fn mount_project_with_roots(server: &MockServer, root: &Path, extra: Vec

PathBuf { "golang" => dir .to_string_lossy() .ends_with(&format!("{name}@{version}")), - "maven" => dir - .join(format!( - "{}-{version}.jar", - name.rsplit('/').next().unwrap() - )) - .is_file(), + "maven" => { + let jar = format!("{}-{version}.jar", name.rsplit('/').next().unwrap()); + if dir.join(&jar).is_file() { + true + } else if let Some(child) = files21_hash_child(dir, &jar) { + // A Gradle `files-2.1` version dir: the jar sits in its + // `/` child, which is the archive source. + return child; + } else { + false + } + } "nuget" => dir .join(format!("{}.{}.nupkg", name.to_lowercase(), version)) .is_file(), @@ -575,13 +587,24 @@ pub async fn mount_view_from_source( } /// Download fixtures for lifecycle setup. Package-manager offline checks are -/// separate commands and retain their original flags. +/// separate commands and retain their original flags. Every command also +/// gets the JVM isolation of `jvm_env::isolate_cli`: ambient Gradle / JVM +/// options scrubbed, `HOME` / `USERPROFILE` pinned to an empty stand-in, and +/// only the `jvm_env::EXPLICIT` caches named in `env` passed through. pub fn prepare_command( command: &mut std::process::Command, root: &Path, args: &[&str], env: &[(&str, &str)], ) -> Option { + // No ambient Gradle / JVM options and no real home (`common/jvm_env.rs`); + // the explicit JVM caches a test hands over are kept. + jvm_env::isolate_cli(command); + for (key, value) in env { + if jvm_env::EXPLICIT.contains(key) { + command.env(key, value); + } + } let download = matches!(args.first(), Some(&"vendor") | Some(&"repair")) && !args.contains(&"--revert"); if download { @@ -599,6 +622,105 @@ pub fn prepare_command( } } +// ── Gradle `files-2.1` caches ───────────────────────────────────────── + +/// The `files-2.1` tree under a `GRADLE_USER_HOME`. +pub const GRADLE_FILES21: &str = "caches/modules-2/files-2.1"; +/// The `files-2.1` tree under a `GRADLE_RO_DEP_CACHE`. +pub const RO_FILES21: &str = "modules-2/files-2.1"; +const GRADLE_FILES21_LEAF: &str = "files-2.1"; + +/// A `files-2.1` hash-dir name: 1-40 lowercase hex (Gradle may drop the +/// sha1's leading zeros). +pub fn is_sha1_dir(name: &str) -> bool { + (1..=40).contains(&name.len()) + && name + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +/// `////` (relative to `files-2.1`) +/// → the maven2 route `////`. +fn files21_route(relative: &str) -> Option { + let parts: Vec<&str> = relative.split('/').collect(); + let [group, artifact, version, hash, leaf] = parts.as_slice() else { + return None; + }; + is_sha1_dir(hash).then(|| format!("/{}/{artifact}/{version}/{leaf}", group.replace('.', "/"))) +} + +/// The `/` child of a `files-2.1` version dir that holds `leaf`. +fn files21_hash_child(dir: &Path, leaf: &str) -> Option { + let mut children: Vec = std::fs::read_dir(dir) + .ok()? + .flatten() + .filter(|e| e.file_name().to_str().is_some_and(is_sha1_dir)) + .map(|e| e.path()) + .filter(|p| p.join(leaf).is_file()) + .collect(); + children.sort(); + children.into_iter().next() +} + +/// `leaf` beside an installed jar: in `source` itself, or — for a +/// `files-2.1` hash dir — in a sibling hash dir of the same version. +fn maven_sibling(source: &Path, leaf: &str) -> Option { + let direct = source.join(leaf); + if direct.is_file() { + return Some(direct); + } + let name = source.file_name()?.to_str()?; + if !is_sha1_dir(name) { + return None; + } + files21_hash_child(source.parent()?, leaf).map(|dir| dir.join(leaf)) +} + +fn sha1_hex(bytes: &[u8]) -> String { + hex::encode(sha1::Sha1::digest(bytes)) +} + +/// Lay `files` (`(leaf, bytes)`) out the way Gradle caches a download: +/// `/caches/modules-2/files-2.1/////`, +/// each file under its own real sha1. `gav` is `group:artifact:version`. +/// Returns the version dir (what the crawler reports as the package path). +pub fn fabricate_files21(home: &Path, gav: &str, files: &[(&str, &[u8])]) -> PathBuf { + fabricate_files21_named(home, gav, files, |sha1| sha1.to_string()) +} + +/// [`fabricate_files21`] with the hash dirs named the way Gradle releases +/// that format the sha1 as a number do: leading zeros dropped. +pub fn fabricate_files21_unpadded(home: &Path, gav: &str, files: &[(&str, &[u8])]) -> PathBuf { + fabricate_files21_named(home, gav, files, |sha1| { + let trimmed = sha1.trim_start_matches('0'); + if trimmed.is_empty() { "0" } else { trimmed }.to_string() + }) +} + +fn fabricate_files21_named( + home: &Path, + gav: &str, + files: &[(&str, &[u8])], + name: impl Fn(&str) -> String, +) -> PathBuf { + let mut parts = gav.splitn(3, ':'); + let (Some(group), Some(artifact), Some(version)) = (parts.next(), parts.next(), parts.next()) + else { + panic!("fabricate_files21: `{gav}` is not group:artifact:version"); + }; + let dir = home + .join(GRADLE_FILES21) + .join(group) + .join(artifact) + .join(version); + for (leaf, bytes) in files { + let hash_dir = dir.join(name(&sha1_hex(bytes))); + std::fs::create_dir_all(&hash_dir).unwrap(); + std::fs::write(hash_dir.join(leaf), bytes).unwrap(); + } + dir +} + fn copy_tree(from: &Path, to: &Path) { std::fs::create_dir_all(to).unwrap(); for entry in std::fs::read_dir(from).unwrap() { @@ -635,3 +757,108 @@ pub async fn mount_download(server: &MockServer, purl: &str, uuid: &str, leaf: & .mount(server) .await; } + +// ── self-tests (integration crates get no cfg(test)) ─────────────────── + +mod prebuilt_common_selftests { + use super::*; + + fn envs(cmd: &std::process::Command) -> HashMap> { + cmd.get_envs() + .map(|(k, v)| { + ( + k.to_string_lossy().into_owned(), + v.map(|v| v.to_string_lossy().into_owned()), + ) + }) + .collect() + } + + /// A stray GRADLE_OPTS (`-Dgradle.user.home` beats GRADLE_USER_HOME) + /// never reaches the CLI; HOME is the empty stand-in; an explicit + /// GRADLE_USER_HOME handed to `prepare_command` survives. + #[test] + fn prepare_command_scrubs_a_stray_gradle_opts() { + let tmp = tempfile::tempdir().unwrap(); + let mut cmd = std::process::Command::new("socket-patch"); + cmd.env("GRADLE_OPTS", "-Dgradle.user.home=/real/.gradle") + .env("GRADLE_USER_HOME", "/real/.gradle") + .env("JAVA_OPTS", "-Xmx1g"); + let gradle_home = tmp.path().join("gradle-home"); + let fixture = prepare_command( + &mut cmd, + tmp.path(), + &["scan", "--json"], + &[("GRADLE_USER_HOME", gradle_home.to_str().unwrap())], + ); + assert!(fixture.is_none(), "scan needs no fixture server"); + let envs = envs(&cmd); + assert_eq!(envs["GRADLE_OPTS"], None); + assert_eq!(envs["JAVA_OPTS"], None); + assert_eq!(envs["GRADLE_RO_DEP_CACHE"], None); + assert_eq!( + envs["GRADLE_USER_HOME"].as_deref(), + gradle_home.to_str(), + "the explicit cache wins" + ); + let home = jvm_env::stand_in_home().to_string_lossy().into_owned(); + assert_eq!(envs["HOME"].as_deref(), Some(home.as_str())); + assert_eq!(envs["USERPROFILE"].as_deref(), Some(home.as_str())); + let args: Vec<_> = cmd.get_args().collect(); + assert_eq!(args, ["scan", "--json"]); + } + + #[test] + fn files21_layout_routes_and_install_detection() { + assert!(is_sha1_dir("0a1b") && is_sha1_dir(&"f".repeat(40))); + assert!(!is_sha1_dir("") && !is_sha1_dir(&"a".repeat(41)) && !is_sha1_dir("ABC")); + assert_eq!( + files21_route("com.socketfixture/victim/1.10.0/0abc/victim-1.10.0.jar").as_deref(), + Some("/com/socketfixture/victim/1.10.0/victim-1.10.0.jar") + ); + assert_eq!( + files21_route("com.socketfixture/victim/1.10.0/victim.jar"), + None + ); + assert_eq!(files21_route("g/a/v/not-a-hash/a-v.jar"), None); + + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().join("gradle-home"); + let jar: &[u8] = b"jar bytes"; + let pom: &[u8] = b""; + let version_dir = fabricate_files21( + &home, + "com.socketfixture:victim:1.10.0", + &[("victim-1.10.0.jar", jar), ("victim-1.10.0.pom", pom)], + ); + assert_eq!( + version_dir, + home.join("caches/modules-2/files-2.1/com.socketfixture/victim/1.10.0") + ); + let jar_dir = version_dir.join(sha1_hex(jar)); + assert_eq!( + std::fs::read(jar_dir.join("victim-1.10.0.jar")).unwrap(), + jar + ); + assert!(version_dir + .join(sha1_hex(pom)) + .join("victim-1.10.0.pom") + .is_file()); + + // The version dir resolves to the jar's hash dir, and the pom is + // found beside it in its own hash dir. + let purl = "pkg:maven/com.socketfixture/victim@1.10.0"; + let found = source_dir(tmp.path(), &[version_dir.clone()], purl); + assert_eq!(found, jar_dir); + assert_eq!( + maven_sibling(&found, "victim-1.10.0.pom"), + Some(version_dir.join(sha1_hex(pom)).join("victim-1.10.0.pom")) + ); + assert_eq!(maven_sibling(&found, "victim-1.10.0.module"), None); + + // Unpadded naming drops the sha1's leading zeros. + let unpadded = fabricate_files21_unpadded(&home, "g:a:1", &[("a-1.jar", jar)]); + let want = sha1_hex(jar).trim_start_matches('0').to_string(); + assert!(unpadded.join(want).join("a-1.jar").is_file()); + } +} From 1c5ff86de6fabfa8dfa4ac844bbfab4c467a5c27 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 2 Oct 2026 11:22:37 -0400 Subject: [PATCH 05/63] Read Gradle 9's launcher JVM banner and take extra Gradle args Gradle 9 prints `Launcher JVM:` / `Daemon JVM:` instead of `JVM:`, so the JDK the harness logged and probed was empty on 9.x. The new SOCKET_PATCH_GRADLE_E2E_ARGS knob appends arguments to every Gradle run, which is how the compatibility grid's configuration-cache and Isolated Projects cells reach every suite without per-test plumbing. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tests/gradle_build_common/mod.rs | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/crates/socket-patch-cli/tests/gradle_build_common/mod.rs b/crates/socket-patch-cli/tests/gradle_build_common/mod.rs index 9f59f442d..a8c30a780 100644 --- a/crates/socket-patch-cli/tests/gradle_build_common/mod.rs +++ b/crates/socket-patch-cli/tests/gradle_build_common/mod.rs @@ -13,6 +13,9 @@ //! toolchain or an unreachable origin is a hard failure, not a SKIP. //! * `SOCKET_PATCH_GRADLE_E2E_PROBE_DIR` — where [`probe_report`] writes the //! per-cell JSON (default `/gradle-probe`). +//! * `SOCKET_PATCH_GRADLE_E2E_ARGS` — extra whitespace-separated arguments +//! for every Gradle run (the CI grid's `--configuration-cache` and +//! Isolated Projects cells). //! //! Every Gradle run is hermetic: a per-test `GRADLE_USER_HOME`, no daemon, //! plain console, and the ambient JVM / Gradle options ([`AMBIENT_ENV`]) and @@ -32,6 +35,7 @@ pub const GRADLE_ENV: &str = "SOCKET_PATCH_GRADLE_E2E_GRADLE"; pub const GRADLE_VERSION_ENV: &str = "SOCKET_PATCH_GRADLE_E2E_VERSION"; pub const GRADLE_REQUIRED_ENV: &str = "SOCKET_PATCH_GRADLE_E2E_REQUIRED"; pub const GRADLE_PROBE_DIR_ENV: &str = "SOCKET_PATCH_GRADLE_E2E_PROBE_DIR"; +pub const GRADLE_ARGS_ENV: &str = "SOCKET_PATCH_GRADLE_E2E_ARGS"; /// Ambient settings that would change what a Gradle child resolves or where /// it caches: JVM options (a `-Dgradle.user.home` there beats the per-test @@ -196,15 +200,20 @@ impl Gradle { ); } let jvm = jvm_banner(&banner).unwrap_or_default(); + let extra_args: Vec = std::env::var(GRADLE_ARGS_ENV) + .unwrap_or_default() + .split_whitespace() + .map(str::to_string) + .collect(); println!( - "{suite}: driving Gradle {version} on JVM {jvm} ({})", + "{suite}: driving Gradle {version} on JVM {jvm} ({}) {extra_args:?}", program.to_string_lossy() ); Some(Gradle { program, version, jvm, - extra_args: Vec::new(), + extra_args, }) } @@ -267,11 +276,15 @@ fn version_part(version: &str, index: usize) -> u32 { .unwrap_or(0) } -/// The `JVM:` line of a `gradle --version` banner. +/// The `JVM:` line of a `gradle --version` banner (`Launcher JVM:` on +/// Gradle >= 9, which also prints a `Daemon JVM:` line). pub fn jvm_banner(banner: &str) -> Option { - banner - .lines() - .find_map(|l| l.trim().strip_prefix("JVM:").map(|v| v.trim().to_string())) + banner.lines().find_map(|l| { + let l = l.trim(); + l.strip_prefix("JVM:") + .or_else(|| l.strip_prefix("Launcher JVM:")) + .map(|v| v.trim().to_string()) + }) } /// `21.0.8 (…)` → 21; `1.8.0_412 (…)` → 8; `11 (…)` → 11. @@ -648,6 +661,13 @@ mod gradle_build_common_selftests { jvm_banner(banner).as_deref(), Some("21.0.8 (Eclipse Adoptium 21.0.8+9-LTS)") ); + let nine = "Gradle 9.8.0\nLauncher JVM: 21.0.12.1 (Homebrew 21.0.12.1)\n\ + Daemon JVM: /x (no Daemon JVM specified)\n"; + assert_eq!( + jvm_banner(nine).as_deref(), + Some("21.0.12.1 (Homebrew 21.0.12.1)") + ); + assert_eq!(jdk_feature(&jvm_banner(nine).unwrap()), Some(21)); } #[test] From 336eb45d5682f339bb7934958b18b0d81aa63d06 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 2 Oct 2026 11:22:37 -0400 Subject: [PATCH 06/63] Add a deterministic fake Maven Central for the JVM capstones The Gradle suites need artifacts real Central cannot give them on demand: a victim at two versions with Gradle module metadata and classifier jars, a transitive range consumer, a parent pom, a BOM, a platform .module that requires the victim, a buildscript-classpath library whose class prints a marker from build logic, artifact-level maven-metadata.xml, checksum sidecars, PGP signatures, and a jar whose sha1 starts with 0 (Gradle drops that zero from the files-2.1 hash dir). tests/jvm_fixture_repo/ generates all of it byte-for-byte reproducibly (stored zip entries with fixed timestamps and permissions, hand-assembled Java 8 class files, fixed-order JSON/XML, a tabulated MD5) and serves it from wiremock as FakeCentral, with overlays and a patched-jar route for the member-keyed swap. Only the signatures of a committed THROWAWAY key, the key itself and SHA256SUMS are committed; the stability self-test regenerates the repository on every OS and compares it with SHA256SUMS, and SOCKET_PATCH_JVM_FIXTURES_REGENERATE=1 re-signs it reproducibly. gradle_multi_project_fake_central_mirror_smoke_both_dsls resolves the victim through the test-only mirror init script in both DSLs under FAIL_ON_PROJECT_REPOS, through a pom range and from the settings buildscript classpath, and records the hash-dir naming in a probe report. Locally Gradle 6.9.4, 7.6.6, 8.14.3 and 9.8.0 all name the dir with the leading zero dropped. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tests/e2e_vendor_jvm_build.rs | 144 ++- .../jvm_fixture_repo/fixtures/.gitattributes | 2 + .../jvm_fixture_repo/fixtures/SHA256SUMS | 27 + .../fixtures/keys/signing-key.public.asc | 20 + .../fixtures/keys/signing-key.secret.asc | 33 + .../fixtures/keys/verification-keyring.gpg | Bin 0 -> 726 bytes .../1.0/buildlogic-plugin-1.0.jar.asc | 12 + .../1.0/buildlogic-plugin-1.0.pom.asc | 12 + .../2.0/consumer-range-2.0.jar.asc | 12 + .../2.0/consumer-range-2.0.pom.asc | 12 + .../consumer/2.0/consumer-2.0.jar.asc | 12 + .../consumer/2.0/consumer-2.0.pom.asc | 12 + .../fixture-bom/1.0/fixture-bom-1.0.pom.asc | 12 + .../fixture-parent/1/fixture-parent-1.pom.asc | 12 + .../1.0/fixture-platform-1.0.module.asc | 12 + .../1.0/fixture-platform-1.0.pom.asc | 12 + .../1.10.0/victim-1.10.0-sources.jar.asc | 12 + .../victim/1.10.0/victim-1.10.0-tests.jar.asc | 12 + .../victim/1.10.0/victim-1.10.0.jar.asc | 12 + .../victim/1.10.0/victim-1.10.0.module.asc | 12 + .../victim/1.10.0/victim-1.10.0.pom.asc | 12 + .../victim/1.9/victim-1.9-sources.jar.asc | 12 + .../victim/1.9/victim-1.9-tests.jar.asc | 12 + .../victim/1.9/victim-1.9.jar.asc | 12 + .../victim/1.9/victim-1.9.module.asc | 12 + .../victim/1.9/victim-1.9.pom.asc | 12 + .../tests/jvm_fixture_repo/mod.rs | 1068 +++++++++++++++++ 27 files changed, 1533 insertions(+), 1 deletion(-) create mode 100644 crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/.gitattributes create mode 100644 crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/SHA256SUMS create mode 100644 crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/keys/signing-key.public.asc create mode 100644 crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/keys/signing-key.secret.asc create mode 100644 crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/keys/verification-keyring.gpg create mode 100644 crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/buildlogic-plugin/1.0/buildlogic-plugin-1.0.jar.asc create mode 100644 crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/buildlogic-plugin/1.0/buildlogic-plugin-1.0.pom.asc create mode 100644 crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/consumer-range/2.0/consumer-range-2.0.jar.asc create mode 100644 crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/consumer-range/2.0/consumer-range-2.0.pom.asc create mode 100644 crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/consumer/2.0/consumer-2.0.jar.asc create mode 100644 crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/consumer/2.0/consumer-2.0.pom.asc create mode 100644 crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/fixture-bom/1.0/fixture-bom-1.0.pom.asc create mode 100644 crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/fixture-parent/1/fixture-parent-1.pom.asc create mode 100644 crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/fixture-platform/1.0/fixture-platform-1.0.module.asc create mode 100644 crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/fixture-platform/1.0/fixture-platform-1.0.pom.asc create mode 100644 crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.10.0/victim-1.10.0-sources.jar.asc create mode 100644 crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.10.0/victim-1.10.0-tests.jar.asc create mode 100644 crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.10.0/victim-1.10.0.jar.asc create mode 100644 crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.10.0/victim-1.10.0.module.asc create mode 100644 crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.10.0/victim-1.10.0.pom.asc create mode 100644 crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.9/victim-1.9-sources.jar.asc create mode 100644 crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.9/victim-1.9-tests.jar.asc create mode 100644 crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.9/victim-1.9.jar.asc create mode 100644 crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.9/victim-1.9.module.asc create mode 100644 crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.9/victim-1.9.pom.asc create mode 100644 crates/socket-patch-cli/tests/jvm_fixture_repo/mod.rs diff --git a/crates/socket-patch-cli/tests/e2e_vendor_jvm_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_jvm_build.rs index 1a42cb08c..23cf93d7b 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_jvm_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_jvm_build.rs @@ -38,12 +38,17 @@ mod prebuilt_common; #[path = "gradle_build_common/mod.rs"] mod gradle_build_common; +#[path = "jvm_fixture_repo/mod.rs"] +mod jvm_fixture_repo; + use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; use gradle_build_common::{ - fixture_root, gradle_classpath, gradle_skip, lockfiles, snapshot, write_project, Gradle, + assert_patched, fixture_root, gradle_classpath, gradle_skip, init_script, lockfiles, + mirror_init_script, print_cp_task, probe_report, snapshot, write_both_dsls, write_project, Dsl, + Gradle, }; use maven_build_common::*; @@ -878,3 +883,140 @@ fn gradle_multi_project_vendor_locked_offline_tamper_and_byte_exact_revert() { ".socket/gradle residue" ); } + +// ── P3: the fake Central through the mirror init script ───────────────── + +/// Settings with a buildscript-classpath library whose class prints a +/// marker from build logic, and `FAIL_ON_PROJECT_REPOS` + `mavenCentral()`. +fn smoke_settings(dsl: Dsl) -> String { + let (classpath, mode) = match dsl { + Dsl::Groovy => ( + "classpath 'com.socketfixture:buildlogic-plugin:1.0'", + "repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)", + ), + Dsl::Kotlin => ( + "classpath(\"com.socketfixture:buildlogic-plugin:1.0\")", + "repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)", + ), + }; + let (name, include) = match dsl { + Dsl::Groovy => ("rootProject.name = 'smoke'", "include 'app'"), + Dsl::Kotlin => ("rootProject.name = \"smoke\"", "include(\"app\")"), + }; + format!( + "buildscript {{\n repositories {{ mavenCentral() }}\n dependencies {{ {classpath} }}\n}}\n\ + com.socketfixture.buildlogic.BuildLogic.print()\n\ + dependencyResolutionManagement {{\n {mode}\n repositories {{ mavenCentral() }}\n}}\n\ + {name}\n{include}\n" + ) +} + +/// `:app` reaches the victim only through `consumer-range`'s pom range +/// `[1.9,1.11)`, so Gradle lists versions from the artifact-level +/// `maven-metadata.xml` and must pick 1.10.0 (the settings classpath +/// requests it literally, through `buildlogic-plugin`). +fn smoke_app(dsl: Dsl) -> String { + let body = match dsl { + Dsl::Groovy => { + "plugins { id 'java' }\n\ndependencies {\n \ + implementation 'com.socketfixture:consumer-range:2.0'\n}\n" + } + Dsl::Kotlin => { + "plugins { java }\n\ndependencies {\n \ + implementation(\"com.socketfixture:consumer-range:2.0\")\n}\n" + } + }; + format!("{body}\n{}", print_cp_task(dsl, "runtimeClasspath")) +} + +#[test] +#[ignore = "real Gradle (the fake Central, no network); run with --ignored"] +fn gradle_multi_project_fake_central_mirror_smoke_both_dsls() { + use jvm_fixture_repo::*; + const SUITE: &str = "e2e_vendor_jvm_build::fake_central"; + let tmp = tempfile::tempdir().unwrap(); + let root = fixture_root(&tmp); + let home = root.join("gradle-home"); + let Some(gradle) = Gradle::detect(SUITE, &home) else { + return; + }; + let central = FakeCentral::start(); + let init = init_script( + &root.join("init"), + "mirror.gradle", + &mirror_init_script(¢ral.uri(), None), + ); + let projects = write_both_dsls(&root.join("proj"), |dsl| { + vec![ + (dsl.settings_file(), smoke_settings(dsl)), + (format!("app/{}", dsl.build_file()), smoke_app(dsl)), + ] + }); + let pristine = notice(&format!("{GROUP}:{VICTIM}:{VICTIM_VERSION}"), "pristine"); + let jar = generate()[&repo_path(VICTIM, VICTIM_VERSION, None, "jar")].clone(); + for (dsl, proj) in projects { + let what = format!("Gradle {} {} DSL", gradle.version, dsl.name()); + let out = gradle.run( + &proj, + &home, + &[&init[0], &init[1], ":app:printRuntimeClasspath"], + ); + let consumed = assert_patched(&out, VICTIM, NOTICE, pristine.as_bytes(), &what); + assert!( + String::from_utf8_lossy(&out.stdout).contains(&format!( + "{BUILDLOGIC_MARKER}{}", + victim_marker(VICTIM_VERSION, "pristine") + )), + "{what}: the settings buildscript class prints the victim marker:\n{}", + gradle_build_common::dump(&out) + ); + let files21 = home.join("caches/modules-2/files-2.1"); + assert!( + consumed.starts_with(&files21), + "{what}: resolved into the per-test Gradle cache: {}", + consumed.display() + ); + assert_eq!( + std::fs::read(&consumed).unwrap(), + jar, + "{what}: the fixture bytes" + ); + let hash_dir = consumed + .parent() + .and_then(|p| p.file_name()) + .unwrap() + .to_string_lossy() + .into_owned(); + let sha1 = sha1_hex(&jar); + assert_eq!( + format!("{hash_dir:0>40}"), + sha1, + "{what}: the hash dir names the jar's sha1 (leading zeros may be dropped)" + ); + probe_report( + &format!("fake-central-smoke-{}-{}", gradle.version, dsl.name()), + &serde_json::json!({ + "gradle": gradle.version, + "jvm": gradle.jvm, + "dsl": dsl.name(), + "resolved": consumed.to_string_lossy(), + "sha256": sha256_hex(&jar), + "sha1": sha1, + "hashDir": hash_dir, + "leadingZeroKept": hash_dir.len() == 40, + }), + ); + } + let requests = central.requests(); + for leaf in [ + repo_path(VICTIM, VICTIM_VERSION, None, "jar"), + repo_path(BUILDLOGIC, BUILDLOGIC_VERSION, None, "jar"), + format!("{GROUP_PATH}/{VICTIM}/maven-metadata.xml"), + ] { + assert!( + requests.contains(&format!("/{leaf}")), + "the fake Central served {leaf}: {requests:?}" + ); + } + println!("{SUITE}: Gradle {} green", gradle.version); +} diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/.gitattributes b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/.gitattributes new file mode 100644 index 000000000..deaf383bf --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/.gitattributes @@ -0,0 +1,2 @@ +# Committed fixture bytes (signatures, keys, digests): never normalize line endings. +* -text diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/SHA256SUMS b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/SHA256SUMS new file mode 100644 index 000000000..6c5cb74f2 --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/SHA256SUMS @@ -0,0 +1,27 @@ +1d3cd6eb4911c1a24da3ff995abe9ffa5be379de21823e0e780d0822cc01ef0c com/socketfixture/buildlogic-plugin/1.0/buildlogic-plugin-1.0.jar +da8a9981f80fea0607658ec3b49edd1397b53880e010b060173a0f41bd72066b com/socketfixture/buildlogic-plugin/1.0/buildlogic-plugin-1.0.pom +da678ffc1ac59ca1454fdc07286bc4cefb45e27575d9ce8a3916b9cba7a05b96 com/socketfixture/buildlogic-plugin/maven-metadata.xml +3f18c444dd52acddf8adf54f65ed5e4cf94734636124a4ec7c6efefc4c2db41f com/socketfixture/consumer-range/2.0/consumer-range-2.0.jar +e88974c9f8e7b74190ae04bf220fa05fc5b70ba69942476ec81cb1c0a0a28721 com/socketfixture/consumer-range/2.0/consumer-range-2.0.pom +2cb80c25272aa716b077f08b9c940929d59727671a35c7793903c041c6b7fcb6 com/socketfixture/consumer-range/maven-metadata.xml +32d26eface0ffe31c107b67a168a4dc73f57f8b8536944bae122346c8e472375 com/socketfixture/consumer/2.0/consumer-2.0.jar +05d26bb3fc762cb725cc4e62885e34c9a455fbe27366d8f8ad889e7197f509f3 com/socketfixture/consumer/2.0/consumer-2.0.pom +28237bc2c0c14b0973afdfc6a392c96aea84ed2002cd46c81d0201c63596b029 com/socketfixture/consumer/maven-metadata.xml +c524afda369ca674b93df5b2867185ee50f050ebf716afc0a11edf0c6a60f789 com/socketfixture/fixture-bom/1.0/fixture-bom-1.0.pom +15d66d0526299aa847bb4993b0e3b7286c750af0123486b765a2c013075f4d9b com/socketfixture/fixture-bom/maven-metadata.xml +bfc0dc2e4f7ab548249e0a1f168cf51e75c99c4a64fbe4cff27abea88221fbad com/socketfixture/fixture-parent/1/fixture-parent-1.pom +8f89f457e68f626e7757f913e411121e469fe8420e534dca06eb0549779a8657 com/socketfixture/fixture-parent/maven-metadata.xml +340279be700918ea28e184769c11821e67ef579860f4c9798b12d859ec4bc526 com/socketfixture/fixture-platform/1.0/fixture-platform-1.0.module +c78cda57d24c31b98e79c226150da95d69effe3828a6b76684adbb804da16ffe com/socketfixture/fixture-platform/1.0/fixture-platform-1.0.pom +059d99814bb4468a444e565ad948c1e900f6794fd9ce2936c2b8c99d6126bcd3 com/socketfixture/fixture-platform/maven-metadata.xml +615a906296640fea47a4c66cf9d7a40341f409d7ac3dfdf9342ad311d5369fd0 com/socketfixture/victim/1.10.0/victim-1.10.0-sources.jar +802bde18abd579c2e30bc9f7c19e9d4b078ed71d5d28f9981dab91cb1f0f6631 com/socketfixture/victim/1.10.0/victim-1.10.0-tests.jar +fa0c4b3aa359b5a05019167a5332f44b6d19f5c018a1913dcaa73ffd957bb82f com/socketfixture/victim/1.10.0/victim-1.10.0.jar +5be8c832ab26543967dec0aaca94d26d96b32c594a269242adf3740d2f3df31c com/socketfixture/victim/1.10.0/victim-1.10.0.module +0b797534b1bbc437ff8d074738876f3dd7a8a25f93dc61187ad9a36b0ae5096b com/socketfixture/victim/1.10.0/victim-1.10.0.pom +afac36e0a4ebc44116d2c76c3bf46280272ce575c9a0029ff980506eb35a8d93 com/socketfixture/victim/1.9/victim-1.9-sources.jar +be395d671dec8af8d03d9e2f72774425c9e0c48b79ffcbb67a19a83d65e5e1c2 com/socketfixture/victim/1.9/victim-1.9-tests.jar +51260df91559493fed4750f57dc6823a654e8f43cc234c49865f8b0b32b03424 com/socketfixture/victim/1.9/victim-1.9.jar +4144231dcc05292b55c72aded46f1d23858cca0b8278998de9b7d3b0dca15674 com/socketfixture/victim/1.9/victim-1.9.module +ada82bc38fb0d14097cc303bcb7f9dd05d39749ba0104092f7fa4c0c8502c011 com/socketfixture/victim/1.9/victim-1.9.pom +418935f7b77c8bf002f76eef48e4b315cacc40e9e11d97ae83344b9576b74ef5 com/socketfixture/victim/maven-metadata.xml diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/keys/signing-key.public.asc b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/keys/signing-key.public.asc new file mode 100644 index 000000000..da2dfd785 --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/keys/signing-key.public.asc @@ -0,0 +1,20 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQENBGlVuQABCAC3xRji5S7VSgkNFqgrpa5m3+nH3bxt8J/EX8lJp5kuAhfuixC4 +MBstPUEG1xctFbN4Vpc0YHDchXJkcFQqJC04LGKoBcIhUsS5Q4HPXMbi8dfKj6tt +MnGRAqifVV7Bf4Jnt5Jbckk5OogxmTvy66E/yfLEBJ2+SpxTGgCK33POxI6unmPe +wi2I0YTHctvYtnvqZn6sbr3iKdnLwYMwasE9L7SbZlmPFM+9SKrH/UScJxhKHlXB +PvXbIyhYASkB61He04dJ2+PAopLXQ5b6zaLW1sR6QnHTHzdWnXf4vTgQZ5kNe52g +BYTiA5XMfg5PNzEMVnScAqI1EDyJBhP7SWbNABEBAAG0VHNvY2tldC1wYXRjaCB0 +ZXN0IGZpeHR1cmUgKFRIUk9XQVdBWSwgZG8gbm90IHRydXN0KSA8Zml4dHVyZS1z +aWduaW5nQHNvY2tldC5pbnZhbGlkPokBbQQTAQgAVxYhBN0M3dK0g47JVye5S0x3 +qckR1GoZBQJpVbkAGxSAAAAAAAQADm1hbnUyLDIuNSsxLjEyLDAsMwIbAwULCQgH +AgIiAgYVCgkICwIEFgIDAQIeBwIXgAAKCRBMd6nJEdRqGUbzCACY3D/qd5oXnujp +GEtXtV6iPsAbjN2f1ceJFwVrp6u0OlFPDbwU655E9amZwWnEaFVPf+9ujOTLehmd +Rj5J2Ld9L7CYxvNlXTJ6tUUhA/taLScrGbl8jmdP91fKpypOYImq7SccV7Is0VVw +9X7aNGkc6gCZqCbP7M+lOqxTlKwQ/QPi+dvSyMaPPkXZMg4YRL5EJw+iOkCiElHf +DO5oF8qWKzbUrsifnS3aWqTKSryGrgxgwqss46wPnb7uk2oPNgtaBiONCUDJbRhk +rfS6OBK7UgIxq7fTg6lZXqhtiZCI4IFbayR1myxZTKp02GL+ru4AgfeluakdWezM +Dq427Qdj +=9kjM +-----END PGP PUBLIC KEY BLOCK----- diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/keys/signing-key.secret.asc b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/keys/signing-key.secret.asc new file mode 100644 index 000000000..ffbf748a6 --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/keys/signing-key.secret.asc @@ -0,0 +1,33 @@ +-----BEGIN PGP PRIVATE KEY BLOCK----- + +lQOYBGlVuQABCAC3xRji5S7VSgkNFqgrpa5m3+nH3bxt8J/EX8lJp5kuAhfuixC4 +MBstPUEG1xctFbN4Vpc0YHDchXJkcFQqJC04LGKoBcIhUsS5Q4HPXMbi8dfKj6tt +MnGRAqifVV7Bf4Jnt5Jbckk5OogxmTvy66E/yfLEBJ2+SpxTGgCK33POxI6unmPe +wi2I0YTHctvYtnvqZn6sbr3iKdnLwYMwasE9L7SbZlmPFM+9SKrH/UScJxhKHlXB +PvXbIyhYASkB61He04dJ2+PAopLXQ5b6zaLW1sR6QnHTHzdWnXf4vTgQZ5kNe52g +BYTiA5XMfg5PNzEMVnScAqI1EDyJBhP7SWbNABEBAAEAB/oC96eKeGVsc8TYJYLD +K6p4hkqV296ATveheeN78T0fuVBuHWhAdSRnM1tCs0PwSi6q9Yj9A0anRO9fMFIn +mQxy4QKZ0Hf9RkMfc7fbo5WhkgKoRnAf9AXR799NrVj5mme+aYAvQlXs2uVama0W +Y9gneckWAbYTXcyO6fdSxr9tugdCTthUUDJzqg0JTDLjB7NkH/tY6R26cI2SDQmk +CVUIvHf9DN4kDBAfM2CPIJLuNjBhg5gjGCV/cZvjY1dge+V+7RoYGj3iuSRZ1+1x +wbDg0hclw5ZqIBgHZkNNVlrCkKCTrjaM0mW6EgHqtrR8GsMeJFK8EHP+lOycqCiV +n7j5BADD9Hpuv5vWzK8dzvIbjnl7BBi74qhgEeZKak6xg1eyKMS0TWYzIBt5/g/Q +r7DXfhdWb/Az2AKM6DQY9Zygfzqcj2uGGfpDlTx2320sH8ZY7D5O/VWVEb1GZtNh +EitsZrZw0rh6pTjC/cZgnrcPCg+KVxxjkFDt38LGKV7/3aDiFQQA8BTFRwetn2bM +5kBafCMmGgoyjYimVyXFZvTtHjjnf40CQsO5WHf/ChYwQwWUMt6IFZ/i3L1xPqdP +vkj/SnFT4XDjEiG1euyObfqNWg8RDa2kYXHcjP60VZKGAPvR0T8JmdorpVwuS5sg +PgP1RlECV+3og0D9WOIc+SqMeIAKd9kD/2IvpmQFVLBcy2E8ZPcJ7r8QORlOCmEe +F1zazjMr0nTvv5Ax1qqoB8pIe+vwqAWeKdJ6Qupivd6wxxZiTgDbw9InIY4G4lRK +28AtqiQGZM+l+ECJKKUvqIxzAiGkFcRPmlsdFK9tvlK+aDMxUMFMd21xxh2yHPvH +Rs00aeeVUhxONEK0VHNvY2tldC1wYXRjaCB0ZXN0IGZpeHR1cmUgKFRIUk9XQVdB +WSwgZG8gbm90IHRydXN0KSA8Zml4dHVyZS1zaWduaW5nQHNvY2tldC5pbnZhbGlk +PokBbQQTAQgAVxYhBN0M3dK0g47JVye5S0x3qckR1GoZBQJpVbkAGxSAAAAAAAQA +Dm1hbnUyLDIuNSsxLjEyLDAsMwIbAwULCQgHAgIiAgYVCgkICwIEFgIDAQIeBwIX +gAAKCRBMd6nJEdRqGUbzCACY3D/qd5oXnujpGEtXtV6iPsAbjN2f1ceJFwVrp6u0 +OlFPDbwU655E9amZwWnEaFVPf+9ujOTLehmdRj5J2Ld9L7CYxvNlXTJ6tUUhA/ta +LScrGbl8jmdP91fKpypOYImq7SccV7Is0VVw9X7aNGkc6gCZqCbP7M+lOqxTlKwQ +/QPi+dvSyMaPPkXZMg4YRL5EJw+iOkCiElHfDO5oF8qWKzbUrsifnS3aWqTKSryG +rgxgwqss46wPnb7uk2oPNgtaBiONCUDJbRhkrfS6OBK7UgIxq7fTg6lZXqhtiZCI +4IFbayR1myxZTKp02GL+ru4AgfeluakdWezMDq427Qdj +=goEO +-----END PGP PRIVATE KEY BLOCK----- diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/keys/verification-keyring.gpg b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/keys/verification-keyring.gpg new file mode 100644 index 0000000000000000000000000000000000000000..24fedd2abf85d13b639e957037474532389dca2e GIT binary patch literal 726 zcmV;{0xA8O0SyFcRk;8G2mrUm7~l6AoF^Dc9#z3U_1hyTSOF;k z>rvj*he_Mxz@n1ZLzeo@qSn^LdO~s2A2(K=clf9X3YQ*0RRECRC8}*Yh`pTaA9;~XdrZDb95kPX?S#Xa%CVWR7g@! zS3y@nSu7xAZy;`ObRcwcb#rtnAUtMicyx7gWi4}QXKrb3XFzjrV{2t}E@^IdVQgt+ zK8XQs1QP)W09O_v1l zbuuh6E;TDLE-^AJFf20y8v_Lk2?z%Q0wMwi6$%Lm3jzcd0s{d89tQ#!fB*^!5KMQe z$r03Q8AkI60GQlA>UWwKp6KZqOINjCqCUVIjNPBr$B7pOYp1KUI#EvzycFx6MD?ke z!D+;3RZoBKZj9v1dKsNYK1tZOeJ`+>#`9%eGJ3T|Ap`qbEhj4(xqOaiPxn{Krz%ch ziK^`<99Oa|(N%Eue%dr?9O?j>s3yej_?r0avmMb>YuE?LAE!tY7%1XS3t_)zpt1RQJ51qd5lWGq(3t9#vjR`=> zZ5U*&^tw0_yHWx%tGCmGsaamAZHbVG;DK9fBz2oCSxl;Q*kb;!?f`-JrMamcS?tUX It~TulV?*>dF#rGn literal 0 HcmV?d00001 diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/buildlogic-plugin/1.0/buildlogic-plugin-1.0.jar.asc b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/buildlogic-plugin/1.0/buildlogic-plugin-1.0.jar.asc new file mode 100644 index 000000000..c9bfd13e0 --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/buildlogic-plugin/1.0/buildlogic-plugin-1.0.jar.asc @@ -0,0 +1,12 @@ +-----BEGIN PGP SIGNATURE----- + +iQFPBAABCAA5FiEE3Qzd0rSDjslXJ7lLTHepyRHUahkFAmlVuQAbFIAAAAAABAAO +bWFudTIsMi41KzEuMTIsMCwzAAoJEEx3qckR1GoZzl4H/1CVQGIAUvRqW8ZDNxjE +6a7OuDbllElDRtDKCwk9O6BCYydEjcbCUpMPz0AuwTCWNPM1ESr5wD1XFkY9idH7 +AsE1+gav/6rqP4S1ZRyxqI9wxYJm7fX7mA0RTPyKbJDSGnqL97XBVXypOcSVj9F+ +Nw4PLk4XJ8BQHUHX/CjQRqKCG4nlB8J3qmuh7dZW8DtiKVRVZXVV+7IR/EIp6Qgp +j0aMQYhkHhWM3qEpC5JqfBxhe73+AnNEkmykV8OJgNiB8nkqIdBRf2EonZw/Z2kd +CaTLeGE8t9w//hR4nxiBxzeoEhc/HnZbqA2tHGQ/PaLcmO3WZzFiNOA4d6kTtBBX +A/U= +=fmNF +-----END PGP SIGNATURE----- diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/buildlogic-plugin/1.0/buildlogic-plugin-1.0.pom.asc b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/buildlogic-plugin/1.0/buildlogic-plugin-1.0.pom.asc new file mode 100644 index 000000000..5e3b34094 --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/buildlogic-plugin/1.0/buildlogic-plugin-1.0.pom.asc @@ -0,0 +1,12 @@ +-----BEGIN PGP SIGNATURE----- + +iQFOBAABCAA5FiEE3Qzd0rSDjslXJ7lLTHepyRHUahkFAmlVuQAbFIAAAAAABAAO +bWFudTIsMi41KzEuMTIsMCwzAAoJEEx3qckR1GoZ8+sH8gKi8WWdC9sRJ9TiNN5B +kg3QcfmKawh+S+qAYViMzOCdwY8q8vsAGWmam0lg2Gc7IRehCFYWsAFWyG7IbqAs +vEH47qh56hiZ/le73SznyJ8E23v+Cj41w3OYcC2AsG2SJkl0ZE+l62uvlVGus3SM +qhIx1cmL4lmNua4TTJAjLOAtxT+Xb1Rt3RDsBMxcx08M4DP3xPHiTNgZHljx+Ckz +aWZgsX1P3Y/rif/pwc+U5xIsVCSiCQQSVRoXP5LciAaIy8pJKiNmInAN1osfcCbJ +/gbgn/uzt/2mM8FKXjHdq7dtWPGqpOeakwmejnOzgDFQH+uzD9QYcC2GcIDMFEnE +KA== +=ynCw +-----END PGP SIGNATURE----- diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/consumer-range/2.0/consumer-range-2.0.jar.asc b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/consumer-range/2.0/consumer-range-2.0.jar.asc new file mode 100644 index 000000000..97918e66f --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/consumer-range/2.0/consumer-range-2.0.jar.asc @@ -0,0 +1,12 @@ +-----BEGIN PGP SIGNATURE----- + +iQFPBAABCAA5FiEE3Qzd0rSDjslXJ7lLTHepyRHUahkFAmlVuQAbFIAAAAAABAAO +bWFudTIsMi41KzEuMTIsMCwzAAoJEEx3qckR1GoZ8ugH+wXj/1+b3K49imiYYpTx +/sT45U42NVB6nv92rnOSmMSVM/XSVHHUT6mSfNhNbOiHx9ZOMG40AG0lpneSo5+D +jqrXQHTz4wX422ATG13ZG0+ODdjn4LnWnRmNUE+KOt39Epmegj9DdcCCDOasM2Az +5ITNnoIc3Lu4PtZwG6zcvCDVHeEOz68LzVTQXklWVMQULGS3j5LdBxQhE0OF5Po/ ++jvwzEc3o9DxNO7HMmpKKFAqRY2j0ilWRlDiRDxk0bC0gUKT+reNA2HWhdNjLit7 +ls1xz/FSZTs42Qv+6LJnYcN9TrUSoCM7pNqv/5QcQ9194+JJ+0y9Ys3A54oCS0Bj +RWU= +=KTKX +-----END PGP SIGNATURE----- diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/consumer-range/2.0/consumer-range-2.0.pom.asc b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/consumer-range/2.0/consumer-range-2.0.pom.asc new file mode 100644 index 000000000..73be089d5 --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/consumer-range/2.0/consumer-range-2.0.pom.asc @@ -0,0 +1,12 @@ +-----BEGIN PGP SIGNATURE----- + +iQFPBAABCAA5FiEE3Qzd0rSDjslXJ7lLTHepyRHUahkFAmlVuQAbFIAAAAAABAAO +bWFudTIsMi41KzEuMTIsMCwzAAoJEEx3qckR1GoZN5wIAJcUb5shJFdRPw29QAOG +nm6QfCE8nuBew6x8LDJdmH8Rtfjob86bD7Sf+QwnhzwlUEr+psBs9emI6EgvXet8 +iBsaQlT8D/4V6EEuuG8X+IyqJbwxhcoYfd7BlTFucswCn+0vX2NLr8WFJ93iouJe +RCRVm0ckPwHpU34VlqymU0P9EDVqS8s+O2AFZMkwkQfG/0Ayu7FDUIMZHtY/+Pgb +VJL1e2xxgw6wKCHMwWxKj/W5eZnOOec11k0P5rFwn3w4lPhGJQog/OMgVir/bgdY +1jF3ymwf64YWHn5Ck+1Rr5fVcf0focXqerhS/l8IJoc39VATq5f3j6T9PkJxsSF0 +gww= +=Qp6k +-----END PGP SIGNATURE----- diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/consumer/2.0/consumer-2.0.jar.asc b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/consumer/2.0/consumer-2.0.jar.asc new file mode 100644 index 000000000..9fcecf3b6 --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/consumer/2.0/consumer-2.0.jar.asc @@ -0,0 +1,12 @@ +-----BEGIN PGP SIGNATURE----- + +iQFPBAABCAA5FiEE3Qzd0rSDjslXJ7lLTHepyRHUahkFAmlVuQAbFIAAAAAABAAO +bWFudTIsMi41KzEuMTIsMCwzAAoJEEx3qckR1GoZea8H/jZz9/TUaZOOW4pLz4dn +wXO+j0q+M2EruBrnrMkVz3Muvjyma/BeFrtfb8dl8Qb7nCM3VPowjjT1Q8b+w2bg +jBnmIQpxSAlfBx1hD8JgWU6LAN3bbb1tLNoiHljj6aLHtGPaU04waWvyc3WhuM70 +1TmWy1Nj/3Cimm4Xas5aozbwY9frugd16cAPbUerY4SyOwsSoqJqXNji3mNRlsDC +VGYKfpXTGbM9z7LRBaNYmQL3WdF5hzpkmM553vDV9KFKotjR7HJrmSulwmWTZB7E +m5TsWFE7w25qAUWOq7l1wDBP55EMO6MEVzg1sZ2Snr0SIZTOId6sB37CJDkPr67O +dew= +=SkFu +-----END PGP SIGNATURE----- diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/consumer/2.0/consumer-2.0.pom.asc b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/consumer/2.0/consumer-2.0.pom.asc new file mode 100644 index 000000000..af4e3f6ae --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/consumer/2.0/consumer-2.0.pom.asc @@ -0,0 +1,12 @@ +-----BEGIN PGP SIGNATURE----- + +iQFPBAABCAA5FiEE3Qzd0rSDjslXJ7lLTHepyRHUahkFAmlVuQAbFIAAAAAABAAO +bWFudTIsMi41KzEuMTIsMCwzAAoJEEx3qckR1GoZXjgIAKhMDiUDw2OHxjgsAJec +dqudIWUmKOPkFjX1LhB+qHV5SzwltOpfaQ2/JnHuS0PMIMjAPrnqw0Y+YFADop1V +L5En+23FVlrHItj0cHVSm4QbiNuL5Ym150zxT0UhjsX6RMjOgW5OZwJjsIowV0V2 +BDcsY09gpPTlbcnN79NC57gItVxFGbYEb1iLsS17I3XLA6pNDiM0lPkFcjRg/czu +t3e30JQ1l3Tei9gwWwHJcGvcRVgOoXiSU3XZjB6y3dNx7tyNxmzMAh8e8VG8qL32 +fhV2rGMPgJP0XmiT/b8Zloj3akcJXhTvJcZo0r0BQMo3TuZPUKB+PkwG/zu15bT1 +esQ= +=0iv/ +-----END PGP SIGNATURE----- diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/fixture-bom/1.0/fixture-bom-1.0.pom.asc b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/fixture-bom/1.0/fixture-bom-1.0.pom.asc new file mode 100644 index 000000000..d4fa3b989 --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/fixture-bom/1.0/fixture-bom-1.0.pom.asc @@ -0,0 +1,12 @@ +-----BEGIN PGP SIGNATURE----- + +iQFPBAABCAA5FiEE3Qzd0rSDjslXJ7lLTHepyRHUahkFAmlVuQAbFIAAAAAABAAO +bWFudTIsMi41KzEuMTIsMCwzAAoJEEx3qckR1GoZ01oH/3TxZ/uemDCy7NKQogXK +2h/ZYw+r7gtCPTo3wwc1H3CMJxh/YN3asr2nwTRTRpuYpnnVwq2sE1eZEhC5zBSe +fTcL8vEsF/hLU9b6AqVtaGtnIQWFXcm/grUlJngD3TS5/lS5w7zeUt930h4+UINu +krAdTKpLp37qpAnp5N04xN0ZOF4mmSSSP9tHMJyZJlPcwScRgUJunY9D6ic8CZdC +rNQnesPdZHjzszGlyANkkDUAiD9/ofC17aMVV+dInvE3Eyc7gthS+//N2ifk03v+ +MWkWqGB/uUiKZTgpMRgmbuRXVSx6/YoOVfRG0hQ7p7UmM/6BU3imEOMhZuKvU4Jw +lyw= +=E7CJ +-----END PGP SIGNATURE----- diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/fixture-parent/1/fixture-parent-1.pom.asc b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/fixture-parent/1/fixture-parent-1.pom.asc new file mode 100644 index 000000000..3ba378763 --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/fixture-parent/1/fixture-parent-1.pom.asc @@ -0,0 +1,12 @@ +-----BEGIN PGP SIGNATURE----- + +iQFPBAABCAA5FiEE3Qzd0rSDjslXJ7lLTHepyRHUahkFAmlVuQAbFIAAAAAABAAO +bWFudTIsMi41KzEuMTIsMCwzAAoJEEx3qckR1GoZmOYIAJt6NNjc5pTgQ3OJwda0 +09bulpoTl75qKPZwheML2RDwHxr4ergnNb6QOeivf7swRaJ3wOvr1nnF1GGMj/L/ +qNMkwMLCAB4Sx9NhEPkG8Ll3Wvl08/kugPgEiEcUoWybyuMGv5hUgxnrNkgdYQJW +BstCZe0pM0feNAbCflyNFwtGj7tdYkGQXQS/3yshFHZte78O7NHpFm0AaUgXWzia +8IjIufaIdkv7OoEsQLJz+rBjrzkZktZ8xuan6LbUKVY8QM1akJaPGN80iPvDNib1 +nqow6f1AEcaizRs2bCyUfKRW3DiYz0p/7hWJL/B8XdTRLxHjoaIWhKlEc9XQxuN7 +vhc= +=sbzH +-----END PGP SIGNATURE----- diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/fixture-platform/1.0/fixture-platform-1.0.module.asc b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/fixture-platform/1.0/fixture-platform-1.0.module.asc new file mode 100644 index 000000000..cacc5ee91 --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/fixture-platform/1.0/fixture-platform-1.0.module.asc @@ -0,0 +1,12 @@ +-----BEGIN PGP SIGNATURE----- + +iQFPBAABCAA5FiEE3Qzd0rSDjslXJ7lLTHepyRHUahkFAmlVuQAbFIAAAAAABAAO +bWFudTIsMi41KzEuMTIsMCwzAAoJEEx3qckR1GoZVvwH/1o9jPMUtKS3nY6VwDgZ +I7fh7q0Qy547tE9HIS0tAugF7eAK/TSRfixeaC0fvBnrSKbHIV3F1fTm4Aj3HT5v +4k22jhd3ZZPU1qSoTQFPoQCmzFNurOV7CNobt/AAQ9uzbxi0yTOnF33b1aWKbr/r +Ylmzu0UggZq2I2eQynkLJ+O04Dk1Gk5DIwUSS4WHNd0x9AQyXMmyrHr71zd86ILE +peuju2Huqi9c+BjfnWHqT81X+PXb4OA2qfaOfO+kCY9Qo8hqwzLnJx4jk68hDFIe +NWkW/XAQZ7o4L0YdGKFUXhKssmBjtuq4VHjh9eXpgA2rcZhGs8Kaw8lrXic4X6bk +krI= +=JnFR +-----END PGP SIGNATURE----- diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/fixture-platform/1.0/fixture-platform-1.0.pom.asc b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/fixture-platform/1.0/fixture-platform-1.0.pom.asc new file mode 100644 index 000000000..ac76dafa8 --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/fixture-platform/1.0/fixture-platform-1.0.pom.asc @@ -0,0 +1,12 @@ +-----BEGIN PGP SIGNATURE----- + +iQFPBAABCAA5FiEE3Qzd0rSDjslXJ7lLTHepyRHUahkFAmlVuQAbFIAAAAAABAAO +bWFudTIsMi41KzEuMTIsMCwzAAoJEEx3qckR1GoZRG8H/iyXUh1NH/jX07Qi7FWd +PJR7UObXxu4a8YWqx3iV5+yscDD1WeiUGXZAd74d3Xs6PdvtS0fRaInzsKTq8YK+ +tVIxALrni/m5mVCIAI1P0/ivA+EYNczAd9HwFYfcZ0IV1/wPQYOpcTi0PkvbOFxQ +b98UB1xTRA7LsheHSyUqLbjRXyCeKn+jiq2PxOnmNBgttbxCPw/KWdc+Ko1rkaCf +riMne+KIsmFSYc5H1rMCVNhxs63iPNx/o3b0elSBGmAj+PNLrogL+S4veBeiNuH5 +bbs6JJuw0R+CsDWqyyO8CrOS4wgpXT+wikHATV0zTeNDtI5pKEqAi6rjs4U8tNEy +8r8= +=RgMk +-----END PGP SIGNATURE----- diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.10.0/victim-1.10.0-sources.jar.asc b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.10.0/victim-1.10.0-sources.jar.asc new file mode 100644 index 000000000..ff7ba036b --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.10.0/victim-1.10.0-sources.jar.asc @@ -0,0 +1,12 @@ +-----BEGIN PGP SIGNATURE----- + +iQFPBAABCAA5FiEE3Qzd0rSDjslXJ7lLTHepyRHUahkFAmlVuQAbFIAAAAAABAAO +bWFudTIsMi41KzEuMTIsMCwzAAoJEEx3qckR1GoZw/IH/2gWbB9TmsmG8bu9EqX2 +eSNaeCcg+pnaACu3F7W575Z0p0GTTFpf73kF7xZcm0A01YLJsEYJqK5ZH9xF4c2c +ZQqJZsPJ1bjAOU74hYSG7VElWSAkeisf//lUj7hDFbEA36I0qLb+jnlQXfZOSLt1 +tX7nc6X92pIEZewOqxeUYxScb8j2k+vYMvNf/SPx65NQL6zXj16NLkyWscu5w6/S +m9x4uDm9UZB/16Lv790XVAGtO3uxejjF0RTKihuCDg4v0NGVupIHnaYmXc3ynQZq +oeL+0pbskd5VpO9SftKI0h9avIsDexvYUGwyp/syvUf3ekijzZRv3BaT7uFIbiqP +i88= +=2ltE +-----END PGP SIGNATURE----- diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.10.0/victim-1.10.0-tests.jar.asc b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.10.0/victim-1.10.0-tests.jar.asc new file mode 100644 index 000000000..d24a8754c --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.10.0/victim-1.10.0-tests.jar.asc @@ -0,0 +1,12 @@ +-----BEGIN PGP SIGNATURE----- + +iQFPBAABCAA5FiEE3Qzd0rSDjslXJ7lLTHepyRHUahkFAmlVuQAbFIAAAAAABAAO +bWFudTIsMi41KzEuMTIsMCwzAAoJEEx3qckR1GoZS10H/3muC/bR/uwowHyDx6J8 +ItQpCFRXR3/vNF/0Ddz2bQQMe0v3U6NroHeH7rkfH52UWMPH21z/p1qUCJMvnGRY +pb34p9KEs1ErVRXIm5vtQ2IkR6fF+B0QtH2StlOEIztOYRGEClBvQ/l/IMiBlLz6 +ObSbo7nPk+fVdfPS52nYWjf+NLyMNYjWz/qRn4rEyERAtC+MlRfjvUjvPAAbub7+ +Hn6XrFM5S+290YctjA0eKNp4v8JY7zqMskmTgJRJ4Pu5jaSirZskHa92DSJed86h +RmSavUoCpri6jwE/kdNILub+zWT6ksZGzTnwswOXCQhqkGeBwa6+4LpYPIIIiTBl +lHQ= +=FVUs +-----END PGP SIGNATURE----- diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.10.0/victim-1.10.0.jar.asc b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.10.0/victim-1.10.0.jar.asc new file mode 100644 index 000000000..43c75c95b --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.10.0/victim-1.10.0.jar.asc @@ -0,0 +1,12 @@ +-----BEGIN PGP SIGNATURE----- + +iQFPBAABCAA5FiEE3Qzd0rSDjslXJ7lLTHepyRHUahkFAmlVuQAbFIAAAAAABAAO +bWFudTIsMi41KzEuMTIsMCwzAAoJEEx3qckR1GoZBGYIAIf+VKl3xGM3lnnWBMA/ +zQkJGkoP7OvB//NI2XbYZv0nSRmRmJaqP312IXgBBlloeyyV97+VAtsA/PD6QIqu +Rd82uPWRMdC4L0kKdJ1Lrn9Da/2NmCNX2i1EeEvm37xlowRY88hXndb3cPZVK3Jq +xn4a2q7S22jbNIKQ8RyRrJoiDDq7K3aky+Ss46MMH+LZIEWHX9OXjd3Tvc1InVnZ +84fIfNmFTMqniPg0GaaJBARhimP2SjXFCciSsGV5XimuuGslFqA/2a63HNo5RC8Y +miVxB+Z8ndIEU6+RrXMy2KEstk0eckmAvc+PnbQPrLw5p8xGMtUx1BdOUfnM3qSb +k1Y= +=CUem +-----END PGP SIGNATURE----- diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.10.0/victim-1.10.0.module.asc b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.10.0/victim-1.10.0.module.asc new file mode 100644 index 000000000..0857f407c --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.10.0/victim-1.10.0.module.asc @@ -0,0 +1,12 @@ +-----BEGIN PGP SIGNATURE----- + +iQFPBAABCAA5FiEE3Qzd0rSDjslXJ7lLTHepyRHUahkFAmlVuQAbFIAAAAAABAAO +bWFudTIsMi41KzEuMTIsMCwzAAoJEEx3qckR1GoZq1AH/RN/TnwQUwhsFmReSsY1 +qcvn1RbFtTRbkiyXqbEORORDYpXYJFLzad0wVfPOjUBaNbeyrkMtRcIvVgb4A53x +GJUIt/8z+iF3FMpAkaq5X6OUHmmjMH0ApGiXYiJX9Gz2vEGkwambi3BIjjitSZeV +7AZwd+iFtpP2ZWA4os9wBmVdwnMgZGRuObqLk9glMvVPFbIE6zaMoUxjR4QyMn2Y +P0Ibp/ZLJKUsdSRUniFQKeeKcgF1GjaQFvLg8OTcbNcpXxWWOxcC2PTdLdUUSMZW +ofrTv42dHgvhHkLl/XMMn66W99VZlm2IhnkadU7UcQk2WAzcBBhVwU4UkLaaVutf +cEc= +=/hSi +-----END PGP SIGNATURE----- diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.10.0/victim-1.10.0.pom.asc b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.10.0/victim-1.10.0.pom.asc new file mode 100644 index 000000000..2e59940b0 --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.10.0/victim-1.10.0.pom.asc @@ -0,0 +1,12 @@ +-----BEGIN PGP SIGNATURE----- + +iQFPBAABCAA5FiEE3Qzd0rSDjslXJ7lLTHepyRHUahkFAmlVuQAbFIAAAAAABAAO +bWFudTIsMi41KzEuMTIsMCwzAAoJEEx3qckR1GoZgTgH/0aoIgcTI/xw4+/ZZEFm +uMyKPrMhrBmJSofoRCahvfYktFr+tBTph4UGTsAW++3sHhm5KVWi8UaqXNLjSTF9 +hTIdZ9syVH/sgbaWSyGBmB7fGrQPp/9r0Hxtr0zw68feZPRMqwXlhSOGDbJU/r6s +fqpCrEU63DPu421Bu7gLYGFMhcK6amZ2pwd6PEPzJbyK8LE3Nf6rx10YpNQNpWtt +xzB8hoB1WGX/Zyc5nMNs8JyJcNikC/fQK8Rw+kChuhaQ01vgGjF/MHG9/m1KtpgV +lduBur4OQif19td0yu1j5/b2AoKsKrIGqpbtFI+tFVraQQLopvrVxvJaF03cRuPY +NU4= +=vNsP +-----END PGP SIGNATURE----- diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.9/victim-1.9-sources.jar.asc b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.9/victim-1.9-sources.jar.asc new file mode 100644 index 000000000..f3182f4fe --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.9/victim-1.9-sources.jar.asc @@ -0,0 +1,12 @@ +-----BEGIN PGP SIGNATURE----- + +iQFPBAABCAA5FiEE3Qzd0rSDjslXJ7lLTHepyRHUahkFAmlVuQAbFIAAAAAABAAO +bWFudTIsMi41KzEuMTIsMCwzAAoJEEx3qckR1GoZGb0H/RmpWJyN3ZixgggRdKzQ +4T1lZ3pe/FXzs0Te2/1lXQ0aCeaAbQO31fHYOIrsK9UtMtnrlTIBFWYOSQrFnVRe ++qxlVKSVZOUQVJ56biC/jSNFiFeHOz8kCCVll5kdyfi4FbxOr5uZj2GKB/aEyPqi +FRcFVCeVNfOeknZ/c7/3WuxVZfvBfdi7/oph56XDVJ9NlzoDwjUpMuDMpUvjjpFU +mpSMEcOHdBhdu4SkaoVsTtih4QGQI+//HfIaF1U15khM0B/ah5o47SXstJ/L2AzD +uDOAPrefJygoBtwl4yeYrjWswvRrGr00FK31JDZPh0GcbDRfrrfivhzLsdppBUcc +vIg= +=+oXQ +-----END PGP SIGNATURE----- diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.9/victim-1.9-tests.jar.asc b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.9/victim-1.9-tests.jar.asc new file mode 100644 index 000000000..d103c4793 --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.9/victim-1.9-tests.jar.asc @@ -0,0 +1,12 @@ +-----BEGIN PGP SIGNATURE----- + +iQFPBAABCAA5FiEE3Qzd0rSDjslXJ7lLTHepyRHUahkFAmlVuQAbFIAAAAAABAAO +bWFudTIsMi41KzEuMTIsMCwzAAoJEEx3qckR1GoZJVsH/34vBHZj1k0mA6Cqpql/ +ivVUedLfM8A2tAMl+Vizxc44DLBfIDfPFPnzMu5M593jPaVPWWI75qqbN6K35svR +d00wohXG2m14hYtR9WsGFSlzwBeR6BAXrIcl8cFn09WqVDQ6JC4o9GyomMfquVJ3 +H5n7EH6kBGdZiMkC9DWtRN8daS0mvNY1VYTIUzx5lx6NTSJVF6CMn6Ly+kpNOToS +26/qF6dNHf2ZLB5HZRm8yypmymVlQTz5OLSiPZfiZ0jyM/pkRq3z4T4nGplvzNXQ +5PAjYdeZyzNVXN1Es3AHjNESLMlvsXhiC1yILPP9KLSwF+5u8S9BqY9/9/QWNNcB +6L8= +=GneL +-----END PGP SIGNATURE----- diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.9/victim-1.9.jar.asc b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.9/victim-1.9.jar.asc new file mode 100644 index 000000000..c8b721728 --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.9/victim-1.9.jar.asc @@ -0,0 +1,12 @@ +-----BEGIN PGP SIGNATURE----- + +iQFPBAABCAA5FiEE3Qzd0rSDjslXJ7lLTHepyRHUahkFAmlVuQAbFIAAAAAABAAO +bWFudTIsMi41KzEuMTIsMCwzAAoJEEx3qckR1GoZuoQH/iNi4ZeXqbra+As8fxnl +CCLkf9LG+yUUEzkvQn+G+lv8vPPpkSFcEHCKtcgcWWWDlggVX6k2w1RL1eEaGRMt +iNltEpqMK1oT20D/FEdDA2FVGI9w9NFn7XFyQGtEaphIOKZKweuol1T+Q+w1HDF7 +7G9MVo3HFSKrrv8LEbCNIEr5uORUo8gv3QK7B1dvWmNKMByo89crcsesPW4C9og0 +5uJElWw1OdvkWFuGuTTYf0a+lEmgNoUgBIBHuj/xPRXCL6cm4KOSvutJYXsNmzr5 +M5wiQ8SThNC+JPg/CPmmxytVpTxevy0J9A6VQ2CYw84gbzAgpfNQDKTy8pilLsfP +hgQ= +=ZnYI +-----END PGP SIGNATURE----- diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.9/victim-1.9.module.asc b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.9/victim-1.9.module.asc new file mode 100644 index 000000000..aa7fa932a --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.9/victim-1.9.module.asc @@ -0,0 +1,12 @@ +-----BEGIN PGP SIGNATURE----- + +iQFPBAABCAA5FiEE3Qzd0rSDjslXJ7lLTHepyRHUahkFAmlVuQAbFIAAAAAABAAO +bWFudTIsMi41KzEuMTIsMCwzAAoJEEx3qckR1GoZpwYH/jxL+BD1z0nDh3OWa8Eq +S8SBYNw2CJSmd9fpeHDOTpuhI95atnG4fLtO/iovMmpDSto5P22drEIX72Qs2/Zt +E8HZ4XuPBLF9G85pUkFGHHlhkrFu66GRLQ8MuXuLkoJCsy216GcSRk/JfRCX5shy +FRhHjtGA5KtNC0R9/fsTyVfIV9hwwNJXM57Q6VUofJe2YnsoF7jSDpJ5wnHhIBAa +0WayVYsy9DZOSHZNvdqln8KcoIG9Gnr7yCRdv8UzSOpCAMF7s2ivTSmEbmlqHXVq +38N+ztxbeB84kV/G0Mto5/Y3Oh4sfv7vkPzIZUwBS/rfNFNNcHPEOAIqMIXGwRG8 +fR4= +=wMcW +-----END PGP SIGNATURE----- diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.9/victim-1.9.pom.asc b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.9/victim-1.9.pom.asc new file mode 100644 index 000000000..0b783dd02 --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/fixtures/signatures/com/socketfixture/victim/1.9/victim-1.9.pom.asc @@ -0,0 +1,12 @@ +-----BEGIN PGP SIGNATURE----- + +iQFPBAABCAA5FiEE3Qzd0rSDjslXJ7lLTHepyRHUahkFAmlVuQAbFIAAAAAABAAO +bWFudTIsMi41KzEuMTIsMCwzAAoJEEx3qckR1GoZVLIIAJHV0PXAQFNiZ9Zlatso +m3puyZ7Ld6yI1g3Iz4srbFnjxjpgRabwTukPhfm/SlOtpBeYnDkEfhGYUrx4n58t +1KhEezIPbcu3SeMS+meZo+KeinG/LNkbjF9YDwnDGXj2w4rlaYKFw/jnnDTCOu63 ++JZr4kLeBle/17iZ6SYz8QuhNU+sXuqSJoBrq4M+GykCXl/VNGn1ikOyNCmK4/4m +riUH/BsuWLSbMzSR/KvI4Ay0m4OrdEQHR5Xj2huKtdUg3kjxNJo62zNBnNWkaRmH +igSyYV/yxfN0ywzIRVP5pOoCgyNFnEov27zNketYVU/GdZpt7S/mWeWxomTx2zqg +Q2M= +=j2I/ +-----END PGP SIGNATURE----- diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/mod.rs b/crates/socket-patch-cli/tests/jvm_fixture_repo/mod.rs new file mode 100644 index 000000000..9b14a89ae --- /dev/null +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/mod.rs @@ -0,0 +1,1068 @@ +//! A deterministic fake Maven Central for the JVM build capstones (Gradle, +//! and sbt/Coursier, which resolve from the same maven2 layout). +//! +//! Everything under `com.socketfixture` is GENERATED here, byte-for-byte +//! reproducibly (stored zip entries, fixed timestamps and permissions, +//! hand-assembled class files, fixed-order JSON/XML): +//! +//! * `victim:{1.9,1.10.0}` — jar (a `Victim.marker()` class + a +//! `META-INF/NOTICE.txt` marker), pom (parent `fixture-parent:1`, the +//! `published-with-gradle-metadata` hint), `.module` (api / runtime / +//! sources variants with size + md5/sha1/sha256/sha512), and +//! `-tests` / `-sources` classifier jars. `victim-1.10.0.jar` carries a +//! padding member brute-forced so its sha1 starts with `0` (Gradle may +//! drop the leading zero from the `files-2.1` hash dir). +//! * `consumer:2.0` → `victim:1.10.0`; `consumer-range:2.0` → `victim:[1.9,1.11)`. +//! * `fixture-parent:1` (parent pom), `fixture-bom:1.0` (a Maven BOM, for a +//! non-enforced `platform()` import), `fixture-platform:1.0` (a Gradle +//! platform `.module` whose variants `require` `victim:1.10.0`). +//! * `buildlogic-plugin:1.0` — a buildscript-classpath library depending on +//! `victim:1.10.0`; `BuildLogic.print()` prints [`BUILDLOGIC_MARKER`] + +//! `Victim.marker()` from build logic. +//! * artifact-level `maven-metadata.xml`, and `.md5` / `.sha1` / `.sha256` / +//! `.sha512` sidecars for every file (computed when served). +//! +//! COMMITTED under `fixtures/`: `.asc` signatures of every jar / pom / +//! `.module` (`fixtures/signatures/.asc`) by a THROWAWAY key +//! (`fixtures/keys/`, secret included on purpose — never trust it), and +//! `fixtures/SHA256SUMS`, the digest of every generated file. The +//! stability self-test regenerates the repository and compares it with +//! `SHA256SUMS` on every OS. To change the fixture, edit the generator and +//! run that test with `SOCKET_PATCH_JVM_FIXTURES_REGENERATE=1` (needs +//! `gpg`): it rewrites `SHA256SUMS` and re-signs with the committed key. +//! +//! [`FakeCentral`] serves the repository over plain http (wiremock), plus +//! any overlay a test adds (e.g. the service-built patched jar the +//! member-keyed swap downloads, [`FakeCentral::serve_patched_jar`]). + +#![allow(dead_code)] + +use std::collections::BTreeMap; +use std::io::Write as _; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use wiremock::matchers::{method, path_regex}; +use wiremock::{Mock, MockServer, Request, ResponseTemplate}; + +pub const GROUP: &str = "com.socketfixture"; +pub const GROUP_PATH: &str = "com/socketfixture"; +pub const VICTIM: &str = "victim"; +/// The base version patches target, and the older one a downgrade lands on. +pub const VICTIM_VERSION: &str = "1.10.0"; +pub const VICTIM_OLD: &str = "1.9"; +pub const VICTIM_VERSIONS: [&str; 2] = [VICTIM_OLD, VICTIM_VERSION]; +pub const CONSUMER: &str = "consumer"; +pub const CONSUMER_RANGE: &str = "consumer-range"; +pub const CONSUMER_VERSION: &str = "2.0"; +/// What `consumer-range:2.0`'s pom requests. +pub const VICTIM_RANGE: &str = "[1.9,1.11)"; +pub const PARENT: &str = "fixture-parent"; +pub const PARENT_VERSION: &str = "1"; +pub const BOM: &str = "fixture-bom"; +pub const PLATFORM: &str = "fixture-platform"; +pub const PLATFORM_VERSION: &str = "1.0"; +pub const BUILDLOGIC: &str = "buildlogic-plugin"; +pub const BUILDLOGIC_VERSION: &str = "1.0"; +pub const BUILDLOGIC_CLASS: &str = "com.socketfixture.buildlogic.BuildLogic"; +pub const VICTIM_CLASS: &str = "com.socketfixture.victim.Victim"; +/// The jar member a text patch rewrites. +pub const NOTICE: &str = "META-INF/NOTICE.txt"; +/// The class member `Victim.marker()` lives in. +pub const VICTIM_CLASS_MEMBER: &str = "com/socketfixture/victim/Victim.class"; +/// The leading-zero padding member of `victim-1.10.0.jar`. +pub const PAD_MEMBER: &str = "META-INF/socket-fixture-pad.txt"; +/// What `BuildLogic.print()` prints before `Victim.marker()`. +pub const BUILDLOGIC_MARKER: &str = "SOCKET-FIXTURE-BUILDLOGIC "; +/// The `lastUpdated` of every `maven-metadata.xml`. +pub const LAST_UPDATED: &str = "20260101000000"; +/// The throwaway signing key's fingerprint. +pub const KEY_FINGERPRINT: &str = "DD0CDDD2B4838EC95727B94B4C77A9C911D46A19"; +pub const REGENERATE_ENV: &str = "SOCKET_PATCH_JVM_FIXTURES_REGENERATE"; + +/// The committed part of the fixture. +pub fn fixtures_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/jvm_fixture_repo/fixtures") +} + +/// The throwaway key as an armored public keyring (Gradle accepts it as +/// `gradle/verification-keyring.keys`). +pub fn public_key_armored() -> PathBuf { + fixtures_dir().join("keys/signing-key.public.asc") +} + +/// The same keyring in binary form (`gradle/verification-keyring.gpg`). +pub fn public_keyring_gpg() -> PathBuf { + fixtures_dir().join("keys/verification-keyring.gpg") +} + +pub fn victim_purl(version: &str) -> String { + format!("pkg:maven/{GROUP}/{VICTIM}@{version}") +} + +/// `///-[-].`. +pub fn repo_path(artifact: &str, version: &str, classifier: Option<&str>, ext: &str) -> String { + let classifier = classifier.map(|c| format!("-{c}")).unwrap_or_default(); + format!("{GROUP_PATH}/{artifact}/{version}/{artifact}-{version}{classifier}.{ext}") +} + +// ── member content ────────────────────────────────────────────────────── + +/// `Victim.marker()`'s return value. +pub fn victim_marker(version: &str, state: &str) -> String { + format!("SOCKET-FIXTURE-VICTIM {version} {state}") +} + +/// A jar's `META-INF/NOTICE.txt`; `state` is `pristine` upstream. +pub fn notice(coordinate: &str, state: &str) -> String { + format!("Socket fixture: {coordinate}\nSOCKET-FIXTURE-NOTICE {state}\n") +} + +/// `com.socketfixture.victim.Victim` whose `marker()` returns +/// [`victim_marker`]`(version, state)`: build a patched class member with a +/// different `state`. +pub fn victim_class(version: &str, state: &str) -> Vec { + let mut c = ClassFile::new("com/socketfixture/victim/Victim"); + let text = c.string(&victim_marker(version, state)); + let mut code = ldc(text); + code.push(0xb0); // areturn + c.method("marker", "()Ljava/lang/String;", 1, code); + c.finish() +} + +/// `com.socketfixture.buildlogic.BuildLogic`: `marker()` returns +/// [`BUILDLOGIC_MARKER`] + `Victim.marker()`, `print()` prints it. +pub fn buildlogic_class() -> Vec { + let mut c = ClassFile::new("com/socketfixture/buildlogic/BuildLogic"); + let prefix = c.string(BUILDLOGIC_MARKER); + let victim = c.methodref( + "com/socketfixture/victim/Victim", + "marker", + "()Ljava/lang/String;", + ); + let concat = c.methodref( + "java/lang/String", + "concat", + "(Ljava/lang/String;)Ljava/lang/String;", + ); + let out = c.fieldref("java/lang/System", "out", "Ljava/io/PrintStream;"); + let println = c.methodref("java/io/PrintStream", "println", "(Ljava/lang/String;)V"); + let this_marker = c.methodref( + "com/socketfixture/buildlogic/BuildLogic", + "marker", + "()Ljava/lang/String;", + ); + // marker(): ldc prefix; invokestatic Victim.marker; invokevirtual concat; areturn + let mut code = ldc(prefix); + code.extend(op_u2(0xb8, victim)); + code.extend(op_u2(0xb6, concat)); + code.push(0xb0); + c.method("marker", "()Ljava/lang/String;", 2, code); + // print(): getstatic System.out; invokestatic marker; invokevirtual println; return + let mut code = op_u2(0xb2, out); + code.extend(op_u2(0xb8, this_marker)); + code.extend(op_u2(0xb6, println)); + code.push(0xb1); + c.method("print", "()V", 2, code); + c.finish() +} + +fn manifest_mf() -> Vec { + b"Manifest-Version: 1.0\r\nCreated-By: socket-patch test fixture\r\n\r\n".to_vec() +} + +fn victim_jar(version: &str) -> Vec { + let coordinate = format!("{GROUP}:{VICTIM}:{version}"); + let mut members = vec![ + ("META-INF/MANIFEST.MF".to_string(), manifest_mf()), + ( + NOTICE.to_string(), + notice(&coordinate, "pristine").into_bytes(), + ), + ( + VICTIM_CLASS_MEMBER.to_string(), + victim_class(version, "pristine"), + ), + ]; + if version != VICTIM_VERSION { + return jar(&members); + } + // Brute-force the padding so the jar's sha1 starts with `0`. + members.push((PAD_MEMBER.to_string(), Vec::new())); + for n in 0u32.. { + members.last_mut().unwrap().1 = format!("pad {n}\n").into_bytes(); + let bytes = jar(&members); + if sha1_hex(&bytes).starts_with('0') { + return bytes; + } + } + unreachable!() +} + +fn classifier_jar(artifact: &str, version: &str, classifier: &str) -> Vec { + let coordinate = format!("{GROUP}:{artifact}:{version}:{classifier}"); + let mut members = vec![ + ("META-INF/MANIFEST.MF".to_string(), manifest_mf()), + ( + NOTICE.to_string(), + notice(&coordinate, "pristine").into_bytes(), + ), + ]; + if classifier == "sources" { + members.push(( + "com/socketfixture/victim/Victim.java".to_string(), + format!( + "package com.socketfixture.victim;\n\npublic final class Victim {{\n \ + public static String marker() {{ return \"{}\"; }}\n}}\n", + victim_marker(version, "pristine") + ) + .into_bytes(), + )); + } + jar(&members) +} + +fn notice_jar(artifact: &str, version: &str, extra: Vec<(String, Vec)>) -> Vec { + let coordinate = format!("{GROUP}:{artifact}:{version}"); + let mut members = vec![ + ("META-INF/MANIFEST.MF".to_string(), manifest_mf()), + ( + NOTICE.to_string(), + notice(&coordinate, "pristine").into_bytes(), + ), + ]; + members.extend(extra); + jar(&members) +} + +/// A deterministic jar: stored entries, a fixed 2026-01-01 timestamp and +/// 0644 Unix permissions, in the given order. +pub fn jar(members: &[(String, Vec)]) -> Vec { + let mut out = std::io::Cursor::new(Vec::new()); + { + let mut writer = zip::ZipWriter::new(&mut out); + let opts = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Stored) + .last_modified_time(zip::DateTime::from_date_and_time(2026, 1, 1, 0, 0, 0).unwrap()) + .system(zip::System::Unix) + .unix_permissions(0o644); + for (name, bytes) in members { + writer.start_file(name.as_str(), opts).unwrap(); + writer.write_all(bytes).unwrap(); + } + writer.finish().unwrap(); + } + out.into_inner() +} + +// ── metadata ──────────────────────────────────────────────────────────── + +const POM_HEAD: &str = "\n\ +\n"; + +/// Gradle reads the `.module` when a pom carries this comment. +const GRADLE_METADATA_HINT: &str = " \n"; + +fn parent_block() -> String { + format!( + " \n {GROUP}\n {PARENT}\n \ + {PARENT_VERSION}\n \n" + ) +} + +fn dependency(artifact: &str, version: &str) -> String { + format!( + " \n {GROUP}\n {artifact}\n \ + {version}\n \n" + ) +} + +fn parent_pom() -> String { + format!( + "{POM_HEAD} 4.0.0\n {GROUP}\n \ + {PARENT}\n {PARENT_VERSION}\n \ + pom\n socket-patch fixture parent\n \n \ + \n MIT\n \n \n\n" + ) +} + +/// A jar pom under `fixture-parent` (the project's own `` follows +/// ``, the shape `hosted_maven_common::Hosted::served_pom` rewrites). +fn jar_pom(artifact: &str, version: &str, gradle_metadata: bool, deps: &[(&str, &str)]) -> String { + let mut pom = format!("{POM_HEAD}"); + if gradle_metadata { + pom.push_str(GRADLE_METADATA_HINT); + } + pom.push_str(&format!( + " 4.0.0\n{} {artifact}\n \ + {version}\n jar\n", + parent_block() + )); + if !deps.is_empty() { + pom.push_str(" \n"); + for (a, v) in deps { + pom.push_str(&dependency(a, v)); + } + pom.push_str(" \n"); + } + pom.push_str("\n"); + pom +} + +/// A `pom`-packaged pom managing `victim:1.10.0` (the BOM, and the +/// platform's Maven face). +fn managing_pom(artifact: &str, version: &str, gradle_metadata: bool) -> String { + let mut pom = format!("{POM_HEAD}"); + if gradle_metadata { + pom.push_str(GRADLE_METADATA_HINT); + } + pom.push_str(&format!( + " 4.0.0\n{} {artifact}\n \ + {version}\n pom\n \ + \n \n{} \n \ + \n\n", + parent_block(), + dependency(VICTIM, VICTIM_VERSION) + .lines() + .map(|l| format!(" {l}\n")) + .collect::() + )); + pom +} + +fn file_entry(name: &str, bytes: &[u8]) -> serde_json::Value { + serde_json::json!({ + "name": name, + "url": name, + "size": bytes.len(), + "sha512": sha512_hex(bytes), + "sha256": sha256_hex(bytes), + "sha1": sha1_hex(bytes), + "md5": md5_hex(bytes), + }) +} + +fn module_head(artifact: &str, version: &str) -> serde_json::Value { + serde_json::json!({ + "formatVersion": "1.1", + "component": { + "group": GROUP, + "module": artifact, + "version": version, + "attributes": { "org.gradle.status": "release" } + }, + "createdBy": { "gradle": { "version": "8.14.3" } }, + "variants": [] + }) +} + +fn library_variant(name: &str, usage: &str, jar_name: &str, jar: &[u8]) -> serde_json::Value { + serde_json::json!({ + "name": name, + "attributes": { + "org.gradle.category": "library", + "org.gradle.dependency.bundling": "external", + "org.gradle.jvm.version": 8, + "org.gradle.libraryelements": "jar", + "org.gradle.usage": usage + }, + "files": [file_entry(jar_name, jar)] + }) +} + +fn victim_module(version: &str, jar: &[u8], sources: &[u8]) -> String { + let mut module = module_head(VICTIM, version); + let jar_name = format!("{VICTIM}-{version}.jar"); + let sources_name = format!("{VICTIM}-{version}-sources.jar"); + module["variants"] = serde_json::json!([ + library_variant("apiElements", "java-api", &jar_name, jar), + library_variant("runtimeElements", "java-runtime", &jar_name, jar), + { + "name": "sourcesElements", + "attributes": { + "org.gradle.category": "documentation", + "org.gradle.dependency.bundling": "external", + "org.gradle.docstype": "sources", + "org.gradle.usage": "java-runtime" + }, + "files": [file_entry(&sources_name, sources)] + } + ]); + serde_json::to_string_pretty(&module).unwrap() + "\n" +} + +fn platform_module() -> String { + let mut module = module_head(PLATFORM, PLATFORM_VERSION); + let constraint = serde_json::json!([{ + "group": GROUP, + "module": VICTIM, + "version": { "requires": VICTIM_VERSION } + }]); + module["variants"] = serde_json::json!([ + { + "name": "apiElements", + "attributes": { "org.gradle.category": "platform", "org.gradle.usage": "java-api" }, + "dependencyConstraints": constraint.clone() + }, + { + "name": "runtimeElements", + "attributes": { "org.gradle.category": "platform", "org.gradle.usage": "java-runtime" }, + "dependencyConstraints": constraint + } + ]); + serde_json::to_string_pretty(&module).unwrap() + "\n" +} + +fn maven_metadata(artifact: &str, versions: &[&str]) -> String { + let latest = versions.last().unwrap(); + let listed: String = versions + .iter() + .map(|v| format!(" {v}\n")) + .collect(); + format!( + "\n\n {GROUP}\n \ + {artifact}\n \n {latest}\n \ + {latest}\n \n{listed} \n \ + {LAST_UPDATED}\n \n\n" + ) +} + +// ── the repository ────────────────────────────────────────────────────── + +/// Every primary file of the repository, keyed by its maven2 path (no +/// leading `/`). Sidecars (checksums, signatures) are not included. +pub fn generate() -> BTreeMap> { + let mut repo = BTreeMap::new(); + let mut put = |path: String, bytes: Vec| { + assert!(repo.insert(path, bytes).is_none()); + }; + put( + repo_path(PARENT, PARENT_VERSION, None, "pom"), + parent_pom().into_bytes(), + ); + put( + format!("{GROUP_PATH}/{PARENT}/maven-metadata.xml"), + maven_metadata(PARENT, &[PARENT_VERSION]).into_bytes(), + ); + for version in VICTIM_VERSIONS { + let jar = victim_jar(version); + let sources = classifier_jar(VICTIM, version, "sources"); + let tests = classifier_jar(VICTIM, version, "tests"); + put( + repo_path(VICTIM, version, None, "module"), + victim_module(version, &jar, &sources).into_bytes(), + ); + put( + repo_path(VICTIM, version, None, "pom"), + jar_pom(VICTIM, version, true, &[]).into_bytes(), + ); + put(repo_path(VICTIM, version, None, "jar"), jar); + put(repo_path(VICTIM, version, Some("sources"), "jar"), sources); + put(repo_path(VICTIM, version, Some("tests"), "jar"), tests); + } + put( + format!("{GROUP_PATH}/{VICTIM}/maven-metadata.xml"), + maven_metadata(VICTIM, &VICTIM_VERSIONS).into_bytes(), + ); + for (artifact, victim) in [(CONSUMER, VICTIM_VERSION), (CONSUMER_RANGE, VICTIM_RANGE)] { + put( + repo_path(artifact, CONSUMER_VERSION, None, "pom"), + jar_pom(artifact, CONSUMER_VERSION, false, &[(VICTIM, victim)]).into_bytes(), + ); + put( + repo_path(artifact, CONSUMER_VERSION, None, "jar"), + notice_jar(artifact, CONSUMER_VERSION, Vec::new()), + ); + put( + format!("{GROUP_PATH}/{artifact}/maven-metadata.xml"), + maven_metadata(artifact, &[CONSUMER_VERSION]).into_bytes(), + ); + } + put( + repo_path(BOM, PLATFORM_VERSION, None, "pom"), + managing_pom(BOM, PLATFORM_VERSION, false).into_bytes(), + ); + put( + format!("{GROUP_PATH}/{BOM}/maven-metadata.xml"), + maven_metadata(BOM, &[PLATFORM_VERSION]).into_bytes(), + ); + put( + repo_path(PLATFORM, PLATFORM_VERSION, None, "pom"), + managing_pom(PLATFORM, PLATFORM_VERSION, true).into_bytes(), + ); + put( + repo_path(PLATFORM, PLATFORM_VERSION, None, "module"), + platform_module().into_bytes(), + ); + put( + format!("{GROUP_PATH}/{PLATFORM}/maven-metadata.xml"), + maven_metadata(PLATFORM, &[PLATFORM_VERSION]).into_bytes(), + ); + put( + repo_path(BUILDLOGIC, BUILDLOGIC_VERSION, None, "pom"), + jar_pom( + BUILDLOGIC, + BUILDLOGIC_VERSION, + false, + &[(VICTIM, VICTIM_VERSION)], + ) + .into_bytes(), + ); + put( + repo_path(BUILDLOGIC, BUILDLOGIC_VERSION, None, "jar"), + notice_jar( + BUILDLOGIC, + BUILDLOGIC_VERSION, + vec![( + "com/socketfixture/buildlogic/BuildLogic.class".to_string(), + buildlogic_class(), + )], + ), + ); + put( + format!("{GROUP_PATH}/{BUILDLOGIC}/maven-metadata.xml"), + maven_metadata(BUILDLOGIC, &[BUILDLOGIC_VERSION]).into_bytes(), + ); + repo +} + +/// Whether a repository file carries a committed `.asc`. +pub fn is_signed(path: &str) -> bool { + path.ends_with(".jar") || path.ends_with(".pom") || path.ends_with(".module") +} + +/// The committed `.asc` of every signed file, keyed by its repository path +/// with `.asc` appended. +pub fn signatures() -> BTreeMap> { + let root = fixtures_dir().join("signatures"); + generate() + .keys() + .filter(|p| is_signed(p)) + .filter_map(|p| { + let asc = format!("{p}.asc"); + std::fs::read(root.join(&asc)).ok().map(|b| (asc, b)) + }) + .collect() +} + +/// The full served repository: [`generate`] + [`signatures`] + checksum +/// sidecars of both. +pub fn repository() -> BTreeMap> { + let mut repo = generate(); + repo.extend(signatures()); + with_checksums(repo) +} + +/// `files` plus `.md5` / `.sha1` / `.sha256` / `.sha512` of each. +pub fn with_checksums(files: BTreeMap>) -> BTreeMap> { + let mut out = files.clone(); + for (path, bytes) in files { + for (ext, digest) in checksums(&bytes) { + out.insert(format!("{path}.{ext}"), digest.into_bytes()); + } + } + out +} + +fn checksums(bytes: &[u8]) -> [(&'static str, String); 4] { + [ + ("md5", md5_hex(bytes)), + ("sha1", sha1_hex(bytes)), + ("sha256", sha256_hex(bytes)), + ("sha512", sha512_hex(bytes)), + ] +} + +/// `SHA256SUMS` text for `files` (` ` lines, sorted). +pub fn sha256sums(files: &BTreeMap>) -> String { + files + .iter() + .map(|(path, bytes)| format!("{} {path}\n", sha256_hex(bytes))) + .collect() +} + +// ── the server ────────────────────────────────────────────────────────── + +/// The fake Central: every [`repository`] file at `/`, plus +/// overlays, over plain http. 404 for anything else. Requests are logged. +pub struct FakeCentral { + server: MockServer, + rt: tokio::runtime::Runtime, + files: Arc>>>, +} + +impl FakeCentral { + pub fn start() -> Self { + let rt = tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .enable_all() + .build() + .unwrap(); + let server = rt.block_on(MockServer::start()); + let files = Arc::new(Mutex::new(repository())); + let served = files.clone(); + rt.block_on( + Mock::given(method("GET")) + .and(path_regex("^/.+")) + .respond_with(move |request: &Request| { + let path = request.url.path().trim_start_matches('/').to_string(); + match served.lock().unwrap().get(&path) { + Some(bytes) => ResponseTemplate::new(200).set_body_bytes(bytes.clone()), + None => ResponseTemplate::new(404), + } + }) + .mount(&server), + ); + rt.block_on( + Mock::given(method("HEAD")) + .and(path_regex("^/.+")) + .respond_with({ + let served = files.clone(); + move |request: &Request| { + let path = request.url.path().trim_start_matches('/'); + if served.lock().unwrap().contains_key(path) { + ResponseTemplate::new(200) + } else { + ResponseTemplate::new(404) + } + } + }) + .mount(&server), + ); + FakeCentral { server, rt, files } + } + + /// The repository url (`http://127.0.0.1:`). + pub fn uri(&self) -> String { + self.server.uri() + } + + /// Serve `bytes` (plus checksum sidecars) at `path` (no leading `/`), + /// replacing whatever was there. + pub fn put(&self, path: &str, bytes: &[u8]) { + let one = BTreeMap::from([(path.to_string(), bytes.to_vec())]); + self.files.lock().unwrap().extend(with_checksums(one)); + } + + /// Stop serving `path` and its sidecars. + pub fn remove(&self, path: &str) { + let mut files = self.files.lock().unwrap(); + files.remove(path); + for (ext, _) in checksums(b"") { + files.remove(&format!("{path}.{ext}")); + } + } + + /// The service-built patched jar a member-keyed record swaps in, at + /// `/patched//-.jar`. Returns its url. + pub fn serve_patched_jar( + &self, + uuid: &str, + artifact: &str, + version: &str, + jar: &[u8], + ) -> String { + let path = format!("patched/{uuid}/{artifact}-{version}.jar"); + self.put(&path, jar); + format!("{}/{path}", self.uri()) + } + + /// Every request path seen so far (leading `/` kept), in order. + pub fn requests(&self) -> Vec { + self.rt + .block_on(self.server.received_requests()) + .unwrap_or_default() + .iter() + .map(|r| r.url.path().to_string()) + .collect() + } +} + +// ── digests ───────────────────────────────────────────────────────────── + +pub fn sha1_hex(bytes: &[u8]) -> String { + use sha1::{Digest, Sha1}; + hex::encode(Sha1::digest(bytes)) +} + +pub fn sha256_hex(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + hex::encode(Sha256::digest(bytes)) +} + +pub fn sha512_hex(bytes: &[u8]) -> String { + use sha2::{Digest, Sha512}; + hex::encode(Sha512::digest(bytes)) +} + +/// RFC 1321 MD5 (the dev-dependency set has no md5 crate; Gradle module +/// metadata and Maven sidecars still carry it). +pub fn md5_hex(bytes: &[u8]) -> String { + const S: [u32; 64] = [ + 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5, + 9, 14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10, + 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, + ]; + // floor(|sin(i + 1)| * 2^32), tabulated: libm `sin` is not bit-identical + // across platforms. + const K: [u32; 64] = [ + 0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, 0xf57c0faf, 0x4787c62a, 0xa8304613, + 0xfd469501, 0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be, 0x6b901122, 0xfd987193, + 0xa679438e, 0x49b40821, 0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa, 0xd62f105d, + 0x02441453, 0xd8a1e681, 0xe7d3fbc8, 0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed, + 0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a, 0xfffa3942, 0x8771f681, 0x6d9d6122, + 0xfde5380c, 0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70, 0x289b7ec6, 0xeaa127fa, + 0xd4ef3085, 0x04881d05, 0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665, 0xf4292244, + 0x432aff97, 0xab9423a7, 0xfc93a039, 0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1, + 0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1, 0xf7537e82, 0xbd3af235, 0x2ad7d2bb, + 0xeb86d391, + ]; + let mut state: [u32; 4] = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476]; + let mut msg = bytes.to_vec(); + let bit_len = (bytes.len() as u64).wrapping_mul(8); + msg.push(0x80); + while msg.len() % 64 != 56 { + msg.push(0); + } + msg.extend_from_slice(&bit_len.to_le_bytes()); + for chunk in msg.chunks(64) { + let m: Vec = chunk + .chunks(4) + .map(|w| u32::from_le_bytes([w[0], w[1], w[2], w[3]])) + .collect(); + let [mut a, mut b, mut c, mut d] = state; + for i in 0..64 { + let (f, g) = match i / 16 { + 0 => ((b & c) | (!b & d), i), + 1 => ((d & b) | (!d & c), (5 * i + 1) % 16), + 2 => (b ^ c ^ d, (3 * i + 5) % 16), + _ => (c ^ (b | !d), (7 * i) % 16), + }; + let rotated = a + .wrapping_add(f) + .wrapping_add(K[i]) + .wrapping_add(m[g]) + .rotate_left(S[i]); + a = d; + d = c; + c = b; + b = b.wrapping_add(rotated); + } + for (s, v) in state.iter_mut().zip([a, b, c, d]) { + *s = s.wrapping_add(v); + } + } + state + .iter() + .flat_map(|w| w.to_le_bytes()) + .map(|b| format!("{b:02x}")) + .collect() +} + +// ── a minimal class-file writer ───────────────────────────────────────── + +/// Just enough of JVMS §4 for public static methods without branches: +/// class version 52 (Java 8, loads on every JDK the matrix runs), no +/// StackMapTable needed, no constructor. +struct ClassFile { + pool: Vec>, + this: u16, + object: u16, + code_name: u16, + methods: Vec>, +} + +fn op_u2(op: u8, index: u16) -> Vec { + let [hi, lo] = index.to_be_bytes(); + vec![op, hi, lo] +} + +fn ldc(index: u16) -> Vec { + match u8::try_from(index) { + Ok(small) => vec![0x12, small], + Err(_) => op_u2(0x13, index), + } +} + +impl ClassFile { + fn new(name: &str) -> Self { + let mut c = ClassFile { + pool: Vec::new(), + this: 0, + object: 0, + code_name: 0, + methods: Vec::new(), + }; + c.this = c.class(name); + c.object = c.class("java/lang/Object"); + c.code_name = c.utf8("Code"); + c + } + + fn add(&mut self, entry: Vec) -> u16 { + if let Some(i) = self.pool.iter().position(|e| *e == entry) { + return i as u16 + 1; + } + self.pool.push(entry); + self.pool.len() as u16 + } + + fn utf8(&mut self, text: &str) -> u16 { + let mut entry = vec![1]; + entry.extend((text.len() as u16).to_be_bytes()); + entry.extend(text.as_bytes()); + self.add(entry) + } + + fn with_index(&mut self, tag: u8, indices: &[u16]) -> u16 { + let mut entry = vec![tag]; + for i in indices { + entry.extend(i.to_be_bytes()); + } + self.add(entry) + } + + fn class(&mut self, name: &str) -> u16 { + let name = self.utf8(name); + self.with_index(7, &[name]) + } + + fn string(&mut self, text: &str) -> u16 { + let text = self.utf8(text); + self.with_index(8, &[text]) + } + + fn name_and_type(&mut self, name: &str, descriptor: &str) -> u16 { + let name = self.utf8(name); + let descriptor = self.utf8(descriptor); + self.with_index(12, &[name, descriptor]) + } + + fn methodref(&mut self, class: &str, name: &str, descriptor: &str) -> u16 { + let class = self.class(class); + let nt = self.name_and_type(name, descriptor); + self.with_index(10, &[class, nt]) + } + + fn fieldref(&mut self, class: &str, name: &str, descriptor: &str) -> u16 { + let class = self.class(class); + let nt = self.name_and_type(name, descriptor); + self.with_index(9, &[class, nt]) + } + + /// `public static ` with no locals. + fn method(&mut self, name: &str, descriptor: &str, max_stack: u16, code: Vec) { + let name = self.utf8(name); + let descriptor = self.utf8(descriptor); + let mut m = Vec::new(); + m.extend(0x0009u16.to_be_bytes()); // ACC_PUBLIC | ACC_STATIC + m.extend(name.to_be_bytes()); + m.extend(descriptor.to_be_bytes()); + m.extend(1u16.to_be_bytes()); + m.extend(self.code_name.to_be_bytes()); + m.extend((12 + code.len() as u32).to_be_bytes()); + m.extend(max_stack.to_be_bytes()); + m.extend(0u16.to_be_bytes()); // max_locals + m.extend((code.len() as u32).to_be_bytes()); + m.extend(code); + m.extend(0u16.to_be_bytes()); // exception table + m.extend(0u16.to_be_bytes()); // attributes + self.methods.push(m); + } + + fn finish(self) -> Vec { + let mut out = vec![0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 52]; + out.extend((self.pool.len() as u16 + 1).to_be_bytes()); + for entry in &self.pool { + out.extend(entry); + } + out.extend(0x0031u16.to_be_bytes()); // ACC_PUBLIC | ACC_FINAL | ACC_SUPER + out.extend(self.this.to_be_bytes()); + out.extend(self.object.to_be_bytes()); + out.extend(0u16.to_be_bytes()); // interfaces + out.extend(0u16.to_be_bytes()); // fields + out.extend((self.methods.len() as u16).to_be_bytes()); + for m in &self.methods { + out.extend(m); + } + out.extend(0u16.to_be_bytes()); // attributes + out + } +} + +// ── regeneration ──────────────────────────────────────────────────────── + +/// Rewrite `SHA256SUMS` and re-sign every signed file with the committed +/// throwaway key (`gpg` with a scratch `GNUPGHOME`, a faked fixed signing +/// time, so the signatures are reproducible too). +fn regenerate(repo: &BTreeMap>) { + let dir = fixtures_dir(); + std::fs::write(dir.join("SHA256SUMS"), sha256sums(repo)).unwrap(); + let gnupg = tempfile::tempdir().unwrap(); + let gpg = |args: &[&str]| { + let out = std::process::Command::new("gpg") + .env("GNUPGHOME", gnupg.path()) + .args(["--batch", "--yes", "--quiet"]) + .args(args) + .output() + .expect("run gpg"); + assert!( + out.status.success(), + "gpg {args:?}: {}", + String::from_utf8_lossy(&out.stderr) + ); + }; + let secret = dir.join("keys/signing-key.secret.asc"); + gpg(&["--import", secret.to_str().unwrap()]); + let scratch = tempfile::tempdir().unwrap(); + for (path, bytes) in repo.iter().filter(|(p, _)| is_signed(p)) { + let input = scratch.path().join("input"); + std::fs::write(&input, bytes).unwrap(); + let asc = dir.join("signatures").join(format!("{path}.asc")); + std::fs::create_dir_all(asc.parent().unwrap()).unwrap(); + gpg(&[ + "--faked-system-time", + "20260101T000000!", + "--digest-algo", + "SHA256", + "--no-emit-version", + "--armor", + "--local-user", + KEY_FINGERPRINT, + "--output", + asc.to_str().unwrap(), + "--detach-sign", + input.to_str().unwrap(), + ]); + } +} + +fn path_of(root: &Path, rel: &str) -> PathBuf { + rel.split('/').fold(root.to_path_buf(), |p, c| p.join(c)) +} + +// ── self-tests (integration crates get no cfg(test)) ──────────────────── + +mod jvm_fixture_repo_selftests { + use super::*; + + /// The generator reproduces the committed digests byte-for-byte on this + /// OS, and every signed file has its committed signature. + #[test] + fn jvm_fixture_repo_is_stable() { + let repo = generate(); + if std::env::var_os(REGENERATE_ENV).is_some_and(|v| !v.is_empty()) { + regenerate(&repo); + } + let committed = std::fs::read_to_string(fixtures_dir().join("SHA256SUMS")).unwrap(); + assert_eq!( + sha256sums(&repo), + committed, + "the generated fixture repository drifted from fixtures/SHA256SUMS; \ + rerun with {REGENERATE_ENV}=1 if the change is intended" + ); + let signatures = signatures(); + for path in repo.keys().filter(|p| is_signed(p)) { + let asc = signatures + .get(&format!("{path}.asc")) + .unwrap_or_else(|| panic!("no committed signature for {path}")); + assert!( + asc.starts_with(b"-----BEGIN PGP SIGNATURE-----"), + "{path}.asc" + ); + } + assert!(path_of(&fixtures_dir(), "keys/signing-key.public.asc").is_file()); + assert!(public_keyring_gpg().is_file()); + } + + #[test] + fn the_patched_jar_has_a_leading_zero_sha1() { + let repo = generate(); + let jar = &repo[&repo_path(VICTIM, VICTIM_VERSION, None, "jar")]; + assert!(sha1_hex(jar).starts_with('0'), "{}", sha1_hex(jar)); + let old = &repo[&repo_path(VICTIM, VICTIM_OLD, None, "jar")]; + assert_ne!(sha1_hex(old), sha1_hex(jar)); + } + + #[test] + fn module_metadata_describes_the_served_jar() { + let repo = generate(); + let jar = &repo[&repo_path(VICTIM, VICTIM_VERSION, None, "jar")]; + let module: serde_json::Value = + serde_json::from_slice(&repo[&repo_path(VICTIM, VICTIM_VERSION, None, "module")]) + .unwrap(); + let file = &module["variants"][1]["files"][0]; + assert_eq!(file["name"], "victim-1.10.0.jar"); + assert_eq!(file["size"], jar.len()); + assert_eq!(file["sha1"], sha1_hex(jar)); + assert_eq!(file["sha256"], sha256_hex(jar)); + assert_eq!(file["md5"], md5_hex(jar)); + let pom = String::from_utf8(repo[&repo_path(VICTIM, VICTIM_VERSION, None, "pom")].clone()) + .unwrap(); + assert!(pom.contains("published-with-gradle-metadata")); + assert!(pom + .contains("\n victim\n 1.10.0")); + let range = String::from_utf8( + repo[&repo_path(CONSUMER_RANGE, CONSUMER_VERSION, None, "pom")].clone(), + ) + .unwrap(); + assert!(range.contains("[1.9,1.11)")); + } + + #[test] + fn md5_matches_rfc1321_vectors() { + assert_eq!(md5_hex(b""), "d41d8cd98f00b204e9800998ecf8427e"); + assert_eq!(md5_hex(b"abc"), "900150983cd24fb0d6963f7d28e17f72"); + assert_eq!( + md5_hex( + b"12345678901234567890123456789012345678901234567890123456789012345678901234567890" + ), + "57edf4a22be3c955ac49da2e2107b67a" + ); + } + + #[test] + fn class_files_are_well_formed() { + let class = victim_class(VICTIM_VERSION, "patched"); + assert_eq!(&class[..8], &[0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 52]); + let text = victim_marker(VICTIM_VERSION, "patched"); + assert!(class.windows(text.len()).any(|w| w == text.as_bytes())); + assert_ne!(class, victim_class(VICTIM_VERSION, "pristine")); + assert!(buildlogic_class() + .windows(BUILDLOGIC_MARKER.len()) + .any(|w| w == BUILDLOGIC_MARKER.as_bytes())); + } + + #[test] + fn fake_central_serves_files_sidecars_and_overlays() { + let central = FakeCentral::start(); + let get = |path: &str| { + let url = format!("{}/{path}", central.uri()); + central.rt.block_on(async move { + let response = reqwest::get(url).await.unwrap(); + let status = response.status().as_u16(); + (status, response.bytes().await.unwrap().to_vec()) + }) + }; + let jar_path = repo_path(VICTIM, VICTIM_VERSION, None, "jar"); + let jar = generate()[&jar_path].clone(); + assert_eq!(get(&jar_path), (200, jar.clone())); + assert_eq!( + get(&format!("{jar_path}.sha1")), + (200, sha1_hex(&jar).into_bytes()) + ); + assert_eq!(get(&format!("{jar_path}.asc")).0, 200); + assert_eq!(get("com/socketfixture/nope/1/nope-1.jar").0, 404); + let url = central.serve_patched_jar("u-1", VICTIM, VICTIM_VERSION, b"patched"); + assert!(url.ends_with("/patched/u-1/victim-1.10.0.jar")); + assert_eq!( + get("patched/u-1/victim-1.10.0.jar"), + (200, b"patched".to_vec()) + ); + central.remove("patched/u-1/victim-1.10.0.jar"); + assert_eq!(get("patched/u-1/victim-1.10.0.jar").0, 404); + assert!(central.requests().contains(&format!("/{jar_path}"))); + } +} From 90cec6c70d203132913727307591d0a9dc386db5 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 2 Oct 2026 11:22:53 -0400 Subject: [PATCH 07/63] Scaffold the Gradle CI tiers around a jvm_tool matrix key The Gradle campaign lands its suites package by package, so CI needs the rows before the tests exist, without letting an empty leg pass forever. ci.yml: JVM legs carry `jvm_tool` (gradle | maven | sbt). One step picks the JDK from the runner image (JAVA_HOME__X64 / _arm64), falling back to setup-java, and decides whether Maven is needed: Maven legs, and Gradle legs whose filter selects a Maven-seeded test (gradle_vendor_*, multi-project); agent / hosted Gradle legs run without it. The PR tier is the lean table: ubuntu x {6.9.4/11, 7.6.6/17, 8.14.3/21, 9.8.0/21} x {agent + hosted, vendor + multi-project}, plus the existing windows 8.14.3 multi-project leg. A row's `suite` may list several binaries; `allow_empty` skips suites that have not landed and tolerates zero tests, and a Gradle leg without it that runs nothing fails. Probe reports are uploaded. gradle-compatibility.yml runs the full grid (3 OSes x 4 lines x 3 modes, fail-fast off, 60 min) plus JDK-ceiling, configuration-cache, Isolated Projects (recording only) and real-Central rows, path-filtered on PRs, nightly and on dispatch. It compiles its own binaries once per OS and documents the JDK ceilings per Gradle line; 9.8.0 is still current. ci-e2e-bundle.py learns multi-suite rows, `--suites` and a per-suite prefix guard: every #[ignore] test of a Gradle suite must start with gradle_agent_ / gradle_hosted_ / gradle_vendor_ / gradle_multi_project, the prefixes the rows filter on, or the bundle (and `--check`) fails. test_ci_gradle_prefixes.py covers the guard (including a stray name) and forces `allow_empty` off once every suite of a row has landed; test_ci_e2e_tiers.py pins the PR table, the jvm_tool steps and the grid expansion. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 150 ++++++++-- .github/workflows/gradle-compatibility.yml | 332 +++++++++++++++++++++ scripts/ci-e2e-bundle.py | 98 +++++- scripts/tests/test_ci_e2e_tiers.py | 147 ++++++++- scripts/tests/test_ci_gradle_prefixes.py | 140 +++++++++ 5 files changed, 825 insertions(+), 42 deletions(-) create mode 100644 .github/workflows/gradle-compatibility.yml create mode 100644 scripts/tests/test_ci_gradle_prefixes.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c164670e..7a9415ad5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1065,30 +1065,46 @@ jobs: # 3.9 (trusted checksums), 4.0 rc. Hosted also runs both sides of # the trusted-checksums floor: 3.9.3 (last release that ignores # the .mvn/checksums pin) and 3.9.4 (first that enforces it). - - {os: ubuntu-latest, suite: e2e_redirect_maven_build, maven: '3.6.3'} - - {os: ubuntu-latest, suite: e2e_redirect_maven_build, maven: '3.8.9'} - - {os: ubuntu-latest, suite: e2e_redirect_maven_build, maven: '3.9.3'} - - {os: ubuntu-latest, suite: e2e_redirect_maven_build, maven: '3.9.4'} - - {os: ubuntu-latest, suite: e2e_redirect_maven_build, maven: '3.9.16'} - - {os: ubuntu-latest, suite: e2e_redirect_maven_build, maven: '4.0.0-rc-6'} - - {os: macos-latest, suite: e2e_redirect_maven_build, maven: '3.9.16'} - - {os: ubuntu-latest, suite: e2e_vendor_maven_build, maven: '3.6.3'} - - {os: ubuntu-latest, suite: e2e_vendor_maven_build, maven: '3.8.9'} - - {os: ubuntu-latest, suite: e2e_vendor_maven_build, maven: '3.9.16'} - - {os: ubuntu-latest, suite: e2e_vendor_maven_build, maven: '4.0.0-rc-6'} - - {os: macos-latest, suite: e2e_vendor_maven_build, maven: '3.9.16'} - - {os: ubuntu-latest, suite: e2e_vendor_jvm_build, maven: '3.6.3', test_filter: '--ignored maven_reactor'} - - {os: ubuntu-latest, suite: e2e_vendor_jvm_build, maven: '3.8.9', test_filter: '--ignored maven_reactor'} - - {os: ubuntu-latest, suite: e2e_vendor_jvm_build, maven: '3.9.2', test_filter: '--ignored maven_reactor'} - - {os: ubuntu-latest, suite: e2e_vendor_jvm_build, maven: '3.9.16', test_filter: '--ignored maven_reactor'} - - {os: ubuntu-latest, suite: e2e_vendor_jvm_build, maven: '4.0.0-rc-6', test_filter: '--ignored maven_reactor'} - - {os: macos-latest, suite: e2e_vendor_jvm_build, maven: '3.9.16', test_filter: '--ignored maven_reactor'} - - {os: windows-latest, suite: e2e_vendor_jvm_build, maven: '3.9.16', test_filter: '--ignored maven_reactor'} - - {os: ubuntu-latest, suite: e2e_vendor_jvm_build, gradle: '6.9.4', java: '11', test_filter: '--ignored gradle_multi_project'} - - {os: ubuntu-latest, suite: e2e_vendor_jvm_build, gradle: '7.6.4', java: '17', test_filter: '--ignored gradle_multi_project'} - - {os: ubuntu-latest, suite: e2e_vendor_jvm_build, gradle: '8.14.3', java: '17', test_filter: '--ignored gradle_multi_project'} - - {os: ubuntu-latest, suite: e2e_vendor_jvm_build, gradle: '9.8.0', java: '17', test_filter: '--ignored gradle_multi_project'} - - {os: windows-latest, suite: e2e_vendor_jvm_build, gradle: '8.14.3', java: '17', test_filter: '--ignored gradle_multi_project'} + - {os: ubuntu-latest, suite: e2e_redirect_maven_build, jvm_tool: maven, maven: '3.6.3'} + - {os: ubuntu-latest, suite: e2e_redirect_maven_build, jvm_tool: maven, maven: '3.8.9'} + - {os: ubuntu-latest, suite: e2e_redirect_maven_build, jvm_tool: maven, maven: '3.9.3'} + - {os: ubuntu-latest, suite: e2e_redirect_maven_build, jvm_tool: maven, maven: '3.9.4'} + - {os: ubuntu-latest, suite: e2e_redirect_maven_build, jvm_tool: maven, maven: '3.9.16'} + - {os: ubuntu-latest, suite: e2e_redirect_maven_build, jvm_tool: maven, maven: '4.0.0-rc-6'} + - {os: macos-latest, suite: e2e_redirect_maven_build, jvm_tool: maven, maven: '3.9.16'} + - {os: ubuntu-latest, suite: e2e_vendor_maven_build, jvm_tool: maven, maven: '3.6.3'} + - {os: ubuntu-latest, suite: e2e_vendor_maven_build, jvm_tool: maven, maven: '3.8.9'} + - {os: ubuntu-latest, suite: e2e_vendor_maven_build, jvm_tool: maven, maven: '3.9.16'} + - {os: ubuntu-latest, suite: e2e_vendor_maven_build, jvm_tool: maven, maven: '4.0.0-rc-6'} + - {os: macos-latest, suite: e2e_vendor_maven_build, jvm_tool: maven, maven: '3.9.16'} + - {os: ubuntu-latest, suite: e2e_vendor_jvm_build, jvm_tool: maven, maven: '3.6.3', test_filter: '--ignored maven_reactor'} + - {os: ubuntu-latest, suite: e2e_vendor_jvm_build, jvm_tool: maven, maven: '3.8.9', test_filter: '--ignored maven_reactor'} + - {os: ubuntu-latest, suite: e2e_vendor_jvm_build, jvm_tool: maven, maven: '3.9.2', test_filter: '--ignored maven_reactor'} + - {os: ubuntu-latest, suite: e2e_vendor_jvm_build, jvm_tool: maven, maven: '3.9.16', test_filter: '--ignored maven_reactor'} + - {os: ubuntu-latest, suite: e2e_vendor_jvm_build, jvm_tool: maven, maven: '4.0.0-rc-6', test_filter: '--ignored maven_reactor'} + - {os: macos-latest, suite: e2e_vendor_jvm_build, jvm_tool: maven, maven: '3.9.16', test_filter: '--ignored maven_reactor'} + - {os: windows-latest, suite: e2e_vendor_jvm_build, jvm_tool: maven, maven: '3.9.16', test_filter: '--ignored maven_reactor'} + # Real-Gradle capstones, PR tier: one leg per Gradle line x {agent + + # hosted, vendor + multi-project} on ubuntu, each on its line's LTS + # JDK. Every other OS x line x mode cell (and the JDK-ceiling, + # configuration-cache, Isolated Projects and real-Central rows) runs + # in gradle-compatibility.yml. `suite` lists every binary the leg + # runs (space-separated); the libtest filters select by the prefix + # contract ci-e2e-bundle.py enforces. `allow_empty: 'true'` marks a + # leg whose owning suites have not all landed: unlanded suites are + # skipped and zero selected tests is not a failure (without it a + # Gradle leg that ran nothing fails). test_ci_gradle_prefixes.py + # makes the last package drop it. Maven is installed only where a + # selected test seeds through it (gradle_vendor_395, multi-project). + - {os: ubuntu-latest, suite: 'e2e_gradle_discovery_build e2e_gradle_agent_build e2e_redirect_gradle_build', jvm_tool: gradle, gradle: '6.9.4', java: '11', test_filter: '--ignored gradle_agent_ gradle_hosted_', allow_empty: 'true'} + - {os: ubuntu-latest, suite: 'e2e_vendor_gradle_build e2e_vendor_jvm_build', jvm_tool: gradle, gradle: '6.9.4', java: '11', test_filter: '--ignored gradle_vendor_ gradle_multi_project', allow_empty: 'true'} + - {os: ubuntu-latest, suite: 'e2e_gradle_discovery_build e2e_gradle_agent_build e2e_redirect_gradle_build', jvm_tool: gradle, gradle: '7.6.6', java: '17', test_filter: '--ignored gradle_agent_ gradle_hosted_', allow_empty: 'true'} + - {os: ubuntu-latest, suite: 'e2e_vendor_gradle_build e2e_vendor_jvm_build', jvm_tool: gradle, gradle: '7.6.6', java: '17', test_filter: '--ignored gradle_vendor_ gradle_multi_project', allow_empty: 'true'} + - {os: ubuntu-latest, suite: 'e2e_gradle_discovery_build e2e_gradle_agent_build e2e_redirect_gradle_build', jvm_tool: gradle, gradle: '8.14.3', java: '21', test_filter: '--ignored gradle_agent_ gradle_hosted_', allow_empty: 'true'} + - {os: ubuntu-latest, suite: 'e2e_vendor_gradle_build e2e_vendor_jvm_build', jvm_tool: gradle, gradle: '8.14.3', java: '21', test_filter: '--ignored gradle_vendor_ gradle_multi_project', allow_empty: 'true'} + - {os: ubuntu-latest, suite: 'e2e_gradle_discovery_build e2e_gradle_agent_build e2e_redirect_gradle_build', jvm_tool: gradle, gradle: '9.8.0', java: '21', test_filter: '--ignored gradle_agent_ gradle_hosted_', allow_empty: 'true'} + - {os: ubuntu-latest, suite: 'e2e_vendor_gradle_build e2e_vendor_jvm_build', jvm_tool: gradle, gradle: '9.8.0', java: '21', test_filter: '--ignored gradle_vendor_ gradle_multi_project', allow_empty: 'true'} + - {os: windows-latest, suite: e2e_vendor_jvm_build, jvm_tool: gradle, gradle: '8.14.3', java: '17', test_filter: '--ignored gradle_multi_project'} # Real .NET SDK capstones: hosted + vendored nuget, one leg per SDK # major (the suite pins the major through a sandbox global.json): # the oldest and newest here, 7-9 on ubuntu in e2e-full. @@ -1258,15 +1274,54 @@ jobs: php-version: '8.2' tools: composer:${{ matrix.composer }} - - name: Setup Java (Maven and Gradle legs) - if: matrix.maven != '' || matrix.gradle != '' + - name: Select the JVM toolchain (JVM legs) + id: jvm + if: matrix.jvm_tool != '' + # `jvm_tool` (gradle | maven | sbt) marks a JVM leg. The JDK comes + # from the runner image (JAVA_HOME__X64 / _arm64: 8, 11, 17 and 21 + # on every hosted OS) when it has the pinned feature release, else + # from setup-java below. Maven is needed by the Maven legs and by the + # Gradle legs whose filter selects a Maven-seeded test + # (gradle_vendor_*, which covers gradle_vendor_395, and the + # multi-project capstone); agent / hosted Gradle legs run without it. + shell: bash + env: + JVM_TOOL: ${{ matrix.jvm_tool }} + JAVA_FEATURE: ${{ matrix.java || '17' }} + TEST_FILTER: ${{ matrix.test_filter }} + run: | + set -euo pipefail + home='' + for arch in X64 arm64 ARM64; do + var="JAVA_HOME_${JAVA_FEATURE}_${arch}" + if [ -n "${!var:-}" ]; then home="${!var}"; break; fi + done + if [ -n "$home" ]; then + bin="$home/bin" + if [ "$RUNNER_OS" = Windows ]; then bin="$home\\bin"; fi + echo "JAVA_HOME=$home" >> "$GITHUB_ENV" + echo "$bin" >> "$GITHUB_PATH" + echo "runner-jdk=true" >> "$GITHUB_OUTPUT" + echo "JDK $JAVA_FEATURE from the runner image: $home" + else + echo "runner-jdk=false" >> "$GITHUB_OUTPUT" + fi + maven=false + case "$JVM_TOOL" in + maven) maven=true ;; + gradle) case " $TEST_FILTER " in *gradle_vendor_*|*gradle_multi_project*) maven=true ;; esac ;; + esac + echo "maven=$maven" >> "$GITHUB_OUTPUT" + + - name: Setup Java (JDK not on the runner image) + if: matrix.jvm_tool != '' && steps.jvm.outputs.runner-jdk != 'true' uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: ${{ matrix.java || '17' }} - name: Install Maven ${{ matrix.maven || '3.9.16' }} - if: matrix.maven != '' || matrix.gradle != '' + if: steps.jvm.outputs.maven == 'true' # Straight from the Apache archive (sha512-verified), so a leg gets # exactly the release it names rather than the runner's Maven. shell: bash @@ -1401,26 +1456,57 @@ jobs: SOCKET_PATCH_BUNDLER_E2E_VERSION: ${{ matrix.bundler }} SOCKET_PATCH_COMPOSER_E2E_REQUIRED: ${{ matrix.composer != '' && '1' || '' }} SOCKET_PATCH_COMPOSER_E2E_VERSION: ${{ matrix.composer }} - SOCKET_PATCH_MAVEN_E2E_REQUIRED: ${{ (matrix.maven != '' || matrix.gradle != '') && '1' || '' }} - SOCKET_PATCH_MAVEN_E2E_VERSION: ${{ matrix.maven || (matrix.gradle != '' && '3.9.16') || '' }} + SOCKET_PATCH_MAVEN_E2E_REQUIRED: ${{ steps.jvm.outputs.maven == 'true' && '1' || '' }} + SOCKET_PATCH_MAVEN_E2E_VERSION: ${{ steps.jvm.outputs.maven == 'true' && (matrix.maven || '3.9.16') || '' }} SOCKET_PATCH_GRADLE_E2E_REQUIRED: ${{ matrix.gradle != '' && '1' || '' }} SOCKET_PATCH_GRADLE_E2E_VERSION: ${{ matrix.gradle }} + SOCKET_PATCH_GRADLE_E2E_PROBE_DIR: ${{ matrix.gradle != '' && format('{0}/target/gradle-probe', github.workspace) || '' }} SOCKET_PATCH_DOTNET_E2E_REQUIRED: ${{ matrix.dotnet != '' && '1' || '' }} SOCKET_PATCH_DOTNET_E2E_VERSION: ${{ matrix.dotnet }} SOCKET_PATCH_DENO_E2E_REQUIRED: ${{ matrix.deno != '' && '1' || '' }} SOCKET_PATCH_DENO_E2E_VERSION: ${{ matrix.deno }} E2E_SUITE: ${{ matrix.suite }} E2E_TEST_FILTER: ${{ matrix.test_filter || '--ignored' }} + E2E_JVM_TOOL: ${{ matrix.jvm_tool }} + E2E_ALLOW_EMPTY: ${{ matrix.allow_empty }} shell: bash # Runs from the package root with CARGO_MANIFEST_DIR set, as - # `cargo test` would. + # `cargo test` would. `suite` may name several binaries; an + # `allow_empty` row skips the ones whose test file has not landed. + # A Gradle leg must run at least one test unless `allow_empty`. run: | + set -uo pipefail exe='' if [ "$RUNNER_OS" = Windows ]; then exe=.exe; fi cd crates/socket-patch-cli export CARGO_MANIFEST_DIR="$PWD" - # shellcheck disable=SC2086 # the filter is several libtest arguments - "../../target/e2e-bin/$E2E_SUITE$exe" $E2E_TEST_FILTER + status=0 + ran=0 + for suite in $E2E_SUITE; do + if [ "$E2E_ALLOW_EMPTY" = true ] && [ ! -f "tests/$suite.rs" ] && [ ! -f "tests/$suite/main.rs" ]; then + echo "::notice::$suite has not landed yet; skipped (allow_empty)" + continue + fi + log="../../target/e2e-$suite.log" + # shellcheck disable=SC2086 # the filter is several libtest arguments + "../../target/e2e-bin/$suite$exe" $E2E_TEST_FILTER 2>&1 | tee "$log" || status=1 + passed=$(sed -n 's/^test result: .* \([0-9][0-9]*\) passed;.*/\1/p' "$log" | head -n 1) + ran=$((ran + ${passed:-0})) + done + if [ "$status" = 0 ] && [ "$E2E_JVM_TOOL" = gradle ] && [ "$ran" = 0 ] && [ "$E2E_ALLOW_EMPTY" != true ]; then + echo "::error::the Gradle leg ran no test ($E2E_SUITE: $E2E_TEST_FILTER)" + status=1 + fi + exit "$status" + + - name: Upload the Gradle probe reports + if: always() && matrix.gradle != '' + uses: ./.github/actions/upload-artifact + with: + name: gradle-probe-pr-${{ matrix.os }}-${{ matrix.gradle }}-${{ strategy.job-index }} + path: target/gradle-probe/ + if-no-files-found: ignore + retention-days: 14 - name: Run vlt e2e tests if: matrix.vlt != '' diff --git a/.github/workflows/gradle-compatibility.yml b/.github/workflows/gradle-compatibility.yml new file mode 100644 index 000000000..3d4af11d5 --- /dev/null +++ b/.github/workflows/gradle-compatibility.yml @@ -0,0 +1,332 @@ +name: Gradle patch compatibility + +# The full real-Gradle grid: every hosted OS x every supported Gradle line x +# every socket-patch mode (agent, hosted, vendored), plus rows the PR tier +# never runs. ci.yml's `e2e` job keeps a lean ubuntu-only PR tier (one leg +# per Gradle line x {agent + hosted, vendor + multi-project}). +# +# Grid (`cells`, 36): {ubuntu, macos (arm64), windows} x +# 6.9.4 / JDK 11, 7.6.6 / JDK 17, 8.14.3 / JDK 21, 9.8.0 / JDK 21 +# x {agent, hosted, vendor}. +# Extra ubuntu rows (`extras`): +# * JDK ceilings — the newest JDK each line runs on: 6.9 <= 16, +# 7.6 <= 19, 8.14 <= 24 (9.x runs on 17-25; the grid's 21 is its LTS). +# * Configuration cache — 9.8.0 with --configuration-cache, hosted and +# vendor. +# * Isolated Projects — 9.8.0 with +# -Dorg.gradle.unsafe.isolated-projects=true, hosted; recording only +# (continue-on-error), the probe report is the deliverable. +# * Real Central — 8.14.3 against the real Maven Central (#511 derived +# maven-metadata.xml, #487 pgp-only verification entries). +# +# 9.8.0 is the current release on services.gradle.org (re-checked +# 2026-10-02; bump it here and in ci.yml together when a newer 9.x ships). +# 7.6.6 is the last 7.x. Every distribution is sha256-checked against +# services.gradle.org before use. +# +# Each mode runs its suites' `#[ignore]` tests by name prefix (the contract +# scripts/ci-e2e-bundle.py --check enforces): agent = e2e_gradle_discovery_build +# + e2e_gradle_agent_build (`gradle_agent_`), hosted = e2e_redirect_gradle_build +# (`gradle_hosted_`), vendor = e2e_vendor_gradle_build + e2e_vendor_jvm_build +# (`gradle_vendor_`, `gradle_multi_project`). A suite whose test file has not +# landed yet is skipped; a cell with a landed suite that runs zero tests fails. +# +# Unlike ci.yml's e2e-build (scripts/ci-e2e-bundle.py reads ci.yml's rows), +# `build` compiles exactly the Gradle suites once per OS and the cells +# download them. Every cell uploads its JSON probe reports +# (gradle_build_common::probe_report: Gradle / JDK version, resolved jar path +# and sha256, hash-dir naming, refresh / RO-cache / transform canaries). + +on: + pull_request: + paths: + - '.github/workflows/gradle-compatibility.yml' + - 'scripts/ci-e2e-bundle.py' + - 'Cargo.lock' + - 'Cargo.toml' + - 'crates/*/Cargo.toml' + - 'crates/socket-patch-core/src/gradle/**' + - 'crates/socket-patch-core/src/crawlers/jvm_cache.rs' + - 'crates/socket-patch-core/src/crawlers/maven_crawler.rs' + - 'crates/socket-patch-core/src/crawlers/gradle_cache.rs' + - 'crates/socket-patch-core/src/vendor/jvm/**' + - 'crates/socket-patch-core/src/vendor/maven_repo.rs' + - 'crates/socket-patch-core/src/vendor/redownload.rs' + - 'crates/socket-patch-core/src/patch/redirect/gradle.rs' + - 'crates/socket-patch-core/src/patch/redirect/*.gradle' + - 'crates/socket-patch-core/src/patch/jvm_jar.rs' + - 'crates/socket-patch-cli/tests/gradle_*' + - 'crates/socket-patch-cli/tests/gradle_*/**' + - 'crates/socket-patch-cli/tests/e2e_*gradle*' + - 'crates/socket-patch-cli/tests/e2e_*gradle*/**' + - 'crates/socket-patch-cli/tests/e2e_vendor_jvm_build.rs' + - 'crates/socket-patch-cli/tests/jvm_fixture_repo/**' + - 'crates/socket-patch-cli/tests/hosted_maven_common/**' + schedule: + # Nightly, off ci.yml's 05:41. + - cron: '17 4 * * *' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: gradle-compat-${{ github.event.pull_request.number || github.ref }}${{ github.event_name == 'schedule' && '-nightly' || '' }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + SOCKET_NO_CONFIG: '1' + SOCKET_NO_UPDATE_CHECK: '1' + +jobs: + build: + name: build ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + env: + CARGO_PROFILE_DEV_DEBUG: '0' + CARGO_INCREMENTAL: '0' + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Install Rust + run: rustup show + + - name: Cache cargo + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + key: gradle-compat + save-if: ${{ github.ref == 'refs/heads/main' }} + + - name: Check the Gradle test-name prefixes + run: python3 scripts/ci-e2e-bundle.py --check + + - name: Compile the CLI and the landed Gradle suites + id: compile + shell: bash + run: | + set -euo pipefail + suites='' + targets=() + for suite in e2e_gradle_discovery_build e2e_gradle_agent_build e2e_redirect_gradle_build e2e_vendor_gradle_build e2e_vendor_jvm_build; do + if [ -f "crates/socket-patch-cli/tests/$suite.rs" ] || [ -f "crates/socket-patch-cli/tests/$suite/main.rs" ]; then + suites="$suites $suite" + targets+=(--test "$suite") + fi + done + echo "suites=$suites" >> "$GITHUB_OUTPUT" + cargo build --locked -p socket-patch-cli --bin socket-patch + cargo test --locked -p socket-patch-cli --no-run --message-format=json-render-diagnostics "${targets[@]}" > target-build.json + + - name: Bundle the binaries the cells run + shell: bash + env: + BUNDLE_OS: ${{ matrix.os }} + BUNDLE_SUITES: ${{ steps.compile.outputs.suites }} + run: | + # shellcheck disable=SC2086 # the suite list is several arguments + python3 scripts/ci-e2e-bundle.py --os "$BUNDLE_OS" --cargo-json target-build.json --dest target/gradle-bin --suites $BUNDLE_SUITES + + - uses: ./.github/actions/upload-artifact + with: + name: gradle-bin-${{ matrix.os }} + path: target/gradle-bin/ + if-no-files-found: error + retention-days: 3 + + cells: + name: gradle ${{ matrix.gradle }} / jdk ${{ matrix.java }} / ${{ matrix.mode }} / ${{ matrix.os }} + needs: [build] + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + gradle: ['6.9.4', '7.6.6', '8.14.3', '9.8.0'] + mode: [agent, hosted, vendor] + # Each line's LTS JDK (extends every cell of that line); the empty + # keys are the `extras` knobs the shared steps read. + include: + - {gradle: '6.9.4', java: '11', label: '', gradle_args: '', real_central: '', test_filter: ''} + - {gradle: '7.6.6', java: '17', label: '', gradle_args: '', real_central: '', test_filter: ''} + - {gradle: '8.14.3', java: '21', label: '', gradle_args: '', real_central: '', test_filter: ''} + - {gradle: '9.8.0', java: '21', label: '', gradle_args: '', real_central: '', test_filter: ''} + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + steps: &cell-steps + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Download the Gradle suites + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: gradle-bin-${{ matrix.os }}* + merge-multiple: true + path: target/gradle-bin + + - name: Stage the CLI where the test binaries expect it + # `CARGO_BIN_EXE_socket-patch` was baked in at compile time as + # /target/debug/socket-patch. + shell: bash + run: | + set -euo pipefail + exe='' + if [ "$RUNNER_OS" = Windows ]; then exe=.exe; fi + chmod +x target/gradle-bin/* || true + mkdir -p target/debug target/tmp target/gradle-probe + cp "target/gradle-bin/socket-patch$exe" "target/debug/socket-patch$exe" + + - name: Select the JDK + id: jdk + # The runner image's JDK when it has the feature release (8, 11, 17 + # and 21 on every hosted OS), else setup-java (the ceiling rows). + shell: bash + env: + JAVA_FEATURE: ${{ matrix.java }} + run: | + set -euo pipefail + home='' + for arch in X64 arm64 ARM64; do + var="JAVA_HOME_${JAVA_FEATURE}_${arch}" + if [ -n "${!var:-}" ]; then home="${!var}"; break; fi + done + if [ -n "$home" ]; then + bin="$home/bin" + if [ "$RUNNER_OS" = Windows ]; then bin="$home\\bin"; fi + echo "JAVA_HOME=$home" >> "$GITHUB_ENV" + echo "$bin" >> "$GITHUB_PATH" + echo "runner-jdk=true" >> "$GITHUB_OUTPUT" + else + echo "runner-jdk=false" >> "$GITHUB_OUTPUT" + fi + + - name: Setup Java ${{ matrix.java }} + if: steps.jdk.outputs.runner-jdk != 'true' + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + with: + distribution: temurin + java-version: ${{ matrix.java }} + + - name: Install Maven 3.9.16 (vendor cells) + # gradle_vendor_395 and the multi-project capstone seed through Maven. + if: matrix.mode == 'vendor' + shell: bash + env: + MAVEN_VERSION: '3.9.16' + run: | + url="https://archive.apache.org/dist/maven/maven-3/${MAVEN_VERSION}/binaries/apache-maven-${MAVEN_VERSION}-bin.tar.gz" + curl -fsSL --retry 3 "$url" -o "$RUNNER_TEMP/maven.tgz" + curl -fsSL --retry 3 "$url.sha512" -o "$RUNNER_TEMP/maven.sha512" + python -c 'import hashlib, pathlib, os; p=pathlib.Path(os.environ["RUNNER_TEMP"]); assert hashlib.sha512((p/"maven.tgz").read_bytes()).hexdigest() == (p/"maven.sha512").read_text().split()[0]' + python -m tarfile -e "$RUNNER_TEMP/maven.tgz" "$RUNNER_TEMP" + launcher="$RUNNER_TEMP/apache-maven-${MAVEN_VERSION}/bin/mvn" + if [ "$RUNNER_OS" = Windows ]; then launcher="${launcher}.cmd"; fi + { + echo "SOCKET_PATCH_MAVEN_E2E_MVN=$launcher" + echo "SOCKET_PATCH_MAVEN_E2E_VERSION=$MAVEN_VERSION" + echo "SOCKET_PATCH_MAVEN_E2E_REQUIRED=1" + } >> "$GITHUB_ENV" + + - name: Install Gradle ${{ matrix.gradle }} + shell: bash + env: + GRADLE_VERSION: ${{ matrix.gradle }} + run: | + url="https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip" + curl -fsSL --retry 3 "$url" -o "$RUNNER_TEMP/gradle.zip" + curl -fsSL --retry 3 "$url.sha256" -o "$RUNNER_TEMP/gradle.sha256" + python -c 'import hashlib, pathlib, os; p=pathlib.Path(os.environ["RUNNER_TEMP"]); assert hashlib.sha256((p/"gradle.zip").read_bytes()).hexdigest() == (p/"gradle.sha256").read_text().strip()' + unzip -q "$RUNNER_TEMP/gradle.zip" -d "$RUNNER_TEMP" + launcher="$RUNNER_TEMP/gradle-${GRADLE_VERSION}/bin/gradle" + if [ "$RUNNER_OS" = Windows ]; then launcher="${launcher}.bat"; fi + echo "SOCKET_PATCH_GRADLE_E2E_GRADLE=$launcher" >> "$GITHUB_ENV" + + - name: Run the ${{ matrix.mode }} suites + shell: bash + env: + SOCKET_PATCH_GRADLE_E2E_REQUIRED: '1' + SOCKET_PATCH_GRADLE_E2E_VERSION: ${{ matrix.gradle }} + SOCKET_PATCH_GRADLE_E2E_ARGS: ${{ matrix.gradle_args }} + SOCKET_PATCH_GRADLE_E2E_REAL_CENTRAL: ${{ matrix.real_central }} + SOCKET_PATCH_GRADLE_E2E_PROBE_DIR: ${{ github.workspace }}/target/gradle-probe + CELL_MODE: ${{ matrix.mode }} + CELL_FILTER: ${{ matrix.test_filter }} + run: | + set -uo pipefail + exe='' + if [ "$RUNNER_OS" = Windows ]; then exe=.exe; fi + case "$CELL_MODE" in + agent) suites='e2e_gradle_discovery_build e2e_gradle_agent_build'; filter='gradle_agent_' ;; + hosted) suites='e2e_redirect_gradle_build'; filter='gradle_hosted_' ;; + vendor) suites='e2e_vendor_gradle_build e2e_vendor_jvm_build'; filter='gradle_vendor_ gradle_multi_project' ;; + *) echo "::error::unknown mode $CELL_MODE"; exit 1 ;; + esac + if [ -n "$CELL_FILTER" ]; then filter="$CELL_FILTER"; fi + cd crates/socket-patch-cli + export CARGO_MANIFEST_DIR="$PWD" + status=0 + ran=0 + landed=0 + for suite in $suites; do + if [ ! -f "tests/$suite.rs" ] && [ ! -f "tests/$suite/main.rs" ]; then + echo "::notice::$suite has not landed yet; skipped" + continue + fi + landed=1 + log="../../target/gradle-$suite.log" + # shellcheck disable=SC2086 # the filter is several libtest arguments + "../../target/gradle-bin/$suite$exe" --ignored --nocapture $filter 2>&1 | tee "$log" || status=1 + passed=$(sed -n 's/^test result: .* \([0-9][0-9]*\) passed;.*/\1/p' "$log" | head -n 1) + ran=$((ran + ${passed:-0})) + done + if [ "$status" = 0 ] && [ "$landed" = 1 ] && [ "$ran" = 0 ]; then + echo "::error::the $CELL_MODE cell ran no test ($suites: $filter)" + status=1 + fi + exit "$status" + + - name: Upload the probe reports + if: always() + uses: ./.github/actions/upload-artifact + with: + name: gradle-probe-${{ matrix.os }}-${{ matrix.gradle }}-jdk${{ matrix.java }}-${{ matrix.mode }}${{ matrix.label && format('-{0}', matrix.label) || '' }} + path: target/gradle-probe/ + if-no-files-found: ignore + retention-days: 30 + + extras: + name: gradle ${{ matrix.gradle }} / jdk ${{ matrix.java }} / ${{ matrix.mode }} / ${{ matrix.label }} + needs: [build] + strategy: + fail-fast: false + matrix: + include: + # JDK ceilings. + - {os: ubuntu-latest, gradle: '6.9.4', java: '16', mode: agent, label: jdk-ceiling} + - {os: ubuntu-latest, gradle: '6.9.4', java: '16', mode: hosted, label: jdk-ceiling} + - {os: ubuntu-latest, gradle: '6.9.4', java: '16', mode: vendor, label: jdk-ceiling} + - {os: ubuntu-latest, gradle: '7.6.6', java: '19', mode: agent, label: jdk-ceiling} + - {os: ubuntu-latest, gradle: '7.6.6', java: '19', mode: hosted, label: jdk-ceiling} + - {os: ubuntu-latest, gradle: '7.6.6', java: '19', mode: vendor, label: jdk-ceiling} + - {os: ubuntu-latest, gradle: '8.14.3', java: '24', mode: agent, label: jdk-ceiling} + - {os: ubuntu-latest, gradle: '8.14.3', java: '24', mode: hosted, label: jdk-ceiling} + - {os: ubuntu-latest, gradle: '8.14.3', java: '24', mode: vendor, label: jdk-ceiling} + # Configuration cache. + - {os: ubuntu-latest, gradle: '9.8.0', java: '21', mode: hosted, label: configuration-cache, gradle_args: '--configuration-cache'} + - {os: ubuntu-latest, gradle: '9.8.0', java: '21', mode: vendor, label: configuration-cache, gradle_args: '--configuration-cache'} + # Isolated Projects (recording only). + - {os: ubuntu-latest, gradle: '9.8.0', java: '21', mode: hosted, label: isolated-projects, gradle_args: '-Dorg.gradle.unsafe.isolated-projects=true', record_only: 'true'} + # The real Maven Central (#511, #487). + - {os: ubuntu-latest, gradle: '8.14.3', java: '21', mode: vendor, label: real-central, real_central: '1', test_filter: 'gradle_vendor_511 gradle_vendor_487'} + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + continue-on-error: ${{ matrix.record_only == 'true' }} + steps: *cell-steps diff --git a/scripts/ci-e2e-bundle.py b/scripts/ci-e2e-bundle.py index 8212429a5..d469907f2 100644 --- a/scripts/ci-e2e-bundle.py +++ b/scripts/ci-e2e-bundle.py @@ -5,17 +5,29 @@ (`cargo test --no-run --message-format=json`); this picks out the binaries the `e2e` and `e2e-full` rows name for that OS, plus the suites every cargo-vex-matrix leg runs, so the legs download them instead of compiling. +A row's `suite` may list several binaries (space-separated); a row marked +`allow_empty: 'true'` names suites that have not landed yet, which are +skipped until their test file exists. `--suites` bundles an explicit list +instead (gradle-compatibility.yml's own build). + +Every run also enforces the per-suite test-name prefix contract +(`PREFIX_GUARDS`): each `#[ignore]` test of a guarded suite must match its +suite's pattern, because the CI rows select those tests by name prefix and a +test outside every prefix would silently run nowhere. `--check` runs only +the guard. """ import argparse import importlib.util import json +import re import shutil import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] CI = ROOT / ".github" / "workflows" / "ci.yml" +TESTS = ROOT / "crates" / "socket-patch-cli" / "tests" MATRIX_JOBS = ("e2e", "e2e-full") # The binaries cargo-vex-matrix and cargo-vex-matrix-full run on every OS. CARGO_VEX_SUITES = ( @@ -27,6 +39,62 @@ ) +# The real-Gradle suites (one per package of the Gradle campaign) and the +# libtest prefixes their CI rows filter on. sbt registers its own suites here. +GRADLE_SUITES = ( + "e2e_gradle_discovery_build", + "e2e_gradle_agent_build", + "e2e_redirect_gradle_build", + "e2e_vendor_gradle_build", +) +GRADLE_PREFIX = re.compile(r"^(?:gradle_(?:agent|hosted|vendor)_|gradle_multi_project)") +PREFIX_GUARDS = {suite: GRADLE_PREFIX for suite in GRADLE_SUITES} + +IGNORED_FN = re.compile( + r"#\[\s*ignore\b(?:\s*=\s*\"(?:[^\"\\]|\\.)*\")?\s*\]" + r"(?:\s*(?://[^\n]*|#\[[^\]]*\]))*" + r"\s*(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?fn\s+([A-Za-z_][A-Za-z0-9_]*)", +) + + +def ignored_tests(text): + """The names of the `#[ignore]` functions in a Rust source text.""" + return IGNORED_FN.findall(text) + + +def suite_files(suite, tests=TESTS): + """A test target's source files: `.rs` or `/**/*.rs`.""" + single = tests / f"{suite}.rs" + files = [single] if single.is_file() else [] + folder = tests / suite + if folder.is_dir(): + files += sorted(folder.rglob("*.rs")) + return files + + +def landed(suite, tests=TESTS): + return (tests / f"{suite}.rs").is_file() or (tests / suite / "main.rs").is_file() + + +def prefix_violations(tests=TESTS, guards=None): + """[(suite, file, test)] for every guarded `#[ignore]` test outside its prefixes.""" + found = [] + for suite, pattern in (PREFIX_GUARDS if guards is None else guards).items(): + for path in suite_files(suite, tests): + for name in ignored_tests(path.read_text(encoding="utf-8")): + if not pattern.match(name): + found.append((suite, path.relative_to(tests).as_posix(), name)) + return found + + +def row_suites(row, tests=TESTS): + """The binaries a row runs; an `allow_empty` row skips unlanded suites.""" + suites = row["suite"].split() + if row.get("allow_empty") == "true": + suites = [s for s in suites if landed(s, tests)] + return suites + + def load_reader(): path = ROOT / "scripts" / "tests" / "test_ci_vlt_rows.py" spec = importlib.util.spec_from_file_location("ci_rows", path) @@ -42,7 +110,7 @@ def suites_for(os_name, text=None): for job in MATRIX_JOBS: for row in reader.matrix_include(jobs[job]): if row["os"] == os_name: - suites.add(row["suite"]) + suites.update(row_suites(row)) return sorted(suites) @@ -58,15 +126,35 @@ def executables(cargo_json): return found +def check_prefixes(): + violations = prefix_violations() + for suite, path, name in violations: + print(f"ci-e2e-bundle: {path}: #[ignore] test `{name}` matches none of {suite}'s CI prefixes " + f"({PREFIX_GUARDS[suite].pattern})", file=sys.stderr) + return not violations + + def main(argv=None): ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) - ap.add_argument("--os", required=True, help="the runner label the rows use, e.g. ubuntu-latest") - ap.add_argument("--cargo-json", required=True, type=Path) - ap.add_argument("--dest", required=True, type=Path) + ap.add_argument("--os", help="the runner label the rows use, e.g. ubuntu-latest") + ap.add_argument("--cargo-json", type=Path) + ap.add_argument("--dest", type=Path) + ap.add_argument("--suites", nargs="*", help="bundle exactly these (landed) suites instead of the rows'") + ap.add_argument("--check", action="store_true", help="only run the test-name prefix guard") args = ap.parse_args(argv) + if not check_prefixes(): + return 1 + if args.check: + print(f"ci-e2e-bundle: prefix guard ok ({', '.join(PREFIX_GUARDS)})") + return 0 + if not (args.os and args.cargo_json and args.dest): + ap.error("--os, --cargo-json and --dest are required unless --check") exe = ".exe" if sys.platform == "win32" else "" built = executables(args.cargo_json.read_text(encoding="utf-8")) - wanted = suites_for(args.os) + if args.suites is not None: + wanted = sorted(s for s in args.suites if landed(s)) + else: + wanted = suites_for(args.os) missing = [s for s in wanted if s not in built] if missing: print(f"ci-e2e-bundle: no test binary for {missing}", file=sys.stderr) diff --git a/scripts/tests/test_ci_e2e_tiers.py b/scripts/tests/test_ci_e2e_tiers.py index 360dc2ba1..221d03d2d 100644 --- a/scripts/tests/test_ci_e2e_tiers.py +++ b/scripts/tests/test_ci_e2e_tiers.py @@ -40,12 +40,14 @@ def job_text(job): class Tiers(unittest.TestCase): def test_every_row_is_a_test_target(self): + # `suite` may list several binaries; an `allow_empty` row may name + # suites that have not landed yet (test_ci_gradle_prefixes.py). for job in ("e2e", "e2e-full"): for row in rows(job): - with self.subTest(job=job, row=row): - suite = row["suite"] - self.assertTrue((TESTS / f"{suite}.rs").is_file() or (TESTS / suite / "main.rs").is_file(), - f"no test target {suite}") + for suite in bundle.row_suites(row): + with self.subTest(job=job, row=row, suite=suite): + self.assertTrue((TESTS / f"{suite}.rs").is_file() or (TESTS / suite / "main.rs").is_file(), + f"no test target {suite}") def test_full_rows_only_add_releases_to_pr_suites(self): pr = {(r["suite"], r["os"], r.get("test_filter", "")) for r in rows("e2e")} @@ -98,7 +100,8 @@ def test_bundle_covers_every_os(self): for job in ("e2e", "e2e-full"): for row in rows(job): if row["os"] == os_name: - self.assertIn(row["suite"], suites) + for suite in bundle.row_suites(row): + self.assertIn(suite, suites) def test_bundle_reads_cargo_json(self): json_lines = "\n".join([ @@ -114,6 +117,140 @@ def test_bundle_reads_cargo_json(self): {"e2e_vlt": "/t/deps/e2e_vlt-abc"}) +GRADLE_COMPAT = ROOT / ".github" / "workflows" / "gradle-compatibility.yml" +GRADLE_LINES = {"6.9.4": "11", "7.6.6": "17", "8.14.3": "21", "9.8.0": "21"} +AGENT_HOSTED = ("e2e_gradle_discovery_build e2e_gradle_agent_build e2e_redirect_gradle_build", + "--ignored gradle_agent_ gradle_hosted_") +VENDOR = ("e2e_vendor_gradle_build e2e_vendor_jvm_build", "--ignored gradle_vendor_ gradle_multi_project") + + +def matrix_axes(job_lines): + """`{axis: [values]}` of a job's flow-list matrix axes (`os: [a, b]`).""" + axes = {} + for line in job_lines: + match = re.match(r"^ ([a-z_]+): \[(.*)\]\s*$", rows_mod.strip_comment(line)) + if match: + axes[match.group(1)] = [rows_mod.scalar(v) for v in rows_mod.split_top(match.group(2))] + return axes + + +def expand(job_lines): + """GitHub's matrix expansion for axes + an `include` that only extends.""" + axes = matrix_axes(job_lines) + names = list(axes) + cells = [dict(zip(names, combo)) for combo in itertools.product(*(axes[n] for n in names))] + for extra in rows_mod.matrix_include(job_lines): + matched = [c for c in cells if all(c.get(k, v) == v for k, v in extra.items() if k in names)] + assert matched, f"include {extra} would add a cell" + for cell in matched: + for key, value in extra.items(): + cell.setdefault(key, value) + return cells + + +class GradleRows(unittest.TestCase): + """The PR-tier Gradle rows are the lean table the campaign decided on, the + JVM-tool steps key on `jvm_tool`, and gradle-compatibility.yml expands to + the full grid.""" + + def test_pr_rows_are_the_lean_table(self): + gradle = [r for r in rows("e2e") if r.get("jvm_tool") == "gradle"] + want = [] + for line, java in GRADLE_LINES.items(): + for suite, test_filter in (AGENT_HOSTED, VENDOR): + want.append({"os": "ubuntu-latest", "suite": suite, "jvm_tool": "gradle", "gradle": line, + "java": java, "test_filter": test_filter, "allow_empty": "true"}) + want.append({"os": "windows-latest", "suite": "e2e_vendor_jvm_build", "jvm_tool": "gradle", + "gradle": "8.14.3", "java": "17", "test_filter": "--ignored gradle_multi_project"}) + self.assertEqual(len(gradle), 9) + self.assertEqual(sorted(map(str, gradle)), sorted(map(str, want))) + self.assertFalse([r for r in rows("e2e-full") if "gradle" in r or "jvm_tool" in r]) + + def test_jvm_tool_marks_every_jvm_row(self): + for job in ("e2e", "e2e-full"): + for row in rows(job): + with self.subTest(row=row): + if "gradle" in row: + self.assertEqual(row.get("jvm_tool"), "gradle") + elif "maven" in row: + self.assertEqual(row.get("jvm_tool"), "maven") + else: + self.assertNotIn("jvm_tool", row) + self.assertIn(row.get("jvm_tool", "gradle"), ("gradle", "maven", "sbt")) + + def test_steps_key_on_jvm_tool_and_maven_only_where_seeded(self): + steps = dict(rows_mod.steps(JOBS["e2e"])) + select = steps["Select the JVM toolchain (JVM legs)"] + self.assertIn("if: matrix.jvm_tool != ''", select) + self.assertIn('var="JAVA_HOME_${JAVA_FEATURE}_${arch}"', select) + self.assertIn("maven) maven=true ;;", select) + self.assertIn("gradle) case \" $TEST_FILTER \" in *gradle_vendor_*|*gradle_multi_project*) maven=true ;; esac ;;", + select) + self.assertIn("if: matrix.jvm_tool != '' && steps.jvm.outputs.runner-jdk != 'true'", + steps["Setup Java (JDK not on the runner image)"]) + self.assertIn("if: steps.jvm.outputs.maven == 'true'", steps["Install Maven ${{ matrix.maven || '3.9.16' }}"]) + run = steps["Run e2e tests"] + self.assertIn("SOCKET_PATCH_MAVEN_E2E_REQUIRED: ${{ steps.jvm.outputs.maven == 'true' && '1' || '' }}", run) + self.assertIn("SOCKET_PATCH_MAVEN_E2E_VERSION: ${{ steps.jvm.outputs.maven == 'true' && " + "(matrix.maven || '3.9.16') || '' }}", run) + self.assertNotIn("matrix.gradle != '') && '1'", run) + + def test_maven_seeding_follows_the_filter(self): + def needs_maven(row): + if row.get("jvm_tool") == "maven": + return True + words = row.get("test_filter", "").split() + return row.get("jvm_tool") == "gradle" and any( + w.startswith("gradle_vendor_") or w.startswith("gradle_multi_project") for w in words) + gradle = [r for r in rows("e2e") if r.get("jvm_tool") == "gradle"] + self.assertEqual(sum(needs_maven(r) for r in gradle), 5, "the vendor legs + the windows multi-project leg") + for row in gradle: + self.assertEqual(needs_maven(row), "gradle_hosted_" not in row["test_filter"], row) + + def test_compat_grid_expands_to_36_cells_plus_extras(self): + compat = rows_mod.jobs(GRADLE_COMPAT.read_text(encoding="utf-8")) + cells = expand(compat["cells"]) + self.assertEqual(len(cells), 36) + self.assertEqual({(c["os"], c["gradle"], c["java"], c["mode"]) for c in cells}, + {(o, g, j, m) for o in ("ubuntu-latest", "macos-latest", "windows-latest") + for g, j in GRADLE_LINES.items() for m in ("agent", "hosted", "vendor")}) + extras = rows_mod.matrix_include(compat["extras"]) + self.assertEqual(len(extras), 13) + labels = {} + for row in extras: + labels.setdefault(row["label"], []).append(row) + self.assertEqual(row["os"], "ubuntu-latest") + ceilings = {(r["gradle"], r["java"]) for r in labels["jdk-ceiling"]} + self.assertEqual(ceilings, {("6.9.4", "16"), ("7.6.6", "19"), ("8.14.3", "24")}) + self.assertEqual(len(labels["jdk-ceiling"]), 9) + self.assertEqual({(r["gradle"], r["mode"]) for r in labels["configuration-cache"]}, + {("9.8.0", "hosted"), ("9.8.0", "vendor")}) + self.assertEqual([(r["gradle"], r["mode"], r.get("record_only")) for r in labels["isolated-projects"]], + [("9.8.0", "hosted", "true")]) + self.assertEqual([(r["gradle"], r["real_central"]) for r in labels["real-central"]], [("8.14.3", "1")]) + self.assertEqual(set(GRADLE_LINES), {r["gradle"] for r in rows("e2e") if r.get("jvm_tool") == "gradle"}, + "both tiers run the same Gradle lines") + + def test_compat_workflow_builds_its_own_binaries(self): + text = GRADLE_COMPAT.read_text(encoding="utf-8") + compat = rows_mod.jobs(text) + self.assertIn("fail-fast: false", "\n".join(compat["cells"])) + self.assertIn("timeout-minutes: 60", "\n".join(compat["cells"])) + self.assertIn("--suites $BUNDLE_SUITES", "\n".join(compat["build"])) + self.assertIn("python3 scripts/ci-e2e-bundle.py --check", "\n".join(compat["build"])) + self.assertNotIn("e2e-bin", text, "the grid never reuses ci.yml's bundle") + for trigger in ("pull_request:", "schedule:", "workflow_dispatch:"): + self.assertIn(trigger, text) + for path in ("crates/socket-patch-core/src/gradle/**", "crates/socket-patch-core/src/crawlers/gradle_cache.rs", + "crates/socket-patch-core/src/vendor/jvm/**", "crates/socket-patch-core/src/patch/jvm_jar.rs", + "crates/socket-patch-cli/tests/jvm_fixture_repo/**", "crates/socket-patch-cli/tests/e2e_*gradle*"): + self.assertIn(f"'{path}'", text) + self.assertIn("6.9 <= 16", text) + self.assertIn("7.6 <= 19", text) + self.assertIn("8.14 <= 24", text) + self.assertIn("gradle-probe", text) + + class PdmCapstone(unittest.TestCase): job = rows_mod.jobs(PDM.read_text(encoding="utf-8"))["capstone"] diff --git a/scripts/tests/test_ci_gradle_prefixes.py b/scripts/tests/test_ci_gradle_prefixes.py new file mode 100644 index 000000000..ec5b031b8 --- /dev/null +++ b/scripts/tests/test_ci_gradle_prefixes.py @@ -0,0 +1,140 @@ +"""The Gradle CI test-name contract: every `#[ignore]` test of a real-Gradle +suite starts with a prefix some CI row filters on (ci-e2e-bundle.py's prefix +guard), the guard really rejects a stray name, and an `allow_empty` row only +keeps its allowance while one of its suites has not landed.""" + +import importlib.util +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).parents[2] +CI = ROOT / ".github" / "workflows" / "ci.yml" +COMPAT = ROOT / ".github" / "workflows" / "gradle-compatibility.yml" + + +def load(name, path): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +rows_mod = load("ci_rows", Path(__file__).parent / "test_ci_vlt_rows.py") +bundle = load("ci_e2e_bundle", ROOT / "scripts" / "ci-e2e-bundle.py") + +SUITE = """ +#[test] +#[ignore = "real Gradle; run with --ignored"] +fn gradle_agent_349_applies_every_copy() {} + +#[test] +#[ignore] +// a comment between the attributes and the fn +#[serial_test::serial] +fn gradle_hosted_settings_plugins_block_no_throw() {} + +#[ignore = "a \\"quoted\\" reason"] +#[test] +pub async fn gradle_vendor_395_composed_transaction() {} + +#[test] +#[ignore] +fn gradle_multi_project_fake_central_mirror_smoke_both_dsls() {} + +#[test] +fn unguarded_but_not_ignored() {} +""" + + +class PrefixGuard(unittest.TestCase): + def test_reads_ignored_tests_through_attributes_and_comments(self): + self.assertEqual(bundle.ignored_tests(SUITE), [ + "gradle_agent_349_applies_every_copy", + "gradle_hosted_settings_plugins_block_no_throw", + "gradle_vendor_395_composed_transaction", + "gradle_multi_project_fake_central_mirror_smoke_both_dsls", + ]) + + def test_every_gradle_suite_is_guarded(self): + self.assertEqual(set(bundle.PREFIX_GUARDS), set(bundle.GRADLE_SUITES)) + for name in ("gradle_agent_x", "gradle_hosted_x", "gradle_vendor_x", "gradle_multi_project_x", + "gradle_agent_cache_semantics_canaries"): + self.assertTrue(bundle.GRADLE_PREFIX.match(name), name) + for name in ("gradle_agentx", "agent_gradle_x", "gradle_sbt_x", "gradle_vex_x", "x_gradle_agent_"): + self.assertFalse(bundle.GRADLE_PREFIX.match(name), name) + + def test_conforming_suites_pass_in_both_layouts(self): + with tempfile.TemporaryDirectory() as tmp: + tests = Path(tmp) + (tests / "e2e_gradle_agent_build.rs").write_text(SUITE, encoding="utf-8") + (tests / "e2e_vendor_gradle_build").mkdir() + (tests / "e2e_vendor_gradle_build" / "main.rs").write_text(SUITE, encoding="utf-8") + (tests / "e2e_vendor_gradle_build" / "cells.rs").write_text(SUITE, encoding="utf-8") + self.assertEqual(bundle.prefix_violations(tests), []) + self.assertTrue(bundle.landed("e2e_vendor_gradle_build", tests)) + self.assertFalse(bundle.landed("e2e_redirect_gradle_build", tests)) + + def test_negative_a_stray_ignored_test_is_reported(self): + with tempfile.TemporaryDirectory() as tmp: + tests = Path(tmp) + (tests / "e2e_redirect_gradle_build").mkdir() + (tests / "e2e_redirect_gradle_build" / "main.rs").write_text(SUITE, encoding="utf-8") + (tests / "e2e_redirect_gradle_build" / "extra.rs").write_text( + "#[test]\n#[ignore = \"real Gradle\"]\nfn hosted_wiring_without_prefix() {}\n", encoding="utf-8") + (tests / "e2e_gradle_agent_build.rs").write_text( + "#[test]\n#[ignore]\nfn gradle_vex_attests() {}\n", encoding="utf-8") + self.assertEqual(bundle.prefix_violations(tests), [ + ("e2e_gradle_agent_build", "e2e_gradle_agent_build.rs", "gradle_vex_attests"), + ("e2e_redirect_gradle_build", "e2e_redirect_gradle_build/extra.rs", "hosted_wiring_without_prefix"), + ]) + + def test_the_checkout_passes_the_guard(self): + self.assertEqual(bundle.prefix_violations(), []) + self.assertEqual(bundle.main(["--check"]), 0) + + def test_suites_filter_the_rows_select_exist(self): + """Every prefix the guard admits is selected by an ubuntu PR row, so a + conforming test runs on every PR.""" + filters = " ".join(r.get("test_filter", "") for r in rows_mod.matrix_include( + rows_mod.jobs(CI.read_text(encoding="utf-8"))["e2e"]) if r.get("jvm_tool") == "gradle") + for prefix in ("gradle_agent_", "gradle_hosted_", "gradle_vendor_", "gradle_multi_project"): + self.assertIn(prefix, filters.split()) + + +class AllowEmpty(unittest.TestCase): + rows = rows_mod.matrix_include(rows_mod.jobs(CI.read_text(encoding="utf-8"))["e2e"]) + + def test_allow_empty_only_while_a_suite_is_unlanded(self): + """The allowance is for rows whose owning package has not landed. Once + every suite of a row exists (the campaign's last package), the row + must drop `allow_empty` so an empty selection fails again.""" + for row in self.rows: + if row.get("allow_empty") is None: + continue + with self.subTest(row=row): + self.assertEqual(row["allow_empty"], "true") + self.assertEqual(row.get("jvm_tool"), "gradle", "allow_empty is a Gradle-campaign scaffold") + self.assertTrue(any(not bundle.landed(s) for s in row["suite"].split()), + f"every suite of {row['suite']!r} has landed: drop allow_empty") + + def test_rows_without_allowance_name_landed_suites(self): + for row in self.rows: + if row.get("allow_empty") == "true": + continue + for suite in row["suite"].split(): + with self.subTest(row=row, suite=suite): + self.assertTrue(bundle.landed(suite), f"{suite} has no test target") + + def test_row_suites_skip_unlanded_only_with_allowance(self): + with tempfile.TemporaryDirectory() as tmp: + tests = Path(tmp) + (tests / "e2e_vendor_jvm_build.rs").write_text("", encoding="utf-8") + row = {"suite": "e2e_vendor_gradle_build e2e_vendor_jvm_build"} + self.assertEqual(bundle.row_suites(row, tests), ["e2e_vendor_gradle_build", "e2e_vendor_jvm_build"]) + row["allow_empty"] = "true" + self.assertEqual(bundle.row_suites(row, tests), ["e2e_vendor_jvm_build"]) + + +if __name__ == "__main__": + unittest.main() From 90f593658cf5f0f6f9e38fa289c3110be8b9b58d Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 2 Oct 2026 11:24:05 -0400 Subject: [PATCH 08/63] Clear the clippy lints the new JVM test helpers introduced Two format!-of-a-constant pom heads and a cloned single-element slice in the files-2.1 self-test; behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/tests/jvm_fixture_repo/mod.rs | 4 ++-- crates/socket-patch-cli/tests/prebuilt_common/mod.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/socket-patch-cli/tests/jvm_fixture_repo/mod.rs b/crates/socket-patch-cli/tests/jvm_fixture_repo/mod.rs index 9b14a89ae..09b685c02 100644 --- a/crates/socket-patch-cli/tests/jvm_fixture_repo/mod.rs +++ b/crates/socket-patch-cli/tests/jvm_fixture_repo/mod.rs @@ -291,7 +291,7 @@ fn parent_pom() -> String { /// A jar pom under `fixture-parent` (the project's own `` follows /// ``, the shape `hosted_maven_common::Hosted::served_pom` rewrites). fn jar_pom(artifact: &str, version: &str, gradle_metadata: bool, deps: &[(&str, &str)]) -> String { - let mut pom = format!("{POM_HEAD}"); + let mut pom = POM_HEAD.to_string(); if gradle_metadata { pom.push_str(GRADLE_METADATA_HINT); } @@ -314,7 +314,7 @@ fn jar_pom(artifact: &str, version: &str, gradle_metadata: bool, deps: &[(&str, /// A `pom`-packaged pom managing `victim:1.10.0` (the BOM, and the /// platform's Maven face). fn managing_pom(artifact: &str, version: &str, gradle_metadata: bool) -> String { - let mut pom = format!("{POM_HEAD}"); + let mut pom = POM_HEAD.to_string(); if gradle_metadata { pom.push_str(GRADLE_METADATA_HINT); } diff --git a/crates/socket-patch-cli/tests/prebuilt_common/mod.rs b/crates/socket-patch-cli/tests/prebuilt_common/mod.rs index df936c795..f26a1bf01 100644 --- a/crates/socket-patch-cli/tests/prebuilt_common/mod.rs +++ b/crates/socket-patch-cli/tests/prebuilt_common/mod.rs @@ -848,7 +848,7 @@ mod prebuilt_common_selftests { // The version dir resolves to the jar's hash dir, and the pom is // found beside it in its own hash dir. let purl = "pkg:maven/com.socketfixture/victim@1.10.0"; - let found = source_dir(tmp.path(), &[version_dir.clone()], purl); + let found = source_dir(tmp.path(), std::slice::from_ref(&version_dir), purl); assert_eq!(found, jar_dir); assert_eq!( maven_sibling(&found, "victim-1.10.0.pom"), From 4cba7fe5db3aca0a8408a803910f3d608542f2a2 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 2 Oct 2026 11:24:39 -0400 Subject: [PATCH 09/63] Isolate the autocrlf clone from global git config and test it A developer's global commit signing or hooks would break the fixture commit, and a global autocrlf would change what the clone checks out. The helper now runs git against an empty global config, and a self-test pins the result: LF as committed, CRLF in the clone, -text files untouched. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tests/gradle_build_common/mod.rs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/socket-patch-cli/tests/gradle_build_common/mod.rs b/crates/socket-patch-cli/tests/gradle_build_common/mod.rs index a8c30a780..c42a1ad12 100644 --- a/crates/socket-patch-cli/tests/gradle_build_common/mod.rs +++ b/crates/socket-patch-cli/tests/gradle_build_common/mod.rs @@ -553,8 +553,15 @@ fn git(cwd: &Path) -> Command { cmd.env_remove(&key); } } + // No system or global config (a developer's commit signing, hooks or + // autocrlf must not change the fixture): an empty global file. + let global = std::env::temp_dir().join("socket-patch-e2e-empty.gitconfig"); + if !global.is_file() { + let _ = std::fs::write(&global, ""); + } cmd.current_dir(cwd) .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", &global) .args(["-c", "user.name=socket-patch-e2e"]) .args(["-c", "user.email=e2e@socket.invalid"]) .args(["-c", "init.defaultBranch=main"]); @@ -700,6 +707,31 @@ mod gradle_build_common_selftests { assert!(mirror_init_script("http://h", None).contains("def socketHosted = ''")); } + /// LF as committed, CRLF in the autocrlf clone; `-text` files stay LF. + #[test] + fn git_autocrlf_clone_checks_out_crlf() { + let tmp = tempfile::tempdir().unwrap(); + let src = tmp.path().join("src"); + write_project( + &src, + &[ + ("settings.gradle", "include 'app'\nrootProject.name = 'x'\n"), + (".gitattributes", "*.bin -text\n"), + ("data.bin", "a\nb\n"), + ], + ); + let dst = git_autocrlf_clone(&src, &tmp.path().join("clone")); + assert_eq!( + std::fs::read(dst.join("settings.gradle")).unwrap(), + b"include 'app'\r\nrootProject.name = 'x'\r\n" + ); + assert_eq!(std::fs::read(dst.join("data.bin")).unwrap(), b"a\nb\n"); + assert_eq!( + std::fs::read(src.join("settings.gradle")).unwrap(), + b"include 'app'\nrootProject.name = 'x'\n" + ); + } + #[test] fn print_cp_task_per_dsl() { assert!(print_cp_task(Dsl::Groovy, "runtimeClasspath") From 440b09d3eec846c963985a40bf68a715a276d79f Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 2 Oct 2026 11:36:24 -0400 Subject: [PATCH 10/63] Add a pure Gradle model: tokenizer, line endings, version selectors Gradle support in agent, hosted and vendored mode needs one shared, filesystem-free model of a Gradle build so every mode reads scripts, versions and locks the same way and can be tested in memory on every OS. This starts crate::gradle with the contract the later packages code against: TextReadFn / ListFn / Env / Os. - dsl: a comment- and string-aware Groovy/Kotlin tokenizer (copied from the vendored planner's lexer and extended: Kotlin raw strings and nested comments, `${}` templates with nested strings, decoded escapes, BOM handling, strict UTF-8 decode) plus call-site parsing for both parenthesised and Groovy command-expression calls. - eol: CRLF sniffing, re-spelling and line-ending-blind comparison for files a core.autocrlf clone checks out with CRLF. - selector: Gradle's version ordering and selector scheme. Checked against real Gradle 6.9.4, 7.6.6, 8.14.3 and 9.8.0, which showed two behaviours changed in Gradle 7 (the special-qualifier set, and an exclusive upper bound also rejecting qualified versions of the bound, so `[1.9,1.10.0)` admits `1.10.0-socket.` only on 6.x), so the comparator and admits take the Gradle major. The golden tables are exported for the hosted script's Groovy port, and tests/gradle_selector_golden.rs asks real Gradle for every row when SOCKET_PATCH_GRADLE_E2E_GRADLE is set. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-core/src/gradle/dsl.rs | 826 ++++++++++++++++++ crates/socket-patch-core/src/gradle/eol.rs | 97 ++ crates/socket-patch-core/src/gradle/mod.rs | 190 ++++ .../socket-patch-core/src/gradle/selector.rs | 577 ++++++++++++ crates/socket-patch-core/src/lib.rs | 1 + .../tests/gradle_selector_golden.rs | 195 +++++ 6 files changed, 1886 insertions(+) create mode 100644 crates/socket-patch-core/src/gradle/dsl.rs create mode 100644 crates/socket-patch-core/src/gradle/eol.rs create mode 100644 crates/socket-patch-core/src/gradle/mod.rs create mode 100644 crates/socket-patch-core/src/gradle/selector.rs create mode 100644 crates/socket-patch-core/tests/gradle_selector_golden.rs diff --git a/crates/socket-patch-core/src/gradle/dsl.rs b/crates/socket-patch-core/src/gradle/dsl.rs new file mode 100644 index 000000000..11e74cf07 --- /dev/null +++ b/crates/socket-patch-core/src/gradle/dsl.rs @@ -0,0 +1,826 @@ +//! A small Groovy / Kotlin script tokenizer: enough of either DSL to find +//! blocks, calls and string literals without being fooled by comments or +//! strings. +//! +//! Comments are skipped (Kotlin block comments nest). A string literal is +//! one [`Tok::Str`]: Groovy `'…'`, `"…"`, `'''…'''`, `"""…"""` and Kotlin +//! `"…"`, `"""…"""` (raw, no escapes) and `'c'`. A string is `literal` +//! only when its value is fully known: any `$name` / `${…}` interpolation, +//! an escape we do not decode or a missing close quote makes it +//! non-literal (its `value` then holds the raw text, interpolations kept). +//! Everything else is an identifier, a number or one punctuation byte. +//! Slashy strings are not recognised (a `/` is punctuation). + +/// Which DSL a script is written in. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Dsl { + Groovy, + Kotlin, +} + +/// The DSL of a script from its file name: `*.gradle.kts` (and `*.kts`) is +/// Kotlin, `*.gradle` is Groovy, anything else is not a Gradle script. +pub fn dsl_of(rel: &str) -> Option { + if rel.ends_with(".kts") { + Some(Dsl::Kotlin) + } else if rel.ends_with(".gradle") { + Some(Dsl::Groovy) + } else { + None + } +} + +/// `s` without a leading UTF-8 byte-order mark. +pub fn strip_bom(s: &str) -> &str { + s.strip_prefix('\u{feff}').unwrap_or(s) +} + +/// Script bytes as text: a leading BOM is dropped and anything that is not +/// UTF-8 is `None` (unparseable), never decoded lossily. +pub fn decode(bytes: &[u8]) -> Option { + let bytes = bytes.strip_prefix(b"\xef\xbb\xbf").unwrap_or(bytes); + String::from_utf8(bytes.to_vec()).ok() +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Tok { + Ident(String), + /// A string literal. `literal` is false when it interpolates, uses an + /// escape we do not decode, or is unterminated. + Str { + value: String, + literal: bool, + }, + /// A run of digits and the letters / underscores glued to it. + Num(String), + Punct(u8), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Token { + pub tok: Tok, + /// Byte range in the source. + pub start: usize, + pub end: usize, +} + +/// Tokenize `text` (a BOM is skipped). +pub fn tokens(text: &str, dsl: Dsl) -> Vec { + lex(text, dsl).0 +} + +/// Whether `text` tokenizes cleanly: every string and block comment is +/// closed and every bracket is matched. A script that fails this is +/// treated as unparseable by the callers that must fail safe. +pub fn well_formed(text: &str, dsl: Dsl) -> bool { + let (toks, clean) = lex(text, dsl); + if !clean { + return false; + } + let mut stack = Vec::new(); + for t in &toks { + match t.tok { + Tok::Punct(c @ (b'(' | b'[' | b'{')) => stack.push(c), + Tok::Punct(c @ (b')' | b']' | b'}')) => { + let want = match c { + b')' => b'(', + b']' => b'[', + _ => b'{', + }; + if stack.pop() != Some(want) { + return false; + } + } + _ => {} + } + } + stack.is_empty() +} + +/// The tokens and whether every string / comment was closed. +fn lex(src: &str, dsl: Dsl) -> (Vec, bool) { + let b = src.as_bytes(); + let mut out = Vec::new(); + let mut clean = true; + let mut i = if src.starts_with('\u{feff}') { 3 } else { 0 }; + // A `#!` first line (shebang) is a comment in both DSLs. + if b[i..].starts_with(b"#!") { + while i < b.len() && b[i] != b'\n' { + i += 1; + } + } + while i < b.len() { + let c = b[i]; + if c.is_ascii_whitespace() { + i += 1; + } else if b[i..].starts_with(b"//") { + while i < b.len() && b[i] != b'\n' { + i += 1; + } + } else if b[i..].starts_with(b"/*") { + match skip_block_comment(b, i, dsl == Dsl::Kotlin) { + Some(end) => i = end, + None => { + clean = false; + i = b.len(); + } + } + } else if c == b'\'' || c == b'"' { + let s = scan_string(src, i, dsl); + clean &= s.closed; + out.push(Token { + tok: Tok::Str { + value: s.value, + literal: s.literal, + }, + start: i, + end: s.end, + }); + i = s.end; + } else if c == b'`' && dsl == Dsl::Kotlin { + // A backticked Kotlin identifier. + let start = i; + let close = src[i + 1..].find(['`', '\n']).map(|j| i + 1 + j); + match close { + Some(j) if b[j] == b'`' => { + out.push(Token { + tok: Tok::Ident(src[i + 1..j].to_string()), + start, + end: j + 1, + }); + i = j + 1; + } + _ => { + out.push(Token { + tok: Tok::Punct(b'`'), + start, + end: i + 1, + }); + i += 1; + } + } + } else if c.is_ascii_digit() { + let start = i; + while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == b'_') { + i += 1; + } + out.push(Token { + tok: Tok::Num(src[start..i].to_string()), + start, + end: i, + }); + } else if is_ident_start(src, i) { + let start = i; + while i < b.len() && is_ident_part(src, i) { + i += src[i..].chars().next().map_or(1, char::len_utf8); + } + out.push(Token { + tok: Tok::Ident(src[start..i].to_string()), + start, + end: i, + }); + } else if c.is_ascii() { + out.push(Token { + tok: Tok::Punct(c), + start: i, + end: i + 1, + }); + i += 1; + } else { + i += src[i..].chars().next().map_or(1, char::len_utf8); + } + } + (out, clean) +} + +fn is_ident_start(src: &str, i: usize) -> bool { + src[i..] + .chars() + .next() + .is_some_and(|ch| ch.is_alphabetic() || ch == '_' || ch == '$') +} + +fn is_ident_part(src: &str, i: usize) -> bool { + src[i..] + .chars() + .next() + .is_some_and(|ch| ch.is_alphanumeric() || ch == '_' || ch == '$') +} + +/// The end of the block comment opening at `i`; `None` when unclosed. +fn skip_block_comment(b: &[u8], mut i: usize, nests: bool) -> Option { + let mut depth = 0usize; + while i < b.len() { + if b[i..].starts_with(b"/*") && (nests || depth == 0) { + depth += 1; + i += 2; + } else if b[i..].starts_with(b"*/") { + depth -= 1; + i += 2; + if depth == 0 { + return Some(i); + } + } else { + i += 1; + } + } + None +} + +struct Scanned { + value: String, + literal: bool, + closed: bool, + end: usize, +} + +/// Scan the string literal whose opening quote is at `start`. +fn scan_string(src: &str, start: usize, dsl: Dsl) -> Scanned { + let b = src.as_bytes(); + let q = b[start]; + let triple = b[start..].starts_with(&[q, q, q]) && !(dsl == Dsl::Kotlin && q == b'\''); + // Kotlin raw strings decode no escapes; every other form does. + let raw = dsl == Dsl::Kotlin && triple; + // Groovy single-quoted strings never interpolate. + let templates = q == b'"'; + let mut i = start + if triple { 3 } else { 1 }; + let mut value = String::new(); + let mut literal = true; + loop { + if i >= b.len() { + return Scanned { + value, + literal: false, + closed: false, + end: b.len(), + }; + } + if triple { + if b[i..].starts_with(&[q, q, q]) { + // Kotlin: a run of more than three quotes keeps the extras. + let mut run = 3; + while dsl == Dsl::Kotlin && b.get(i + run) == Some(&q) { + run += 1; + } + for _ in 3..run { + value.push(q as char); + } + i += run; + break; + } + } else if b[i] == q { + i += 1; + break; + } else if b[i] == b'\n' { + return Scanned { + value, + literal: false, + closed: false, + end: i, + }; + } + if b[i] == b'\\' && !raw { + match decode_escape(src, i) { + Some((ch, len)) => { + if let Some(ch) = ch { + value.push(ch); + } + i += len; + } + None => { + literal = false; + let len = src[i + 1..].chars().next().map_or(0, char::len_utf8); + value.push_str(&src[i..i + 1 + len]); + i += 1 + len; + } + } + continue; + } + if b[i] == b'$' && templates { + if b.get(i + 1) == Some(&b'{') { + literal = false; + let end = skip_template(src, i + 1, dsl); + value.push_str(&src[i..end]); + i = end; + continue; + } + let ident = i + 1 < b.len() && is_ident_start(src, i + 1) && b[i + 1] != b'$'; + if ident || dsl == Dsl::Groovy { + // Groovy rejects a bare `$` in a GString; either way the + // value is not known. + literal = false; + } + } + let ch = src[i..].chars().next().unwrap_or('\u{fffd}'); + value.push(ch); + i += ch.len_utf8(); + } + Scanned { + value, + literal, + closed: true, + end: i, + } +} + +/// Decode the escape at `i` (a backslash): `(char, bytes consumed)`, with +/// `None` for a line continuation. `None` overall for an escape we do not +/// decode. +fn decode_escape(src: &str, i: usize) -> Option<(Option, usize)> { + let b = src.as_bytes(); + let e = *b.get(i + 1)?; + let ch = match e { + b'\\' | b'\'' | b'"' | b'$' => e as char, + b'n' => '\n', + b't' => '\t', + b'r' => '\r', + b'b' => '\u{8}', + b'f' => '\u{c}', + b'\n' => return Some((None, 2)), + b'u' => { + let hex = src.get(i + 2..i + 6)?; + if !hex.bytes().all(|h| h.is_ascii_hexdigit()) { + return None; + } + return Some((Some(char::from_u32(u32::from_str_radix(hex, 16).ok()?)?), 6)); + } + _ => return None, + }; + Some((Some(ch), 2)) +} + +/// The end (one past the `}`) of the `${…}` template whose `{` is at +/// `open`; nested strings and braces are skipped. +fn skip_template(src: &str, open: usize, dsl: Dsl) -> usize { + let b = src.as_bytes(); + let mut depth = 0usize; + let mut i = open; + while i < b.len() { + match b[i] { + b'{' => depth += 1, + b'}' => { + depth -= 1; + if depth == 0 { + return i + 1; + } + } + b'\'' | b'"' => { + i = scan_string(src, i, dsl).end; + continue; + } + _ => {} + } + i += 1; + } + b.len() +} + +// ── token helpers ─────────────────────────────────────────────────────────────── + +pub fn is_ident(t: Option<&Token>, name: &str) -> bool { + matches!(t, Some(Token { tok: Tok::Ident(n), .. }) if n == name) +} + +pub fn is_punct(t: Option<&Token>, p: u8) -> bool { + matches!(t, Some(Token { tok: Tok::Punct(c), .. }) if *c == p) +} + +/// The literal value of a string token. +pub fn literal_of(t: Option<&Token>) -> Option<&str> { + match t { + Some(Token { + tok: Tok::Str { + value, + literal: true, + }, + .. + }) => Some(value), + _ => None, + } +} + +/// Index of the bracket closing the `(`, `[` or `{` at `open`. +pub fn matching_close(toks: &[Token], open: usize) -> Option { + let (o, c) = match toks.get(open)?.tok { + Tok::Punct(b'(') => (b'(', b')'), + Tok::Punct(b'[') => (b'[', b']'), + Tok::Punct(b'{') => (b'{', b'}'), + _ => return None, + }; + let mut depth = 0usize; + for (i, t) in toks.iter().enumerate().skip(open) { + match t.tok { + Tok::Punct(p) if p == o => depth += 1, + Tok::Punct(p) if p == c => { + depth = depth.checked_sub(1)?; + if depth == 0 { + return Some(i); + } + } + _ => {} + } + } + None +} + +/// Every `name {` block at any depth: `(open index, close index)` of the +/// braces. +pub fn all_blocks(toks: &[Token], name: &str) -> Vec<(usize, usize)> { + (0..toks.len()) + .filter(|&i| is_ident(toks.get(i), name) && is_punct(toks.get(i + 1), b'{')) + .filter_map(|i| matching_close(toks, i + 1).map(|c| (i + 1, c))) + .collect() +} + +/// The string values (literal or not) in `toks`. +pub fn strings(toks: &[Token]) -> impl Iterator { + toks.iter().filter_map(|t| match &t.tok { + Tok::Str { value, .. } => Some(value.as_str()), + _ => None, + }) +} + +/// Whether a line break separates tokens `a` and `b` (`a` before `b`). +pub fn newline_between(text: &str, toks: &[Token], a: usize, b: usize) -> bool { + match (toks.get(a), toks.get(b)) { + (Some(x), Some(y)) if x.end <= y.start => text[x.end..y.start].contains('\n'), + _ => false, + } +} + +// ── calls ─────────────────────────────────────────────────────────────────────── + +/// One argument of a call. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CallArg { + /// The name of a named argument (Groovy `group: 'g'`, Kotlin + /// `group = "g"`). + pub name: Option, + /// The value when it is exactly one literal string. + pub literal: Option, + /// The value's token range `[first, last)`. + pub first: usize, + pub last: usize, +} + +/// One call of a named method: `f(a, b)`, `f(a) { … }`, Groovy's +/// parenthesis-free `f a, b` and a closure-only `f { … }`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CallSite { + /// Token index of the callee name. + pub callee: usize, + /// The identifier before a `.` / `?.` in front of the callee + /// (`settings.include` → `settings`); `Some("")` when the receiver is + /// an expression (`project(':a').include`). + pub receiver: Option, + /// 1-based line of the callee. + pub line: usize, + pub args: Vec, + /// Token range `(open, close)` of a trailing closure. + pub closure: Option<(usize, usize)>, + /// Token index one past the call (closure included). + pub end: usize, +} + +impl CallSite { + /// Every argument's literal value, or `None` when any argument is not a + /// literal string (or the call has none). + pub fn literals(&self) -> Option> { + if self.args.is_empty() { + return None; + } + self.args.iter().map(|a| a.literal.clone()).collect() + } + + /// The literal value of the named argument `name`; `Some(None)` when + /// it is present but not a literal. + pub fn named(&self, name: &str) -> Option> { + self.args + .iter() + .find(|a| a.name.as_deref() == Some(name)) + .map(|a| a.literal.as_deref()) + } +} + +/// Every call of `callee` in `text`. +pub fn literal_strings_in_call(text: &str, dsl: Dsl, callee: &str) -> Vec { + call_sites(text, &tokens(text, dsl), callee) +} + +/// Every call of `callee` among `toks` (the tokens of `text`). +pub fn call_sites(text: &str, toks: &[Token], callee: &str) -> Vec { + (0..toks.len()) + .filter(|&i| is_ident(toks.get(i), callee)) + .filter_map(|i| call_at(text, toks, i)) + .collect() +} + +/// The call whose callee is token `i`, if `i` is called at all. +pub fn call_at(text: &str, toks: &[Token], i: usize) -> Option { + let prev = |k: usize| i.checked_sub(k).and_then(|p| toks.get(p)); + // A named-argument key or a declaration (`fun include(`) is no call. + if is_ident(prev(1), "fun") || is_ident(prev(1), "def") { + return None; + } + let receiver = if is_punct(prev(1), b'.') { + let recv = if is_punct(prev(2), b'?') { + prev(3) + } else { + prev(2) + }; + Some(match recv { + Some(Token { + tok: Tok::Ident(n), .. + }) => n.clone(), + _ => String::new(), + }) + } else { + None + }; + let line = super::line_of(text, toks[i].start); + let next = toks.get(i + 1)?; + let (args, mut end) = match next.tok { + Tok::Punct(b'(') => { + let close = matching_close(toks, i + 1)?; + (split_args(toks, i + 2, close), close + 1) + } + Tok::Punct(b'{') => (Vec::new(), i + 1), + Tok::Punct(_) => return None, + _ if newline_between(text, toks, i, i + 1) => return None, + _ => { + let end = command_end(text, toks, i + 1); + (split_args(toks, i + 1, end), end) + } + }; + let closure = if is_punct(toks.get(end), b'{') { + let close = matching_close(toks, end)?; + let c = (end, close); + end = close + 1; + Some(c) + } else { + None + }; + Some(CallSite { + callee: i, + receiver, + line, + args, + closure, + end, + }) +} + +/// The end (token index) of a Groovy command expression's argument list, +/// or of an assignment's right-hand side, starting at `from`: a line +/// break, `;` or an unmatched closer at bracket depth 0 (a trailing `,` +/// continues the list on the next line), or a `{` at depth 0 (a trailing +/// closure). +pub fn command_end(text: &str, toks: &[Token], from: usize) -> usize { + let mut depth = 0isize; + let mut i = from; + while i < toks.len() { + match toks[i].tok { + Tok::Punct(b'(' | b'[') => depth += 1, + Tok::Punct(b'{') if depth > 0 => depth += 1, + // A `{` at depth 0 is a trailing closure. + Tok::Punct(b'{') => return i, + Tok::Punct(b')' | b']' | b'}') => { + if depth == 0 { + return i; + } + depth -= 1; + } + Tok::Punct(b';') if depth == 0 => return i, + _ => {} + } + if depth == 0 + && i > from + && newline_between(text, toks, i - 1, i) + && !is_punct(toks.get(i - 1), b',') + { + return i; + } + i += 1; + } + i +} + +/// Split `toks[from..to]` at depth-0 commas. +fn split_args(toks: &[Token], from: usize, to: usize) -> Vec { + let mut out = Vec::new(); + if from >= to { + return out; + } + let mut depth = 0isize; + let mut start = from; + for i in from..=to { + let at_end = i == to; + if !at_end { + match toks[i].tok { + Tok::Punct(b'(' | b'[' | b'{') => depth += 1, + Tok::Punct(b')' | b']' | b'}') => depth -= 1, + _ => {} + } + } + if at_end || (depth == 0 && is_punct(toks.get(i), b',')) { + if i > start { + out.push(make_arg(toks, start, i)); + } + start = i + 1; + } + } + out +} + +fn make_arg(toks: &[Token], first: usize, last: usize) -> CallArg { + let key = match &toks[first].tok { + Tok::Ident(n) => Some(n.clone()), + Tok::Str { + value, + literal: true, + } => Some(value.clone()), + _ => None, + }; + let named = key.is_some() + && (is_punct(toks.get(first + 1), b':') + || (is_punct(toks.get(first + 1), b'=') && !is_punct(toks.get(first + 2), b'='))); + let (name, vfirst) = if named { + (key, first + 2) + } else { + (None, first) + }; + let literal = (last == vfirst + 1) + .then(|| literal_of(toks.get(vfirst)).map(str::to_string)) + .flatten(); + CallArg { + name, + literal, + first: vfirst, + last, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A compact rendering of the tokens: `I:name`, `S:value` (literal), + /// `s:value` (not literal), `N:digits`, `P:c`. + fn render(text: &str, dsl: Dsl) -> Vec { + tokens(text, dsl) + .into_iter() + .map(|t| match t.tok { + Tok::Ident(n) => format!("I:{n}"), + Tok::Str { + value, + literal: true, + } => format!("S:{value}"), + Tok::Str { value, .. } => format!("s:{value}"), + Tok::Num(n) => format!("N:{n}"), + Tok::Punct(c) => format!("P:{}", c as char), + }) + .collect() + } + + #[test] + fn groovy_golden_tokens() { + let table: &[(&str, &[&str])] = &[ + ("include ':a'", &["I:include", "S::a"]), + ("x \"a$b\"", &["I:x", "s:a$b"]), + ("x \"a${b.c('}')}d\"", &["I:x", "s:a${b.c('}')}d"]), + ("x \"a\\$b\"", &["I:x", "S:a$b"]), + ("x 'a$b'", &["I:x", "S:a$b"]), + ("x '''a\n'b'\n'''", &["I:x", "S:a\n'b'\n"]), + ("x \"\"\"a\n$b\"\"\"", &["I:x", "s:a\n$b"]), + ("x 'it\\'s'", &["I:x", "S:it's"]), + ("x '\\u0041\\n'", &["I:x", "S:A\n"]), + ("x '\\q'", &["I:x", "s:\\q"]), + ("// 'no'\nx /* 'no' */ y", &["I:x", "I:y"]), + ("a /* /* */ b", &["I:a", "I:b"]), + ("v = 1.10", &["I:v", "P:=", "N:1", "P:.", "N:10"]), + ("#!/usr/bin/env groovy\nx", &["I:x"]), + ("x 'unterminated\ny", &["I:x", "s:unterminated", "I:y"]), + ("x \"a\" + \"b\"", &["I:x", "S:a", "P:+", "S:b"]), + ]; + for (src, want) in table { + assert_eq!(render(src, Dsl::Groovy), *want, "{src:?}"); + } + } + + #[test] + fn kotlin_golden_tokens() { + let table: &[(&str, &[&str])] = &[ + ("include(\":a\")", &["I:include", "P:(", "S::a", "P:)"]), + ("x(\"a$b\")", &["I:x", "P:(", "s:a$b", "P:)"]), + ("x(\"a${'$'}b\")", &["I:x", "P:(", "s:a${'$'}b", "P:)"]), + ("x(\"a$\")", &["I:x", "P:(", "S:a$", "P:)"]), + ("x(\"a\\$b\")", &["I:x", "P:(", "S:a$b", "P:)"]), + ("x(\"\"\"a\\nb\"\"\")", &["I:x", "P:(", "S:a\\nb", "P:)"]), + ("x(\"\"\"a$b\"\"\")", &["I:x", "P:(", "s:a$b", "P:)"]), + ("x(\"\"\"q\"\"\"\")", &["I:x", "P:(", "S:q\"", "P:)"]), + ("x('c')", &["I:x", "P:(", "S:c", "P:)"]), + ("a /* x /* y */ z */ b", &["I:a", "I:b"]), + ( + "`my-conf`(\"g:a:1\")", + &["I:my-conf", "P:(", "S:g:a:1", "P:)"], + ), + ("val é = 1", &["I:val", "I:é", "P:=", "N:1"]), + ]; + for (src, want) in table { + assert_eq!(render(src, Dsl::Kotlin), *want, "{src:?}"); + } + } + + #[test] + fn gstring_interpolation_is_non_literal_but_escaped_dollar_is() { + let toks = tokens("apply from: \"$rootDir/x.gradle\"", Dsl::Groovy); + assert!( + matches!(&toks[3].tok, Tok::Str { literal: false, value } if value == "$rootDir/x.gradle") + ); + let toks = tokens("apply from: \"\\$rootDir/x.gradle\"", Dsl::Groovy); + assert_eq!(literal_of(toks.get(3)), Some("$rootDir/x.gradle")); + } + + #[test] + fn bom_is_skipped() { + let src = "\u{feff}include ':a'\n"; + assert_eq!(render(src, Dsl::Groovy), ["I:include", "S::a"]); + assert_eq!(strip_bom(src), "include ':a'\n"); + assert_eq!(strip_bom("x"), "x"); + assert_eq!( + decode(b"\xef\xbb\xbfinclude ':a'").as_deref(), + Some("include ':a'") + ); + assert_eq!(decode(b"include '\xff'"), None); + let calls = literal_strings_in_call(src, Dsl::Groovy, "include"); + assert_eq!(calls[0].literals(), Some(vec![":a".to_string()])); + assert_eq!(calls[0].line, 1); + } + + #[test] + fn well_formed_detects_unclosed() { + assert!(well_formed("a { b(c) [d] }", Dsl::Groovy)); + assert!(!well_formed("a { b(c) ", Dsl::Groovy)); + assert!(!well_formed("a ) (", Dsl::Groovy)); + assert!(!well_formed("x 'open", Dsl::Groovy)); + assert!(!well_formed("x /* open", Dsl::Kotlin)); + assert!(!well_formed("x \"\"\"open", Dsl::Kotlin)); + } + + #[test] + fn dsl_of_by_extension() { + assert_eq!(dsl_of("a/settings.gradle"), Some(Dsl::Groovy)); + assert_eq!(dsl_of("build.gradle.kts"), Some(Dsl::Kotlin)); + assert_eq!(dsl_of("init.d/x.init.kts"), Some(Dsl::Kotlin)); + assert_eq!(dsl_of("pom.xml"), None); + } + + #[test] + fn call_forms() { + let g = "include ':a', ':b'\nsettings.include(':c')\ninclude \"$x\"\ninclude ':d',\n ':e'\nfoo.include ':f'\n"; + let calls = literal_strings_in_call(g, Dsl::Groovy, "include"); + let lits: Vec<_> = calls.iter().map(CallSite::literals).collect(); + assert_eq!( + lits, + vec![ + Some(vec![":a".into(), ":b".into()]), + Some(vec![":c".into()]), + None, + Some(vec![":d".into(), ":e".into()]), + Some(vec![":f".into()]), + ] + ); + assert_eq!(calls[1].receiver.as_deref(), Some("settings")); + assert_eq!(calls[4].receiver.as_deref(), Some("foo")); + assert_eq!(calls[3].line, 4); + + let k = "include(listOf(\"x\"))\ninclude(\":a\", \":b\")\n"; + let calls = literal_strings_in_call(k, Dsl::Kotlin, "include"); + assert_eq!(calls.len(), 2); + assert_eq!(calls[0].literals(), None); + assert_eq!(calls[0].args.len(), 1); + assert_eq!(calls[1].literals().map(|v| v.len()), Some(2)); + } + + #[test] + fn named_args_and_closures() { + let g = "implementation group: 'g', name: 'a', version: \"$v\"\n"; + let c = &literal_strings_in_call(g, Dsl::Groovy, "implementation")[0]; + assert_eq!(c.named("group"), Some(Some("g"))); + assert_eq!(c.named("name"), Some(Some("a"))); + assert_eq!(c.named("version"), Some(None)); + assert_eq!(c.named("classifier"), None); + + let k = "implementation(group = \"g\", name = \"a\") { isTransitive = false }\nx == y\n"; + let toks = tokens(k, Dsl::Kotlin); + let c = &call_sites(k, &toks, "implementation")[0]; + assert_eq!(c.named("group"), Some(Some("g"))); + let (open, close) = c.closure.expect("closure"); + assert!(is_punct(toks.get(open), b'{') && is_punct(toks.get(close), b'}')); + + // A property assignment is not a call. + let g = "classifier = 'tests'\nversion 'x'\n"; + assert!(literal_strings_in_call(g, Dsl::Groovy, "classifier").is_empty()); + assert_eq!(literal_strings_in_call(g, Dsl::Groovy, "version").len(), 1); + } +} diff --git a/crates/socket-patch-core/src/gradle/eol.rs b/crates/socket-patch-core/src/gradle/eol.rs new file mode 100644 index 000000000..e4d37210d --- /dev/null +++ b/crates/socket-patch-core/src/gradle/eol.rs @@ -0,0 +1,97 @@ +//! Line endings of the text files socket-patch owns or edits in a Gradle +//! build. A clone with `core.autocrlf=true` checks those files out with +//! CRLF, so "is this still our file" compares must not see the line-ending +//! difference, and edits must hand the file back in the style they found. + +use std::borrow::Cow; + +/// Whether `a` and `b` are equal once every `\r\n` is read as `\n`. +/// Nothing else is normalised: a lone `\r`, trailing whitespace or a +/// missing final newline still differ. +pub fn eol_eq(a: &[u8], b: &[u8]) -> bool { + to_lf(a) == to_lf(b) +} + +/// Whether the file uses CRLF: its first line break is `\r\n`. A file +/// without any line break is not CRLF. +pub fn sniff_crlf(text: &[u8]) -> bool { + match text.iter().position(|&b| b == b'\n') { + Some(i) => i > 0 && text[i - 1] == b'\r', + None => false, + } +} + +/// `text` with every line break spelled `\r\n` when `crlf`, else `\n`. +/// Existing `\r\n` pairs are folded first, so the result never holds +/// `\r\r\n`. +pub fn apply_eol(text: &str, crlf: bool) -> String { + let lf = text.replace("\r\n", "\n"); + if crlf { + lf.replace('\n', "\r\n") + } else { + lf + } +} + +/// The line break [`sniff_crlf`] found in `text`. +pub fn newline_of(text: &str) -> &'static str { + if sniff_crlf(text.as_bytes()) { + "\r\n" + } else { + "\n" + } +} + +/// `bytes` with every `\r\n` folded to `\n` (borrowed when there is none). +pub fn to_lf(bytes: &[u8]) -> Cow<'_, [u8]> { + if !bytes.windows(2).any(|w| w == b"\r\n") { + return Cow::Borrowed(bytes); + } + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'\r' && bytes.get(i + 1) == Some(&b'\n') { + i += 1; + continue; + } + out.push(bytes[i]); + i += 1; + } + Cow::Owned(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn eol_eq_ignores_only_crlf() { + assert!(eol_eq(b"a\nb\n", b"a\r\nb\r\n")); + assert!(eol_eq(b"a\r\nb", b"a\nb")); + assert!(eol_eq(b"", b"")); + assert!(!eol_eq(b"a\n", b"a")); + assert!(!eol_eq(b"a\rb", b"a\nb")); + assert!(!eol_eq(b"a \n", b"a\n")); + } + + #[test] + fn sniff_reads_the_first_break() { + assert!(sniff_crlf(b"a\r\nb\n")); + assert!(!sniff_crlf(b"a\nb\r\n")); + assert!(!sniff_crlf(b"abc")); + assert!(!sniff_crlf(b"\n")); + assert!(sniff_crlf(b"\r\n")); + assert_eq!(newline_of("x\r\ny"), "\r\n"); + assert_eq!(newline_of("x"), "\n"); + } + + #[test] + fn apply_eol_round_trips() { + assert_eq!(apply_eol("a\nb\n", true), "a\r\nb\r\n"); + assert_eq!(apply_eol("a\r\nb\n", true), "a\r\nb\r\n"); + assert_eq!(apply_eol("a\r\nb\r\n", false), "a\nb\n"); + assert_eq!(apply_eol("a", true), "a"); + let crlf = "x\r\ny\r\n"; + assert_eq!(apply_eol(&apply_eol(crlf, false), true), crlf); + } +} diff --git a/crates/socket-patch-core/src/gradle/mod.rs b/crates/socket-patch-core/src/gradle/mod.rs new file mode 100644 index 000000000..ae4c54247 --- /dev/null +++ b/crates/socket-patch-core/src/gradle/mod.rs @@ -0,0 +1,190 @@ +//! A pure model of a Gradle build, shared by discovery, agent, vendored and +//! hosted mode. +//! +//! Nothing here touches the filesystem, the process environment or the +//! network. Project files come in through a [`TextReadFn`] and directory +//! listings through a [`ListFn`]; environment variables through an [`Env`]. +//! The filesystem and process-env adapters live with their callers +//! (`crawlers::gradle_cache`), so every function here is testable against +//! in-memory fixtures on every OS. +//! +//! - [`dsl`]: a comment- and string-aware Groovy/Kotlin tokenizer. +//! - [`eol`]: line-ending sniffing and line-ending-blind comparison. +//! - [`selector`]: Gradle version ordering and version selectors. + +pub mod dsl; +pub mod eol; +pub mod selector; + +use std::collections::{BTreeMap, HashMap}; + +/// Reads a forward-slash path (relative to the root the caller chose) as +/// text. `None` = missing or unreadable. Adapters strip a UTF-8 BOM and +/// return `None` for bytes that are not UTF-8; text is never decoded +/// lossily (see [`dsl::decode`]). +pub type TextReadFn<'a> = &'a dyn Fn(&str) -> Option; + +/// Lists the children of a forward-slash directory path (`""` = the root): +/// bare names, with directories ending in `/`. A missing directory lists +/// as empty. +pub type ListFn<'a> = &'a dyn Fn(&str) -> Vec; + +/// Environment variable lookup. +pub trait Env { + fn var(&self, k: &str) -> Option; +} + +impl Env for HashMap { + fn var(&self, k: &str) -> Option { + self.get(k).cloned() + } +} + +impl Env for BTreeMap { + fn var(&self, k: &str) -> Option { + self.get(k).cloned() + } +} + +impl Env for [(&str, &str)] { + fn var(&self, k: &str) -> Option { + self.iter() + .rev() + .find(|(name, _)| *name == k) + .map(|(_, v)| v.to_string()) + } +} + +/// The host family a path or environment convention belongs to. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Os { + Windows, + Unix, +} + +impl Os { + /// The OS this binary runs on. + pub fn current() -> Self { + if cfg!(windows) { + Self::Windows + } else { + Self::Unix + } + } +} + +/// `dir` joined with `name` in forward-slash form (`""` is the root). +pub(crate) fn join_rel(dir: &str, name: &str) -> String { + match (dir.is_empty(), name.is_empty()) { + (true, _) => name.to_string(), + (_, true) => dir.to_string(), + _ => format!("{}/{name}", dir.trim_end_matches('/')), + } +} + +/// The directory part of a forward-slash path (`""` for a root-level file). +pub(crate) fn parent_rel(rel: &str) -> &str { + rel.rfind('/').map_or("", |i| &rel[..i]) +} + +/// `base` joined with the relative path `p`, normalised. `None` when `p` +/// is absolute (POSIX, drive, UNC or a URL), uses backslashes, or climbs +/// above `floor` (a prefix directory of `base`, `""` = the read root). +pub(crate) fn resolve_rel(floor: &str, base: &str, p: &str) -> Option { + if p.is_empty() + || p.starts_with('/') + || p.contains('\\') + || p.contains(':') + || p.starts_with('~') + { + return None; + } + let mut parts: Vec<&str> = base.split('/').filter(|s| !s.is_empty()).collect(); + let floor_len = floor.split('/').filter(|s| !s.is_empty()).count(); + for seg in p.split('/') { + match seg { + "" | "." => {} + ".." => { + if parts.len() <= floor_len { + return None; + } + parts.pop(); + } + s => parts.push(s), + } + } + Some(parts.join("/")) +} + +/// 1-based line number of byte offset `at` in `text`. +pub(crate) fn line_of(text: &str, at: usize) -> usize { + 1 + text.as_bytes()[..at.min(text.len())] + .iter() + .filter(|&&b| b == b'\n') + .count() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_rel_normalises_and_rejects_escapes() { + assert_eq!( + resolve_rel("", "a/b", "../c.gradle").as_deref(), + Some("a/c.gradle") + ); + assert_eq!(resolve_rel("", "", "./x/./y").as_deref(), Some("x/y")); + assert_eq!(resolve_rel("", "a", "../.."), None); + assert_eq!(resolve_rel("root", "root/a", "../../x"), None); + assert_eq!(resolve_rel("", "", "/etc/x"), None); + assert_eq!(resolve_rel("", "", "C:/x"), None); + assert_eq!(resolve_rel("", "", "a\\b"), None); + assert_eq!(resolve_rel("", "", "https://h/x.gradle"), None); + assert_eq!(resolve_rel("", "", "~/x"), None); + } + + #[test] + fn env_slice_last_wins() { + let env: &[(&str, &str)] = &[("A", "1"), ("A", "2")]; + assert_eq!(env.var("A").as_deref(), Some("2")); + assert_eq!(env.var("B"), None); + } + + /// The module stays pure: no filesystem, process-environment or + /// network access in any of its files (adapters live with callers). + #[test] + fn module_uses_no_fs_env_or_network() { + let sources = [ + ("mod.rs", include_str!("mod.rs")), + ("dsl.rs", include_str!("dsl.rs")), + ("eol.rs", include_str!("eol.rs")), + ("selector.rs", include_str!("selector.rs")), + ]; + // Spelled in pieces so this test does not match itself. + let banned = [ + ["std", "::fs"].concat(), + ["std", "::env"].concat(), + ["std", "::net"].concat(), + ["std", "::process"].concat(), + ["tokio", "::fs"].concat(), + ["reqwest", "::"].concat(), + ["use std", "::{"].concat(), + ]; + for (name, src) in sources { + for needle in &banned { + assert!( + !src.contains(needle.as_str()), + "gradle/{name} uses {needle}" + ); + } + } + } + + #[test] + fn line_of_counts_newlines() { + assert_eq!(line_of("a\nb\r\nc", 0), 1); + assert_eq!(line_of("a\nb\r\nc", 2), 2); + assert_eq!(line_of("a\nb\r\nc", 5), 3); + } +} diff --git a/crates/socket-patch-core/src/gradle/selector.rs b/crates/socket-patch-core/src/gradle/selector.rs new file mode 100644 index 000000000..7b49e4f17 --- /dev/null +++ b/crates/socket-patch-core/src/gradle/selector.rs @@ -0,0 +1,577 @@ +//! Gradle's version ordering and version selectors, ported from Gradle's +//! `VersionParser`, `StaticVersionComparator`, `VersionRangeSelector` and +//! `DefaultVersionSelectorScheme`. +//! +//! The ordering matters for hosted mode: a suffixed patched version +//! `1.10.0-socket.4d5e6f70` sorts BELOW its base `1.10.0` (an extra +//! non-numeric part makes a version lower) and above every earlier +//! release. The hosted settings script carries a Groovy port of this +//! module; the `GOLDEN_*` tables are the shared cases both ports are tested +//! against, and `tests/gradle_selector_golden.rs` checks them against real +//! Gradle. +//! +//! Two behaviours changed in Gradle 7 (measured on 6.9.4 against 7.6.6, +//! 8.14.3 and 9.8.0), so the `*_for` functions take the Gradle major: +//! +//! - Gradle 6 ranks only `dev` < (any other word) < `rc` < `release` < +//! `final`; Gradle 7+ ranks `dev` < (any other word) < `rc` < `snapshot` +//! < `final` < `ga` < `release` < `sp` (case-insensitively). +//! - Gradle 7+ makes an exclusive upper bound also exclude the qualified +//! versions of that bound: `[1.9,1.10.0)` rejects `1.10.0-rc1` and +//! `1.10.0-socket.4d5e6f70`, which Gradle 6 admits because they sort +//! below `1.10.0`. +//! +//! The plain functions use the current (7+) semantics. + +use std::cmp::Ordering; +use std::sync::OnceLock; + +/// The Gradle major the plain (non-`_for`) functions model. +pub const CURRENT_MAJOR: u32 = 9; + +/// One end of a version range. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Bound { + pub version: String, + pub inclusive: bool, +} + +/// A parsed version selector (the version part of a dependency request). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Selector { + /// A plain version (`1.10.0`), also the single-value range `[1.10.0]`. + /// Like Gradle, anything that is not a well-formed range, prefix or + /// `latest.*` selector is an exact (string-equality) selector, so + /// `[1.0` or `[1,2),[3,4)` only admit themselves. + Exact(String), + /// `[a,b]`, `[a,b)`, `]a,b]`, `(a,b]`, `[a,b[`, `(,b]`, `[a,)`; + /// `None` = open. + Range { + lower: Option, + upper: Option, + }, + /// `1.+`, `1.1+`, `+`: the text before the `+`. + Prefix(String), + /// `latest.release`, `latest.integration`, `latest.`. + Latest(String), + /// No version (Gradle: any version, decided by constraints), an + /// interpolated one, or a `!!` strict shorthand not yet split. + Unknown, +} + +struct RangeRes { + finite: regex::Regex, + lower_infinite: regex::Regex, + upper_infinite: regex::Regex, + single: regex::Regex, +} + +fn range_res() -> &'static RangeRes { + static RES: OnceLock = OnceLock::new(); + RES.get_or_init(|| { + // Gradle's VersionRangeSelector patterns. + const ANY: &str = r"[^\[\]\(\),]+?"; + let re = |p: String| regex::Regex::new(&p).expect("static range pattern"); + RangeRes { + finite: re(format!( + r"^([\[\]\(])\s*({ANY})\s*,\s*({ANY})\s*([\]\[\)])$" + )), + lower_infinite: re(format!(r"^\(\s*,\s*({ANY})\s*([\]\[\)])$")), + upper_infinite: re(format!(r"^([\[\]\(])\s*({ANY})\s*,\s*\)$")), + single: re(format!(r"^\[\s*({ANY})\s*\]$")), + } + }) +} + +/// Parse a version selector the way Gradle does. +pub fn parse_selector(s: &str) -> Selector { + if s.is_empty() || s.contains('$') || s.contains("!!") { + return Selector::Unknown; + } + if let Some(range) = parse_range(s) { + return range; + } + if let Some(prefix) = s.strip_suffix('+') { + return Selector::Prefix(prefix.to_string()); + } + if s.starts_with("latest.") { + return Selector::Latest(s.to_string()); + } + Selector::Exact(s.to_string()) +} + +fn parse_range(s: &str) -> Option { + let res = range_res(); + let bound = |v: &str, inclusive: bool| Bound { + version: v.to_string(), + inclusive, + }; + if let Some(c) = res.single.captures(s) { + return Some(Selector::Exact(c[1].to_string())); + } + if let Some(c) = res.finite.captures(s) { + return Some(Selector::Range { + lower: Some(bound(&c[2], &c[1] == "[")), + upper: Some(bound(&c[3], &c[4] == "]")), + }); + } + if let Some(c) = res.lower_infinite.captures(s) { + return Some(Selector::Range { + lower: None, + upper: Some(bound(&c[1], &c[2] == "]")), + }); + } + if let Some(c) = res.upper_infinite.captures(s) { + return Some(Selector::Range { + lower: Some(bound(&c[2], &c[1] == "[")), + upper: None, + }); + } + None +} + +/// Whether `sel` admits version `v` on current Gradle. `None` when that +/// depends on more than the version string (`latest.*` needs the module's +/// status) or the selector is [`Selector::Unknown`]. +pub fn admits(sel: &Selector, v: &str) -> Option { + admits_for(sel, v, CURRENT_MAJOR) +} + +/// [`admits`] as Gradle `major` decides it. +pub fn admits_for(sel: &Selector, v: &str, major: u32) -> Option { + match sel { + Selector::Exact(e) => Some(e == v), + Selector::Prefix(p) => Some(v.starts_with(p.as_str())), + Selector::Range { lower, upper } => { + let lower_ok = lower.as_ref().is_none_or(|b| { + let c = gradle_version_cmp_for(v, &b.version, major); + c == Ordering::Greater || (b.inclusive && c == Ordering::Equal) + }); + let upper_ok = upper.as_ref().is_none_or(|b| { + let c = gradle_version_cmp_for(v, &b.version, major); + if b.inclusive { + c != Ordering::Greater + } else { + c == Ordering::Less && !(major >= 7 && qualifies(v, &b.version)) + } + }); + Some(lower_ok && upper_ok) + } + Selector::Latest(_) | Selector::Unknown => None, + } +} + +/// Whether `v` is `bound` plus more parts (`1.10.0-rc1` of `1.10.0`). +fn qualifies(v: &str, bound: &str) -> bool { + let (pv, pb) = (version_parts(v), version_parts(bound)); + pv.len() > pb.len() && pv[..pb.len()] == pb[..] +} + +/// Gradle's parts of a version: split at `.`, `-`, `_`, `+` and between +/// digits and non-digits. Empty parts between two separators are kept; a +/// trailing separator adds none. +pub fn version_parts(v: &str) -> Vec<&str> { + let b = v.as_bytes(); + let mut parts = Vec::new(); + let mut start = 0; + let mut digit = false; + for (pos, &ch) in b.iter().enumerate() { + if matches!(ch, b'.' | b'_' | b'-' | b'+') { + parts.push(&v[start..pos]); + start = pos + 1; + digit = false; + } else if ch.is_ascii_digit() { + if !digit && pos > start { + parts.push(&v[start..pos]); + start = pos; + } + digit = true; + } else { + if digit && pos > start { + parts.push(&v[start..pos]); + start = pos; + } + digit = false; + } + } + if b.len() > start { + parts.push(&v[start..]); + } + parts +} + +fn is_numeric(part: &str) -> bool { + !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit()) +} + +fn numeric_cmp(a: &str, b: &str) -> Ordering { + let a = a.trim_start_matches('0'); + let b = b.trim_start_matches('0'); + a.len().cmp(&b.len()).then_with(|| a.cmp(b)) +} + +/// Gradle's rank of the qualifiers it treats specially: `dev` below any +/// other word, the rest above any other word in this order. +fn special(part: &str, major: u32) -> Option { + let lower = part.to_ascii_lowercase(); + if major < 7 { + return match lower.as_str() { + "dev" => Some(-1), + "rc" => Some(1), + "release" => Some(2), + "final" => Some(3), + _ => None, + }; + } + match lower.as_str() { + "dev" => Some(-1), + "rc" => Some(1), + "snapshot" => Some(2), + "final" => Some(3), + "ga" => Some(4), + "release" => Some(5), + "sp" => Some(6), + _ => None, + } +} + +/// Compare two versions the way current Gradle orders them. +pub fn gradle_version_cmp(a: &str, b: &str) -> Ordering { + gradle_version_cmp_for(a, b, CURRENT_MAJOR) +} + +/// [`gradle_version_cmp`] as Gradle `major` orders them. +pub fn gradle_version_cmp_for(a: &str, b: &str, major: u32) -> Ordering { + if a == b { + return Ordering::Equal; + } + let pa = version_parts(a); + let pb = version_parts(b); + let common = pa.len().min(pb.len()); + for i in 0..common { + let (x, y) = (pa[i], pb[i]); + match (is_numeric(x), is_numeric(y)) { + (true, true) => match numeric_cmp(x, y) { + Ordering::Equal => continue, + o => return o, + }, + (true, false) => return Ordering::Greater, + (false, true) => return Ordering::Less, + (false, false) => {} + } + if x == y { + continue; + } + return match (special(x, major), special(y, major)) { + (Some(s), Some(t)) => s.cmp(&t), + (Some(s), None) => { + if s < 0 { + Ordering::Less + } else { + Ordering::Greater + } + } + (None, Some(t)) => { + if t < 0 { + Ordering::Greater + } else { + Ordering::Less + } + } + (None, None) => x.cmp(y), + }; + } + // One is a prefix of the other: an extra numeric part makes it higher, + // an extra qualifier lower. + match pa.len().cmp(&pb.len()) { + Ordering::Greater => { + if is_numeric(pa[common]) { + Ordering::Greater + } else { + Ordering::Less + } + } + Ordering::Less => { + if is_numeric(pb[common]) { + Ordering::Less + } else { + Ordering::Greater + } + } + Ordering::Equal => Ordering::Equal, + } +} + +/// `(a, b, gradle_version_cmp_for(a, b, major))` on every Gradle major +/// from 6 to 9. +pub const GOLDEN_ORDERING: &[(&str, &str, Ordering)] = &[ + ("1.10.0-socket.4d5e6f70", "1.10.0", Ordering::Less), + ("1.10.0", "1.10.0-socket.4d5e6f70", Ordering::Greater), + ("1.10.0-socket.4d5e6f70", "1.9", Ordering::Greater), + ("1.10.0-socket.4d5e6f70", "1.10.1", Ordering::Less), + ( + "1.10.0-socket.4d5e6f70", + "1.10.0-socket.4d5e6f70", + Ordering::Equal, + ), + ( + "1.10.0-socket.0abcdef1", + "1.10.0-socket.4d5e6f70", + Ordering::Less, + ), + ("1.10.0-socket.4d5e6f70", "1.10.0-rc1", Ordering::Less), + ("1.10.0-socket.4d5e6f70", "1.10.0-alpha1", Ordering::Greater), + ("2.13.4-socket.00000001", "2.13.4", Ordering::Less), + ("2.13.4-socket.00000001", "2.13.3", Ordering::Greater), + ("1.9", "1.10.0", Ordering::Less), + ("1.10", "1.10.0", Ordering::Less), + ("2.0", "10.0", Ordering::Less), + ("1.01", "1.1", Ordering::Equal), + ("1.0a", "1.0-a", Ordering::Equal), + ("1.0-SNAPSHOT", "1.0", Ordering::Less), + ("1.0-rc-1", "1.0", Ordering::Less), + ("1.0-alpha", "1.0-beta", Ordering::Less), + ("1.0-dev", "1.0-alpha", Ordering::Less), + ("1.0-snapshot", "1.0-final", Ordering::Less), + ("1.0-ga", "1.0-release", Ordering::Less), + ("1.0-rc", "1.0-release", Ordering::Less), + ("1.0-sp", "1.0", Ordering::Less), + ("1.0-RC", "1.0-rc", Ordering::Equal), + ("1.0.1", "1.0-beta", Ordering::Greater), + ("1.0.0.Final", "1.0.0", Ordering::Less), + ("31.1-jre", "31.1-android", Ordering::Greater), + ("1.2.3+build.5", "1.2.3", Ordering::Less), +]; + +/// Orderings that differ on Gradle 6: `(a, b, on 6, on 7+)`. +pub const GOLDEN_ORDERING_BY_MAJOR: &[(&str, &str, Ordering, Ordering)] = &[ + ( + "1.10.0-socket.4d5e6f70", + "1.10.0-SNAPSHOT", + Ordering::Greater, + Ordering::Less, + ), + ("1.0-rc", "1.0-snapshot", Ordering::Greater, Ordering::Less), + ("1.0-final", "1.0-ga", Ordering::Greater, Ordering::Less), + ("1.0-release", "1.0-sp", Ordering::Greater, Ordering::Less), + ( + "1.0-release", + "1.0-final", + Ordering::Less, + Ordering::Greater, + ), + ("1.0-SNAPSHOT", "1.0-a", Ordering::Less, Ordering::Greater), +]; + +/// `(selector, version, admits_for(parse_selector(selector), version, +/// major))` on every Gradle major from 6 to 9. +pub const GOLDEN_ADMITS: &[(&str, &str, Option)] = &[ + ("[1.9,1.10.0]", "1.10.0", Some(true)), + ("[1.9,1.10.0)", "1.10.0", Some(false)), + ("[1.9,1.11)", "1.10.0", Some(true)), + ("[1.9,1.11)", "1.10.0-socket.4d5e6f70", Some(true)), + ("[1.9, 1.11)", "1.10.0", Some(true)), + ("[ 1.9 , 1.11 ]", "1.10.0", Some(true)), + ("[1.9,1.11)", "1.11", Some(false)), + ("[1.9,1.11[", "1.11", Some(false)), + ("]1.9,1.10.0]", "1.9", Some(false)), + ("(1.9,1.10.0]", "1.9", Some(false)), + ("(1.9,1.10.0)", "1.9.5", Some(true)), + ("(,1.10.0]", "1.10.0", Some(true)), + ("[1.10.0,)", "1.10.0", Some(true)), + ("[1.10.0,)", "1.10.0-socket.4d5e6f70", Some(false)), + ("[1.10.0,)", "1.11", Some(true)), + ("]1.9,)", "1.10.0", Some(true)), + ("[1.10.0]", "1.10.0", Some(true)), + ("[1.10.0]", "1.10.0-socket.4d5e6f70", Some(false)), + ("[1.0,2.0)", "2-rc1", Some(true)), + ("[1.0,2.0-rc1)", "2.0-rc0", Some(true)), + // Not ranges to Gradle: exact selectors that only admit themselves. + ("],1.10.0]", "1.10.0", Some(false)), + ("[1.10.0,]", "1.10.0", Some(false)), + ("[1.10.0,[", "1.10.0", Some(false)), + ("(,)", "1.10.0", Some(false)), + ("[1.9", "1.9", Some(false)), + ("[1,2),[3,4)", "1.5", Some(false)), + (" 1.10.0", "1.10.0", Some(false)), + ("1.+", "1.10.0", Some(true)), + ("1.1+", "1.10.0", Some(true)), + ("1.2+", "1.10.0", Some(false)), + ("+", "1.10.0", Some(true)), + ("1.10.0", "1.10.0", Some(true)), + ("1.10.0", "1.10", Some(false)), + ("1.10.0", "1.10.0-socket.4d5e6f70", Some(false)), + ("latest.release", "1.10.0", None), + ("latest.integration", "1.10.0", None), +]; + +/// Admissions that differ on Gradle 6: `(selector, version, on 6, on 7+)`. +pub const GOLDEN_ADMITS_BY_MAJOR: &[(&str, &str, Option, Option)] = &[ + ( + "[1.9,1.10.0)", + "1.10.0-socket.4d5e6f70", + Some(true), + Some(false), + ), + ("[1.0,2.0)", "2.0-rc1", Some(true), Some(false)), + ("[1.0,2.0[", "2.0-SNAPSHOT", Some(true), Some(false)), + ("(,2.0)", "2.0-dev", Some(true), Some(false)), + ("[1.0,2)", "2-rc1", Some(true), Some(false)), + ("[1.0,2.0-rc1)", "2.0-SNAPSHOT", Some(true), Some(false)), + ("[1.0,2.0-rc1)", "2.0-rc1-x", Some(true), Some(false)), +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn golden_ordering() { + for major in 6..=9 { + for (a, b, want) in GOLDEN_ORDERING { + assert_eq!( + gradle_version_cmp_for(a, b, major), + *want, + "{a} vs {b} on {major}" + ); + assert_eq!( + gradle_version_cmp_for(b, a, major), + want.reverse(), + "{b} vs {a} on {major}" + ); + } + for (a, b, six, later) in GOLDEN_ORDERING_BY_MAJOR { + let want = if major < 7 { six } else { later }; + assert_eq!( + gradle_version_cmp_for(a, b, major), + *want, + "{a} vs {b} on {major}" + ); + assert_eq!(gradle_version_cmp_for(b, a, major), want.reverse()); + } + } + assert_eq!( + gradle_version_cmp("1.0-final", "1.0-ga"), + Ordering::Less, + "the plain function models current Gradle" + ); + } + + #[test] + fn golden_admits() { + for major in 6..=9 { + for (sel, v, want) in GOLDEN_ADMITS { + assert_eq!( + admits_for(&parse_selector(sel), v, major), + *want, + "{sel} admits {v} on {major}" + ); + } + for (sel, v, six, later) in GOLDEN_ADMITS_BY_MAJOR { + let want = if major < 7 { six } else { later }; + assert_eq!( + admits_for(&parse_selector(sel), v, major), + *want, + "{sel} admits {v} on {major}" + ); + } + } + assert_eq!( + admits(&parse_selector("[1.9,1.10.0]"), "1.10.0"), + Some(true) + ); + assert_eq!( + admits(&parse_selector("[1.9,1.10.0)"), "1.10.0-socket.4d5e6f70"), + Some(false) + ); + } + + #[test] + fn selector_kinds() { + assert_eq!(parse_selector("1.+"), Selector::Prefix("1.".into())); + assert_eq!(parse_selector("1.10.0"), Selector::Exact("1.10.0".into())); + assert_eq!(parse_selector("[1.10.0]"), Selector::Exact("1.10.0".into())); + assert_eq!( + parse_selector("latest.release"), + Selector::Latest("latest.release".into()) + ); + assert_eq!( + parse_selector("[1.9,1.10.0)"), + Selector::Range { + lower: Some(Bound { + version: "1.9".into(), + inclusive: true + }), + upper: Some(Bound { + version: "1.10.0".into(), + inclusive: false + }), + } + ); + assert_eq!( + parse_selector("]1.9,)"), + Selector::Range { + lower: Some(Bound { + version: "1.9".into(), + inclusive: false + }), + upper: None, + } + ); + for unknown in ["", "$v", "${v}", "1.0!!", "[1.0,2.0)!!1.5"] { + assert_eq!(parse_selector(unknown), Selector::Unknown, "{unknown:?}"); + } + for exact in [ + "[1.0", + "[,]", + "(1.0]", + "[1,2),[3,4)", + "1.0,2.0", + "[1.0,)x", + "1 .0", + ] { + assert_eq!( + parse_selector(exact), + Selector::Exact(exact.into()), + "{exact:?}" + ); + } + assert_eq!(admits(&Selector::Unknown, "1"), None); + } + + #[test] + fn parts_follow_gradle() { + assert_eq!( + version_parts("1.10.0-socket.4d5e6f70"), + ["1", "10", "0", "socket", "4", "d", "5", "e", "6", "f", "70"] + ); + assert_eq!(version_parts("1..2"), ["1", "", "2"]); + assert_eq!(version_parts("1.0-"), ["1", "0"]); + assert_eq!(version_parts("rc1"), ["rc", "1"]); + assert!(version_parts("").is_empty()); + } + + #[test] + fn suffixed_versions_sort_between_neighbours() { + let mut vs = vec![ + "1.10.1", + "1.10.0", + "1.10.0-socket.4d5e6f70", + "1.9", + "1.10.0-rc1", + "1.9.9", + ]; + vs.sort_by(|a, b| gradle_version_cmp(a, b)); + assert_eq!( + vs, + [ + "1.9", + "1.9.9", + "1.10.0-socket.4d5e6f70", + "1.10.0-rc1", + "1.10.0", + "1.10.1" + ] + ); + } +} diff --git a/crates/socket-patch-core/src/lib.rs b/crates/socket-patch-core/src/lib.rs index f257d0bef..143eae979 100644 --- a/crates/socket-patch-core/src/lib.rs +++ b/crates/socket-patch-core/src/lib.rs @@ -2,6 +2,7 @@ pub mod api; pub mod constants; pub mod crawlers; pub mod formats; +pub mod gradle; pub mod hash; pub mod hosted; pub mod ledgers; diff --git a/crates/socket-patch-core/tests/gradle_selector_golden.rs b/crates/socket-patch-core/tests/gradle_selector_golden.rs new file mode 100644 index 000000000..449b7f4be --- /dev/null +++ b/crates/socket-patch-core/tests/gradle_selector_golden.rs @@ -0,0 +1,195 @@ +//! Checks the `gradle::selector` golden tables (and the Rust port itself) +//! against real Gradle's own version comparator and selector scheme. +//! +//! The hosted settings script ships a Groovy port of the selector logic and +//! is tested against the same tables, so a table entry that real Gradle +//! disagrees with would make both ports wrong together. This suite asks +//! Gradle directly, through its internal `VersionParser`, +//! `DefaultVersionComparator` and `DefaultVersionSelectorScheme` (stable +//! from 6.9 through 9.x). +//! +//! It runs only when `SOCKET_PATCH_GRADLE_E2E_GRADLE` names a Gradle +//! launcher (the JDK comes from the ambient `JAVA_HOME`); +//! `SOCKET_PATCH_GRADLE_E2E_REQUIRED` turns the skip into a failure and +//! `SOCKET_PATCH_GRADLE_E2E_VERSION` pins the version the launcher must +//! report. + +use std::process::Command; + +use socket_patch_core::gradle::selector::{ + admits_for, gradle_version_cmp_for, parse_selector, GOLDEN_ADMITS, GOLDEN_ADMITS_BY_MAJOR, + GOLDEN_ORDERING, GOLDEN_ORDERING_BY_MAJOR, +}; + +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"; + +fn flag(name: &str) -> bool { + std::env::var_os(name).is_some_and(|v| !v.is_empty()) +} + +fn groovy_str(s: &str) -> String { + format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'")) +} + +/// A build script printing Gradle's answer for every table row. +fn probe_script() -> String { + let mut ords: Vec<(&str, &str)> = GOLDEN_ORDERING.iter().map(|(a, b, _)| (*a, *b)).collect(); + ords.extend(GOLDEN_ORDERING_BY_MAJOR.iter().map(|(a, b, _, _)| (*a, *b))); + let mut adm: Vec<(&str, &str)> = GOLDEN_ADMITS.iter().map(|(s, v, _)| (*s, *v)).collect(); + adm.extend(GOLDEN_ADMITS_BY_MAJOR.iter().map(|(s, v, _, _)| (*s, *v))); + let list = |rows: &[(&str, &str)]| { + rows.iter() + .map(|(a, b)| format!(" [{}, {}],", groovy_str(a), groovy_str(b))) + .collect::>() + .join("\n") + }; + format!( + r#"import org.gradle.api.internal.artifacts.ivyservice.ivyresolve.strategy.* +def parser = new VersionParser() +def cmp = new DefaultVersionComparator() +def scheme = new DefaultVersionSelectorScheme(cmp, parser) +def ords = [ +{} +] +def adm = [ +{} +] +println "VER\t${{gradle.gradleVersion}}" +for (r in ords) {{ + int c = Integer.signum(cmp.asVersionComparator().compare(parser.transform(r[0]), parser.transform(r[1]))) + println "ORD\t${{r[0]}}\t${{r[1]}}\t${{c}}" +}} +for (r in adm) {{ + def sel = scheme.parseSelector(r[0]) + def got = sel.requiresMetadata() ? 'None' : String.valueOf(sel.accept(r[1])) + println "ADM\t${{r[0]}}\t${{r[1]}}\t${{got}}" +}} +"#, + list(&ords), + list(&adm) + ) +} + +#[test] +fn gradle_hosted_selector_golden_tables_match_real_gradle() { + let Some(program) = std::env::var_os(GRADLE_ENV).filter(|v| !v.is_empty()) else { + assert!( + !flag(GRADLE_REQUIRED_ENV), + "{GRADLE_REQUIRED_ENV} is set but {GRADLE_ENV} names no Gradle launcher" + ); + println!("SKIP: {GRADLE_ENV} is not set"); + return; + }; + let tmp = tempfile::tempdir().expect("tempdir"); + let project = tmp.path().join("probe"); + std::fs::create_dir_all(&project).expect("mkdir"); + std::fs::write( + project.join("settings.gradle"), + "rootProject.name = 'probe'\n", + ) + .expect("settings"); + std::fs::write(project.join("build.gradle"), probe_script()).expect("build"); + let mut cmd = Command::new(&program); + for key in ["GRADLE_OPTS", "JAVA_OPTS"] { + cmd.env_remove(key); + } + let out = cmd + .env("GRADLE_USER_HOME", tmp.path().join("home")) + .current_dir(&project) + .args(["--no-daemon", "--console=plain", "-q", "help"]) + .output() + .expect("spawn gradle"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + out.status.success(), + "gradle failed:\n{stdout}\n{}", + String::from_utf8_lossy(&out.stderr) + ); + + let version = stdout + .lines() + .find_map(|l| l.strip_prefix("VER\t")) + .expect("version line") + .trim() + .to_string(); + if let Some(pin) = std::env::var(GRADLE_VERSION_ENV) + .ok() + .filter(|v| !v.is_empty()) + { + assert_eq!(version, pin, "{GRADLE_VERSION_ENV} pins {pin}"); + } + let major: u32 = version + .split('.') + .next() + .and_then(|m| m.parse().ok()) + .expect("major"); + println!("checking the golden tables against Gradle {version}"); + + let mut mismatches = Vec::new(); + let mut ord_rows = 0; + let mut adm_rows = 0; + for line in stdout.lines() { + let cols: Vec<&str> = line.split('\t').collect(); + match cols.as_slice() { + ["ORD", a, b, c] => { + ord_rows += 1; + let gradle = c.parse::().expect("signum").cmp(&0); + let table = GOLDEN_ORDERING + .iter() + .find(|(x, y, _)| x == a && y == b) + .map(|(_, _, o)| *o) + .or_else(|| { + GOLDEN_ORDERING_BY_MAJOR + .iter() + .find(|(x, y, _, _)| x == a && y == b) + .map(|(_, _, six, later)| if major < 7 { *six } else { *later }) + }) + .expect("row"); + let port = gradle_version_cmp_for(a, b, major); + if gradle != table || gradle != port { + mismatches.push(format!( + "order {a} vs {b}: gradle {gradle:?}, table {table:?}, port {port:?}" + )); + } + } + ["ADM", sel, v, got] => { + adm_rows += 1; + let gradle = match *got { + "None" => None, + "true" => Some(true), + _ => Some(false), + }; + let table = GOLDEN_ADMITS + .iter() + .find(|(s, x, _)| s == sel && x == v) + .map(|(_, _, w)| *w) + .or_else(|| { + GOLDEN_ADMITS_BY_MAJOR + .iter() + .find(|(s, x, _, _)| s == sel && x == v) + .map(|(_, _, six, later)| if major < 7 { *six } else { *later }) + }) + .expect("row"); + let port = admits_for(&parse_selector(sel), v, major); + if gradle != table || gradle != port { + mismatches.push(format!( + "{sel:?} admits {v}: gradle {gradle:?}, table {table:?}, port {port:?}" + )); + } + } + _ => {} + } + } + assert_eq!( + ord_rows, + GOLDEN_ORDERING.len() + GOLDEN_ORDERING_BY_MAJOR.len() + ); + assert_eq!(adm_rows, GOLDEN_ADMITS.len() + GOLDEN_ADMITS_BY_MAJOR.len()); + assert!( + mismatches.is_empty(), + "Gradle {version} disagrees:\n{}", + mismatches.join("\n") + ); +} From 337ada3e49cdb838f81f5b5d3edc9fc8842701ee Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 2 Oct 2026 11:37:12 -0400 Subject: [PATCH 11/63] Add Gradle user-home resolution and dependency-lock parsing Discovery and agent mode must find the same Gradle caches Gradle itself uses, and hosted mode must rewrite locked versions without disturbing anything else in a lock file. - home: GradleHome::resolve over an explicit Env (the process-env adapter lives with the crawler): -Dgradle.user.home from GRADLE_OPTS then JAVA_OPTS (quote-aware per OS, last wins), a non-empty GRADLE_USER_HOME, then /.gradle with USERPROFILE first on Windows; the files-2.1 cache, the read-only GRADLE_RO_DEP_CACHE copy, GRADLE_HOME, and the init-script locations (init.gradle(.kts), both init.d directories, sorted as Gradle runs them). - locks: every gradle.lockfile / buildscript- / settings- lock file and legacy gradle/dependency-locks/*.lockfile under a root (pruning build output, .gradle, node_modules, .socket and .git, eight levels deep), a parser for both formats with empty= and CRLF, and a one-entry rewrite that keeps each line's ending and configuration tail and merges into an already-locked target version. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-core/src/gradle/home.rs | 411 ++++++++++++++++++ crates/socket-patch-core/src/gradle/locks.rs | 417 +++++++++++++++++++ crates/socket-patch-core/src/gradle/mod.rs | 56 +++ 3 files changed, 884 insertions(+) create mode 100644 crates/socket-patch-core/src/gradle/home.rs create mode 100644 crates/socket-patch-core/src/gradle/locks.rs diff --git a/crates/socket-patch-core/src/gradle/home.rs b/crates/socket-patch-core/src/gradle/home.rs new file mode 100644 index 000000000..46339b70c --- /dev/null +++ b/crates/socket-patch-core/src/gradle/home.rs @@ -0,0 +1,411 @@ +//! The Gradle user home and the dependency caches in it, resolved from an +//! explicit environment (the process-env adapter lives in +//! `crawlers::gradle_cache`). +//! +//! Gradle picks its user home from, in order: the `gradle.user.home` +//! system property (`-Dgradle.user.home=…` in `GRADLE_OPTS`, which the +//! launcher places after `JAVA_OPTS`, so it wins), `GRADLE_USER_HOME`, and +//! `/.gradle`. Downloaded modules live in +//! `/caches/modules-2/files-2.1`; a read-only shared cache can +//! sit beside it at `$GRADLE_RO_DEP_CACHE/modules-2/files-2.1`. + +use std::path::{Path, PathBuf}; + +use super::{Env, Os}; + +/// A resolved Gradle user home. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GradleHome { + pub user_home: PathBuf, + /// `/caches/modules-2/files-2.1`. + pub files21: PathBuf, + /// `$GRADLE_RO_DEP_CACHE/modules-2/files-2.1`; scanned, never written. + pub ro_files21: Option, + /// `$GRADLE_HOME` (a Gradle distribution), for its `init.d`. + pub gradle_home: Option, +} + +/// The `files-2.1` directory of a cache root (a user home or the +/// read-only cache): `/caches/modules-2/files-2.1` for a user +/// home. +pub fn files21_of_user_home(user_home: &Path) -> PathBuf { + user_home.join("caches").join("modules-2").join("files-2.1") +} + +/// A non-empty variable. +fn var(env: &dyn Env, k: &str) -> Option { + env.var(k).filter(|v| !v.is_empty()) +} + +impl GradleHome { + /// Resolve the user home from `env`. `home_dir` is the account's home + /// directory, used when the environment names none (`HOME`; on + /// Windows `USERPROFILE` first). `None` when no home can be found. + pub fn resolve(env: &dyn Env, os: Os, home_dir: Option<&Path>) -> Option { + let user_home = ["GRADLE_OPTS", "JAVA_OPTS"] + .iter() + .find_map(|k| { + var(env, k) + .and_then(|opts| system_property(&opts, "gradle.user.home", os)) + .filter(|v| !v.is_empty()) + }) + .map(PathBuf::from) + .or_else(|| var(env, "GRADLE_USER_HOME").map(PathBuf::from)) + .or_else(|| { + let home = match os { + Os::Windows => var(env, "USERPROFILE").or_else(|| var(env, "HOME")), + Os::Unix => var(env, "HOME"), + }; + home.map(PathBuf::from) + .or_else(|| home_dir.map(Path::to_path_buf)) + .map(|h| h.join(".gradle")) + })?; + Some(Self { + files21: files21_of_user_home(&user_home), + ro_files21: var(env, "GRADLE_RO_DEP_CACHE") + .map(|ro| PathBuf::from(ro).join("modules-2").join("files-2.1")), + gradle_home: var(env, "GRADLE_HOME").map(PathBuf::from), + user_home, + }) + } + + /// The fixed init-script files: `/init.gradle` and + /// `/init.gradle.kts`. + pub fn init_script_paths(&self) -> Vec { + vec![ + self.user_home.join("init.gradle"), + self.user_home.join("init.gradle.kts"), + ] + } + + /// The init-script directories: `/init.d` and + /// `$GRADLE_HOME/init.d`. Every `*.gradle` / `*.gradle.kts` in them + /// runs (see [`is_init_script_name`]). + pub fn init_dirs(&self) -> Vec { + let mut dirs = vec![self.user_home.join("init.d")]; + if let Some(g) = &self.gradle_home { + dirs.push(g.join("init.d")); + } + dirs + } + + /// Every init-script candidate in Gradle's order: the fixed files, + /// then each init directory's scripts sorted by name. `list` returns a + /// directory's child names (directories ending in `/`); whether the + /// fixed files exist is the caller's to check. + pub fn init_scripts_with(&self, list: &dyn Fn(&Path) -> Vec) -> Vec { + let mut out = self.init_script_paths(); + for dir in self.init_dirs() { + let mut names: Vec = list(&dir) + .into_iter() + .filter(|n| is_init_script_name(n)) + .collect(); + names.sort(); + out.extend(names.into_iter().map(|n| dir.join(n))); + } + out + } +} + +/// Whether a file in an `init.d` directory is an init script. +pub fn is_init_script_name(name: &str) -> bool { + !name.ends_with('/') && (name.ends_with(".gradle") || name.ends_with(".gradle.kts")) +} + +/// The value of `-D=…` in a JVM option string; the last one wins. +/// `-D` without a value is the empty string. +pub fn system_property(opts: &str, name: &str, os: Os) -> Option { + let flag = format!("-D{name}"); + split_opts(opts, os).into_iter().rev().find_map(|arg| { + let rest = arg.strip_prefix(&flag)?; + if rest.is_empty() { + Some(String::new()) + } else { + rest.strip_prefix('=').map(str::to_string) + } + }) +} + +/// Split a JVM option string into arguments the way the launcher does: +/// on Unix like `xargs` (single and double quotes group, a backslash +/// escapes the next character outside single quotes); on Windows only +/// double quotes group and backslashes are literal (they are path +/// separators there). +pub fn split_opts(opts: &str, os: Os) -> Vec { + let mut out = Vec::new(); + let mut cur = String::new(); + let mut in_arg = false; + let mut quote: Option = None; + let mut chars = opts.chars(); + while let Some(ch) = chars.next() { + match quote { + Some(q) if ch == q => quote = None, + Some('"') if ch == '\\' && os == Os::Unix => { + if let Some(next) = chars.next() { + cur.push(next); + } + } + Some(_) => cur.push(ch), + None if ch.is_whitespace() => { + if in_arg { + out.push(std::mem::take(&mut cur)); + in_arg = false; + } + } + None => { + in_arg = true; + match ch { + '"' => quote = Some('"'), + '\'' if os == Os::Unix => quote = Some('\''), + '\\' if os == Os::Unix => { + if let Some(next) = chars.next() { + cur.push(next); + } + } + _ => cur.push(ch), + } + } + } + } + if in_arg { + out.push(cur); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn env(pairs: &[(&str, &str)]) -> Vec<(String, String)> { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() + } + + struct VecEnv(Vec<(String, String)>); + + impl Env for VecEnv { + fn var(&self, k: &str) -> Option { + self.0.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone()) + } + } + + #[test] + fn user_home_precedence_table() { + let fallback = Path::new("/fallback"); + // (env, os, expected user home) + type Row<'a> = (&'a [(&'a str, &'a str)], Os, Option<&'a str>); + let table: &[Row<'_>] = &[ + (&[("HOME", "/h")], Os::Unix, Some("/h/.gradle")), + (&[], Os::Unix, Some("/fallback/.gradle")), + (&[("HOME", "")], Os::Unix, Some("/fallback/.gradle")), + ( + &[("GRADLE_USER_HOME", "/g"), ("HOME", "/h")], + Os::Unix, + Some("/g"), + ), + ( + &[("GRADLE_USER_HOME", ""), ("HOME", "/h")], + Os::Unix, + Some("/h/.gradle"), + ), + ( + &[ + ("GRADLE_OPTS", "-Xmx1g -Dgradle.user.home=/o"), + ("GRADLE_USER_HOME", "/g"), + ], + Os::Unix, + Some("/o"), + ), + ( + &[( + "GRADLE_OPTS", + "-Dgradle.user.home=\"/with space/gh\" -Xmx1g", + )], + Os::Unix, + Some("/with space/gh"), + ), + ( + &[("GRADLE_OPTS", "'-Dgradle.user.home=/single q'")], + Os::Unix, + Some("/single q"), + ), + ( + &[("GRADLE_OPTS", "-Dgradle.user.home=/with\\ esc")], + Os::Unix, + Some("/with esc"), + ), + ( + &[("GRADLE_OPTS", "-Dgradle.user.home=/a -Dgradle.user.home=/b")], + Os::Unix, + Some("/b"), + ), + ( + &[ + ("JAVA_OPTS", "-Dgradle.user.home=/j"), + ("GRADLE_USER_HOME", "/g"), + ], + Os::Unix, + Some("/j"), + ), + ( + &[ + ("GRADLE_OPTS", "-Dgradle.user.home=/o"), + ("JAVA_OPTS", "-Dgradle.user.home=/j"), + ], + Os::Unix, + Some("/o"), + ), + ( + &[ + ("GRADLE_OPTS", "-Dgradle.user.home="), + ("JAVA_OPTS", "-Dgradle.user.home=/j"), + ], + Os::Unix, + Some("/j"), + ), + ( + &[("GRADLE_OPTS", "-Dgradle.user.homeX=/x"), ("HOME", "/h")], + Os::Unix, + Some("/h/.gradle"), + ), + ( + &[("USERPROFILE", "C:\\Users\\u"), ("HOME", "/h")], + Os::Windows, + Some("C:\\Users\\u/.gradle"), + ), + ( + &[("USERPROFILE", ""), ("HOME", "/h")], + Os::Windows, + Some("/h/.gradle"), + ), + ( + &[("USERPROFILE", "C:\\Users\\u"), ("HOME", "/h")], + Os::Unix, + Some("/h/.gradle"), + ), + ( + &[("GRADLE_OPTS", "\"-Dgradle.user.home=C:\\Gradle Home\"")], + Os::Windows, + Some("C:\\Gradle Home"), + ), + ( + &[("GRADLE_OPTS", "-Dgradle.user.home=C:\\g\\h")], + Os::Windows, + Some("C:\\g\\h"), + ), + ]; + for (pairs, os, want) in table { + let e = VecEnv(env(pairs)); + let got = GradleHome::resolve(&e, *os, Some(fallback)).map(|h| h.user_home); + let want = want.map(|w| { + // `/.gradle` is a host-OS join. + match w.strip_suffix("/.gradle") { + Some(base) => PathBuf::from(base).join(".gradle"), + None => PathBuf::from(w), + } + }); + assert_eq!(got, want, "{pairs:?} on {os:?}"); + } + assert_eq!( + GradleHome::resolve(&VecEnv(Vec::new()), Os::Unix, None), + None + ); + } + + #[test] + fn caches_and_ro_cache() { + let e = VecEnv(env(&[ + ("GRADLE_USER_HOME", "/g"), + ("GRADLE_RO_DEP_CACHE", "/ro"), + ("GRADLE_HOME", "/dist"), + ])); + let h = GradleHome::resolve(&e, Os::Unix, None).unwrap(); + assert_eq!( + h.files21, + Path::new("/g").join("caches/modules-2/files-2.1") + ); + assert_eq!( + h.ro_files21.as_deref(), + Some(Path::new("/ro").join("modules-2/files-2.1").as_path()) + ); + assert_eq!(h.gradle_home.as_deref(), Some(Path::new("/dist"))); + let e = VecEnv(env(&[ + ("GRADLE_USER_HOME", "/g"), + ("GRADLE_RO_DEP_CACHE", ""), + ])); + assert_eq!( + GradleHome::resolve(&e, Os::Unix, None).unwrap().ro_files21, + None + ); + } + + #[test] + fn init_script_locations() { + let e = VecEnv(env(&[("GRADLE_USER_HOME", "/g"), ("GRADLE_HOME", "/dist")])); + let h = GradleHome::resolve(&e, Os::Unix, None).unwrap(); + assert_eq!( + h.init_script_paths(), + [ + Path::new("/g").join("init.gradle"), + Path::new("/g").join("init.gradle.kts") + ] + ); + assert_eq!( + h.init_dirs(), + [ + Path::new("/g").join("init.d"), + Path::new("/dist").join("init.d") + ] + ); + let list = |d: &Path| -> Vec { + if d == Path::new("/g").join("init.d") { + vec![ + "z.gradle".into(), + "a.gradle.kts".into(), + "notes.txt".into(), + "sub.gradle/".into(), + ] + } else { + vec!["mirror.gradle".into()] + } + }; + assert_eq!( + h.init_scripts_with(&list), + [ + Path::new("/g").join("init.gradle"), + Path::new("/g").join("init.gradle.kts"), + Path::new("/g").join("init.d").join("a.gradle.kts"), + Path::new("/g").join("init.d").join("z.gradle"), + Path::new("/dist").join("init.d").join("mirror.gradle"), + ] + ); + let no_dist = VecEnv(env(&[("GRADLE_USER_HOME", "/g")])); + assert_eq!( + GradleHome::resolve(&no_dist, Os::Unix, None) + .unwrap() + .init_dirs() + .len(), + 1 + ); + } + + #[test] + fn opts_splitting() { + assert_eq!( + split_opts(" -a \"b c\" 'd e' f\\ g ", Os::Unix), + ["-a", "b c", "d e", "f g"] + ); + assert_eq!( + split_opts("-a \"b c\" 'd e' x\\y", Os::Windows), + ["-a", "b c", "'d", "e'", "x\\y"] + ); + assert_eq!(split_opts("\"\"", Os::Unix), [""]); + assert_eq!( + system_property("-Dgradle.user.home", "gradle.user.home", Os::Unix).as_deref(), + Some("") + ); + } +} diff --git a/crates/socket-patch-core/src/gradle/locks.rs b/crates/socket-patch-core/src/gradle/locks.rs new file mode 100644 index 000000000..010da2b07 --- /dev/null +++ b/crates/socket-patch-core/src/gradle/locks.rs @@ -0,0 +1,417 @@ +//! Gradle dependency-lock files. +//! +//! Gradle 6+ keeps one `gradle.lockfile` per project (plus +//! `buildscript-gradle.lockfile` and, from 7.x, `settings-gradle.lockfile` +//! for the build-logic classpaths), each line `group:name:version=conf,…` +//! and an `empty=` line naming configurations that resolved nothing. The +//! legacy (pre-6.0 format) layout keeps one file per configuration under +//! `gradle/dependency-locks/.lockfile`, each line a bare +//! `group:name:version`. +//! +//! Locks never filter scan; hosted mode rewrites the base version to the +//! suffixed one in every lock file, and discovery reports lock membership. + +use super::{join_rel, ListFn}; + +/// Per-project lock file names. +pub const LOCKFILE_NAMES: &[&str] = &[ + "gradle.lockfile", + "buildscript-gradle.lockfile", + "settings-gradle.lockfile", +]; + +/// The legacy per-configuration lock directory, relative to a project. +pub const LEGACY_LOCK_DIR: &str = "gradle/dependency-locks"; + +/// Directories never searched for lock files. +const PRUNED: &[&str] = &["build", ".gradle", "node_modules", ".socket", ".git"]; +/// How deep below the root the search goes. +const MAX_DEPTH: usize = 8; +/// A bound on directories listed, so a huge checkout cannot stall. +const MAX_DIRS: usize = 20_000; + +/// Every lock file in the tree under `root` (in `list`'s path space, so +/// the results join `root`): the per-project files of the root build, +/// its subprojects, `buildSrc` and included builds (with their own +/// subprojects), and every legacy `gradle/dependency-locks/*.lockfile`. +/// `build/`, `.gradle/`, `node_modules/`, `.socket/` and `.git/` are not +/// searched, nor anything deeper than eight directories. Sorted. +pub fn lockfile_paths(list: ListFn<'_>, root: &str) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![(root.to_string(), 0usize)]; + let mut listed = 0usize; + while let Some((dir, depth)) = stack.pop() { + listed += 1; + if listed > MAX_DIRS { + break; + } + for child in list(&dir) { + if let Some(name) = child.strip_suffix('/') { + if name.is_empty() || PRUNED.contains(&name) { + continue; + } + let sub = join_rel(&dir, name); + if name == "gradle" { + let legacy = join_rel(&sub, "dependency-locks"); + for f in list(&legacy) { + if !f.ends_with('/') && f.ends_with(".lockfile") { + out.push(join_rel(&legacy, &f)); + } + } + } + if depth < MAX_DEPTH { + stack.push((sub, depth + 1)); + } + } else if LOCKFILE_NAMES.contains(&child.as_str()) { + out.push(join_rel(&dir, &child)); + } + } + } + out.sort(); + out.dedup(); + out +} + +/// One locked module. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LockEntry { + pub group: String, + pub artifact: String, + pub version: String, + /// The configurations it is locked for; empty in a legacy file. + pub confs: Vec, + /// 1-based line. + pub line: usize, +} + +/// The parsed content of one lock file. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct LockState { + pub entries: Vec, + /// Configurations named on the `empty=` line. + pub empty_confs: Vec, + /// 1-based lines that are neither an entry, a comment nor `empty=`. + pub malformed: Vec, +} + +impl LockState { + /// The entries for `group:artifact`. + pub fn entries_of<'a>( + &'a self, + group: &'a str, + artifact: &'a str, + ) -> impl Iterator + 'a { + self.entries + .iter() + .filter(move |e| e.group == group && e.artifact == artifact) + } +} + +fn split_coords(coords: &str) -> Option<(&str, &str, &str)> { + let mut it = coords.split(':'); + let (g, a, v) = (it.next()?, it.next()?, it.next()?); + (it.next().is_none() && !g.is_empty() && !a.is_empty() && !v.is_empty()).then_some((g, a, v)) +} + +fn split_confs(confs: &str) -> Vec { + confs + .split(',') + .map(str::trim) + .filter(|c| !c.is_empty()) + .map(str::to_string) + .collect() +} + +/// Parse a lock file (either format; CRLF and a BOM are fine). +pub fn parse(text: &str) -> LockState { + let mut state = LockState::default(); + for (idx, raw) in super::dsl::strip_bom(text).lines().enumerate() { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let (coords, confs) = match line.split_once('=') { + Some((c, rest)) => (c.trim(), Some(rest)), + None => (line, None), + }; + if coords == "empty" { + state + .empty_confs + .extend(split_confs(confs.unwrap_or_default())); + continue; + } + match split_coords(coords) { + Some((g, a, v)) => state.entries.push(LockEntry { + group: g.to_string(), + artifact: a.to_string(), + version: v.to_string(), + confs: confs.map(split_confs).unwrap_or_default(), + line: idx + 1, + }), + None => state.malformed.push(idx + 1), + } + } + state +} + +/// `line` without its terminator, and the terminator. +fn split_eol(line: &str) -> (&str, &str) { + if let Some(body) = line.strip_suffix("\r\n") { + (body, "\r\n") + } else if let Some(body) = line.strip_suffix('\n') { + (body, "\n") + } else { + (line, "") + } +} + +/// The coordinates and the `=…` tail of an entry line. +fn entry_parts(body: &str) -> (&str, &str) { + match body.find('=') { + Some(i) => (&body[..i], &body[i..]), + None => (body, ""), + } +} + +/// `text` with the entry `group:artifact:from_v` locked at `to_v` instead. +/// Each line keeps its own line ending and its configuration tail, and the +/// file keeps its final newline (or lack of one). When `to_v` is already +/// locked, the configurations of both lines merge into the `to_v` line and +/// the `from_v` line goes. `None` when `from_v` is not locked. +pub fn rewrite_entry( + text: &str, + group: &str, + artifact: &str, + from_v: &str, + to_v: &str, +) -> Option { + let from = format!("{group}:{artifact}:{from_v}"); + let to = format!("{group}:{artifact}:{to_v}"); + let lines: Vec<&str> = text.split_inclusive('\n').collect(); + let coord_of = |line: &str| entry_parts(split_eol(line).0).0.trim().to_string(); + if !lines.iter().any(|l| coord_of(l) == from) { + return None; + } + let existing = lines.iter().position(|l| coord_of(l) == to); + let mut out = String::with_capacity(text.len() + to_v.len()); + match existing { + None => { + for line in &lines { + let (body, eol) = split_eol(line); + let (coords, tail) = entry_parts(body); + if coords.trim() == from { + let lead = &coords[..coords.len() - coords.trim_start().len()]; + out.push_str(lead); + out.push_str(&to); + out.push_str(tail); + out.push_str(eol); + } else { + out.push_str(line); + } + } + } + Some(keep) => { + // Merge the configurations of every `from` line into `to`. + let mut confs: Vec = Vec::new(); + let mut any_tail = false; + for line in &lines { + let (body, _) = split_eol(line); + let (coords, tail) = entry_parts(body); + let c = coords.trim(); + if c == from || c == to { + if let Some(rest) = tail.strip_prefix('=') { + any_tail = true; + for conf in split_confs(rest) { + if !confs.contains(&conf) { + confs.push(conf); + } + } + } + } + } + confs.sort(); + for (i, line) in lines.iter().enumerate() { + let (body, eol) = split_eol(line); + let c = entry_parts(body).0.trim().to_string(); + if c == from { + continue; + } + if i == keep { + out.push_str(&to); + if any_tail { + out.push('='); + out.push_str(&confs.join(",")); + } + out.push_str(eol); + } else { + out.push_str(line); + } + } + // Dropping the last line must not drop the file's final newline. + let last_eol = lines.last().map_or("", |l| split_eol(l).1); + if !last_eol.is_empty() && !out.is_empty() && !out.ends_with('\n') { + out.push_str(last_eol); + } + } + } + Some(out) +} + +#[cfg(test)] +mod tests { + use super::super::test_fs::MemFs; + use super::*; + + const SINGLE: &str = "\ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +com.socketfixture:consumer:2.0=compileClasspath,runtimeClasspath +com.socketfixture:victim:1.10.0=compileClasspath,runtimeClasspath +empty=annotationProcessor,testAnnotationProcessor +"; + + #[test] + fn parses_single_file_format() { + let s = parse(SINGLE); + assert_eq!(s.entries.len(), 2); + let v = &s.entries[1]; + assert_eq!( + (v.group.as_str(), v.artifact.as_str(), v.version.as_str()), + ("com.socketfixture", "victim", "1.10.0") + ); + assert_eq!(v.confs, ["compileClasspath", "runtimeClasspath"]); + assert_eq!(v.line, 5); + assert_eq!( + s.empty_confs, + ["annotationProcessor", "testAnnotationProcessor"] + ); + assert!(s.malformed.is_empty()); + assert_eq!(s.entries_of("com.socketfixture", "victim").count(), 1); + } + + #[test] + fn parses_legacy_buildscript_and_settings_files() { + let legacy = "# comment\r\ncom.socketfixture:victim:1.10.0\r\norg.x:y:1\r\n"; + let s = parse(legacy); + assert_eq!(s.entries.len(), 2); + assert!(s.entries[0].confs.is_empty()); + let buildscript = "com.socketfixture:victim:1.10.0=classpath\nempty=\n"; + let s = parse(buildscript); + assert_eq!(s.entries[0].confs, ["classpath"]); + assert!(s.empty_confs.is_empty()); + let settings = "\u{feff}com.socketfixture:victim:1.10.0=incomingCatalogForLibs0\nempty=\n"; + assert_eq!(parse(settings).entries.len(), 1); + let bad = "g:a\ng:a:1:x=c\n=x\n"; + assert_eq!(parse(bad).malformed, [1, 2, 3]); + } + + #[test] + fn rewrite_preserves_tail_and_eol() { + let crlf = SINGLE.replace('\n', "\r\n"); + let out = rewrite_entry( + &crlf, + "com.socketfixture", + "victim", + "1.10.0", + "1.10.0-socket.4d5e6f70", + ) + .unwrap(); + assert_eq!( + out, + crlf.replace("victim:1.10.0=", "victim:1.10.0-socket.4d5e6f70=") + ); + assert!(out.ends_with("testAnnotationProcessor\r\n")); + assert_eq!(parse(&out).entries[1].version, "1.10.0-socket.4d5e6f70"); + + // No final newline stays that way; legacy bare entries rewrite too. + let bare = "com.socketfixture:victim:1.10.0"; + assert_eq!( + rewrite_entry( + bare, + "com.socketfixture", + "victim", + "1.10.0", + "1.10.0-socket.1" + ) + .unwrap(), + "com.socketfixture:victim:1.10.0-socket.1" + ); + // Prefix matches are not entries. + assert_eq!( + rewrite_entry(SINGLE, "com.socketfixture", "victim", "1.10", "x"), + None + ); + assert_eq!( + rewrite_entry(SINGLE, "com.socketfixture", "victi", "1.10.0", "x"), + None + ); + } + + #[test] + fn rewrite_merges_into_an_existing_target() { + let text = "g:a:1.0-socket.1=compileClasspath\ng:a:1.0=runtimeClasspath,compileClasspath\nempty=\n"; + let out = rewrite_entry(text, "g", "a", "1.0", "1.0-socket.1").unwrap(); + assert_eq!( + out, + "g:a:1.0-socket.1=compileClasspath,runtimeClasspath\nempty=\n" + ); + let text = "g:a:1.0-socket.1\r\ng:a:1.0\r\n"; + assert_eq!( + rewrite_entry(text, "g", "a", "1.0", "1.0-socket.1").unwrap(), + "g:a:1.0-socket.1\r\n" + ); + } + + #[test] + fn lists_every_lockfile_location() { + let fs = MemFs::new(&[ + ("settings.gradle", ""), + ("gradle.lockfile", ""), + ("buildscript-gradle.lockfile", ""), + ("settings-gradle.lockfile", ""), + ("app/gradle.lockfile", ""), + ("libs/core/gradle.lockfile", ""), + ("buildSrc/gradle.lockfile", ""), + ("buildSrc/buildscript-gradle.lockfile", ""), + ("build-logic/gradle.lockfile", ""), + ("build-logic/convention/gradle.lockfile", ""), + ("gradle/dependency-locks/compileClasspath.lockfile", ""), + ("gradle/dependency-locks/readme.txt", ""), + ("app/gradle/dependency-locks/runtimeClasspath.lockfile", ""), + ("build/gradle.lockfile", ""), + ("app/build/gradle.lockfile", ""), + (".gradle/gradle.lockfile", ""), + ("node_modules/x/gradle.lockfile", ""), + (".socket/gradle.lockfile", ""), + (".git/gradle.lockfile", ""), + ("a/b/c/d/e/f/g/h/gradle.lockfile", ""), + ("a/b/c/d/e/f/g/h/i/gradle.lockfile", ""), + ("other.lockfile", ""), + ]); + let got = lockfile_paths(&|d: &str| fs.list(d), ""); + assert_eq!( + got, + [ + "a/b/c/d/e/f/g/h/gradle.lockfile", + "app/gradle.lockfile", + "app/gradle/dependency-locks/runtimeClasspath.lockfile", + "build-logic/convention/gradle.lockfile", + "build-logic/gradle.lockfile", + "buildSrc/buildscript-gradle.lockfile", + "buildSrc/gradle.lockfile", + "buildscript-gradle.lockfile", + "gradle.lockfile", + "gradle/dependency-locks/compileClasspath.lockfile", + "libs/core/gradle.lockfile", + "settings-gradle.lockfile", + ] + ); + // A sub-root keeps its prefix. + let fs = MemFs::new(&[("w/gradle.lockfile", ""), ("w/x/gradle.lockfile", "")]); + assert_eq!( + lockfile_paths(&|d: &str| fs.list(d), "w"), + ["w/gradle.lockfile", "w/x/gradle.lockfile"] + ); + } +} diff --git a/crates/socket-patch-core/src/gradle/mod.rs b/crates/socket-patch-core/src/gradle/mod.rs index ae4c54247..9f7853dde 100644 --- a/crates/socket-patch-core/src/gradle/mod.rs +++ b/crates/socket-patch-core/src/gradle/mod.rs @@ -9,11 +9,16 @@ //! in-memory fixtures on every OS. //! //! - [`dsl`]: a comment- and string-aware Groovy/Kotlin tokenizer. +//! - [`locks`]: dependency-lock files: where they are, what they hold and a +//! one-entry rewrite. +//! - [`home`]: the Gradle user home and the caches inside it. //! - [`eol`]: line-ending sniffing and line-ending-blind comparison. //! - [`selector`]: Gradle version ordering and version selectors. pub mod dsl; pub mod eol; +pub mod home; +pub mod locks; pub mod selector; use std::collections::{BTreeMap, HashMap}; @@ -124,6 +129,55 @@ pub(crate) fn line_of(text: &str, at: usize) -> usize { .count() } +#[cfg(test)] +pub(crate) mod test_fs { + //! An in-memory tree behind [`TextReadFn`] / [`ListFn`] for the tests. + + use std::collections::BTreeMap; + + #[derive(Default)] + pub struct MemFs { + pub files: BTreeMap, + } + + impl MemFs { + pub fn new(files: &[(&str, &str)]) -> Self { + Self { + files: files + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + } + } + + pub fn read(&self, rel: &str) -> Option { + self.files.get(rel).cloned() + } + + pub fn list(&self, dir: &str) -> Vec { + let prefix = if dir.is_empty() { + String::new() + } else { + format!("{}/", dir.trim_end_matches('/')) + }; + let mut out: Vec = Vec::new(); + for key in self.files.keys() { + let Some(rest) = key.strip_prefix(&prefix) else { + continue; + }; + let child = match rest.find('/') { + Some(i) => format!("{}/", &rest[..i]), + None => rest.to_string(), + }; + if !out.contains(&child) { + out.push(child); + } + } + out + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -159,6 +213,8 @@ mod tests { ("mod.rs", include_str!("mod.rs")), ("dsl.rs", include_str!("dsl.rs")), ("eol.rs", include_str!("eol.rs")), + ("home.rs", include_str!("home.rs")), + ("locks.rs", include_str!("locks.rs")), ("selector.rs", include_str!("selector.rs")), ]; // Spelled in pieces so this test does not match itself. From 6a9d710c5d464f6f59409367671517e52e0e348d Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 2 Oct 2026 11:38:04 -0400 Subject: [PATCH 12/63] Add the Gradle script graph and its queries Every mode has to reason about the whole build, not only the root scripts: #461 (exclusiveContent and Android checks that only read the root build), #428 (vendoring from a subproject), #511 / #533 (range, rich, catalog and classifier declarations the root-only scan missed) and #551 (mavenLocal declared in an init script or convention plugin). ScriptGraph::collect follows, statically and with caps (8 levels of apply-from / included-build nesting, 512 files, 1 MiB each): the root settings, literal include forms with implied parents, projectDir and buildFileName overrides, each project's build script, buildSrc and literal includeBuild roots with their subprojects and precompiled convention plugins, literal apply-from targets (including rootProject.file, file(), new File(rootDir, ..) and "$rootDir/.." spellings, with a visited set), each build's libs.versions.toml and versionCatalogs files(..) catalogs, and the caller's init scripts. Anything it cannot follow (computed paths, URLs, escapes, missing, oversized or malformed files, caps) lands in `unresolved`, so callers that must fail safe can. Queries: settings_includes / project_dirs, subproject_owner for an ancestor settings file, declarations_of (string, map, Kotlin named and positional, rich version blocks, `!!`, classifier in all four forms, catalog entries with version refs), exclusive_content_filters with filter_claims_group (non-literal or uncompilable rules claim), android_or_kmp, settings_classpath_has, maven_local (Declared / NotDeclared / Undetermined), custom_lock_file and wrapper_version. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-core/src/gradle/graph.rs | 2296 ++++++++++++++++++ crates/socket-patch-core/src/gradle/mod.rs | 5 + 2 files changed, 2301 insertions(+) create mode 100644 crates/socket-patch-core/src/gradle/graph.rs diff --git a/crates/socket-patch-core/src/gradle/graph.rs b/crates/socket-patch-core/src/gradle/graph.rs new file mode 100644 index 000000000..a3930c982 --- /dev/null +++ b/crates/socket-patch-core/src/gradle/graph.rs @@ -0,0 +1,2296 @@ +//! The script graph of a Gradle checkout: every settings and build script +//! Gradle would evaluate that can be found statically, and the queries +//! the modes need over it. +//! +//! [`ScriptGraph::collect`] starts from the root settings and follows: +//! +//! - literal `include` forms (with their implied parents), literal +//! `projectDir` / `buildFileName` overrides, and each project's build +//! script; +//! - `buildSrc/` and literal `includeBuild` roots (recursively, with their +//! own subprojects), plus the precompiled convention plugins under +//! `src/main/{groovy,kotlin}` of those builds; +//! - literal `apply from` targets, recursively with a visited set; +//! - each build's `gradle/libs.versions.toml` and literal +//! `versionCatalogs { from(files(…)) }` catalogs; +//! - the init scripts the caller supplies (scanned only for `mavenLocal` +//! and unresolved targets). +//! +//! Anything that cannot be followed statically (an interpolated or +//! computed path, a URL, a path escaping the root, a missing or oversized +//! file, a script that does not tokenize cleanly, a cap) is recorded in +//! [`ScriptGraph::unresolved`]; callers that must fail safe treat a +//! non-empty list as "could be anything". Caps: `apply from` and +//! included-build nesting ≤ 8 deep, ≤ 512 files, ≤ 1 MiB per file. +//! +//! All paths are forward-slash and in the caller's [`TextReadFn`] space +//! (so they already include `root_rel`); init scripts keep the caller's +//! tag as their `rel`. + +use std::collections::BTreeSet; + +use super::dsl::{ + self, all_blocks, call_at, call_sites, command_end, is_ident, is_punct, literal_of, + matching_close, Dsl, Tok, Token, +}; +use super::selector::{parse_selector, Selector}; +use super::{join_rel, line_of, parent_rel, resolve_rel, ListFn, TextReadFn}; + +/// `apply from` and included-build nesting depth. +pub const MAX_DEPTH: usize = 8; +/// Script, catalog and init-script files collected. +pub const MAX_FILES: usize = 512; +/// Bytes per file. +pub const MAX_FILE_BYTES: usize = 1 << 20; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ScriptKind { + Settings, + Build, + /// A precompiled script plugin of `buildSrc` or an included build. + ConventionPlugin, + /// The target of an `apply from`. + Applied, + Init, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Script { + pub rel: String, + pub kind: ScriptKind, + pub dsl: Dsl, + /// The root directory of the build the script belongs to (`""` for + /// init scripts). + pub build: String, + pub text: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum BuildKind { + Root, + BuildSrc, + Included, +} + +/// One Gradle build of the checkout. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Build { + pub dir: String, + pub kind: BuildKind, + /// The settings script, when there is one. + pub settings: Option, + pub projects: Vec, +} + +/// One project of a build. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Project { + /// The Gradle path (`:` for the root project, `:a:b`). + pub path: String, + pub dir: String, + /// The build script, when there is one. + pub build_script: Option, +} + +/// The kind of reference that could not be followed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Site { + ApplyFrom, + Include, + ProjectDir, + BuildFileName, + IncludeBuild, + Catalog, + /// The script itself (oversized, unreadable or malformed). + Script, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Reason { + /// Interpolated or computed. + NonLiteral, + Url, + /// Absolute, or climbs above the root. + Escapes, + /// Names a file that cannot be read. + Missing, + TooLarge, + /// Does not tokenize cleanly. + Unparseable, + /// Relative to a context only known when the plugin is applied (an + /// `apply from` inside a convention plugin or init script). + Contextual, + DepthCap, + FileCap, +} + +/// A reference the graph could not follow. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Unresolved { + /// The script holding the reference. + pub rel: String, + /// 1-based line (0 for [`Site::Script`]). + pub line: usize, + pub site: Site, + pub reason: Reason, + /// The source text of the reference, trimmed to one line. + pub snippet: String, +} + +/// A version catalog file. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Catalog { + pub rel: String, + pub text: String, +} + +/// Whether the build (or a Gradle init script) adds `mavenLocal()`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MavenLocal { + /// Declared in this script (`rel`). + Declared(String), + NotDeclared, + /// Something could not be read, so it cannot be ruled out. + Undetermined(String), +} + +/// A rich version constraint (`version { strictly … }`, or `…!!`). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RichVersion { + pub strictly: Option, + pub require: Option, + pub prefer: Option, + pub reject: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DeclKind { + /// An exact version, or none / a non-literal one. + Plain, + /// A range, prefix (`1.+`) or `latest.*` selector. + Range, + Rich(RichVersion), + /// Requests a classifier artifact. + Classifier, + /// A `[libraries]` entry of a version catalog. + Catalog, +} + +/// One declaration of a module. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Decl { + pub rel: String, + pub line: usize, + pub kind: DeclKind, + /// The version (selector) text; `None` when absent or not literal. + pub version: Option, + pub rich: Option, + pub classifier: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum FilterKind { + Group, + GroupAndSubgroups, + GroupByRegex, + Module, + ModuleByRegex, + Version, + VersionByRegex, +} + +/// One `include*` rule of an `exclusiveContent { filter { … } }`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FilterRule { + pub kind: FilterKind, + /// The arguments; `None` for a non-literal one. + pub args: Vec>, +} + +/// One `exclusiveContent` block. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExclusiveFilter { + pub rel: String, + pub line: usize, + /// Every string in its `forRepository` part (names, URLs). + pub repo_strings: Vec, + pub rules: Vec, +} + +/// Whether `filter` could route `group:artifact` to its repository. A +/// non-literal argument, a regex that does not compile or a malformed rule +/// counts as a claim. +pub fn filter_claims_group(filter: &ExclusiveFilter, group: &str, artifact: &str) -> bool { + filter.rules.iter().any(|r| rule_claims(r, group, artifact)) +} + +fn rule_claims(rule: &FilterRule, g: &str, a: &str) -> bool { + let eq = |i: usize, want: &str| { + rule.args + .get(i) + .cloned() + .flatten() + .is_none_or(|v| v == want) + }; + let re = |i: usize, want: &str| match rule.args.get(i).cloned().flatten() { + None => true, + Some(pat) => regex::Regex::new(&format!("^(?:{pat})$")).map_or(true, |r| r.is_match(want)), + }; + let arity = |n: usize| rule.args.len() < n; + match rule.kind { + FilterKind::Group => arity(1) || eq(0, g), + FilterKind::GroupAndSubgroups => { + arity(1) + || rule.args[0] + .as_deref() + .is_none_or(|p| g == p || g.starts_with(&format!("{p}."))) + } + FilterKind::GroupByRegex => arity(1) || re(0, g), + FilterKind::Module | FilterKind::Version => arity(2) || (eq(0, g) && eq(1, a)), + FilterKind::ModuleByRegex | FilterKind::VersionByRegex => { + arity(2) || (re(0, g) && re(1, a)) + } + } +} + +/// The statically known script graph of a checkout. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ScriptGraph { + pub root: String, + pub builds: Vec, + pub scripts: Vec