From 5f6bce766a31a89c4ed112828551e941d3014562 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 17:23:56 +0000 Subject: [PATCH 1/6] Start fix for #534 Assisted-by: Claude Code:claude-opus-5-5 From c8703b0d65436489709416c54d8143787c69fae5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 17:34:08 +0000 Subject: [PATCH 2/6] Accept a Maven parent relativePath naming a dir vex and vendor --check refused a correctly vendored Maven reactor when a module's named a directory (.., ../corp-parent): the reactor read the bare path before /pom.xml and the project reader counted the directory (or the empty path for ..) as a failed read. Both commands exited 1 although the build used the patched jar. The parent is now located like a : /pom.xml first, then the path itself, never the empty path. A probe that runs through a regular file (pom.xml/pom.xml) reads as missing instead of failing. Fixes #534 Assisted-by: Claude Code:claude-opus-5-5 --- .../socket-patch-core/src/vendor/jvm/apply.rs | 31 +++++++++- .../src/vendor/jvm/maven_reactor.rs | 15 +++-- .../socket-patch-core/src/vendor/jvm/mod.rs | 56 +++++++++++++++++++ 3 files changed, 92 insertions(+), 10 deletions(-) diff --git a/crates/socket-patch-core/src/vendor/jvm/apply.rs b/crates/socket-patch-core/src/vendor/jvm/apply.rs index e9d17f762..edf269c54 100644 --- a/crates/socket-patch-core/src/vendor/jvm/apply.rs +++ b/crates/socket-patch-core/src/vendor/jvm/apply.rs @@ -184,7 +184,7 @@ impl ProjectReader { }; match group_commit::read(&path).unwrap_or_else(|| read_regular_to_bytes_sync(&path)) { Ok(bytes) => Some(bytes), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) if is_missing(&e) => None, Err(e) => { self.read_error .borrow_mut() @@ -246,7 +246,7 @@ impl ProjectReader { for (i, seg) in segments.iter().enumerate() { let next = cur.join(seg); match std::fs::symlink_metadata(&next) { - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + Err(e) if is_missing(&e) => { return Ok(segments[i + 1..].iter().fold(next, |p, s| p.join(s))); } Err(e) => return Err(format!("cannot inspect {}: {e}", next.display())), @@ -270,6 +270,16 @@ impl ProjectReader { } } +/// A path that does not exist, including one that runs through a regular +/// file (`pom.xml/pom.xml`, probed when a `relativePath` or `` +/// names a pom file rather than its directory). +fn is_missing(e: &std::io::Error) -> bool { + matches!( + e.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory + ) +} + /// Read a project file for the planners' tests (see [`ProjectReader::read`]). #[cfg(test)] pub(crate) fn read_project_file(root: &Path, rel: &str) -> Option> { @@ -1397,6 +1407,23 @@ mod tests { assert!(!outside.path().join("vendor").exists()); } + /// #534: probing `/pom.xml` where `` is a pom file reads as + /// missing, not as a read error that withholds `vex` liveness; a + /// directory read itself still records one. + #[test] + fn a_path_through_a_file_reads_as_missing() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir_all(root.join("parent")).unwrap(); + std::fs::write(root.join("pom.xml"), "\n").unwrap(); + let reader = ProjectReader::new(root); + assert_eq!(reader.read("pom.xml/pom.xml"), None); + assert_eq!(reader.read("pom.xml/x/pom.xml"), None); + assert_eq!(reader.read_error.borrow().as_deref(), None); + assert_eq!(reader.read("parent"), None); + assert!(reader.read_error.borrow().is_some()); + } + #[cfg(unix)] #[tokio::test] async fn an_in_checkout_symlinked_file_is_edited_through_its_link() { 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 c8c54f681..1116cf45e 100644 --- a/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs +++ b/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs @@ -1881,14 +1881,13 @@ fn local_parent_of( parent.artifact.clone(), parent.version.clone(), ); - let candidates = [ - path.clone(), - if path.is_empty() { - "pom.xml".to_string() - } else { - format!("{path}/pom.xml") - }, - ]; + // Like a ``: Maven reads a directory as its `pom.xml`, and `..` + // normalizes to the root itself (#534). + let candidates = if path.is_empty() { + vec!["pom.xml".to_string()] + } else { + vec![format!("{path}/pom.xml"), path] + }; let Some(parent_rel) = candidates .into_iter() .find(|c| poms.contains_key(c) || read(c).is_some()) diff --git a/crates/socket-patch-core/src/vendor/jvm/mod.rs b/crates/socket-patch-core/src/vendor/jvm/mod.rs index f7ac0adf9..258d518be 100644 --- a/crates/socket-patch-core/src/vendor/jvm/mod.rs +++ b/crates/socket-patch-core/src/vendor/jvm/mod.rs @@ -985,6 +985,62 @@ mod tests { assert_eq!(testing::dirs(root), pristine_dirs); } + /// #534: Maven reads a `` naming a directory + /// (`..`, `../corp-parent`) as that directory's `pom.xml`. A reactor + /// vendored that way stays live for `vex` and passes `vendor --check` + /// instead of failing on "is not a regular file" / "unsafe path". + #[tokio::test] + async fn parent_relative_path_naming_a_directory_stays_checkable() { + let parent = |artifact: &str, relative: &str| { + format!( + "\n 4.0.0\n \n \ + com.x\n {artifact}\n \ + 1\n {relative}\n \ + \n a\n \n \ + \n org.apache.commons\n \ + commons-text\n 1.10.0\n \ + \n \n\n" + ) + }; + let pom = |artifact: &str, modules: &str| { + format!( + "\n 4.0.0\n com.x\n \ + {artifact}\n 1\n \ + pom\n {modules}\n\n" + ) + }; + let up = pom("root", "a"); + let up_a = parent("root", ".."); + let corp = pom("root", "corp-parenta"); + let corp_parent = pom("corp", ""); + let corp_a = parent("corp", "../corp-parent"); + let layouts: [&[(&str, &str)]; 2] = [ + &[("pom.xml", &up), ("a/pom.xml", &up_a)], + &[ + ("pom.xml", &corp), + ("corp-parent/pom.xml", &corp_parent), + ("a/pom.xml", &corp_a), + ], + ]; + for files in layouts { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + testing::populate(root, files); + let mut ledger = std::collections::BTreeMap::new(); + let p = mixed_patch(); + testing::vendor(root, Shape::MavenReactor, &p, &mut ledger) + .await + .unwrap(); + let entry = ledger.values().next().unwrap().clone(); + assert_eq!( + apply::entry_wired_checked(root, &entry), + Ok(true), + "{files:?}" + ); + apply::check_entry(root, &entry, None).unwrap_or_else(|e| panic!("{files:?}: {e}")); + } + } + /// #488: `vendor --check` weighs an imported BOM from the local /// repository. A BOM it cannot read accepts the pin `vendor` wrote; a /// BOM bumped to another version of the artifact reports the pin as From 95c728b63ba0dc197faeba4576d017f3c954ce8f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 17:35:46 +0000 Subject: [PATCH 3/6] Check vendor --check on the Maven reactor e2e The reactor capstone's module b names its parent with a directory relativePath (../corp-parent), the layout from #534, but never ran vendor --check. Run it after vendoring so a regression shows up against real Maven too. Refs #534 Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/e2e_vendor_jvm_build.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/socket-patch-cli/tests/e2e_vendor_jvm_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_jvm_build.rs index 36abe8385..df4b8ba6f 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_jvm_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_jvm_build.rs @@ -505,6 +505,23 @@ fn maven_reactor_vendor_fresh_checkout_offline_build_and_byte_exact_revert() { "re-vendor: changed {changed:?}, added {added:?}, removed {removed:?}" ); + // #534: `b`'s `../corp-parent` names a + // directory; `vendor --check` reads it as Maven does. + let (code, env, stderr) = socket_in( + &proj, + &m2, + None, + &[ + "vendor", + "--check", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!(code, Some(0), "vendor --check: {env}\n{stderr}"); + // FRESH CHECKOUT: commons-text only from the committed tree. let fresh = root.join("fresh"); fresh_checkout_all(&proj, &fresh); From cbb6eb4e1ec5de2121ca7327de93f6ae0307dfba Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 18:00:50 +0000 Subject: [PATCH 4/6] Run vex on the Maven reactor e2e The capstone's module b names its parent with a directory relativePath (../corp-parent). Without the reactor fix, vex on that vendored tree printed "corp-parent is not a regular file", dropped the patch and exited 1. Assert it attests not_affected now. Refs #534 Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/e2e_vendor_jvm_build.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/socket-patch-cli/tests/e2e_vendor_jvm_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_jvm_build.rs index df4b8ba6f..d458fb83f 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_jvm_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_jvm_build.rs @@ -506,7 +506,21 @@ fn maven_reactor_vendor_fresh_checkout_offline_build_and_byte_exact_revert() { ); // #534: `b`'s `../corp-parent` names a - // directory; `vendor --check` reads it as Maven does. + // directory; `vex` and `vendor --check` read it as Maven does. + let (code, doc, stderr) = socket_in( + &proj, + &m2, + None, + &[ + "vex", + "--cwd", + proj.to_str().unwrap(), + "--product", + "pkg:maven/com.example/aggregator@1.0.0", + ], + ); + assert_eq!(code, Some(0), "vex: {doc}\n{stderr}"); + assert_eq!(doc["statements"][0]["status"], "not_affected", "{doc}"); let (code, env, stderr) = socket_in( &proj, &m2, From 13c44076fee906578a1d429947b8df4dac6023ef Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 18:17:31 +0000 Subject: [PATCH 5/6] Keep writes refusing a path through a file Review follow-up: only project reads treat a path that runs through a regular file as missing. Writes, reverts and the created-dir scan still refuse it before anything is written, as they did before. Also share the / pom lookup between both call sites, and parse a local parent from a single read. Refs #534 Assisted-by: Claude Code:claude-opus-5-5 --- .../socket-patch-core/src/vendor/jvm/apply.rs | 26 +++++++++--- .../src/vendor/jvm/maven_reactor.rs | 41 ++++++++++--------- 2 files changed, 42 insertions(+), 25 deletions(-) diff --git a/crates/socket-patch-core/src/vendor/jvm/apply.rs b/crates/socket-patch-core/src/vendor/jvm/apply.rs index edf269c54..7323d8063 100644 --- a/crates/socket-patch-core/src/vendor/jvm/apply.rs +++ b/crates/socket-patch-core/src/vendor/jvm/apply.rs @@ -172,10 +172,11 @@ impl ProjectReader { } } - /// Regular files only; a directory, special file or unsafe path reads - /// as missing. + /// Regular files only. A path that does not exist, or that runs through + /// a regular file, reads as missing; a directory, special file or + /// unsafe path reads as `None` and records a read error. pub fn read(&self, rel: &str) -> Option> { - let path = match self.resolve(rel) { + let path = match self.resolve_in(rel, true) { Ok(path) => path, Err(e) => { self.read_error.borrow_mut().get_or_insert(e); @@ -232,6 +233,13 @@ impl ProjectReader { /// `rel` under the canonical root, following in-checkout symlinks. fn resolve(&self, rel: &str) -> Result { + self.resolve_in(rel, false) + } + + /// [`Self::resolve`]; `through_file` lets a path that runs through a + /// regular file resolve (a read then sees it as missing). Writes keep + /// refusing it up front. + fn resolve_in(&self, rel: &str, through_file: bool) -> Result { if !safe_rel(rel) { return Err(format!("unsafe path {rel:?}")); } @@ -246,7 +254,10 @@ impl ProjectReader { for (i, seg) in segments.iter().enumerate() { let next = cur.join(seg); match std::fs::symlink_metadata(&next) { - Err(e) if is_missing(&e) => { + Err(e) + if e.kind() == std::io::ErrorKind::NotFound + || through_file && is_missing(&e) => + { return Ok(segments[i + 1..].iter().fold(next, |p, s| p.join(s))); } Err(e) => return Err(format!("cannot inspect {}: {e}", next.display())), @@ -272,7 +283,7 @@ impl ProjectReader { /// A path that does not exist, including one that runs through a regular /// file (`pom.xml/pom.xml`, probed when a `relativePath` or `` -/// names a pom file rather than its directory). +/// names a pom file rather than its directory, #534). fn is_missing(e: &std::io::Error) -> bool { matches!( e.kind(), @@ -1409,7 +1420,8 @@ mod tests { /// #534: probing `/pom.xml` where `` is a pom file reads as /// missing, not as a read error that withholds `vex` liveness; a - /// directory read itself still records one. + /// directory read itself still records one, and writes still refuse + /// a path through a file. #[test] fn a_path_through_a_file_reads_as_missing() { let dir = tempfile::tempdir().unwrap(); @@ -1422,6 +1434,8 @@ mod tests { assert_eq!(reader.read_error.borrow().as_deref(), None); assert_eq!(reader.read("parent"), None); assert!(reader.read_error.borrow().is_some()); + // A write through a file is still refused before anything lands. + assert!(reader.resolve("pom.xml/pom.xml").is_err()); } #[cfg(unix)] 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 1116cf45e..fb65b54c4 100644 --- a/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs +++ b/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs @@ -1840,12 +1840,7 @@ fn resolve_module( format!("{declaring}: {module} leaves the project root"), )); }; - let candidates = if path.is_empty() { - vec!["pom.xml".to_string()] - } else { - vec![format!("{path}/pom.xml"), path] - }; - candidates + pom_candidates(path) .into_iter() .find_map(|c| read(&c).map(|bytes| (c, bytes))) .ok_or_else(|| { @@ -1856,6 +1851,17 @@ fn resolve_module( }) } +/// Where Maven looks for the pom at the normalized `path` a `` or +/// `` names: a directory means its `pom.xml` (`""`, from +/// `..`, is the root), else the file itself. +fn pom_candidates(path: String) -> Vec { + if path.is_empty() { + vec!["pom.xml".to_string()] + } else { + vec![format!("{path}/pom.xml"), path] + } +} + /// The local parent of `rel`: its `` file exists in the checkout /// and names the same groupId/artifactId (and version, when both are /// literal). Loads the parent into `poms`. @@ -1881,21 +1887,18 @@ fn local_parent_of( parent.artifact.clone(), parent.version.clone(), ); - // Like a ``: Maven reads a directory as its `pom.xml`, and `..` - // normalizes to the root itself (#534). - let candidates = if path.is_empty() { - vec!["pom.xml".to_string()] - } else { - vec![format!("{path}/pom.xml"), path] - }; - let Some(parent_rel) = candidates - .into_iter() - .find(|c| poms.contains_key(c) || read(c).is_some()) - else { + // Like a ``: a directory names its `pom.xml` (#534). + let found = pom_candidates(path).into_iter().find_map(|c| { + if poms.contains_key(&c) { + Some((c, None)) + } else { + read(&c).map(|bytes| (c, Some(bytes))) + } + }); + let Some((parent_rel, bytes)) = found else { return Ok(None); }; - if !poms.contains_key(&parent_rel) { - let bytes = read(&parent_rel).unwrap_or_default(); + if let Some(bytes) = bytes { let parsed = Pom::parse(&parent_rel, bytes)?; poms.insert(parent_rel.clone(), parsed); } From 273eef7fd95f31a1a5c9daf78210b38c5146fefc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 18:29:07 +0000 Subject: [PATCH 6/6] Gate the through-file write assertion to Unix Windows reports a path through a regular file as not found rather than not-a-directory, so resolve() lets it through there just as it did before this PR. Only assert the up-front refusal on Unix. Refs #534 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/vendor/jvm/apply.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/socket-patch-core/src/vendor/jvm/apply.rs b/crates/socket-patch-core/src/vendor/jvm/apply.rs index 7323d8063..2b591294f 100644 --- a/crates/socket-patch-core/src/vendor/jvm/apply.rs +++ b/crates/socket-patch-core/src/vendor/jvm/apply.rs @@ -1434,7 +1434,9 @@ mod tests { assert_eq!(reader.read_error.borrow().as_deref(), None); assert_eq!(reader.read("parent"), None); assert!(reader.read_error.borrow().is_some()); - // A write through a file is still refused before anything lands. + // A write through a file is still refused before anything lands + // (Unix reports ENOTDIR; Windows reports such a path as not found). + #[cfg(unix)] assert!(reader.resolve("pom.xml/pom.xml").is_err()); }