From e4fac67020064585699773de69b02c285a243c41 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 10:57:33 -0400 Subject: [PATCH 1/5] Add one JVM layout module and route every copy through it vendor::jvm::layout now owns every JVM layout fact that was spelled in several places: - maven2 paths: group_path, version_dir, file_name, artifact_path, registry_base/registry_url. Deletes 13 production copies of `group.replace('.', "/")` (maven_repo, maven_crawler, jvm/mod, gradle x2, coursier_tree, sbt owned_file, vex, jvm_jar, redirect/mod, upstream/maven, jvm_cache x2) and redirect's local_repo_artifact_path. - coordinates: the path guard (was maven_crawler::is_safe_maven_coordinate, now is_path_safe) and the stricter writer grammar (was jvm::safe_coordinates) live side by side with their relationship documented; the two grammars are kept as they were. - committed trees: MAVEN2_TREE, GRADLE_TREE, COURSIER_TREE, VENDOR_TREES, ORPHAN_PATHS, CAPTURED_FILES and the per-version marker name. Deletes the three duplicate `socket-patch.vendor.json` constants, two literals, the CLI's own orphan list and group_commit's own captured list. - tree index rows: one validator shared by the Gradle and Coursier indexes (each keeps its own uuid rule). - the `jvm` ledger ecosystem name and its maven meaning (ledger_ecosystem), used by revert dispatch, path, redownload and hosted takeover. - build markers: one table per tool (BuildTool::markers) and one stat rule (marker_present: exists, following symlinks). Replaces gradle_cache's GRADLE_MARKERS/SETTINGS, maven_crawler's SCALA_TOOL_MARKERS and its two probes, redirect's GRADLE_ROOT_FILES, jvm/mod's GRADLE_FILES, scala_guidance's MILL_MARKERS/SCALA_CLI_MARKERS, maven_repo's project_has_gradle and the hand-written Gradle lists in not_build_root, sbt and buildSrc. JVM_PROJECT_MARKERS is derived from the same constants. The sbt reactor-wired probe now uses maven_reactor's own BEGIN_MARKER and PIN_TAG instead of copying them. Behavior is unchanged except the stat rule: a Gradle marker that is a directory, or a Scala marker that is a dangling symlink, now counts the same way everywhere (previously three different rules), and the eject snapshot captures the whole JVM captured-file list (adding the maven2 and Gradle tree .gitattributes it previously missed). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/commands/scan/hosted.rs | 2 +- .../socket-patch-cli/src/commands/scan/mod.rs | 5 +- .../src/commands/scan/policy.rs | 4 +- .../socket-patch-cli/src/commands/vendor.rs | 20 +- .../src/crawlers/coursier_cache.rs | 5 +- .../src/crawlers/gradle_cache.rs | 36 +- .../src/crawlers/ivy_cache.rs | 9 +- .../src/crawlers/jvm_cache.rs | 82 +-- .../src/crawlers/maven_crawler.rs | 148 +----- .../src/crawlers/scala_evidence.rs | 2 +- .../src/formats/sbt/owned_file.rs | 19 +- .../src/hosted/memory/roots.rs | 2 +- crates/socket-patch-core/src/patch/jvm_jar.rs | 6 +- .../src/patch/redirect/gradle.rs | 11 +- .../src/patch/redirect/mod.rs | 33 +- .../src/patch/redirect/scala_guidance.rs | 9 +- .../src/patch/redirect/upstream/maven.rs | 4 +- .../src/utils/group_commit.rs | 9 +- crates/socket-patch-core/src/utils/purl.rs | 2 +- .../socket-patch-core/src/vendor/jvm/apply.rs | 41 +- .../src/vendor/jvm/coursier_gate.rs | 8 +- .../src/vendor/jvm/coursier_tree.rs | 52 +- .../src/vendor/jvm/gradle.rs | 47 +- .../src/vendor/jvm/layout.rs | 502 ++++++++++++++++++ .../src/vendor/jvm/maven_reactor.rs | 23 +- .../socket-patch-core/src/vendor/jvm/mod.rs | 40 +- .../socket-patch-core/src/vendor/jvm/sbt.rs | 24 +- .../src/vendor/jvm/scala_cli.rs | 16 +- .../src/vendor/maven_repo.rs | 206 ++++--- crates/socket-patch-core/src/vendor/path.rs | 6 +- .../src/vendor/redownload.rs | 17 +- .../src/vex/discover/maven.rs | 15 +- 32 files changed, 799 insertions(+), 606 deletions(-) create mode 100644 crates/socket-patch-core/src/vendor/jvm/layout.rs diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index e28629891..2c3a21fdf 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -1671,7 +1671,7 @@ async fn vendored_takeover( || p.starts_with("pkg:maven/") }; let gradle_jvm_entry = |entry: &socket_patch_core::vendor::VendorEntry| { - entry.ecosystem == "jvm" + entry.ecosystem == socket_patch_core::vendor::jvm::layout::LEDGER_ECOSYSTEM && entry.wiring.iter().any(|w| { w.file.ends_with(".gradle") || w.file.ends_with(".gradle.kts") diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index 874f40aa7..bb8120784 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -1226,7 +1226,10 @@ async fn gradle_scan( .unwrap_or_default(); let want_locks = !out.gradle_purls.is_empty(); let Ok((gate, locked, mismatch, env)) = tokio::task::spawn_blocking(move || { - let gradle_build = gradle_cache::has_gradle_marker(&cwd); + let gradle_build = socket_patch_core::vendor::jvm::layout::has_build( + &cwd, + socket_patch_core::vendor::jvm::layout::BuildTool::Gradle, + ); let env = JvmEnv::from_process(); let gate = (!global && gradle_build).then(|| m2_gate(&cwd, &env)); // The cwd's build locks annotate Gradle-cached packages in a global diff --git a/crates/socket-patch-cli/src/commands/scan/policy.rs b/crates/socket-patch-cli/src/commands/scan/policy.rs index 98b1ecc45..964964380 100644 --- a/crates/socket-patch-cli/src/commands/scan/policy.rs +++ b/crates/socket-patch-cli/src/commands/scan/policy.rs @@ -90,7 +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) + .chain(socket_patch_core::vendor::jvm::layout::JVM_PROJECT_MARKERS) .filter(|name| dir.join(name).is_file()) .map(|name| name.to_string()) .collect(); @@ -100,7 +100,7 @@ pub(crate) fn dir_markers(dir: &Path) -> Vec { } /// Manifests that stand in as markers for a root with no lockfile (plus -/// every JVM build file, `jvm_cache::JVM_PROJECT_MARKERS`). +/// every JVM build file, `layout::JVM_PROJECT_MARKERS`). const MANIFEST_MARKERS: [&str; 6] = [ "package.json", "pyproject.toml", diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index becee8673..f0f054af1 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -372,7 +372,7 @@ pub(crate) async fn dispatch_revert_one_opts( { return RevertOutcome::failed(vendor::path::vendor_dir_symlink_detail(&link)); } - match entry.ecosystem.as_str() { + match vendor::jvm::layout::ledger_ecosystem(&entry.ecosystem) { "npm" => vendor::npm_flavor::revert_npm_any_opts(entry, project_root, opts).await, "pypi" => vendor::pypi::revert_pypi_opts(entry, project_root, opts).await, "gem" => vendor::gem::revert_gem_opts(entry, project_root, opts).await, @@ -380,7 +380,7 @@ pub(crate) async fn dispatch_revert_one_opts( "golang" => vendor::golang::revert_go_vendor_opts(entry, project_root, opts).await, "composer" => vendor::composer_lock::revert_composer_opts(entry, project_root, opts).await, "nuget" => vendor::nuget_feed::revert_nuget_opts(entry, project_root, opts).await, - "maven" | "jvm" => vendor::maven_repo::revert_maven_opts(entry, project_root, opts).await, + "maven" => vendor::maven_repo::revert_maven_opts(entry, project_root, opts).await, other => RevertOutcome::failed(format!( "this build has no vendor backend for ecosystem `{other}`" )), @@ -1141,15 +1141,9 @@ async fn run_check(args: &VendorArgs) -> i32 { } }; if state.entries.is_empty() - && [ - ".socket/vendor/maven2", - ".socket/vendor/gradle", - ".socket/vendor/gradle-index.tsv", - socket_patch_core::vendor::jvm::sbt::BUILD_FILE, - ] - .iter() - .chain(socket_patch_core::vendor::jvm::coursier_tree::ORPHAN_PATHS) - .any(|rel| root.join(rel).exists()) + && socket_patch_core::vendor::jvm::layout::ORPHAN_PATHS + .iter() + .any(|rel| root.join(rel).exists()) { return emit_eject_refusal(&args.common, "vendor_ledger_missing", "JVM artifacts exist without a vendor ledger; restore .socket/vendor/state.json from version control"); } @@ -1386,7 +1380,7 @@ impl EjectSnapshot { )); } planned.extend( - socket_patch_core::vendor::jvm::coursier_tree::CAPTURED_FILES + socket_patch_core::vendor::jvm::layout::CAPTURED_FILES .iter() .map(|s| s.to_string()), ); @@ -6784,7 +6778,7 @@ mod eject_snapshot_tests { #[tokio::test] async fn snapshot_fails_closed_on_a_fifo() { let tmp = tempfile::tempdir().unwrap(); - let rel = socket_patch_core::vendor::jvm::coursier_tree::CAPTURED_FILES[0]; + let rel = socket_patch_core::vendor::jvm::layout::CAPTURED_FILES[0]; let path = tmp.path().join(rel); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); let c = std::ffi::CString::new(path.to_str().unwrap()).unwrap(); diff --git a/crates/socket-patch-core/src/crawlers/coursier_cache.rs b/crates/socket-patch-core/src/crawlers/coursier_cache.rs index ed37591ab..86e32921c 100644 --- a/crates/socket-patch-core/src/crawlers/coursier_cache.rs +++ b/crates/socket-patch-core/src/crawlers/coursier_cache.rs @@ -20,8 +20,9 @@ use std::collections::HashSet; use std::path::{Path, PathBuf}; use super::jvm_cache::debug_log; -use super::maven_crawler::{is_safe_maven_coordinate, parse_pom_group_artifact_version}; +use super::maven_crawler::parse_pom_group_artifact_version; use crate::utils::fs::read_regular_to_bytes_sync; +use crate::vendor::jvm::layout::is_path_safe; /// The OS whose default cache locations apply (a parameter so every OS's /// table is testable on any host). @@ -201,7 +202,7 @@ fn pom_root(pom: &Path, host: &Path) -> Option { } let bytes = read_regular_to_bytes_sync(pom).ok()?; let (g, a, v) = parse_pom_group_artifact_version(&String::from_utf8_lossy(&bytes))?; - if a != artifact || v != version || !is_safe_maven_coordinate(&g, &a, &v) { + if a != artifact || v != version || !is_path_safe(&g, &a, &v) { return None; } let mut root = artifact_dir.parent()?; diff --git a/crates/socket-patch-core/src/crawlers/gradle_cache.rs b/crates/socket-patch-core/src/crawlers/gradle_cache.rs index 916523416..5f8dfcace 100644 --- a/crates/socket-patch-core/src/crawlers/gradle_cache.rs +++ b/crates/socket-patch-core/src/crawlers/gradle_cache.rs @@ -17,11 +17,11 @@ use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::path::{Path, PathBuf}; use crate::crawlers::jvm_cache::Gav; -use crate::crawlers::maven_crawler::is_safe_maven_coordinate; use crate::gradle::graph::{self, MavenLocal, ScriptGraph}; use crate::gradle::home::{is_init_script_name, GradleHome}; use crate::gradle::{Env, Os}; use crate::manifest::schema::PatchFileInfo; +use crate::vendor::jvm::layout::{self, is_path_safe, BuildTool}; /// The leaf directory name of a Gradle module cache. pub const FILES21: &str = "files-2.1"; @@ -112,7 +112,7 @@ fn is_bookkeeping(name: &str) -> bool { /// literal directory levels (group, artifact, version), then a hash /// directory ([`is_hash_dir_name`]) and its regular files. Bookkeeping /// directories and lock files are skipped, as is any coordinate that -/// [`is_safe_maven_coordinate`] rejects. Sorted (walk order). +/// [`is_path_safe`] rejects. Sorted (walk order). pub fn walk_files21(root: &Path) -> Vec { let mut out = Vec::new(); for (group, is_dir) in children(root) { @@ -126,7 +126,7 @@ pub fn walk_files21(root: &Path) -> Vec { } let artifact_dir = group_dir.join(&artifact); for (version, is_dir) in children(&artifact_dir) { - if !is_dir || !is_safe_maven_coordinate(&group, &artifact, &version) { + if !is_dir || !is_path_safe(&group, &artifact, &version) { continue; } let gav: Gav = (group.clone(), artifact.clone(), version.clone()); @@ -171,7 +171,7 @@ pub fn has_module_file<'a>(entries: impl IntoIterator) -> bool /// [`has_module_file`] for the version directory `root///`. pub fn is_installed(root: &Path, gav: &Gav) -> bool { let (g, a, v) = gav; - if !is_safe_maven_coordinate(g, a, v) { + if !is_path_safe(g, a, v) { return false; } has_module_file(&version_dir_entries(&root.join(g).join(a).join(v), gav)) @@ -926,19 +926,11 @@ pub fn init_scripts_for_build(home: &GradleHome, build_root: &Path) -> InitScrip // ── the build and mavenLocal() ────────────────────────────────────────── -/// Gradle build files (the Gradle half of `jvm_cache::JVM_PROJECT_MARKERS`). -pub const GRADLE_MARKERS: &[&str] = &[ - "build.gradle", - "build.gradle.kts", - "settings.gradle", - "settings.gradle.kts", -]; - -const SETTINGS: &[&str] = &["settings.gradle", "settings.gradle.kts"]; - -/// Whether `dir` holds a Gradle build or settings script. -pub fn has_gradle_marker(dir: &Path) -> bool { - GRADLE_MARKERS.iter().any(|m| dir.join(m).is_file()) +/// Whether `dir` holds a Gradle settings script. +fn has_settings(dir: &Path) -> bool { + layout::GRADLE_SETTINGS_FILES + .iter() + .any(|s| layout::marker_present(dir, s)) } /// The Gradle build roots to analyse for a cwd that is a Gradle project: @@ -946,16 +938,12 @@ pub fn has_gradle_marker(dir: &Path) -> bool { /// that has one (Gradle searches upwards for the settings of a /// subproject). Empty when the cwd has no Gradle marker. pub fn build_roots(cwd: &Path) -> Vec { - if !has_gradle_marker(cwd) { + if !layout::has_build(cwd, BuildTool::Gradle) { return Vec::new(); } let mut roots = vec![cwd.to_path_buf()]; - if !SETTINGS.iter().any(|s| cwd.join(s).is_file()) { - if let Some(up) = cwd - .ancestors() - .skip(1) - .find(|d| SETTINGS.iter().any(|s| d.join(s).is_file())) - { + if !has_settings(cwd) { + if let Some(up) = cwd.ancestors().skip(1).find(|d| has_settings(d)) { roots.push(up.to_path_buf()); } } diff --git a/crates/socket-patch-core/src/crawlers/ivy_cache.rs b/crates/socket-patch-core/src/crawlers/ivy_cache.rs index c1fd42e8c..bf6e584aa 100644 --- a/crates/socket-patch-core/src/crawlers/ivy_cache.rs +++ b/crates/socket-patch-core/src/crawlers/ivy_cache.rs @@ -21,9 +21,10 @@ use std::path::{Path, PathBuf}; use super::coursier_cache::{ existing_dedup, jvm_option_values, log_source, process_home, TargetOs, }; -use super::maven_crawler::{is_safe_maven_coordinate, parse_pom_group_artifact_version}; +use super::maven_crawler::parse_pom_group_artifact_version; use super::types::CrawledPackage; use crate::utils::fs::{open_regular_file_sync, read_regular_to_bytes_sync}; +use crate::vendor::jvm::layout::is_path_safe; /// The artifact directories Ivy files a module's jar under, in lookup order /// (`bundles/` holds OSGi-packaged jars such as guava 19.0's). @@ -122,7 +123,7 @@ pub fn find_by_purls(root: &Path, purls: &[String]) -> HashMap Vec { /// An `.original` from an Ivy-pattern origin is an `ivy.xml`, not a pom, /// and is never returned. pub fn installed_pom(installed_dir: &Path, g: &str, a: &str, v: &str) -> Option> { - if !is_safe_maven_coordinate(g, a, v) { + if !is_path_safe(g, a, v) { return None; } if let Ok(bytes) = read_regular_to_bytes_sync(&installed_dir.join(format!("{a}-{v}.pom"))) { @@ -214,7 +215,7 @@ fn org_roots(root: &Path) -> Vec { /// coordinates, no regular `ivy-.xml`, an `` naming other /// coordinates, or no jar. fn package(module_dir: &Path, org: &str, module: &str, rev: &str) -> Option { - if !is_safe_maven_coordinate(org, module, rev) { + if !is_path_safe(org, module, rev) { return None; } // Neither the module nor the organisation directory may be a link out diff --git a/crates/socket-patch-core/src/crawlers/jvm_cache.rs b/crates/socket-patch-core/src/crawlers/jvm_cache.rs index d7276f290..6555d14ec 100644 --- a/crates/socket-patch-core/src/crawlers/jvm_cache.rs +++ b/crates/socket-patch-core/src/crawlers/jvm_cache.rs @@ -2,8 +2,8 @@ //! 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). +//! - [`is_jvm_project`]: whether a directory is a JVM project root, by +//! [`layout::JVM_PROJECT_MARKERS`]. //! - [`JvmCacheLayout`] / [`JvmCacheRoot`]: an installed-artifact cache //! and how its directories spell coordinates. [`MavenCrawler`] crawls //! and resolves PURLs per root, dispatching on the layout. @@ -22,34 +22,18 @@ 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", - // sbt (`project/build.properties` is no marker: every marker list - // matches a basename, and `build.properties` alone is too generic) - "build.sbt", - // Mill - "build.mill", - "build.mill.yaml", - "build.sc", - // scala-cli - "project.scala", -]; +use crate::vendor::jvm::layout; -/// Whether `dir` holds any [`JVM_PROJECT_MARKERS`] file. +/// Whether `dir` holds any [`layout::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 + let dir = dir.to_path_buf(); + tokio::task::spawn_blocking(move || { + layout::JVM_PROJECT_MARKERS + .iter() + .any(|m| layout::marker_present(&dir, m)) + }) + .await + .unwrap_or(false) } /// How a cache root's directories spell an artifact's coordinates. @@ -223,7 +207,7 @@ pub fn all_local_roots_with(cwd: &Path, env: &super::maven_crawler::JvmEnv) -> V .m2_repo .is_dir() .then(|| JvmCacheRoot::new(env.m2_repo.clone(), JvmCacheLayout::Maven2)); - if super::gradle_cache::has_gradle_marker(cwd) { + if layout::has_build(cwd, layout::BuildTool::Gradle) { gradle.extend(m2); gradle } else { @@ -247,24 +231,13 @@ pub fn locate_artifact( let (group, artifact, version) = gav; let classifier_ok = classifier.is_none_or(crate::patch::path_safety::is_safe_single_segment); let ext_ok = crate::patch::path_safety::is_safe_single_segment(ext); - if !super::maven_crawler::is_safe_maven_coordinate(group, artifact, version) - || !classifier_ok - || !ext_ok - { + if !layout::is_path_safe(group, artifact, version) || !classifier_ok || !ext_ok { return Vec::new(); } - let leaf = match classifier { - Some(c) => format!("{artifact}-{version}-{c}.{ext}"), - None => format!("{artifact}-{version}.{ext}"), - }; + let leaf = layout::file_name(artifact, version, classifier, ext); match root.layout { JvmCacheLayout::Maven2 => { - let path = root - .path - .join(group.replace('.', "/")) - .join(artifact) - .join(version) - .join(&leaf); + let path = layout::version_dir_path(&root.path, group, artifact, version).join(&leaf); if path.is_file() { vec![path] } else { @@ -298,12 +271,7 @@ pub fn locate_artifact( }; repos .into_iter() - .map(|repo| { - repo.join(group.replace('.', "/")) - .join(artifact) - .join(version) - .join(&leaf) - }) + .map(|repo| layout::version_dir_path(&repo, group, artifact, version).join(&leaf)) .filter(|p| p.is_file()) .collect() } @@ -425,20 +393,6 @@ mod tests { assert_eq!(roots, vec![ivy(real)]); } - #[test] - fn scala_build_files_are_markers_but_build_properties_is_not() { - for marker in [ - "build.sbt", - "build.mill", - "build.mill.yaml", - "build.sc", - "project.scala", - ] { - assert!(JVM_PROJECT_MARKERS.contains(&marker), "{marker}"); - } - assert!(JVM_PROJECT_MARKERS.iter().all(|m| !m.contains('/'))); - } - #[test] fn no_provider_means_whole_cache_fallback() { assert_eq!(project_dependency_set(Path::new("/nonexistent")), None); @@ -446,7 +400,7 @@ mod tests { #[tokio::test] async fn every_marker_makes_a_jvm_project() { - for marker in JVM_PROJECT_MARKERS { + for marker in layout::JVM_PROJECT_MARKERS { let dir = tempfile::tempdir().unwrap(); assert!(!is_jvm_project(dir.path()).await); std::fs::write(dir.path().join(marker), "").unwrap(); diff --git a/crates/socket-patch-core/src/crawlers/maven_crawler.rs b/crates/socket-patch-core/src/crawlers/maven_crawler.rs index 8a507a57d..9e2979145 100644 --- a/crates/socket-patch-core/src/crawlers/maven_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/maven_crawler.rs @@ -9,8 +9,8 @@ use super::walk_pool::{par_map, run_walk}; use crate::gradle::graph::MavenLocal; use crate::gradle::home::GradleHome; use crate::gradle::{Env, Os}; -use crate::patch::path_safety; use crate::utils::fs::is_dir; +use crate::vendor::jvm::layout::{self, BuildTool}; #[cfg(test)] mod oracle; @@ -338,11 +338,6 @@ pub fn parse_pom_group_artifact_version(content: &str) -> Option<(String, String // Path coordinate helpers // --------------------------------------------------------------------------- -/// Convert a Maven groupId to a path segment (e.g. `org.apache.commons` -> `org/apache/commons`). -fn group_id_to_path(group_id: &str) -> String { - group_id.replace('.', "/") -} - /// Extract Maven coordinates from a directory path relative to the repository root. /// /// The Maven repository layout is: `///` @@ -515,34 +510,6 @@ impl LayoutTrust { } } -/// Whether the PURL-derived Maven coordinates are safe to join onto the -/// repository root in [`MavenCrawler::find_by_purls`]. -/// -/// The coordinates come straight from the (untrusted) manifest PURL and are -/// joined onto the repo root, after which the resolved directory is patched IN -/// PLACE (Maven has no `replace`-redirect backend). A tampered PURL must not be -/// able to traverse out of the repository. `has_pom_file` only checks for a -/// `.pom` file, so it is no defense — this gate is. Fails closed. -/// -/// - `artifact_id` and `version` are each a single path segment, so a real one -/// never contains a separator, a `.`/`..` segment, a backslash, a colon, or -/// a NUL — [`path_safety::is_safe_single_segment`]. -/// - `group_id` is dot-separated and run through [`group_id_to_path`] (each -/// `.` becomes `/`), so every dot-split segment must independently satisfy -/// [`path_safety::is_safe_single_segment`]. That rejects the forms that -/// would convert to an absolute or `..`-bearing path (`.` -> `/`, `.a` -> -/// `/a`, `a..b` -> `a//b`) and — unlike the previous local check — a `/` -/// smuggled inside a dot-split segment (`/etc`, `com/evil`). -/// -/// The delegation also rejects `:` everywhere — a Windows drive-relative -/// coordinate (`C:evil`) joins as an absolute path. Mirrors the `go_crawler` -/// / `deno_crawler` coordinate guards. -pub(crate) fn is_safe_maven_coordinate(group_id: &str, artifact_id: &str, version: &str) -> bool { - group_id.split('.').all(path_safety::is_safe_single_segment) - && path_safety::is_safe_single_segment(artifact_id) - && path_safety::is_safe_single_segment(version) -} - /// A Coursier per-repository root as a cache root: tagged with the layout /// its path spells, so `find_by_purls` (which recovers the layout from the /// path alone) resolves it the same way. A repository at the host itself @@ -686,14 +653,14 @@ pub enum M2Gate { /// See [`M2Gate`]. Reads the build's scripts and the init scripts of /// `env`'s Gradle user home. /// -/// A Scala-tool build beside the Gradle one ([`SCALA_TOOL_MARKERS`]) keeps +/// A Scala-tool build beside the Gradle one ([`layout::is_scala_tool_build`]) keeps /// m2 as a lone sbt / Mill / scala-cli root does: its own resolvers (an /// sbt `Resolver.mavenLocal`, say) are not the Gradle scripts', so the /// Gradle build's silence on `mavenLocal()` cannot rule `~/.m2` out. pub fn m2_gate(cwd: &Path, env: &JvmEnv) -> M2Gate { - if cwd.join("pom.xml").exists() - || !gradle_cache::has_gradle_marker(cwd) - || has_scala_tool_marker(cwd) + if layout::has_build(cwd, BuildTool::Maven) + || !layout::has_build(cwd, BuildTool::Gradle) + || layout::is_scala_tool_build(cwd) { return M2Gate::NotGradleOnly; } @@ -723,25 +690,6 @@ pub fn maven_local_undetermined(cwd: &Path) -> Option { // MavenCrawler // --------------------------------------------------------------------------- -/// Files (root-relative) that make a directory an sbt, Mill or scala-cli -/// project: the builds whose artifacts live in the Coursier / Ivy caches. -const SCALA_TOOL_MARKERS: &[&str] = &[ - "build.sbt", - "project/build.properties", - "build.mill", - "build.mill.yaml", - "build.sc", - "project.scala", - ".scala-build", -]; - -/// [`scala_tool_project`] for a blocking caller (the walk pool). -fn has_scala_tool_marker(dir: &Path) -> bool { - SCALA_TOOL_MARKERS - .iter() - .any(|marker| std::fs::symlink_metadata(dir.join(marker)).is_ok()) -} - /// `path` with its symlinks resolved, or as given when it cannot be. async fn canonical_or_self(path: &Path) -> PathBuf { tokio::fs::canonicalize(path) @@ -749,16 +697,6 @@ async fn canonical_or_self(path: &Path) -> PathBuf { .unwrap_or_else(|_| path.to_path_buf()) } -/// Whether `dir` holds any [`SCALA_TOOL_MARKERS`] entry. -async fn scala_tool_project(dir: &Path) -> bool { - for marker in SCALA_TOOL_MARKERS { - if tokio::fs::symlink_metadata(dir.join(marker)).await.is_ok() { - return true; - } - } - false -} - /// Whether `path` is spelled like a Coursier per-repository root (an /// `https` / `http` component, host-level repositories included), never /// Maven's local repository. @@ -828,7 +766,7 @@ impl MavenCrawler { /// - The Gradle caches count for a Gradle build (a Gradle marker in the /// cwd) or in global mode. /// - The Coursier / Ivy caches count for an sbt, Mill or scala-cli - /// project (`SCALA_TOOL_MARKERS`) or in global mode; their + /// project ([`layout::is_scala_tool_build`]) or in global mode; their /// directories are read from the process environment. /// - The Maven local repository counts in global mode, for a `pom.xml`, /// a Scala-tool build or a cwd with no Gradle marker, and for a @@ -854,7 +792,7 @@ impl MavenCrawler { } let gradle_build = !options.global && { let cwd = options.cwd.clone(); - run_walk(move || gradle_cache::has_gradle_marker(&cwd)).await + run_walk(move || layout::has_build(&cwd, BuildTool::Gradle)).await }; let mut roots = Vec::new(); if options.global || gradle_build { @@ -875,7 +813,11 @@ impl MavenCrawler { // directory walks are blocking). The first root holding a PURL wins // the crawl dedup. Locally only for a Scala-tool project: a Maven or // Gradle build never reads those caches. - if !options.global && !scala_tool_project(&options.cwd).await { + let scala_tool = !options.global && { + let cwd = options.cwd.clone(); + run_walk(move || layout::is_scala_tool_build(&cwd)).await + }; + if !options.global && !scala_tool { return roots; } let cwd = options.cwd.clone(); @@ -1060,14 +1002,12 @@ impl MavenCrawler { // onto the repo root and then patched IN PLACE. Reject anything // that could traverse out of the repository before touching the // filesystem — the `.pom` check below is no defense. - if !is_safe_maven_coordinate(group_id, artifact_id, version) { + if !layout::is_path_safe(group_id, artifact_id, version) { continue; } - let expected_path = src_path - .join(group_id_to_path(group_id)) - .join(artifact_id) - .join(version); + let expected_path = + layout::version_dir_path(src_path, group_id, artifact_id, version); // The path already encodes the coordinates // (groupId/artifactId/version), so verifying the package is @@ -1231,7 +1171,7 @@ impl MavenCrawler { version.into_owned(), ); // SECURITY: `is_installed` refuses unsafe coordinates before - // joining them onto the root (see `is_safe_maven_coordinate`). + // joining them onto the root (see `layout::is_path_safe`). if !gradle_cache::is_installed(root, &gav) { continue; } @@ -2271,15 +2211,6 @@ mod tests { assert_eq!(extract_xml_value(" ", "groupId"), None); } - // ---- group_id_to_path tests ---- - - #[test] - fn test_group_id_to_path() { - assert_eq!(group_id_to_path("org.apache.commons"), "org/apache/commons"); - assert_eq!(group_id_to_path("com.google.guava"), "com/google/guava"); - assert_eq!(group_id_to_path("single"), "single"); - } - // ---- parse_path_coordinates tests ---- #[test] @@ -2379,53 +2310,6 @@ mod tests { ); } - #[test] - fn test_is_safe_maven_coordinate() { - // Legit coordinates pass. - assert!(is_safe_maven_coordinate( - "org.apache.commons", - "commons-lang3", - "3.12.0" - )); - assert!(is_safe_maven_coordinate( - "com.google.guava", - "guava", - "32.1.3-jre" - )); - // `..` in any single-segment coordinate is rejected. - assert!(!is_safe_maven_coordinate("g", "..", "1.0.0")); - assert!(!is_safe_maven_coordinate("g", "../../escaped", "1.0.0")); - assert!(!is_safe_maven_coordinate("g", "a", "..")); - // A `/` in the artifactId/version (never legitimate) is rejected. - assert!(!is_safe_maven_coordinate("g", "a/b", "1.0.0")); - assert!(!is_safe_maven_coordinate("g", "a", "1/0")); - // groupId forms that convert to an absolute or empty-segment path - // (`.` -> `/`, `.a` -> `/a`) are rejected. - assert!(!is_safe_maven_coordinate(".", "a", "1.0.0")); - assert!(!is_safe_maven_coordinate("..", "a", "1.0.0")); - assert!(!is_safe_maven_coordinate(".org", "a", "1.0.0")); - assert!(!is_safe_maven_coordinate("org.", "a", "1.0.0")); - assert!(!is_safe_maven_coordinate("a..b", "a", "1.0.0")); - // Backslash / NUL anywhere is rejected. - assert!(!is_safe_maven_coordinate("g", "a\\b", "1.0.0")); - assert!(!is_safe_maven_coordinate("g\0x", "a", "1.0.0")); - // Empty coordinates are rejected. - assert!(!is_safe_maven_coordinate("", "a", "1.0.0")); - assert!(!is_safe_maven_coordinate("g", "", "1.0.0")); - assert!(!is_safe_maven_coordinate("g", "a", "")); - // Windows drive-relative escape: a `:` (e.g. `C:evil`) makes the - // joined path absolute under `Path::join`; rejected in every - // coordinate, including inside a dot-split groupId segment. - assert!(!is_safe_maven_coordinate("C:evil.org", "a", "1.0.0")); - assert!(!is_safe_maven_coordinate("g", "C:evil", "1.0.0")); - assert!(!is_safe_maven_coordinate("g", "a", "C:1.0.0")); - // A `/` smuggled inside a dot-split groupId segment never hits the - // per-dot-segment checks (`/etc` has no dots at all) but converts to - // an absolute or deeper path via `group_id_to_path`. - assert!(!is_safe_maven_coordinate("/etc", "a", "1.0.0")); - assert!(!is_safe_maven_coordinate("com/evil", "a", "1.0.0")); - } - // ---- m2_repo_path env tests ---- /// Save and restore an env var around a test body. diff --git a/crates/socket-patch-core/src/crawlers/scala_evidence.rs b/crates/socket-patch-core/src/crawlers/scala_evidence.rs index 55316a77c..8cbdfd9d4 100644 --- a/crates/socket-patch-core/src/crawlers/scala_evidence.rs +++ b/crates/socket-patch-core/src/crawlers/scala_evidence.rs @@ -310,7 +310,7 @@ fn is_stale(root: &Path, sources: &[PathBuf], evidence_time: SystemTime) -> bool return true; } let listed: BTreeSet = sources.iter().filter_map(|p| rel_of(p)).collect(); - let project = crate::vendor::jvm::scala_cli::PROJECT_FILE; + let project = crate::vendor::jvm::layout::SCALA_CLI_FILE; if !listed.contains(project) && std::fs::symlink_metadata(root.join(project)).is_ok() { debug_log("scala-cli evidence: the newest project does not list project.scala"); return true; diff --git a/crates/socket-patch-core/src/formats/sbt/owned_file.rs b/crates/socket-patch-core/src/formats/sbt/owned_file.rs index 0651f80ca..e8d6def41 100644 --- a/crates/socket-patch-core/src/formats/sbt/owned_file.rs +++ b/crates/socket-patch-core/src/formats/sbt/owned_file.rs @@ -45,6 +45,7 @@ use regex::Regex; pub use super::build::SbtLine; use crate::utils::digest::is_hex64_lower; +use crate::vendor::jvm::layout; /// The hosted file, at the build root. pub const HOSTED_FILE: &str = "socket-patch.sbt"; @@ -53,7 +54,7 @@ pub const VENDORED_FILE: &str = "socket-patch-vendor.sbt"; /// Where the hosted file downloads its pins (gitignored by the file itself). pub const HOSTED_REPO_REL: &str = ".socket/sbt-hosted/maven2"; /// The vendored (committed, suffixed) tree. -pub const VENDORED_REPO_REL: &str = ".socket/vendor/maven2"; +pub const VENDORED_REPO_REL: &str = layout::MAVEN2_TREE; /// The first line of every generated file (and the ownership test). pub const HEADER_PREFIX: &str = "// Generated by socket-patch"; @@ -121,7 +122,7 @@ impl SbtPin { /// The pom (`ext == "pom"`) or jar path of this pin, relative to the pin /// repository. pub fn repo_path(&self, ext: &str) -> String { - repo_path(&self.group, &self.artifact, &self.sv, ext) + layout::artifact_path(&self.group, &self.artifact, &self.sv, None, ext) } } @@ -278,14 +279,6 @@ fn template(mode: SbtFileMode, line: SbtLine) -> String { .replace("@DIR@", dir) } -/// `///-.`. -fn repo_path(group: &str, artifact: &str, sv: &str, ext: &str) -> String { - format!( - "{}/{artifact}/{sv}/{artifact}-{sv}.{ext}", - group.replace('.', "/") - ) -} - /// The `src` column of a row: `/` hosted, `""` vendored. fn source_url(pin: &SbtPin, rel: &str) -> String { pin.index_url @@ -551,7 +544,7 @@ pub fn parse(mode: SbtFileMode, text: &str) -> Result Result<(), &'static str> { if !crate::patch::path_safety::is_canonical_uuid(&p.uuid) { return Err("uuid is not a canonical uuid"); } - if !crate::vendor::jvm::safe_coordinates(&p.group, &p.artifact, &p.base) { + if !layout::safe_coordinates(&p.group, &p.artifact, &p.base) { return Err("unsafe maven coordinates"); } if p.sv != suffixed_version(&p.base, &p.uuid) { return Err("suffixed version does not match the base and uuid"); } - if !crate::vendor::jvm::safe_coordinates(&p.group, &p.artifact, &p.sv) { + if !layout::safe_coordinates(&p.group, &p.artifact, &p.sv) { return Err("unsafe suffixed version"); } for hash in [&p.pom_sha256, &p.jar_sha256] { diff --git a/crates/socket-patch-core/src/hosted/memory/roots.rs b/crates/socket-patch-core/src/hosted/memory/roots.rs index 35f39be1a..614e52385 100644 --- a/crates/socket-patch-core/src/hosted/memory/roots.rs +++ b/crates/socket-patch-core/src/hosted/memory/roots.rs @@ -15,7 +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", crate::crawlers::jvm_cache::JVM_PROJECT_MARKERS), + ("maven", crate::vendor::jvm::layout::JVM_PROJECT_MARKERS), ( "nuget", &[ diff --git a/crates/socket-patch-core/src/patch/jvm_jar.rs b/crates/socket-patch-core/src/patch/jvm_jar.rs index f38a84403..4a92dabb5 100644 --- a/crates/socket-patch-core/src/patch/jvm_jar.rs +++ b/crates/socket-patch-core/src/patch/jvm_jar.rs @@ -650,9 +650,9 @@ async fn upstream_for_gradle_copy(purl: &str, jar_leaf: &str, dir: &Path) -> Opt let hash = dir.file_name()?.to_str()?; let (group, artifact, version) = parse_maven_purl(purl)?; let url = format!( - "{}/{}/{artifact}/{version}/{jar_leaf}", - crate::vendor::maven_repo::maven_registry_base(), - group.replace('.', "/") + "{}/{}/{jar_leaf}", + crate::vendor::jvm::layout::registry_base(), + crate::vendor::jvm::layout::version_dir(&group, &artifact, &version) ); let bytes = crate::vendor::maven_repo::fetch_registry_bytes( &url, diff --git a/crates/socket-patch-core/src/patch/redirect/gradle.rs b/crates/socket-patch-core/src/patch/redirect/gradle.rs index fb797e047..e4f28b823 100644 --- a/crates/socket-patch-core/src/patch/redirect/gradle.rs +++ b/crates/socket-patch-core/src/patch/redirect/gradle.rs @@ -67,6 +67,7 @@ use crate::gradle::locks; use crate::gradle::selector::{admits, gradle_version_cmp, parse_selector, Selector}; use crate::patch::path_safety::is_canonical_uuid; use crate::vendor::jvm::gradle as vendored; +use crate::vendor::jvm::layout::GRADLE_ROOT_FILES; /// The owned hosted settings script. Its bytes change only with a CLI /// release. @@ -92,14 +93,6 @@ const VENDORED_INDEX_REL: &str = vendored::INDEX_REL; const VERIFICATION_REL: &str = vendored::VERIFICATION_REL; const WRAPPER_PROPERTIES_REL: &str = "gradle/wrapper/gradle-wrapper.properties"; -/// Root-level files whose presence makes the checkout a Gradle build. -pub const GRADLE_ROOT_FILES: &[&str] = &[ - "settings.gradle", - "settings.gradle.kts", - "build.gradle", - "build.gradle.kts", -]; - /// Whether `rel` is a settings-classpath lock (`settings-gradle.lockfile` /// of any build). Gradle resolves that classpath before any settings /// script runs, so the hosted script can never pin what it locks. @@ -181,7 +174,7 @@ impl HostedRow { /// whitespace and two lowercase sha256s. pub fn valid(&self) -> bool { let hex64 = |s: &str| crate::utils::digest::is_hex64_lower(s); - vendored::safe_coordinates(&self.group, &self.artifact, &self.base) + crate::vendor::jvm::layout::safe_coordinates(&self.group, &self.artifact, &self.base) && self.base.chars().any(|c| c != '.') && is_canonical_uuid(&self.uuid) && self.uuid == self.uuid.to_ascii_lowercase() diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index b255246a6..ef2c5dca0 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -7047,10 +7047,11 @@ fn rewrite_maven_pom( } if jar_sha256.is_some() && pom_sha256.is_some() { - pinned_jar_paths.push(local_repo_artifact_path( + pinned_jar_paths.push(crate::vendor::jvm::layout::artifact_path( &group_id, &artifact_id, &suffixed_version, + None, "jar", )); } @@ -7105,11 +7106,23 @@ fn rewrite_maven_pom( }); } checksum_entries.push(( - local_repo_artifact_path(&group_id, &artifact_id, &suffixed_version, "jar"), + crate::vendor::jvm::layout::artifact_path( + &group_id, + &artifact_id, + &suffixed_version, + None, + "jar", + ), bare_sha256_hex(jar), )); checksum_entries.push(( - local_repo_artifact_path(&group_id, &artifact_id, &suffixed_version, "pom"), + crate::vendor::jvm::layout::artifact_path( + &group_id, + &artifact_id, + &suffixed_version, + None, + "pom", + ), bare_sha256_hex(pom_hash), )); } @@ -7299,20 +7312,6 @@ fn merge_checksums(existing: &str, entries: &[(String, String)]) -> String { format!("{}\n", body.join("\n")) } -/// The local-repository-relative artifact path Maven derives for a coordinate: -/// `///-.`. -pub(crate) fn local_repo_artifact_path( - group_id: &str, - artifact_id: &str, - version: &str, - ext: &str, -) -> String { - format!( - "{}/{artifact_id}/{version}/{artifact_id}-{version}.{ext}", - group_id.replace('.', "/") - ) -} - // ── golang (go.mod fork-replace + go.sum pin) ──────────────────────────────── /// go.mod and go.sum are whitespace-delimited line formats, and the golang /// rewriter interpolates server-controlled strings into both — any embedded diff --git a/crates/socket-patch-core/src/patch/redirect/scala_guidance.rs b/crates/socket-patch-core/src/patch/redirect/scala_guidance.rs index 7979059ce..f387db6b1 100644 --- a/crates/socket-patch-core/src/patch/redirect/scala_guidance.rs +++ b/crates/socket-patch-core/src/patch/redirect/scala_guidance.rs @@ -14,10 +14,7 @@ use super::{ RewriteWarning, }; -/// Mill build files. -pub const MILL_MARKERS: &[&str] = &["build.mill", "build.mill.yaml", "build.sc", ".mill-version"]; -/// scala-cli directory-build markers. -pub const SCALA_CLI_MARKERS: &[&str] = &["project.scala"]; +use crate::vendor::jvm::layout::{MILL_MARKERS, SCALA_CLI_FILE}; /// The pin a snippet names: the Socket repository and the version to force. struct Pin { @@ -108,7 +105,7 @@ pub fn warn( result: &mut RewriteResult, ) { let mill = MILL_MARKERS.iter().any(|m| files.contains_key(*m)); - let scala_cli = SCALA_CLI_MARKERS.iter().any(|m| files.contains_key(*m)); + let scala_cli = files.contains_key(SCALA_CLI_FILE); if !mill && !scala_cli { return; } @@ -146,7 +143,7 @@ pub fn warn( pub fn owns_maven_root(files: &BTreeMap) -> bool { MILL_MARKERS .iter() - .chain(SCALA_CLI_MARKERS) + .chain([&SCALA_CLI_FILE]) .any(|m| files.contains_key(*m)) && no_maven_or_gradle(files) } diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/maven.rs b/crates/socket-patch-core/src/patch/redirect/upstream/maven.rs index 7b90cbdee..8ffb5c964 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/maven.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/maven.rs @@ -301,8 +301,8 @@ pub(crate) async fn restore( Ok(next) => { text = next; checksum_dirs.push(format!( - "{}/{artifact}/{suffixed}/", - group.replace('.', "/") + "{}/", + crate::vendor::jvm::layout::version_dir(group, artifact, &suffixed) )); restored.push(&pin.uuid); } diff --git a/crates/socket-patch-core/src/utils/group_commit.rs b/crates/socket-patch-core/src/utils/group_commit.rs index 3d7f411c2..16077256a 100644 --- a/crates/socket-patch-core/src/utils/group_commit.rs +++ b/crates/socket-patch-core/src/utils/group_commit.rs @@ -182,14 +182,7 @@ fn is_captured(rel: &Path) -> bool { } let spelled = rel.to_string_lossy().replace('\\', "/"); if LEDGERS.contains(&spelled.as_str()) - || [ - ".socket/vendor/gradle-index.tsv", - ".socket/gradle/socket-patch.settings.gradle", - ".socket/vendor/maven2/.gitattributes", - ".socket/vendor/gradle/.gitattributes", - ] - .contains(&spelled.as_str()) - || crate::vendor::jvm::coursier_tree::CAPTURED_FILES.contains(&spelled.as_str()) + || crate::vendor::jvm::layout::CAPTURED_FILES.contains(&spelled.as_str()) { return true; } diff --git a/crates/socket-patch-core/src/utils/purl.rs b/crates/socket-patch-core/src/utils/purl.rs index 0962492a5..bb64cb5f4 100644 --- a/crates/socket-patch-core/src/utils/purl.rs +++ b/crates/socket-patch-core/src/utils/purl.rs @@ -482,7 +482,7 @@ pub fn composer_purl(name: &str, version: &str) -> Option { /// artifact and the version each a safe single segment — an empty group /// fails as one empty segment). pub fn maven_purl(group: &str, artifact: &str, version: &str) -> Option { - crate::crawlers::maven_crawler::is_safe_maven_coordinate(group, artifact, version) + crate::vendor::jvm::layout::is_path_safe(group, artifact, version) .then(|| build_maven_purl(group, artifact, version)) } diff --git a/crates/socket-patch-core/src/vendor/jvm/apply.rs b/crates/socket-patch-core/src/vendor/jvm/apply.rs index 421249265..59d037b24 100644 --- a/crates/socket-patch-core/src/vendor/jvm/apply.rs +++ b/crates/socket-patch-core/src/vendor/jvm/apply.rs @@ -29,10 +29,10 @@ use crate::utils::purl::parse_maven_purl; use super::super::state::{VendorEntry, WiringAction, WiringRecord}; use super::super::{RevertOpts, RevertOutcome, VendorWarning}; use super::{ - coursier_tree, gradle, maven_reactor, op_of, op_str, safe_coordinates, sbt, scala_cli, - sha256_hex, Coords, JvmPlan, JvmUnplan, Shape, CONFIG_LINE_KIND, COURSIER_INDEX_KIND, - CREATED_DIR_KIND, DERIVED_METADATA_KIND, KINDS, OWNED_FILE_KIND, POM_FRAGMENT_KIND, - SBT_FRAGMENT_KIND, SETTINGS_FRAGMENT_KIND, TREE_KIND, VERIFICATION_FRAGMENT_KIND, + coursier_tree, gradle, layout, maven_reactor, op_of, op_str, sbt, scala_cli, sha256_hex, + Coords, JvmPlan, JvmUnplan, Shape, CONFIG_LINE_KIND, COURSIER_INDEX_KIND, CREATED_DIR_KIND, + DERIVED_METADATA_KIND, KINDS, OWNED_FILE_KIND, POM_FRAGMENT_KIND, SBT_FRAGMENT_KIND, + SETTINGS_FRAGMENT_KIND, TREE_KIND, VERIFICATION_FRAGMENT_KIND, }; /// Whether `entry` was written by this backend: it has wiring and every @@ -62,7 +62,7 @@ pub fn entry_gav(entry: &VendorEntry) -> Result<(String, String, String), String } let (g, a, v) = parse_maven_purl(&entry.base_purl) .ok_or_else(|| format!("not a maven purl: {:?}", entry.base_purl))?; - if !safe_coordinates(&g, &a, &v) { + if !layout::safe_coordinates(&g, &a, &v) { return Err(format!("unsafe maven coordinates in {:?}", entry.base_purl)); } Ok((g.into_owned(), a.into_owned(), v.into_owned())) @@ -294,12 +294,12 @@ fn is_vendored_tree_file(reader: &ProjectReader, rel: &str, existing: &[u8]) -> return false; }; let parse = |bytes: &[u8]| serde_json::from_slice::(bytes).ok(); - if name == maven_reactor::MARKER_FILE { + if name == layout::MARKER_FILE { return parse(existing) .is_some_and(|m| m.get("uuid").is_some() && m.get("schema").is_some()); } reader - .read(&format!("{dir}/{}", maven_reactor::MARKER_FILE)) + .read(&format!("{dir}/{}", layout::MARKER_FILE)) .and_then(|m| parse(&m)) .and_then(|m| { m.get("files")? @@ -322,7 +322,7 @@ pub async fn write_plan(root: &Path, plan: &JvmPlan) -> Result let mut targets = Vec::new(); for w in &plan.writes { let allowed = if w.tree { - VENDOR_TREES + layout::VENDOR_TREES .iter() .any(|tree| w.rel.strip_prefix(tree).is_some_and(|r| r.starts_with('/'))) } else { @@ -505,8 +505,7 @@ fn sides(wiring: &[WiringRecord]) -> (bool, bool) { let gradle = is_gradle(wiring); let maven = wiring.iter().any(|w| { matches!(w.kind.as_str(), POM_FRAGMENT_KIND | CONFIG_LINE_KIND) - || w.file - .starts_with(&format!("{}/", maven_reactor::TREE_ROOT)) + || w.file.starts_with(&format!("{}/", layout::MAVEN2_TREE)) }); (maven || !gradle, gradle) } @@ -548,7 +547,7 @@ fn is_gradle(wiring: &[WiringRecord]) -> bool { SETTINGS_FRAGMENT_KIND | VERIFICATION_FRAGMENT_KIND ) || w.file == gradle::INDEX_REL || w.file == gradle::SCRIPT_REL - || w.file.starts_with(&format!("{}/", gradle::TREE_ROOT)) + || w.file.starts_with(&format!("{}/", layout::GRADLE_TREE)) }) } @@ -694,20 +693,12 @@ pub async fn revert(root: &Path, entry: &VendorEntry, opts: RevertOpts) -> Rever } } -/// The vendored repository trees JVM entries write under `.socket/vendor` -/// (sbt's Coursier tree included). -pub(crate) const VENDOR_TREES: &[&str] = &[ - ".socket/vendor/maven2", - ".socket/vendor/gradle", - coursier_tree::TREE_ROOT, -]; - /// Owned directories pruned once empty, up to and including themselves. const OWNED_DIRS: &[&str] = &[ - ".socket/vendor/maven2", - ".socket/vendor/gradle", + layout::MAVEN2_TREE, + layout::GRADLE_TREE, ".socket/gradle", - coursier_tree::TREE_ROOT, + layout::COURSIER_TREE, ]; /// Remove, deepest first and only when empty, the parents of `removed` up to @@ -1061,13 +1052,13 @@ pub fn checked_tree_jar(root: &Path, entry: &VendorEntry, uuid: &str) -> Result< return Err("vendor_artifact_missing".into()); } } - if rel.starts_with(".socket/vendor/maven2/") { + if rel.starts_with(&format!("{}/", layout::MAVEN2_TREE)) { return Ok(rel); } let marker_path = format!( "{}/{}", rel.rsplit_once('/').ok_or("vendor_path_unsafe")?.0, - gradle::MARKER_NAME + layout::MARKER_FILE ); let reader = ProjectReader::new(root); let bytes = reader.read(&marker_path).ok_or("vendor_artifact_missing")?; @@ -1396,7 +1387,7 @@ mod tests { "uuid": UUID, }); std::fs::write( - root.join(tree_file("socket-patch.vendor.json")), + root.join(tree_file(layout::MARKER_FILE)), serde_json::to_vec(&marker).unwrap(), ) .unwrap(); diff --git a/crates/socket-patch-core/src/vendor/jvm/coursier_gate.rs b/crates/socket-patch-core/src/vendor/jvm/coursier_gate.rs index d206df2c2..7e0e8b277 100644 --- a/crates/socket-patch-core/src/vendor/jvm/coursier_gate.rs +++ b/crates/socket-patch-core/src/vendor/jvm/coursier_gate.rs @@ -24,7 +24,7 @@ use std::collections::BTreeSet; use std::path::{Path, PathBuf}; use super::sbt_gate::GateStop; -use super::{coursier_tree, scala_cli, JvmRefusal, JvmWarning}; +use super::{coursier_tree, layout, scala_cli, JvmRefusal, JvmWarning}; use crate::crawlers::scala_evidence::{self, ScalaEvidence}; use crate::formats::sbt::gate::{check_new, GateRefusal}; use crate::utils::fs::read_regular_to_bytes_sync; @@ -150,7 +150,7 @@ pub(crate) fn gate( "the last build after vendoring resolved {g}:{a}:{base} from {} instead of {}; \ a repository passed on the command line (`-r`) or declared elsewhere wins", path.display(), - coursier_tree::TREE_ROOT + layout::COURSIER_TREE ), )); } @@ -308,7 +308,7 @@ fn resolved_elsewhere( if !wired_build || !in_tree { return None; } - let tree = [root, canonical_root.as_path()].map(|r| r.join(coursier_tree::TREE_ROOT)); + let tree = [root, canonical_root.as_path()].map(|r| r.join(layout::COURSIER_TREE)); e.resolution .artifacts .get(&(g.to_string(), a.to_string(), base.to_string())) @@ -689,7 +689,7 @@ mod tests { std::fs::create_dir_all(root.join(".socket/vendor")).unwrap(); std::fs::write(root.join(coursier_tree::INDEX_REL), index).unwrap(); let tree_jar = root - .join(coursier_tree::TREE_ROOT) + .join(layout::COURSIER_TREE) .join("com/typesafe/config/1.4.3/config-1.4.3.jar"); let tree_jar = tree_jar.to_string_lossy().into_owned(); let in_tree: (&str, &str, &str, &[(&str, &str)]) = ( diff --git a/crates/socket-patch-core/src/vendor/jvm/coursier_tree.rs b/crates/socket-patch-core/src/vendor/jvm/coursier_tree.rs index 9b22ebf7d..53c4b70e0 100644 --- a/crates/socket-patch-core/src/vendor/jvm/coursier_tree.rs +++ b/crates/socket-patch-core/src/vendor/jvm/coursier_tree.rs @@ -18,13 +18,14 @@ use std::collections::BTreeSet; use serde_json::json; +use super::layout::{self, safe_coordinates}; use super::{ - adopt, fragment, owned_file_with, safe_coordinates, sha1_hex, sha256_hex, Coords, FileWrite, - JvmPatch, JvmRefusal, ReadFn, WiringAction, WiringRecord, COURSIER_INDEX_KIND, + adopt, fragment, owned_file_with, sha1_hex, sha256_hex, Coords, FileWrite, JvmPatch, + JvmRefusal, ReadFn, WiringAction, WiringRecord, COURSIER_INDEX_KIND, }; /// The tree root. -pub const TREE_ROOT: &str = ".socket/vendor/coursier"; +use super::layout::COURSIER_TREE as TREE_ROOT; /// The index of every tree file. pub const INDEX_REL: &str = ".socket/vendor/coursier-index.tsv"; /// The index's first line. @@ -35,29 +36,11 @@ pub const GITATTRIBUTES_REL: &str = ".socket/vendor/coursier/.gitattributes"; /// The `.gitignore` body: re-include everything below. pub const GITIGNORE: &str = "!*\n"; /// The per-version marker (the name every JVM tree uses). -pub const MARKER_NAME: &str = "socket-patch.vendor.json"; -/// Files under `.socket/` a group commit captures for this tree (plus the -/// vendored sbt tree's `.gitignore`). -pub const CAPTURED_FILES: &[&str] = &[ - INDEX_REL, - GITIGNORE_REL, - GITATTRIBUTES_REL, - super::scala_cli::GUARD_REL, - super::sbt::TREE_GITIGNORE_REL, -]; - -/// Paths whose presence without a vendor ledger means JVM artifacts were -/// orphaned (`vendor --check`'s `vendor_ledger_missing`). -pub const ORPHAN_PATHS: &[&str] = &[TREE_ROOT, INDEX_REL]; +use super::layout::MARKER_FILE as MARKER_NAME; /// The patch's tree directory (same GAV). pub fn tree_dir(c: &Coords<'_>) -> String { - format!( - "{TREE_ROOT}/{}/{}/{}", - c.group_path(), - c.artifact_id, - c.version - ) + c.tree_dir(TREE_ROOT, c.version) } /// The committed tree of `c` as `(jar, pom, None)`. `None` when either is @@ -137,7 +120,7 @@ pub fn plan_tree( if !name.ends_with(".sha1") { new_rows.push(IndexRow { gav: gav.clone(), - rel: format!("{}/{a}/{v}/{name}", c.group_path()), + rel: format!("{}/{name}", layout::version_dir(c.group_id, a, v)), sha256: sha256_hex(bytes), uuid: patch.uuid.to_string(), }); @@ -211,25 +194,8 @@ pub fn parse_index(bytes: &[u8]) -> Result, String> { } fn parse_row(line: &str) -> Option { - let cols: Vec<&str> = line.split('\t').collect(); - let [gav, rel, sha, uuid] = cols.as_slice() else { - return None; - }; - let parts: Vec<&str> = gav.split(':').collect(); - let [g, a, v] = parts.as_slice() else { - return None; - }; - let dir = format!("{}/{a}/{v}/", g.replace('.', "/")); - let ok = safe_coordinates(g, a, v) - && rel - .strip_prefix(&dir) - .is_some_and(|n| n.starts_with(&format!("{a}-{v}")) && !n.contains('/')) - && sha.len() == 64 - && sha - .bytes() - .all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)) - && crate::patch::path_safety::is_canonical_uuid(uuid); - ok.then(|| IndexRow { + let [gav, rel, sha, uuid] = layout::index_row(line)?; + crate::patch::path_safety::is_canonical_uuid(uuid).then(|| IndexRow { gav: gav.to_string(), rel: rel.to_string(), sha256: sha.to_string(), diff --git a/crates/socket-patch-core/src/vendor/jvm/gradle.rs b/crates/socket-patch-core/src/vendor/jvm/gradle.rs index 31a678cb5..bed0956b7 100644 --- a/crates/socket-patch-core/src/vendor/jvm/gradle.rs +++ b/crates/socket-patch-core/src/vendor/jvm/gradle.rs @@ -33,14 +33,14 @@ use super::{ OWNED_FILE_KIND, SETTINGS_FRAGMENT_KIND, VERIFICATION_FRAGMENT_KIND, }; -pub use super::safe_coordinates; +use super::layout::{self, safe_coordinates}; /// The owned settings script. Its bytes change only with a CLI release. pub const SCRIPT: &str = include_str!("socket-patch.settings.gradle"); /// Where [`SCRIPT`] lives, project-relative. pub const SCRIPT_REL: &str = ".socket/gradle/socket-patch.settings.gradle"; /// The Gradle-only artifact tree root. -pub const TREE_ROOT: &str = ".socket/vendor/gradle"; +use super::layout::GRADLE_TREE as TREE_ROOT; /// The tree root's `.gitattributes`, shared by every Gradle patch. pub const GITATTRIBUTES_REL: &str = ".socket/vendor/gradle/.gitattributes"; /// `.socket/gradle/`'s `.gitattributes` (`* -text`): the settings scripts @@ -59,7 +59,7 @@ const HOSTED_SCRIPT_REL: &str = ".socket/gradle/socket-patch.hosted.settings.gra pub const INDEX_REL: &str = ".socket/vendor/gradle-index.tsv"; pub const INDEX_HEADER: &str = "#socket-patch-gradle-index 1"; pub const VERIFICATION_REL: &str = "gradle/verification-metadata.xml"; -pub const MARKER_NAME: &str = "socket-patch.vendor.json"; +use super::layout::MARKER_FILE as MARKER_NAME; /// Repository name shared by the script and the in-block entry: the script /// skips a handler that already holds it. const REPO_NAME: &str = "socketPatchVendor"; @@ -109,19 +109,14 @@ impl WiringTarget { /// The tree directory of `c` (same GAV). pub fn tree_dir(c: &Coords<'_>) -> String { - format!( - "{TREE_ROOT}/{}/{}/{}", - c.group_path(), - c.artifact_id, - c.version - ) + c.tree_dir(TREE_ROOT, c.version) } /// The derived artifact-level `maven-metadata.xml` of `group:artifact`. pub fn derived_metadata_rel(group_id: &str, artifact_id: &str) -> String { format!( "{TREE_ROOT}/{}/{artifact_id}/{METADATA_NAME}", - group_id.replace('.', "/") + layout::group_path(group_id) ) } @@ -1423,14 +1418,9 @@ fn read_settings(read: ReadFn<'_>, dir: &str) -> Result { /// The settings target of `buildSrc`, when the checkout has one. pub(crate) fn read_buildsrc(read: ReadFn<'_>) -> Result, JvmRefusal> { - let present = [ - "build.gradle", - "build.gradle.kts", - "settings.gradle", - "settings.gradle.kts", - ] - .iter() - .any(|f| read(&format!("buildSrc/{f}")).is_some()); + let present = layout::GRADLE_ROOT_FILES + .iter() + .any(|f| read(&format!("buildSrc/{f}")).is_some()); if !present { return Ok(None); } @@ -1917,25 +1907,8 @@ fn marker_json(patch: &JvmPatch<'_>, files: &[(String, &[u8])]) -> String { /// Whether an existing index row is one the script would accept. fn valid_index_row(row: &str) -> bool { - let cols: Vec<&str> = row.split('\t').collect(); - let [gav, path, sha, uuid] = cols.as_slice() else { - return false; - }; - let parts: Vec<&str> = gav.split(':').collect(); - let [g, a, v] = parts.as_slice() else { - return false; - }; - let dir = format!("{}/{a}/{v}/", g.replace('.', "/")); - safe_coordinates(g, a, v) - && path - .strip_prefix(&dir) - .is_some_and(|n| n.starts_with(&format!("{a}-{v}")) && !n.contains('/')) - && sha.len() == 64 - && sha - .bytes() - .all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)) - && !uuid.is_empty() - && !uuid.chars().any(char::is_whitespace) + layout::index_row(row) + .is_some_and(|[.., uuid]| !uuid.is_empty() && !uuid.chars().any(char::is_whitespace)) } /// Merge `rows` for `gav` into the existing index, replacing that GAV's diff --git a/crates/socket-patch-core/src/vendor/jvm/layout.rs b/crates/socket-patch-core/src/vendor/jvm/layout.rs new file mode 100644 index 000000000..d59ba6335 --- /dev/null +++ b/crates/socket-patch-core/src/vendor/jvm/layout.rs @@ -0,0 +1,502 @@ +//! The one place JVM layout facts are spelled: where a GAV lives in a +//! maven2 tree, which coordinates may be joined onto a path, the committed +//! vendor trees and their marker, the ledger name of a JVM entry, and the +//! files that make a directory a JVM build. +//! +//! Every crawler, planner, verifier and redirect that needs one of these +//! asks this module, so a layout rule cannot drift between the copies. + +use std::path::{Path, PathBuf}; + +use crate::patch::path_safety; + +// ── maven2 repository layout ──────────────────────────────────────────── + +/// A dotted groupId as maven2 path segments: `org.apache.commons` → +/// `org/apache/commons`. Run only on coordinates that passed +/// [`is_path_safe`] (or [`safe_coordinates`]). +pub fn group_path(group_id: &str) -> String { + group_id.replace('.', "/") +} + +/// A GAV's version directory, relative to a maven2 root: +/// `//`. +pub fn version_dir(group_id: &str, artifact_id: &str, version: &str) -> String { + format!("{}/{artifact_id}/{version}", group_path(group_id)) +} + +/// [`version_dir`] joined onto `root`, one component per level (so the +/// group keeps the platform separator the caller's root uses below it). +pub fn version_dir_path(root: &Path, group_id: &str, artifact_id: &str, version: &str) -> PathBuf { + root.join(group_path(group_id)) + .join(artifact_id) + .join(version) +} + +/// An artifact's file name: `-[-].`. +pub fn file_name(artifact_id: &str, version: &str, classifier: Option<&str>, ext: &str) -> String { + match classifier { + Some(c) => format!("{artifact_id}-{version}-{c}.{ext}"), + None => format!("{artifact_id}-{version}.{ext}"), + } +} + +/// An artifact's path, relative to a maven2 root: +/// `///`. +pub fn artifact_path( + group_id: &str, + artifact_id: &str, + version: &str, + classifier: Option<&str>, + ext: &str, +) -> String { + format!( + "{}/{}", + version_dir(group_id, artifact_id, version), + file_name(artifact_id, version, classifier, ext) + ) +} + +/// The maven2 registry upstream artifacts are fetched and verified from, +/// overridable with `SOCKET_MAVEN_REGISTRY` (the private-mirror / test +/// escape hatch). Default is Maven Central's maven2 endpoint. +pub fn registry_base() -> String { + std::env::var("SOCKET_MAVEN_REGISTRY") + .ok() + .map(|v| v.trim_end_matches('/').to_string()) + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| "https://repo1.maven.org/maven2".to_string()) +} + +/// The [`registry_base`] URL of an artifact. +pub fn registry_url( + group_id: &str, + artifact_id: &str, + version: &str, + classifier: Option<&str>, + ext: &str, +) -> String { + format!( + "{}/{}", + registry_base(), + artifact_path(group_id, artifact_id, version, classifier, ext) + ) +} + +// ── coordinates ───────────────────────────────────────────────────────── + +/// Whether untrusted coordinates are safe to join onto a maven2 root (the +/// path guard every crawler and the single-pom backend apply). Fails +/// closed. +/// +/// - `artifact_id` and `version` are each a single path segment, so a real +/// one never contains a separator, a `.`/`..` segment, a backslash, a +/// colon, or a NUL — [`path_safety::is_safe_single_segment`]. +/// - `group_id` is dot-separated and run through [`group_path`] (each `.` +/// becomes `/`), so every dot-split segment must independently satisfy +/// [`path_safety::is_safe_single_segment`]. That rejects the forms that +/// would convert to an absolute or `..`-bearing path (`.` -> `/`, `.a` -> +/// `/a`, `a..b` -> `a//b`) and a `/` smuggled inside a dot-split segment +/// (`/etc`, `com/evil`). +/// +/// The delegation also rejects `:` everywhere — a Windows drive-relative +/// coordinate (`C:evil`) joins as an absolute path. +pub fn is_path_safe(group_id: &str, artifact_id: &str, version: &str) -> bool { + group_id.split('.').all(path_safety::is_safe_single_segment) + && path_safety::is_safe_single_segment(artifact_id) + && path_safety::is_safe_single_segment(version) +} + +/// The stricter grammar of the v5 JVM backend: coordinates that every file +/// it writes (Gradle scripts, XML comments, the sbt file, the index rows) +/// accepts unescaped. g is dot-separated `[A-Za-z0-9_-]` segments, a is +/// `[A-Za-z0-9_.-]`, v is `[A-Za-z0-9_.+-]` not ending in `+` nor starting +/// with `latest.`; neither a nor v is all dots, and none holds `--` (it +/// ends an XML comment). The single-pom backend writes none of those, so +/// it keeps [`is_path_safe`] only. +pub fn safe_coordinates(g: &str, a: &str, v: &str) -> bool { + let seg = |s: &str, extra: &str| { + !s.is_empty() + && s.chars() + .all(|c| c.is_ascii_alphanumeric() || "_-".contains(c) || extra.contains(c)) + }; + g.split('.').all(|s| seg(s, "")) + && seg(a, ".") + && seg(v, ".+") + && !a.chars().all(|c| c == '.') + && !v.chars().all(|c| c == '.') + && !v.ends_with('+') + && !v.starts_with("latest.") + && ![g, a, v].iter().any(|s| s.contains("--")) +} + +// ── committed vendor trees ────────────────────────────────────────────── + +/// The reactor's (and the sbt build's) suffixed maven2 tree. +pub const MAVEN2_TREE: &str = ".socket/vendor/maven2"; +/// The Gradle-only artifact tree. +pub const GRADLE_TREE: &str = ".socket/vendor/gradle"; +/// The scala-cli Coursier tree. +pub const COURSIER_TREE: &str = ".socket/vendor/coursier"; +/// Every vendored repository tree JVM entries write under `.socket/vendor`. +pub const VENDOR_TREES: &[&str] = &[MAVEN2_TREE, GRADLE_TREE, COURSIER_TREE]; +/// The per-version marker every tree (and every `/` unit) holds. +pub(crate) use crate::vendor::state::VENDOR_MARKER_FILE as MARKER_FILE; + +/// Paths whose presence without a vendor ledger means JVM artifacts were +/// orphaned (`vendor --check`'s `vendor_ledger_missing`). +pub const ORPHAN_PATHS: &[&str] = &[ + MAVEN2_TREE, + GRADLE_TREE, + super::gradle::INDEX_REL, + super::sbt::BUILD_FILE, + COURSIER_TREE, + super::coursier_tree::INDEX_REL, +]; + +/// The JVM backend's files under `.socket/` a group commit captures. +pub const CAPTURED_FILES: &[&str] = &[ + super::gradle::INDEX_REL, + super::gradle::SCRIPT_REL, + super::maven_reactor::GITATTRIBUTES_REL, + super::gradle::GITATTRIBUTES_REL, + super::coursier_tree::INDEX_REL, + super::coursier_tree::GITIGNORE_REL, + super::coursier_tree::GITATTRIBUTES_REL, + super::scala_cli::GUARD_REL, + super::sbt::TREE_GITIGNORE_REL, +]; + +/// A tree's version directory: `///`. +pub fn tree_dir(tree: &str, group_id: &str, artifact_id: &str, version: &str) -> String { + format!("{tree}/{}", version_dir(group_id, artifact_id, version)) +} + +/// The columns of a tree index row (`\t\t\t`) +/// whose GAV passes [`safe_coordinates`], whose `rel` is a file directly in +/// that GAV's version directory and named after it, and whose sha256 is 64 +/// lowercase hex. The uuid column is returned unchecked: each index applies +/// its own rule. +pub(crate) fn index_row(row: &str) -> Option<[&str; 4]> { + let cols: Vec<&str> = row.split('\t').collect(); + let [gav, rel, sha, uuid] = cols.as_slice() else { + return None; + }; + let parts: Vec<&str> = gav.split(':').collect(); + let [g, a, v] = parts.as_slice() else { + return None; + }; + let dir = format!("{}/", version_dir(g, a, v)); + let ok = safe_coordinates(g, a, v) + && rel + .strip_prefix(&dir) + .is_some_and(|n| n.starts_with(&format!("{a}-{v}")) && !n.contains('/')) + && crate::utils::digest::is_hex64_lower(sha); + ok.then_some([*gav, *rel, *sha, *uuid]) +} + +// ── ledger ────────────────────────────────────────────────────────────── + +/// The ledger ecosystem of every v5 JVM-backend entry (the single-pom +/// backend records `maven`). +pub const LEDGER_ECOSYSTEM: &str = "jvm"; + +/// The package ecosystem a vendor-ledger entry's name stands for: a +/// [`LEDGER_ECOSYSTEM`] entry is a `maven` package (its purl, its +/// `--ecosystems` name, its revert backend); every other name is itself. +pub fn ledger_ecosystem(eco: &str) -> &str { + if eco == LEDGER_ECOSYSTEM { + "maven" + } else { + eco + } +} + +// ── build markers ─────────────────────────────────────────────────────── + +/// Maven's project file. +pub const POM_FILE: &str = "pom.xml"; +/// Gradle's root scripts, settings first. +pub const GRADLE_ROOT_FILES: &[&str] = &[ + "settings.gradle", + "settings.gradle.kts", + "build.gradle", + "build.gradle.kts", +]; +/// Gradle's settings scripts. +pub const GRADLE_SETTINGS_FILES: &[&str] = &[GRADLE_ROOT_FILES[0], GRADLE_ROOT_FILES[1]]; +/// Gradle's build scripts. +pub const GRADLE_BUILD_FILES: &[&str] = &[GRADLE_ROOT_FILES[2], GRADLE_ROOT_FILES[3]]; +/// Mill's build files. +pub const MILL_BUILD_FILES: &[&str] = &["build.mill", "build.mill.yaml", "build.sc"]; +/// Mill's version pin: a Mill marker for hosted guidance and build +/// ambiguity, but no build file of its own. +pub const MILL_VERSION_FILE: &str = ".mill-version"; +/// [`MILL_BUILD_FILES`] and [`MILL_VERSION_FILE`]. +pub const MILL_MARKERS: &[&str] = &[ + MILL_BUILD_FILES[0], + MILL_BUILD_FILES[1], + MILL_BUILD_FILES[2], + MILL_VERSION_FILE, +]; +/// A scala-cli directory build's project file. +pub const SCALA_CLI_FILE: &str = "project.scala"; +/// scala-cli's build output directory. +pub const SCALA_CLI_DIR: &str = ".scala-build"; + +use crate::formats::sbt::build::{BUILD_PROPERTIES as SBT_BUILD_PROPERTIES, BUILD_SBT}; + +/// The basenames that make a directory a JVM project root wherever a +/// marker is matched by name alone (root detection on disk and in memory). +/// `project/build.properties` and `.scala-build` are no basename markers: +/// `build.properties` alone is too generic a name, and a directory is no +/// file a lock-less root is detected by. +pub const JVM_PROJECT_MARKERS: &[&str] = &[ + POM_FILE, + GRADLE_ROOT_FILES[0], + GRADLE_ROOT_FILES[1], + GRADLE_ROOT_FILES[2], + GRADLE_ROOT_FILES[3], + BUILD_SBT, + MILL_BUILD_FILES[0], + MILL_BUILD_FILES[1], + MILL_BUILD_FILES[2], + SCALA_CLI_FILE, +]; + +/// A JVM build tool, by the files that mark its build root. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum BuildTool { + Maven, + Gradle, + Sbt, + Mill, + ScalaCli, +} + +impl BuildTool { + pub const ALL: [BuildTool; 5] = [ + BuildTool::Maven, + BuildTool::Gradle, + BuildTool::Sbt, + BuildTool::Mill, + BuildTool::ScalaCli, + ]; + + /// The root-relative paths whose presence marks this tool's build. + pub fn markers(self) -> &'static [&'static str] { + match self { + BuildTool::Maven => &[POM_FILE], + BuildTool::Gradle => GRADLE_ROOT_FILES, + BuildTool::Sbt => &[BUILD_SBT, SBT_BUILD_PROPERTIES], + BuildTool::Mill => MILL_BUILD_FILES, + BuildTool::ScalaCli => &[SCALA_CLI_FILE, SCALA_CLI_DIR], + } + } + + /// sbt, Mill and scala-cli resolve into the Coursier / Ivy caches. + pub fn is_scala_tool(self) -> bool { + matches!(self, BuildTool::Sbt | BuildTool::Mill | BuildTool::ScalaCli) + } +} + +/// The one stat rule every disk-side marker check applies: the path +/// exists, following symlinks (a file, or for `.scala-build` a directory). +pub fn marker_present(dir: &Path, rel: &str) -> bool { + std::fs::metadata(dir.join(rel)).is_ok() +} + +/// Whether `dir` holds a `tool` build marker. +pub fn has_build(dir: &Path, tool: BuildTool) -> bool { + tool.markers().iter().any(|m| marker_present(dir, m)) +} + +/// Whether `dir` holds any JVM build marker. +pub fn is_jvm_build(dir: &Path) -> bool { + BuildTool::ALL.iter().any(|t| has_build(dir, *t)) +} + +/// Whether `dir` is an sbt, Mill or scala-cli build. +pub fn is_scala_tool_build(dir: &Path) -> bool { + BuildTool::ALL + .iter() + .filter(|t| t.is_scala_tool()) + .any(|t| has_build(dir, *t)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maven2_paths() { + assert_eq!(group_path("org.apache.commons"), "org/apache/commons"); + assert_eq!(group_path("single"), "single"); + assert_eq!(version_dir("org.example", "a", "1.0"), "org/example/a/1.0"); + assert_eq!(file_name("a", "1.0", None, "jar"), "a-1.0.jar"); + assert_eq!( + file_name("a", "1.0", Some("sources"), "jar"), + "a-1.0-sources.jar" + ); + assert_eq!( + artifact_path("org.example", "a", "1.0", Some("tests"), "jar"), + "org/example/a/1.0/a-1.0-tests.jar" + ); + assert_eq!( + tree_dir(GRADLE_TREE, "org.example", "a", "1.0"), + ".socket/vendor/gradle/org/example/a/1.0" + ); + assert_eq!( + version_dir_path(Path::new("/r"), "org.example", "a", "1.0"), + Path::new("/r").join("org/example").join("a").join("1.0") + ); + } + + #[test] + fn path_safe_coordinates() { + // Legit coordinates pass. + assert!(is_path_safe( + "org.apache.commons", + "commons-lang3", + "3.12.0" + )); + assert!(is_path_safe("com.google.guava", "guava", "32.1.3-jre")); + // `..` in any single-segment coordinate is rejected. + assert!(!is_path_safe("g", "..", "1.0.0")); + assert!(!is_path_safe("g", "../../escaped", "1.0.0")); + assert!(!is_path_safe("g", "a", "..")); + // A `/` in the artifactId/version (never legitimate) is rejected. + assert!(!is_path_safe("g", "a/b", "1.0.0")); + assert!(!is_path_safe("g", "a", "1/0")); + // groupId forms that convert to an absolute or empty-segment path + // (`.` -> `/`, `.a` -> `/a`) are rejected. + assert!(!is_path_safe(".", "a", "1.0.0")); + assert!(!is_path_safe("..", "a", "1.0.0")); + assert!(!is_path_safe(".org", "a", "1.0.0")); + assert!(!is_path_safe("org.", "a", "1.0.0")); + assert!(!is_path_safe("a..b", "a", "1.0.0")); + // Backslash / NUL anywhere is rejected. + assert!(!is_path_safe("g", "a\\b", "1.0.0")); + assert!(!is_path_safe("g\0x", "a", "1.0.0")); + // Empty coordinates are rejected. + assert!(!is_path_safe("", "a", "1.0.0")); + assert!(!is_path_safe("g", "", "1.0.0")); + assert!(!is_path_safe("g", "a", "")); + // Windows drive-relative escape: a `:` (e.g. `C:evil`) makes the + // joined path absolute under `Path::join`; rejected in every + // coordinate, including inside a dot-split groupId segment. + assert!(!is_path_safe("C:evil.org", "a", "1.0.0")); + assert!(!is_path_safe("g", "C:evil", "1.0.0")); + assert!(!is_path_safe("g", "a", "C:1.0.0")); + // A `/` smuggled inside a dot-split groupId segment never hits the + // per-dot-segment checks (`/etc` has no dots at all) but converts to + // an absolute or deeper path via `group_path`. + assert!(!is_path_safe("/etc", "a", "1.0.0")); + assert!(!is_path_safe("com/evil", "a", "1.0.0")); + } + + #[test] + fn strict_coordinates_are_path_safe() { + for (g, a, v) in [ + ("org.example", "a", "1.0"), + ("com.google.guava", "guava", "32.1.3-jre"), + ("x", "y.z", "1+build"), + ] { + assert!( + safe_coordinates(g, a, v) && is_path_safe(g, a, v), + "{g}:{a}:{v}" + ); + } + for (g, a, v) in [ + ("org..x", "a", "1"), + ("org", "..", "1"), + ("org", "a", "C:x"), + ] { + assert!( + !safe_coordinates(g, a, v) && !is_path_safe(g, a, v), + "{g}:{a}:{v}" + ); + } + // Path-safe but not writable unescaped. + assert!(is_path_safe("org", "a", "1--x") && !safe_coordinates("org", "a", "1--x")); + } + + #[test] + fn index_rows() { + let sha = "a".repeat(64); + let row = format!("org.example:a:1.0\torg/example/a/1.0/a-1.0.jar\t{sha}\tu"); + assert_eq!( + index_row(&row), + Some([ + "org.example:a:1.0", + "org/example/a/1.0/a-1.0.jar", + sha.as_str(), + "u" + ]) + ); + for bad in [ + format!("org.example:a:1.0\torg/example/a/2.0/a-1.0.jar\t{sha}\tu"), + format!("org.example:a:1.0\torg/example/a/1.0/b-1.0.jar\t{sha}\tu"), + format!("org.example:a:1.0\torg/example/a/1.0/x/a-1.0.jar\t{sha}\tu"), + format!( + "org.example:a:1.0\torg/example/a/1.0/a-1.0.jar\t{}\tu", + "A".repeat(64) + ), + "org.example:a:1.0\torg/example/a/1.0/a-1.0.jar\tabc\tu".to_string(), + format!("org.example:a\torg/example/a/1.0/a-1.0.jar\t{sha}\tu"), + format!("org.example:a:1.0\torg/example/a/1.0/a-1.0.jar\t{sha}"), + ] { + assert_eq!(index_row(&bad), None, "{bad}"); + } + } + + #[test] + fn jvm_ledger_entries_are_maven_packages() { + assert_eq!(ledger_ecosystem(LEDGER_ECOSYSTEM), "maven"); + assert_eq!(ledger_ecosystem("maven"), "maven"); + assert_eq!(ledger_ecosystem("npm"), "npm"); + } + + /// The basename list is exactly every tool marker that is a root file. + #[test] + fn project_markers_are_the_basename_markers_of_every_tool() { + let mut basenames: Vec<&str> = BuildTool::ALL + .iter() + .flat_map(|t| t.markers().iter().copied()) + .filter(|m| !m.contains('/') && !m.starts_with('.')) + .collect(); + let mut listed = JVM_PROJECT_MARKERS.to_vec(); + basenames.sort_unstable(); + listed.sort_unstable(); + assert_eq!(basenames, listed); + assert_eq!( + [GRADLE_SETTINGS_FILES, GRADLE_BUILD_FILES].concat(), + GRADLE_ROOT_FILES + ); + assert_eq!(&MILL_MARKERS[..3], MILL_BUILD_FILES); + } + + #[test] + fn every_marker_marks_its_tool() { + for tool in BuildTool::ALL { + for marker in tool.markers() { + let dir = tempfile::tempdir().unwrap(); + assert!(!is_jvm_build(dir.path())); + let path = dir.path().join(marker); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + if *marker == SCALA_CLI_DIR { + std::fs::create_dir(&path).unwrap(); + } else { + std::fs::write(&path, "").unwrap(); + } + assert!(has_build(dir.path(), tool), "{marker}"); + assert!(is_jvm_build(dir.path()), "{marker}"); + assert_eq!( + is_scala_tool_build(dir.path()), + tool.is_scala_tool(), + "{marker}" + ); + } + } + } +} diff --git a/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs b/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs index 4d311b2ee..85fca13ac 100644 --- a/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs +++ b/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs @@ -12,15 +12,16 @@ use std::ops::Range; use serde_json::{json, Value}; use super::super::state::{WiringAction, WiringRecord}; +use super::layout::{self, safe_coordinates}; use super::{ adopt, changes_between, finish_writes, fragment, op_of, op_str, owned_file, replace_op, - safe_coordinates, sha1_hex, sha256_hex, undo_replace, Coords, FileWrite, JvmPatch, JvmPlan, - JvmRefusal, JvmUnplan, JvmWarning, ReadFn, CONFIG_LINE_KIND, OWNED_FILE_KIND, - POM_FRAGMENT_KIND, TREE_GITATTRIBUTES, + sha1_hex, sha256_hex, undo_replace, Coords, FileWrite, JvmPatch, JvmPlan, JvmRefusal, + JvmUnplan, JvmWarning, ReadFn, CONFIG_LINE_KIND, OWNED_FILE_KIND, POM_FRAGMENT_KIND, + TREE_GITATTRIBUTES, }; /// The committed maven2 tree. -pub const TREE_ROOT: &str = ".socket/vendor/maven2"; +use super::layout::MAVEN2_TREE as TREE_ROOT; pub const MAVEN_CONFIG: &str = ".mvn/maven.config"; /// The tree root's `.gitattributes`, shared by every Maven patch. pub const GITATTRIBUTES_REL: &str = ".socket/vendor/maven2/.gitattributes"; @@ -30,11 +31,10 @@ const TAIL_KEY: &str = "-Dmaven.repo.local.tail="; const TAIL_DIR: &str = "${session.rootDirectory}/.socket/vendor/maven2"; pub const REPO_ID: &str = "socket-patch-vendor"; pub const REPO_URL: &str = "file://${maven.multiModuleProjectDirectory}/.socket/vendor/maven2"; -const BEGIN_MARKER: &str = ""; +pub(crate) const BEGIN_MARKER: &str = ""; const END_MARKER: &str = ""; /// Prefix of the comment tagging a pin: ``. -const PIN_TAG: &str = "") - || pom.contains(""; diff --git a/crates/socket-patch-core/src/vendor/maven_repo.rs b/crates/socket-patch-core/src/vendor/maven_repo.rs index 313a6bb44..3b3ecc269 100644 --- a/crates/socket-patch-core/src/vendor/maven_repo.rs +++ b/crates/socket-patch-core/src/vendor/maven_repo.rs @@ -751,8 +751,9 @@ pub(super) fn not_build_root(project_root: &Path) -> Option { } let read_text = |p: &str| crate::gradle::dsl::decode(&reader.read(p)?); // Gradle reads the Groovy settings first. - let settings = ["settings.gradle", "settings.gradle.kts"] - .into_iter() + let settings = layout::GRADLE_SETTINGS_FILES + .iter() + .copied() .find(|f| reader.read(f).is_some()); // Only a Gradle project can belong to an ancestor Gradle build. if let Some(settings) = settings.filter(|_| own_build || own_settings) {