From 096d479ff59c1086723c3c7303567488cbdff0fd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 23:24:47 +0000 Subject: [PATCH 1/8] Start fix for #423 Assisted-by: Claude Code:claude-opus-5-5 From e33d5b530d094555e54deaf9b67e84c72c990d4e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 23:51:00 +0000 Subject: [PATCH 2/8] Find the Maven local repository like Maven does Agent mode and -g looked for the Maven local repository in $M2_HOME/repository, which is the Maven install, and never read from settings.xml. On Windows they also preferred HOME over the profile directory Java uses. With any of these, scan -g found 0 packages, and rollback -g dropped the patch record while the real local repository stayed patched. The lookup now follows Maven: MAVEN_REPO_LOCAL, then -Dmaven.repo.local in MAVEN_ARGS or MAVEN_OPTS, then in the user settings, then in the install's conf/settings.xml, then .m2/repository under user.home. user.home honours -Duser.home and is USERPROFILE on Windows. ${user.home} and ${env.X} expand in settings. Fixes #423 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/crawlers/maven_crawler.rs | 174 +++++++++++--- .../maven_crawler/local_repo_tests.rs | 213 ++++++++++++++++++ .../maven_crawler/scala_cache_tests.rs | 3 + .../tests/crawler_gradle_e2e.rs | 6 +- docs/ecosystems.md | 6 + 5 files changed, 365 insertions(+), 37 deletions(-) create mode 100644 crates/socket-patch-core/src/crawlers/maven_crawler/local_repo_tests.rs diff --git a/crates/socket-patch-core/src/crawlers/maven_crawler.rs b/crates/socket-patch-core/src/crawlers/maven_crawler.rs index 3575adf6d..d9252bdaa 100644 --- a/crates/socket-patch-core/src/crawlers/maven_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/maven_crawler.rs @@ -12,6 +12,8 @@ use crate::gradle::{Env, Os}; use crate::utils::fs::is_dir; use crate::vendor::jvm::layout::{self, BuildTool}; +#[cfg(test)] +mod local_repo_tests; #[cfg(test)] mod oracle; #[cfg(test)] @@ -541,8 +543,8 @@ pub struct JvmEnv { } impl JvmEnv { - /// The caches of this process: `$MAVEN_REPO_LOCAL` / `$M2_HOME` / - /// `~/.m2`, and [`gradle_cache::home_from_process_env`]. + /// The caches of this process: the Maven local repository + /// ([`m2_repo_path_with`]), and [`gradle_cache::home_from_process_env`]. pub fn from_process() -> Self { Self { m2_repo: MavenCrawler::m2_repo_path(), @@ -554,7 +556,7 @@ impl JvmEnv { /// the passwd entry's), which Gradle prefers to `$HOME`. pub fn resolve(env: &dyn Env, os: Os, home_dir: Option<&Path>) -> Self { Self { - m2_repo: m2_repo_path_with(env, home_dir), + m2_repo: m2_repo_path_with(env, os, home_dir), gradle: GradleHome::resolve(env, os, home_dir), } } @@ -594,28 +596,122 @@ pub fn is_ro_root(path: &Path) -> bool { JvmEnv::from_process().is_ro_root(path) } -/// The Maven local repository `env` names: `$MAVEN_REPO_LOCAL`, else -/// `$M2_HOME/repository`, else `/.m2/repository` with `` = -/// `$HOME`, `$USERPROFILE` or `home_dir`, the first that is set and rooted -/// ([`crate::utils::fs::home_from_env`]). A set-but-empty variable counts -/// as unset (see [`MavenCrawler::m2_repo_path`]). `None` when there is no -/// usable home: a relative or literal `~` would resolve against the working -/// directory and treat a `~/.m2` inside the scanned project as the user's -/// local repository (B66). -pub fn m2_repo_path_with(env: &dyn Env, home_dir: Option<&Path>) -> Option { +/// The Maven local repository `env` names, in Maven's own order (#423): +/// +/// 1. `$MAVEN_REPO_LOCAL` (socket-patch's override); +/// 2. `-Dmaven.repo.local` in `$MAVEN_ARGS`, then `$MAVEN_OPTS`; +/// 3. `` in the user settings +/// (`/.m2/settings.xml`), then in the global settings +/// (`conf/settings.xml` under `$MAVEN_HOME`, else `$M2_HOME`); +/// 4. `/.m2/repository`. +/// +/// `M2_HOME` is the Maven install, never a local repository. `user.home` +/// is `-Duser.home` in `$MAVEN_OPTS`, else the JVM's: on Windows the +/// profile directory (`$USERPROFILE`, else `$HOME`, else `home_dir`), +/// elsewhere `$HOME`, `$USERPROFILE` or `home_dir`, the first that is set +/// and rooted ([`crate::utils::fs::home_from_env`]). A set-but-empty +/// variable counts as unset (see [`MavenCrawler::m2_repo_path`]). A +/// relative or unresolvable location (an undefined `${…}`) is skipped: it +/// would resolve against whatever directory Maven runs in, not ours. `None` +/// when nothing names a repository and there is no usable home: a relative +/// or literal `~` would resolve against the working directory and treat a +/// `~/.m2` inside the scanned project as the user's local repository (B66). +pub fn m2_repo_path_with(env: &dyn Env, os: Os, home_dir: Option<&Path>) -> Option { + use crate::gradle::home::system_property; + use crate::utils::fs::is_usable_home; + let set = |k: &str| env.var(k).filter(|v| !v.is_empty()); if let Some(repo_local) = set("MAVEN_REPO_LOCAL") { return Some(PathBuf::from(repo_local)); } - if let Some(m2_home) = set("M2_HOME") { - return Some(PathBuf::from(m2_home).join("repository")); + // Later sources override earlier ones on the JVM: command-line `-D`s + // (MAVEN_ARGS) are set over the launcher's MAVEN_OPTS. + let property = |name: &str| { + ["MAVEN_ARGS", "MAVEN_OPTS"].iter().find_map(|k| { + set(k) + .and_then(|opts| system_property(&opts, name, os)) + .filter(|v| !v.is_empty()) + }) + }; + let user_home = property("user.home") + .map(PathBuf::from) + .filter(|h| is_usable_home(h)) + .or_else(|| match os { + Os::Windows => ["USERPROFILE", "HOME"] + .iter() + .filter_map(|k| set(k).map(PathBuf::from)) + .find(|h| is_usable_home(h)), + Os::Unix => crate::utils::fs::home_from_env(|k| set(k).map(Into::into)), + }) + .or_else(|| { + home_dir + .filter(|h| is_usable_home(h)) + .map(Path::to_path_buf) + }); + if let Some(repo) = property("maven.repo.local") + .map(PathBuf::from) + .filter(|p| p.has_root()) + { + return Some(repo); } - let home = crate::utils::fs::home_from_env(|k| set(k).map(Into::into)).or_else(|| { - home_dir - .filter(|h| crate::utils::fs::is_usable_home(h)) - .map(Path::to_path_buf) - })?; - Some(home.join(".m2").join("repository")) + let maven_home = set("MAVEN_HOME").or_else(|| set("M2_HOME")); + let settings_files = [ + user_home + .as_ref() + .map(|h| h.join(".m2").join("settings.xml")), + maven_home.map(|m| PathBuf::from(m).join("conf").join("settings.xml")), + ]; + let lookup = |name: &str| match name { + "user.home" => user_home.as_ref().map(|h| h.display().to_string()), + _ => match name.strip_prefix("env.") { + Some(var) => set(var), + None => property(name), + }, + }; + for path in settings_files.into_iter().flatten() { + if let Some(repo) = settings_local_repository(&path, &lookup) { + return Some(repo); + } + } + Some(user_home?.join(".m2").join("repository")) +} + +/// The `` a Maven `settings.xml` at `path` names, with +/// `${…}` placeholders resolved through `lookup`. `None` when the file is +/// missing, unreadable or not well formed where it matters, sets no +/// ``, or names a relative or unresolvable location. +fn settings_local_repository( + path: &Path, + lookup: &dyn Fn(&str) -> Option, +) -> Option { + use crate::formats::xml; + + let raw = std::fs::read_to_string(path).ok()?; + let text = xml::blank_non_markup(&raw).ok()?; + let settings = *xml::elements(&text, "settings").ok()?.first()?; + let value = xml::children(&text, &settings, "localRepository") + .ok()? + .first()? + .inner(&text) + .trim() + .to_string(); + let repo = PathBuf::from(interpolate(&value, lookup)?); + repo.has_root().then_some(repo) +} + +/// `text` with every `${name}` replaced by `lookup(name)`; `None` when one +/// is undefined or unterminated. +fn interpolate(text: &str, lookup: &dyn Fn(&str) -> Option) -> Option { + let mut out = String::new(); + let mut rest = text; + while let Some(at) = rest.find("${") { + out.push_str(&rest[..at]); + let end = rest[at..].find('}')? + at; + out.push_str(&lookup(&rest[at + 2..end])?); + rest = &rest[end + 1..]; + } + out.push_str(rest); + Some(out) } /// A `--global-prefix` as the cache root it names: a Gradle user home @@ -1103,7 +1199,7 @@ impl MavenCrawler { /// Get the Maven local repository path. /// - /// Checks `$MAVEN_REPO_LOCAL`, `$M2_HOME/repository`, `$HOME/.m2/repository`. + /// See [`m2_repo_path_with`] for the order Maven itself uses. /// /// A set-but-EMPTY variable counts as unset. `std::env::var` yields /// `Ok("")` for `export MAVEN_REPO_LOCAL="$M2"` with `$M2` undefined — @@ -1122,7 +1218,7 @@ impl MavenCrawler { /// Same rule as `nuget_home()`, `deno_dir()`, `go_crawler`'s /// `get_gomodcache`, and `utils::fs::home_dir`. fn m2_repo_path() -> Option { - m2_repo_path_with(&gradle_cache::ProcessEnv, None) + m2_repo_path_with(&gradle_cache::ProcessEnv, Os::current(), None) } /// Scan a Maven repository directory and return all valid packages found. @@ -2414,22 +2510,27 @@ mod tests { .map(|(k, v)| (k.to_string(), v.to_string())) .collect() }; - assert_eq!(m2_repo_path_with(&env(&[]), None), None); - assert_eq!(m2_repo_path_with(&env(&[("HOME", "")]), None), None); - assert_eq!(m2_repo_path_with(&env(&[("HOME", "~")]), None), None); + let m2_repo_path_with_unix = + |e: &HashMap, h| m2_repo_path_with(e, Os::Unix, h); + assert_eq!(m2_repo_path_with_unix(&env(&[]), None), None); + assert_eq!(m2_repo_path_with_unix(&env(&[("HOME", "")]), None), None); + assert_eq!(m2_repo_path_with_unix(&env(&[("HOME", "~")]), None), None); assert_eq!( - m2_repo_path_with(&env(&[("HOME", "rel/home")]), Some(Path::new("also-rel"))), + m2_repo_path_with_unix(&env(&[("HOME", "rel/home")]), Some(Path::new("also-rel"))), None ); let abs = std::env::temp_dir(); let abs_s = abs.to_str().unwrap(); let want = Some(abs.join(".m2").join("repository")); assert_eq!( - m2_repo_path_with(&env(&[("HOME", "rel"), ("USERPROFILE", abs_s)]), None), + m2_repo_path_with_unix(&env(&[("HOME", "rel"), ("USERPROFILE", abs_s)]), None), want ); - assert_eq!(m2_repo_path_with(&env(&[("HOME", "~")]), Some(&abs)), want); - assert_eq!(m2_repo_path_with(&env(&[("HOME", abs_s)]), None), want); + assert_eq!( + m2_repo_path_with_unix(&env(&[("HOME", "~")]), Some(&abs)), + want + ); + assert_eq!(m2_repo_path_with_unix(&env(&[("HOME", abs_s)]), None), want); } #[test] @@ -2442,15 +2543,19 @@ mod tests { // Maven patch was skipped — while Maven itself would have used the // default local repo. Empty must fall through, exactly as // `nuget_home()` treats an empty `NUGET_PACKAGES`. - let m2_home = tempfile::tempdir().unwrap(); + let repo_local = tempfile::tempdir().unwrap(); let _local = EnvGuard::set("MAVEN_REPO_LOCAL", ""); - let _m2 = EnvGuard::set("M2_HOME", m2_home.path().to_str().unwrap()); + let _args = EnvGuard::unset("MAVEN_ARGS"); + let _opts = EnvGuard::set( + "MAVEN_OPTS", + &format!("-Dmaven.repo.local={}", repo_local.path().display()), + ); let repo = MavenCrawler::m2_repo_path(); assert_eq!( repo, - Some(m2_home.path().join("repository")), - "empty MAVEN_REPO_LOCAL must fall through to the M2_HOME arm, got {repo:?}" + Some(repo_local.path().to_path_buf()), + "empty MAVEN_REPO_LOCAL must fall through to -Dmaven.repo.local, got {repo:?}" ); } @@ -2464,6 +2569,9 @@ mod tests { // directory inside the user's own project. Same CWD-relative hazard // the `utils::fs::home_dir` and `go_crawler` empty-HOME fixes closed. let _local = EnvGuard::unset("MAVEN_REPO_LOCAL"); + let _args = EnvGuard::unset("MAVEN_ARGS"); + let _opts = EnvGuard::unset("MAVEN_OPTS"); + let _maven_home = EnvGuard::unset("MAVEN_HOME"); let _m2 = EnvGuard::set("M2_HOME", ""); let repo = MavenCrawler::m2_repo_path(); diff --git a/crates/socket-patch-core/src/crawlers/maven_crawler/local_repo_tests.rs b/crates/socket-patch-core/src/crawlers/maven_crawler/local_repo_tests.rs new file mode 100644 index 000000000..ea96b5476 --- /dev/null +++ b/crates/socket-patch-core/src/crawlers/maven_crawler/local_repo_tests.rs @@ -0,0 +1,213 @@ +//! The Maven local repository agent mode and `-g` work on must be the one +//! Maven itself uses (#423): `M2_HOME` is the Maven install, not a local +//! repository; `` in the user or global `settings.xml` +//! and `-Dmaven.repo.local` relocate it; on Windows `user.home` is the +//! profile directory, whatever `HOME` says. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use super::JvmEnv; +use crate::gradle::Os; + +fn env(pairs: &[(&str, &Path)]) -> HashMap { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_str().unwrap().to_string())) + .collect() +} + +fn m2_repo(env: &HashMap, os: Os) -> Option { + JvmEnv::resolve(env, os, None).m2_repo +} + +fn write(p: &Path, text: &str) { + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, text).unwrap(); +} + +fn settings(local_repository: &str) -> String { + format!( + "\n\n \n \ + {local_repository}\n\n" + ) +} + +/// Trigger 1: `M2_HOME` names the Maven install. Maven keeps using +/// `~/.m2/repository`, and so must the crawler. +#[test] +fn m2_home_is_not_a_local_repository() { + let home = tempfile::tempdir().unwrap(); + let install = tempfile::tempdir().unwrap(); + let e = env(&[("HOME", home.path()), ("M2_HOME", install.path())]); + assert_eq!( + m2_repo(&e, Os::Unix), + Some(home.path().join(".m2").join("repository")) + ); +} + +/// Trigger 2: `` in `~/.m2/settings.xml`. +#[test] +fn user_settings_local_repository_is_honoured() { + let home = tempfile::tempdir().unwrap(); + let data = tempfile::tempdir().unwrap(); + write( + &home.path().join(".m2").join("settings.xml"), + &settings(&format!(" {} ", data.path().display())), + ); + let e = env(&[("HOME", home.path())]); + assert_eq!(m2_repo(&e, Os::Unix), Some(data.path().to_path_buf())); +} + +/// `${user.home}` and `${env.NAME}` interpolate in ``, as +/// Maven's settings builder does. +#[test] +fn settings_local_repository_interpolates_user_home_and_env() { + let home = tempfile::tempdir().unwrap(); + let user_settings = home.path().join(".m2").join("settings.xml"); + write(&user_settings, &settings("${user.home}/caches/m2")); + let e = env(&[("HOME", home.path())]); + assert_eq!( + m2_repo(&e, Os::Unix), + Some(PathBuf::from(format!( + "{}/caches/m2", + home.path().display() + ))) + ); + + let ci = tempfile::tempdir().unwrap(); + write(&user_settings, &settings("${env.CI_CACHE}/m2")); + let e = env(&[("HOME", home.path()), ("CI_CACHE", ci.path())]); + assert_eq!( + m2_repo(&e, Os::Unix), + Some(PathBuf::from(format!("{}/m2", ci.path().display()))) + ); + + // A placeholder nothing defines is no usable directory: the default. + let e = env(&[("HOME", home.path())]); + assert_eq!( + m2_repo(&e, Os::Unix), + Some(home.path().join(".m2").join("repository")) + ); +} + +/// The install's `conf/settings.xml` (under `MAVEN_HOME`, or `M2_HOME`) +/// applies when the user settings don't set ``, and the +/// user settings win when both do. +#[test] +fn global_settings_local_repository_is_honoured_below_user_settings() { + let home = tempfile::tempdir().unwrap(); + let install = tempfile::tempdir().unwrap(); + let global = tempfile::tempdir().unwrap(); + let user = tempfile::tempdir().unwrap(); + write( + &install.path().join("conf").join("settings.xml"), + &settings(global.path().to_str().unwrap()), + ); + for var in ["MAVEN_HOME", "M2_HOME"] { + let e = env(&[("HOME", home.path()), (var, install.path())]); + assert_eq!( + m2_repo(&e, Os::Unix), + Some(global.path().to_path_buf()), + "{var}" + ); + } + + // A user settings file without keeps the global one. + let user_settings = home.path().join(".m2").join("settings.xml"); + write( + &user_settings, + "false", + ); + let e = env(&[("HOME", home.path()), ("M2_HOME", install.path())]); + assert_eq!(m2_repo(&e, Os::Unix), Some(global.path().to_path_buf())); + + write(&user_settings, &settings(user.path().to_str().unwrap())); + assert_eq!(m2_repo(&e, Os::Unix), Some(user.path().to_path_buf())); +} + +/// `-Dmaven.repo.local` in `MAVEN_OPTS` or `MAVEN_ARGS` beats every +/// settings file; `MAVEN_REPO_LOCAL` still beats everything. +#[test] +fn maven_repo_local_system_property_beats_settings() { + let home = tempfile::tempdir().unwrap(); + let from_settings = tempfile::tempdir().unwrap(); + let from_opts = tempfile::tempdir().unwrap(); + let from_args = tempfile::tempdir().unwrap(); + let from_env = tempfile::tempdir().unwrap(); + write( + &home.path().join(".m2").join("settings.xml"), + &settings(from_settings.path().to_str().unwrap()), + ); + let mut e = env(&[("HOME", home.path())]); + e.insert( + "MAVEN_OPTS".into(), + format!("-Xmx1g -Dmaven.repo.local={}", from_opts.path().display()), + ); + assert_eq!(m2_repo(&e, Os::Unix), Some(from_opts.path().to_path_buf())); + e.insert( + "MAVEN_ARGS".into(), + format!("-B -Dmaven.repo.local={}", from_args.path().display()), + ); + assert_eq!(m2_repo(&e, Os::Unix), Some(from_args.path().to_path_buf())); + e.insert( + "MAVEN_REPO_LOCAL".into(), + from_env.path().to_str().unwrap().into(), + ); + assert_eq!(m2_repo(&e, Os::Unix), Some(from_env.path().to_path_buf())); +} + +/// `-Duser.home` in `MAVEN_OPTS` moves `~/.m2` (settings and repository) +/// the way it moves Maven's. +#[test] +fn maven_opts_user_home_moves_the_m2_directory() { + let home = tempfile::tempdir().unwrap(); + let jvm_home = tempfile::tempdir().unwrap(); + let mut e = env(&[("HOME", home.path())]); + e.insert( + "MAVEN_OPTS".into(), + format!("-Duser.home={}", jvm_home.path().display()), + ); + assert_eq!( + m2_repo(&e, Os::Unix), + Some(jvm_home.path().join(".m2").join("repository")) + ); +} + +/// Trigger 3: on Windows Java's `user.home` is the profile directory, so +/// a `HOME` that Git for Windows / MSYS or a network-drive mapping set +/// elsewhere is not where Maven keeps `.m2`. +#[test] +fn windows_user_home_is_the_profile_directory() { + let home = tempfile::tempdir().unwrap(); + let profile = tempfile::tempdir().unwrap(); + let e = env(&[("HOME", home.path()), ("USERPROFILE", profile.path())]); + assert_eq!( + m2_repo(&e, Os::Windows), + Some(profile.path().join(".m2").join("repository")) + ); + // Unix keeps HOME first. + assert_eq!( + m2_repo(&e, Os::Unix), + Some(home.path().join(".m2").join("repository")) + ); +} + +/// A relative `` or a settings file that isn't well +/// formed names no directory the crawler could trust: the default. +#[test] +fn unusable_settings_fall_through_to_the_default() { + let home = tempfile::tempdir().unwrap(); + let user_settings = home.path().join(".m2").join("settings.xml"); + let default = Some(home.path().join(".m2").join("repository")); + let e = env(&[("HOME", home.path())]); + for text in [ + settings("relative/repo"), + settings(""), + "/x".to_string(), + "` in `~/.m2/settings.xml`, then in the install's `conf/settings.xml` +(`MAVEN_HOME` or `M2_HOME`), then `.m2/repository` in `user.home`. `M2_HOME` itself +never names a local repository. On Windows `user.home` is `USERPROFILE`, not `HOME`. + A Gradle-only build reads `~/.m2` only through `mavenLocal()`. Scan looks for it in every settings, build, `buildSrc`, included-build, applied and init script, including the wrapper distribution's `init.d`. Without it, `~/.m2` is not scanned, and modules From 20dd097e1fa0d26b855f57f429c920e2be2db6f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 23:54:16 +0000 Subject: [PATCH 3/8] Pin M2_HOME as the Maven install in crawler tests The integration test asserted the old $M2_HOME/repository lookup. It now checks that $M2_HOME/repository is not scanned and that the install's conf/settings.xml is. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/crawler_maven_e2e.rs | 43 ++++++++++++++++--- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/crates/socket-patch-core/tests/crawler_maven_e2e.rs b/crates/socket-patch-core/tests/crawler_maven_e2e.rs index bbd5aed18..7420342d4 100644 --- a/crates/socket-patch-core/tests/crawler_maven_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_maven_e2e.rs @@ -38,6 +38,9 @@ impl NoJvmCaches { "COURSIER_CACHE", "SBT_OPTS", "JAVA_OPTS", + "MAVEN_OPTS", + "MAVEN_ARGS", + "MAVEN_HOME", ]; let saved = keys.iter().map(|k| (*k, std::env::var_os(k))).collect(); for key in keys { @@ -658,24 +661,46 @@ async fn get_maven_repo_paths_with_build_gradle_kts_returns_repo() { assert_gradle_marker_gates_m2("build.gradle.kts", true).await; } +/// `M2_HOME` is the Maven install (#423): its `repository/` is never a +/// local repository, but the `` its `conf/settings.xml` +/// names is. #[tokio::test] #[serial] -async fn get_maven_repo_paths_m2_home_fallback() { +async fn get_maven_repo_paths_m2_home_global_settings() { let _no_jvm_caches = NoJvmCaches::new(); let tmp = tempfile::tempdir().unwrap(); tokio::fs::write(tmp.path().join("pom.xml"), b"") .await .unwrap(); let m2_home = tempfile::tempdir().unwrap(); - let repo_dir = m2_home.path().join("repository"); - tokio::fs::create_dir(&repo_dir).await.unwrap(); + tokio::fs::create_dir(m2_home.path().join("repository")) + .await + .unwrap(); let prev_maven_repo = std::env::var("MAVEN_REPO_LOCAL").ok(); let prev_m2 = std::env::var("M2_HOME").ok(); std::env::remove_var("MAVEN_REPO_LOCAL"); std::env::set_var("M2_HOME", m2_home.path()); let crawler = MavenCrawler; - let paths = crawler + let without_settings = crawler + .get_maven_repo_paths(&options_at(tmp.path())) + .await + .unwrap(); + + let repo_dir = tempfile::tempdir().unwrap(); + tokio::fs::create_dir(m2_home.path().join("conf")) + .await + .unwrap(); + tokio::fs::write( + m2_home.path().join("conf").join("settings.xml"), + format!( + "{}", + repo_dir.path().display() + ), + ) + .await + .unwrap(); + let with_settings = crawler .get_maven_repo_paths(&options_at(tmp.path())) .await .unwrap(); @@ -688,9 +713,13 @@ async fn get_maven_repo_paths_m2_home_fallback() { std::env::set_var("M2_HOME", v); } + assert!( + without_settings.is_empty(), + "$M2_HOME/repository is not a local repository; got {without_settings:?}" + ); assert_eq!( - paths, - vec![repo_dir], - "M2_HOME/repository fallback must be the sole discovered repo; got {paths:?}" + with_settings, + vec![repo_dir.path().to_path_buf()], + "the global settings' must be the sole discovered repo" ); } From dec7a9bb025e285f626d9bf4a4c0d6369ccf9f41 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 00:11:59 +0000 Subject: [PATCH 4/8] Read settings.xml localRepository as XML Maven reads a CDATA as its text and decodes entities, ignores a profile property of the same name, and fixes user.home before command-line -D flags in MAVEN_ARGS apply. The lookup now does the same. CLI e2e children also drop MAVEN_OPTS, MAVEN_ARGS, MAVEN_HOME and M2_HOME so a developer's Maven setup can't redirect their crawl. Assisted-by: Claude Code:claude-opus-5-5 --- .../socket-patch-cli/tests/common/jvm_env.rs | 6 ++ .../src/crawlers/maven_crawler.rs | 31 ++++++++-- .../maven_crawler/local_repo_tests.rs | 61 +++++++++++++++++++ .../src/formats/nuget/mod.rs | 2 +- 4 files changed, 93 insertions(+), 7 deletions(-) diff --git a/crates/socket-patch-cli/tests/common/jvm_env.rs b/crates/socket-patch-cli/tests/common/jvm_env.rs index 9e5082f5e..3b9a866af 100644 --- a/crates/socket-patch-cli/tests/common/jvm_env.rs +++ b/crates/socket-patch-cli/tests/common/jvm_env.rs @@ -34,6 +34,12 @@ pub const AMBIENT: &[&str] = &[ // ambient value would aim a test's crawl at a real cache. "COURSIER_CACHE", "SBT_OPTS", + // The Maven local-repository lookup reads these (#423): `-D`s in + // MAVEN_OPTS / MAVEN_ARGS and the install's conf/settings.xml. + "MAVEN_OPTS", + "MAVEN_ARGS", + "MAVEN_HOME", + "M2_HOME", ]; /// The cache roots a test may hand the CLI explicitly (they survive diff --git a/crates/socket-patch-core/src/crawlers/maven_crawler.rs b/crates/socket-patch-core/src/crawlers/maven_crawler.rs index d9252bdaa..b6d40f16c 100644 --- a/crates/socket-patch-core/src/crawlers/maven_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/maven_crawler.rs @@ -633,7 +633,11 @@ pub fn m2_repo_path_with(env: &dyn Env, os: Os, home_dir: Option<&Path>) -> Opti .filter(|v| !v.is_empty()) }) }; - let user_home = property("user.home") + // `user.home` is fixed when the JVM starts: the launcher's MAVEN_OPTS + // can move it, a command-line `-D` (MAVEN_ARGS) comes too late for the + // `~/.m2` paths Maven derives from it. + let user_home = set("MAVEN_OPTS") + .and_then(|opts| system_property(&opts, "user.home", os)) .map(PathBuf::from) .filter(|h| is_usable_home(h)) .or_else(|| match os { @@ -689,12 +693,27 @@ fn settings_local_repository( let raw = std::fs::read_to_string(path).ok()?; let text = xml::blank_non_markup(&raw).ok()?; let settings = *xml::elements(&text, "settings").ok()?.first()?; - let value = xml::children(&text, &settings, "localRepository") + // `` is a child of ``; a profile property of + // the same name is not it. + let profiles = xml::children(&text, &settings, "profiles").ok()?; + let element = xml::children(&text, &settings, "localRepository") .ok()? - .first()? - .inner(&text) - .trim() - .to_string(); + .into_iter() + .find(|e| { + !profiles + .iter() + .any(|p| e.start > p.start && e.start < p.end) + })?; + // Read from the unmasked text (offsets are kept), so a CDATA value is + // its character data and entities decode, as Maven's parser does. + let inner = raw[element.inner_start..element.inner_end].trim(); + let value = match inner + .strip_prefix("")) + { + Some(cdata) => cdata.trim().to_string(), + None => crate::formats::nuget::decode_entities(inner), + }; let repo = PathBuf::from(interpolate(&value, lookup)?); repo.has_root().then_some(repo) } diff --git a/crates/socket-patch-core/src/crawlers/maven_crawler/local_repo_tests.rs b/crates/socket-patch-core/src/crawlers/maven_crawler/local_repo_tests.rs index ea96b5476..9373f9ee2 100644 --- a/crates/socket-patch-core/src/crawlers/maven_crawler/local_repo_tests.rs +++ b/crates/socket-patch-core/src/crawlers/maven_crawler/local_repo_tests.rs @@ -211,3 +211,64 @@ fn unusable_settings_fall_through_to_the_default() { assert_eq!(m2_repo(&e, Os::Unix), default, "{text}"); } } + +/// `` is read as Maven's XML parser reads it: a CDATA +/// value is its character data, entities decode, and a profile property +/// of the same name is not the setting. +#[test] +fn settings_local_repository_is_read_as_xml() { + let home = tempfile::tempdir().unwrap(); + let data = tempfile::tempdir().unwrap(); + let user_settings = home.path().join(".m2").join("settings.xml"); + let e = env(&[("HOME", home.path())]); + let data_s = data.path().to_str().unwrap(); + + write(&user_settings, &settings(&format!(""))); + assert_eq!(m2_repo(&e, Os::Unix), Some(data.path().to_path_buf())); + + write(&user_settings, &settings(&format!("{data_s}/a&b"))); + assert_eq!( + m2_repo(&e, Os::Unix), + Some(PathBuf::from(format!("{data_s}/a&b"))) + ); + + write( + &user_settings, + &format!( + "\ + /opt/other\ + " + ), + ); + assert_eq!( + m2_repo(&e, Os::Unix), + Some(home.path().join(".m2").join("repository")) + ); + write( + &user_settings, + &format!( + "\ + /opt/other\ + \ + {data_s}" + ), + ); + assert_eq!(m2_repo(&e, Os::Unix), Some(data.path().to_path_buf())); +} + +/// A command-line `-Duser.home` (MAVEN_ARGS) is set after Maven has +/// derived its `~/.m2` paths, so it does not move them. +#[test] +fn maven_args_user_home_does_not_move_the_m2_directory() { + let home = tempfile::tempdir().unwrap(); + let elsewhere = tempfile::tempdir().unwrap(); + let mut e = env(&[("HOME", home.path())]); + e.insert( + "MAVEN_ARGS".into(), + format!("-Duser.home={}", elsewhere.path().display()), + ); + assert_eq!( + m2_repo(&e, Os::Unix), + Some(home.path().join(".m2").join("repository")) + ); +} diff --git a/crates/socket-patch-core/src/formats/nuget/mod.rs b/crates/socket-patch-core/src/formats/nuget/mod.rs index aed746512..3c0300935 100644 --- a/crates/socket-patch-core/src/formats/nuget/mod.rs +++ b/crates/socket-patch-core/src/formats/nuget/mod.rs @@ -327,7 +327,7 @@ fn leading_ws(s: &str) -> usize { /// Decode the five predefined XML entities and numeric character references; /// anything else is kept literally (it then fails the later grammar checks /// rather than being guessed at). -fn decode_entities(raw: &str) -> String { +pub(crate) fn decode_entities(raw: &str) -> String { if !raw.contains('&') { return raw.to_string(); } From 5adf23136f7a369cea7098afeca12a76c6fdf1c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 00:18:14 +0000 Subject: [PATCH 5/8] Read Maven settings.xml FIFO-safely A FIFO planted where settings.xml is expected is now refused instead of blocking the crawl in open(2), as the crawler architecture test requires for every crawler read. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/crawlers/maven_crawler.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/socket-patch-core/src/crawlers/maven_crawler.rs b/crates/socket-patch-core/src/crawlers/maven_crawler.rs index b6d40f16c..c97d48088 100644 --- a/crates/socket-patch-core/src/crawlers/maven_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/maven_crawler.rs @@ -690,7 +690,8 @@ fn settings_local_repository( ) -> Option { use crate::formats::xml; - let raw = std::fs::read_to_string(path).ok()?; + // FIFO-safe: a FIFO planted as settings.xml is refused, not opened. + let raw = crate::utils::fs::read_regular_to_string_sync(path).ok()?; let text = xml::blank_non_markup(&raw).ok()?; let settings = *xml::elements(&text, "settings").ok()?.first()?; // `` is a child of ``; a profile property of From 448fbe0a6e3e75e969563c6ea5983a19e49d331d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 03:43:31 +0000 Subject: [PATCH 6/8] Skip comments in settings.xml localRepository A comment inside in settings.xml was kept as part of the path, so the crawler fell back to ~/.m2/repository while Maven used the real local repository. The value is now read as Maven's parser reads it: comments drop out, entities decode, CDATA is kept verbatim. The Maven crawler e2e tests also clear M2_HOME now, so a host Maven install's settings.xml can't steer tests that pin the repo list. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/crawlers/maven_crawler.rs | 38 +++++++++++++------ .../maven_crawler/local_repo_tests.rs | 28 ++++++++++++++ .../tests/crawler_maven_e2e.rs | 1 + 3 files changed, 56 insertions(+), 11 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/maven_crawler.rs b/crates/socket-patch-core/src/crawlers/maven_crawler.rs index c97d48088..926a54499 100644 --- a/crates/socket-patch-core/src/crawlers/maven_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/maven_crawler.rs @@ -705,20 +705,36 @@ fn settings_local_repository( .iter() .any(|p| e.start > p.start && e.start < p.end) })?; - // Read from the unmasked text (offsets are kept), so a CDATA value is - // its character data and entities decode, as Maven's parser does. - let inner = raw[element.inner_start..element.inner_end].trim(); - let value = match inner - .strip_prefix("")) - { - Some(cdata) => cdata.trim().to_string(), - None => crate::formats::nuget::decode_entities(inner), - }; - let repo = PathBuf::from(interpolate(&value, lookup)?); + // Read from the unmasked text (offsets are kept), so CDATA is its + // character data, entities decode and comments drop out, as Maven's + // parser does. + let value = character_data(&raw[element.inner_start..element.inner_end])?; + let repo = PathBuf::from(interpolate(value.trim(), lookup)?); repo.has_root().then_some(repo) } +/// The character data of a text-only element's raw content: text with its +/// entities decoded, CDATA sections verbatim, comments skipped. `None` when +/// a comment or CDATA section is unterminated. +fn character_data(inner: &str) -> Option { + let mut out = String::new(); + let mut rest = inner; + while let Some(at) = rest.find("")?; + out.push_str(&body[..end]); + rest = &body[end + 3..]; + } else { + let end = rest.strip_prefix("")?; + rest = &rest[4 + end + 3..]; + } + } + out.push_str(&crate::formats::nuget::decode_entities(rest)); + Some(out) +} + /// `text` with every `${name}` replaced by `lookup(name)`; `None` when one /// is undefined or unterminated. fn interpolate(text: &str, lookup: &dyn Fn(&str) -> Option) -> Option { diff --git a/crates/socket-patch-core/src/crawlers/maven_crawler/local_repo_tests.rs b/crates/socket-patch-core/src/crawlers/maven_crawler/local_repo_tests.rs index 9373f9ee2..407d2bed8 100644 --- a/crates/socket-patch-core/src/crawlers/maven_crawler/local_repo_tests.rs +++ b/crates/socket-patch-core/src/crawlers/maven_crawler/local_repo_tests.rs @@ -256,6 +256,34 @@ fn settings_local_repository_is_read_as_xml() { assert_eq!(m2_repo(&e, Os::Unix), Some(data.path().to_path_buf())); } +/// A comment inside `` is not part of its value, as +/// Maven's parser skips it; text and CDATA around it join up. +#[test] +fn settings_local_repository_skips_comments() { + let home = tempfile::tempdir().unwrap(); + let data = tempfile::tempdir().unwrap(); + let user_settings = home.path().join(".m2").join("settings.xml"); + let e = env(&[("HOME", home.path())]); + let data_s = data.path().to_str().unwrap(); + + write( + &user_settings, + &settings(&format!("{data_s}")), + ); + assert_eq!(m2_repo(&e, Os::Unix), Some(data.path().to_path_buf())); + + write( + &user_settings, + &settings(&format!( + "\n {data_s}/r&\n" + )), + ); + assert_eq!( + m2_repo(&e, Os::Unix), + Some(PathBuf::from(format!("{data_s}/r&&x"))) + ); +} + /// A command-line `-Duser.home` (MAVEN_ARGS) is set after Maven has /// derived its `~/.m2` paths, so it does not move them. #[test] diff --git a/crates/socket-patch-core/tests/crawler_maven_e2e.rs b/crates/socket-patch-core/tests/crawler_maven_e2e.rs index 7420342d4..138f65d76 100644 --- a/crates/socket-patch-core/tests/crawler_maven_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_maven_e2e.rs @@ -41,6 +41,7 @@ impl NoJvmCaches { "MAVEN_OPTS", "MAVEN_ARGS", "MAVEN_HOME", + "M2_HOME", ]; let saved = keys.iter().map(|k| (*k, std::env::var_os(k))).collect(); for key in keys { From 72f4c7115585605dbc59c33fab7a41f123912beb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 06:21:01 +0000 Subject: [PATCH 7/8] Make the Maven local-repo tests hermetic and clippy-clean m2_repo_path_treats_empty_m2_home_as_unset now reads the user's settings.xml, so a developer whose ~/.m2/settings.xml sets got a red cargo test. Point HOME/USERPROFILE at an empty temp dir for that test. Also drop a format! with no placeholders in local_repo_tests, which clippy --all-targets flags as useless_format. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-core/src/crawlers/maven_crawler.rs | 5 +++++ .../src/crawlers/maven_crawler/local_repo_tests.rs | 8 +++----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/maven_crawler.rs b/crates/socket-patch-core/src/crawlers/maven_crawler.rs index 926a54499..0b454704f 100644 --- a/crates/socket-patch-core/src/crawlers/maven_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/maven_crawler.rs @@ -2609,6 +2609,11 @@ mod tests { let _opts = EnvGuard::unset("MAVEN_OPTS"); let _maven_home = EnvGuard::unset("MAVEN_HOME"); let _m2 = EnvGuard::set("M2_HOME", ""); + // Point the home directory at an empty dir so a developer's own + // `~/.m2/settings.xml` can't change the result. + let home = tempfile::tempdir().unwrap(); + let _home = EnvGuard::set("HOME", home.path().to_str().unwrap()); + let _profile = EnvGuard::set("USERPROFILE", home.path().to_str().unwrap()); let repo = MavenCrawler::m2_repo_path(); assert_ne!( diff --git a/crates/socket-patch-core/src/crawlers/maven_crawler/local_repo_tests.rs b/crates/socket-patch-core/src/crawlers/maven_crawler/local_repo_tests.rs index 407d2bed8..1855877e0 100644 --- a/crates/socket-patch-core/src/crawlers/maven_crawler/local_repo_tests.rs +++ b/crates/socket-patch-core/src/crawlers/maven_crawler/local_repo_tests.rs @@ -234,11 +234,9 @@ fn settings_local_repository_is_read_as_xml() { write( &user_settings, - &format!( - "\ - /opt/other\ - " - ), + "\ + /opt/other\ + ", ); assert_eq!( m2_repo(&e, Os::Unix), From a39bc3982c37054c932497137b50b53284e10e62 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 08:18:50 +0000 Subject: [PATCH 8/8] Parse MAVEN_OPTS in local-repo tests as the host OS does Three local_repo_tests put a tempdir path into MAVEN_OPTS/MAVEN_ARGS but resolved with Os::Unix. Unix-mode split_opts treats backslashes as escapes, so on Windows `C:\Users\...\Temp\.tmpX` became the drive-relative `C:Users...`, failed has_root(), and the tests got the settings or HOME path instead. Resolve with Os::current() so the options are split the way the host would split them; with only HOME set the Windows branch falls back to HOME, so expectations are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../maven_crawler/local_repo_tests.rs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/maven_crawler/local_repo_tests.rs b/crates/socket-patch-core/src/crawlers/maven_crawler/local_repo_tests.rs index 1855877e0..3efe0985e 100644 --- a/crates/socket-patch-core/src/crawlers/maven_crawler/local_repo_tests.rs +++ b/crates/socket-patch-core/src/crawlers/maven_crawler/local_repo_tests.rs @@ -144,17 +144,26 @@ fn maven_repo_local_system_property_beats_settings() { "MAVEN_OPTS".into(), format!("-Xmx1g -Dmaven.repo.local={}", from_opts.path().display()), ); - assert_eq!(m2_repo(&e, Os::Unix), Some(from_opts.path().to_path_buf())); + assert_eq!( + m2_repo(&e, Os::current()), + Some(from_opts.path().to_path_buf()) + ); e.insert( "MAVEN_ARGS".into(), format!("-B -Dmaven.repo.local={}", from_args.path().display()), ); - assert_eq!(m2_repo(&e, Os::Unix), Some(from_args.path().to_path_buf())); + assert_eq!( + m2_repo(&e, Os::current()), + Some(from_args.path().to_path_buf()) + ); e.insert( "MAVEN_REPO_LOCAL".into(), from_env.path().to_str().unwrap().into(), ); - assert_eq!(m2_repo(&e, Os::Unix), Some(from_env.path().to_path_buf())); + assert_eq!( + m2_repo(&e, Os::current()), + Some(from_env.path().to_path_buf()) + ); } /// `-Duser.home` in `MAVEN_OPTS` moves `~/.m2` (settings and repository) @@ -169,7 +178,7 @@ fn maven_opts_user_home_moves_the_m2_directory() { format!("-Duser.home={}", jvm_home.path().display()), ); assert_eq!( - m2_repo(&e, Os::Unix), + m2_repo(&e, Os::current()), Some(jvm_home.path().join(".m2").join("repository")) ); } @@ -294,7 +303,7 @@ fn maven_args_user_home_does_not_move_the_m2_directory() { format!("-Duser.home={}", elsewhere.path().display()), ); assert_eq!( - m2_repo(&e, Os::Unix), + m2_repo(&e, Os::current()), Some(home.path().join(".m2").join("repository")) ); }