This is an automated email from the ASF dual-hosted git repository.
paulk-asert pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git
The following commit(s) were added to refs/heads/master by this push:
new 1559a9d12a turn on ivy debugging temporarily
1559a9d12a is described below
commit 1559a9d12a0730b7c5e53dd3e2f645fa05d54808
Author: Paul King <[email protected]>
AuthorDate: Mon May 11 13:14:52 2026 +1000
turn on ivy debugging temporarily
---
.../main/groovy/org.apache.groovy-tested.gradle | 2 +-
.../groovy/grape/ivy/StrictLocalM2Resolver.groovy | 145 ++++++++++++++
.../grape/ivy/StrictLocalM2ResolverTest.groovy | 215 +++++++++++++++++++++
3 files changed, 361 insertions(+), 1 deletion(-)
diff --git a/build-logic/src/main/groovy/org.apache.groovy-tested.gradle
b/build-logic/src/main/groovy/org.apache.groovy-tested.gradle
index a044571ab6..8758584af6 100644
--- a/build-logic/src/main/groovy/org.apache.groovy-tested.gradle
+++ b/build-logic/src/main/groovy/org.apache.groovy-tested.gradle
@@ -67,7 +67,7 @@ tasks.withType(Test).configureEach {
// Harmless when TestLens isn't on the classpath (no entries match).
systemProperty 'groovy.junit6.forked.excludeClasspath',
'junit-platform-instrumentation'
// systemProperty 'groovy.grape.report.downloads', 'true'
-// systemProperty 'ivy.message.logger.level', '4'
+ systemProperty 'ivy.message.logger.level', '4'
jvmArgumentProviders.add(new TestCommandLineArgumentProvider(
grapeRoot: grapeDirectory,
diff --git
a/subprojects/groovy-grape-ivy/src/main/groovy/groovy/grape/ivy/StrictLocalM2Resolver.groovy
b/subprojects/groovy-grape-ivy/src/main/groovy/groovy/grape/ivy/StrictLocalM2Resolver.groovy
new file mode 100644
index 0000000000..78e4f887d0
--- /dev/null
+++
b/subprojects/groovy-grape-ivy/src/main/groovy/groovy/grape/ivy/StrictLocalM2Resolver.groovy
@@ -0,0 +1,145 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package groovy.grape.ivy
+
+import groovy.transform.CompileStatic
+import org.apache.ivy.core.module.descriptor.DependencyDescriptor
+import org.apache.ivy.core.module.id.ModuleRevisionId
+import org.apache.ivy.core.resolve.ResolveData
+import org.apache.ivy.plugins.resolver.IBiblioResolver
+import org.apache.ivy.plugins.resolver.util.ResolvedResource
+
+import java.util.regex.Pattern
+
+/**
+ * IBiblioResolver subclass that, for a local Maven repository (file://),
validates
+ * that the primary artifact actually exists alongside the POM before reporting
+ * the descriptor as found. Without this, a half-populated local m2 entry (POM
+ * present, JAR missing — common after Apache release-vote staging workflows or
+ * partial Maven downloads) causes Ivy's chain to bind resolution to localm2
and
+ * then fail to download the missing JAR, never falling through to Maven
Central.
+ *
+ * Override is on findIvyFileRef rather than getDependency to avoid grape-cache
+ * poisoning: returning null at descriptor-lookup time prevents Ivy from
caching
+ * the descriptor with resolver=localm2.
+ *
+ * Strictness is gated by -Dgroovy.grape.strict-localm2=true (default false for
+ * this initial release; planned to flip to default-true in 6.1, drop the flag
+ * in 7.0). Strictness is also automatically skipped for snapshot revisions
+ * (Maven uses timestamp-suffixed filenames there), for non-m2-compatible
+ * configurations, and when the resolver root is not a file URL.
+ */
+@CompileStatic
+class StrictLocalM2Resolver extends IBiblioResolver {
+
+ private static final String ENABLE_PROPERTY = 'groovy.grape.strict-localm2'
+
+ /** Maven packaging values whose primary artifact has a non-jar extension.
*/
+ private static final Map<String, String> NON_JAR_PACKAGING_EXT = [
+ 'war': 'war',
+ 'ear': 'ear',
+ 'aar': 'aar',
+ 'rar': 'rar',
+ 'zip': 'zip',
+ ].asImmutable()
+
+ private static final Pattern PACKAGING_PATTERN =
+ ~/(?ms)<packaging>\s*([\w-]+)\s*<\/packaging>/
+
+ @Override
+ ResolvedResource findIvyFileRef(DependencyDescriptor dd, ResolveData data)
{
+ ResolvedResource pom = super.findIvyFileRef(dd, data)
+ if (pom == null) return null
+ ModuleRevisionId mrid = dd.getDependencyRevisionId()
+ File pomFile = resolvedPomAsFile(pom)
+ if (shouldRejectAsHalfPopulated(mrid, pomFile)) {
+ return null
+ }
+ return pom
+ }
+
+ /**
+ * Visible for testing: decide whether to reject this localm2 lookup as
+ * half-populated. Returns true iff strictness is enabled, the revision is
+ * not a snapshot, the resolver root is a file URL, and no primary artifact
+ * matching the POM's packaging exists alongside the POM.
+ */
+ boolean shouldRejectAsHalfPopulated(ModuleRevisionId mrid, File pomFile) {
+ if (!shouldEnforce()) return false
+ if (mrid == null) return false
+ String rev = mrid.revision
+ if (rev != null && rev.endsWith('-SNAPSHOT')) return false
+ File m2dir = computeM2Dir(mrid)
+ if (m2dir == null) return false
+ File jar = new File(m2dir, "${mrid.name}-${rev}.jar")
+ if (jar.exists()) return false
+ String packaging = (pomFile != null && pomFile.isFile()) ?
readPackaging(pomFile) : null
+ if (packaging == 'pom') return false
+ String ext = NON_JAR_PACKAGING_EXT.getOrDefault(packaging, 'jar')
+ File primary = new File(m2dir, "${mrid.name}-${rev}.${ext}")
+ return !primary.exists()
+ }
+
+ private boolean shouldEnforce() {
+ if (!isM2compatible()) return false
+ Boolean.parseBoolean(System.getProperty(ENABLE_PROPERTY, 'false'))
+ }
+
+ /**
+ * Visible for testing: compute the local m2 directory for a module
+ * revision based on the resolver's root.
+ */
+ File computeM2Dir(ModuleRevisionId mrid) {
+ if (mrid == null) return null
+ String rootStr = getRoot()
+ if (rootStr == null || !rootStr.startsWith('file:')) return null
+ // The rendered ${user.home.url} can produce double-slashes; normalize
them.
+ String path = rootStr.substring('file:'.length()).replaceAll(/\/+/,
'/')
+ File rootDir = new File(path)
+ if (!rootDir.isDirectory()) return null
+ new File(rootDir, "${mrid.organisation.replace('.',
'/')}/${mrid.name}/${mrid.revision}")
+ }
+
+ /**
+ * Visible for testing: read the {@code <packaging>} element from a POM
file.
+ * Returns null if the element is absent or the file cannot be read.
+ */
+ static String readPackaging(File pomFile) {
+ try {
+ String text = pomFile.getText('UTF-8')
+ def matcher = PACKAGING_PATTERN.matcher(text)
+ return matcher.find() ? matcher.group(1) : null
+ } catch (Exception ignored) {
+ return null
+ }
+ }
+
+ private static File resolvedPomAsFile(ResolvedResource pom) {
+ try {
+ String name = pom.resource.name
+ if (name?.startsWith('file:')) {
+ String path =
name.substring('file:'.length()).replaceAll(/\/+/, '/')
+ return new File(path)
+ }
+ } catch (Exception ignored) {
+ // fall through
+ }
+ null
+ }
+}
diff --git
a/subprojects/groovy-grape-ivy/src/test/groovy/groovy/grape/ivy/StrictLocalM2ResolverTest.groovy
b/subprojects/groovy-grape-ivy/src/test/groovy/groovy/grape/ivy/StrictLocalM2ResolverTest.groovy
new file mode 100644
index 0000000000..4fa46dd7e2
--- /dev/null
+++
b/subprojects/groovy-grape-ivy/src/test/groovy/groovy/grape/ivy/StrictLocalM2ResolverTest.groovy
@@ -0,0 +1,215 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package groovy.grape.ivy
+
+import org.apache.ivy.core.module.id.ModuleRevisionId
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.io.TempDir
+
+import static org.junit.jupiter.api.Assertions.assertEquals
+import static org.junit.jupiter.api.Assertions.assertFalse
+import static org.junit.jupiter.api.Assertions.assertNull
+import static org.junit.jupiter.api.Assertions.assertTrue
+
+final class StrictLocalM2ResolverTest {
+
+ private static final String ENABLE_PROPERTY = 'groovy.grape.strict-localm2'
+
+ @TempDir
+ File m2
+
+ StrictLocalM2Resolver resolver
+
+ @BeforeEach
+ void setUp() {
+ resolver = new StrictLocalM2Resolver()
+ resolver.setRoot(m2.toURI().toURL().toString())
+ resolver.setM2compatible(true)
+ System.setProperty(ENABLE_PROPERTY, 'true')
+ }
+
+ @AfterEach
+ void tearDown() {
+ System.clearProperty(ENABLE_PROPERTY)
+ }
+
+ @Test
+ void rejects_pomOnly_packagingJar_default() {
+ File dir = layout('com.example', 'foo', '1.0')
+ writePom(dir, 'foo', '1.0', null)
+ // no JAR
+ assertTrue resolver.shouldRejectAsHalfPopulated(
+ ModuleRevisionId.newInstance('com.example', 'foo', '1.0'),
+ new File(dir, 'foo-1.0.pom'))
+ }
+
+ @Test
+ void rejects_pomOnly_packagingJar_explicit() {
+ File dir = layout('com.example', 'foo', '1.0')
+ writePom(dir, 'foo', '1.0', 'jar')
+ assertTrue resolver.shouldRejectAsHalfPopulated(
+ ModuleRevisionId.newInstance('com.example', 'foo', '1.0'),
+ new File(dir, 'foo-1.0.pom'))
+ }
+
+ @Test
+ void accepts_pomOnly_packagingPom() {
+ File dir = layout('com.example', 'parent', '1.0')
+ writePom(dir, 'parent', '1.0', 'pom')
+ assertFalse resolver.shouldRejectAsHalfPopulated(
+ ModuleRevisionId.newInstance('com.example', 'parent', '1.0'),
+ new File(dir, 'parent-1.0.pom'))
+ }
+
+ @Test
+ void accepts_pomAndJar_present() {
+ File dir = layout('com.example', 'foo', '1.0')
+ writePom(dir, 'foo', '1.0', 'jar')
+ new File(dir, 'foo-1.0.jar') << 'fake-jar-bytes'
+ assertFalse resolver.shouldRejectAsHalfPopulated(
+ ModuleRevisionId.newInstance('com.example', 'foo', '1.0'),
+ new File(dir, 'foo-1.0.pom'))
+ }
+
+ @Test
+ void rejects_pomOnly_packagingWar_warAbsent() {
+ File dir = layout('com.example', 'webapp', '1.0')
+ writePom(dir, 'webapp', '1.0', 'war')
+ // no .war file
+ assertTrue resolver.shouldRejectAsHalfPopulated(
+ ModuleRevisionId.newInstance('com.example', 'webapp', '1.0'),
+ new File(dir, 'webapp-1.0.pom'))
+ }
+
+ @Test
+ void accepts_pomOnly_packagingWar_warPresent() {
+ File dir = layout('com.example', 'webapp', '1.0')
+ writePom(dir, 'webapp', '1.0', 'war')
+ new File(dir, 'webapp-1.0.war') << 'fake-war-bytes'
+ assertFalse resolver.shouldRejectAsHalfPopulated(
+ ModuleRevisionId.newInstance('com.example', 'webapp', '1.0'),
+ new File(dir, 'webapp-1.0.pom'))
+ }
+
+ @Test
+ void accepts_packagingBundle_jarPresent() {
+ // OSGi bundle packaging produces a .jar file in Maven layout.
+ File dir = layout('com.example', 'osgi-thing', '1.0')
+ writePom(dir, 'osgi-thing', '1.0', 'bundle')
+ new File(dir, 'osgi-thing-1.0.jar') << 'fake-jar-bytes'
+ assertFalse resolver.shouldRejectAsHalfPopulated(
+ ModuleRevisionId.newInstance('com.example', 'osgi-thing', '1.0'),
+ new File(dir, 'osgi-thing-1.0.pom'))
+ }
+
+ @Test
+ void accepts_snapshotRevision_unconditionally() {
+ // Snapshot filenames are timestamp-based; literal name check would
+ // false-fail. Skip strictness for snapshots.
+ File dir = layout('com.example', 'snap', '1.0-SNAPSHOT')
+ writePom(dir, 'snap', '1.0-SNAPSHOT', 'jar')
+ // no JAR alongside the POM (yet still accept)
+ assertFalse resolver.shouldRejectAsHalfPopulated(
+ ModuleRevisionId.newInstance('com.example', 'snap',
'1.0-SNAPSHOT'),
+ new File(dir, 'snap-1.0-SNAPSHOT.pom'))
+ }
+
+ @Test
+ void accepts_when_strictness_disabled_via_system_property() {
+ System.setProperty(ENABLE_PROPERTY, 'false')
+ File dir = layout('com.example', 'foo', '1.0')
+ writePom(dir, 'foo', '1.0', 'jar')
+ // No JAR; would normally reject.
+ assertFalse resolver.shouldRejectAsHalfPopulated(
+ ModuleRevisionId.newInstance('com.example', 'foo', '1.0'),
+ new File(dir, 'foo-1.0.pom'))
+ }
+
+ @Test
+ void accepts_when_not_m2compatible() {
+ resolver.setM2compatible(false)
+ File dir = layout('com.example', 'foo', '1.0')
+ writePom(dir, 'foo', '1.0', 'jar')
+ assertFalse resolver.shouldRejectAsHalfPopulated(
+ ModuleRevisionId.newInstance('com.example', 'foo', '1.0'),
+ new File(dir, 'foo-1.0.pom'))
+ }
+
+ @Test
+ void readPackaging_findsLiteralValue() {
+ File pom = File.createTempFile('pom-', '.xml')
+ pom.deleteOnExit()
+ pom.text = '<project><packaging>war</packaging></project>'
+ assertEquals 'war', StrictLocalM2Resolver.readPackaging(pom)
+ }
+
+ @Test
+ void readPackaging_returnsNullWhenAbsent() {
+ File pom = File.createTempFile('pom-', '.xml')
+ pom.deleteOnExit()
+ pom.text = '<project><groupId>x</groupId></project>'
+ assertNull StrictLocalM2Resolver.readPackaging(pom)
+ }
+
+ @Test
+ void readPackaging_returnsNullForMalformedFile() {
+ File pom = File.createTempFile('pom-', '.xml')
+ pom.deleteOnExit()
+ pom.text = 'not a valid xml file'
+ assertNull StrictLocalM2Resolver.readPackaging(pom)
+ }
+
+ @Test
+ void computeM2Dir_returnsExpectedPath() {
+ File dir = resolver.computeM2Dir(
+ ModuleRevisionId.newInstance('org.apache.commons',
'commons-lang3', '3.9'))
+ assertEquals new File(m2, 'org/apache/commons/commons-lang3/3.9'), dir
+ }
+
+ @Test
+ void computeM2Dir_handlesDoubleSlashRoot() {
+ // Mimic the rendered ${user.home.url}/.m2/repository/ which can yield
+ // a double-slash mid-path (e.g. file:/Users/x//.m2/repository/).
+ resolver.setRoot('file:' + m2.absolutePath.replaceFirst('/', '//'))
+ File dir = resolver.computeM2Dir(
+ ModuleRevisionId.newInstance('com.example', 'foo', '1.0'))
+ assertEquals new File(m2, 'com/example/foo/1.0'), dir
+ }
+
+ private File layout(String org, String mod, String rev) {
+ File dir = new File(m2, "${org.replace('.', '/')}/${mod}/${rev}")
+ dir.mkdirs()
+ dir
+ }
+
+ private static void writePom(File dir, String mod, String rev, String
packaging) {
+ StringBuilder pom = new StringBuilder()
+ pom << '<project xmlns="http://maven.apache.org/POM/4.0.0">\n'
+ pom << " <groupId>com.example</groupId>\n"
+ pom << " <artifactId>${mod}</artifactId>\n"
+ pom << " <version>${rev}</version>\n"
+ if (packaging != null) {
+ pom << " <packaging>${packaging}</packaging>\n"
+ }
+ pom << '</project>\n'
+ new File(dir, "${mod}-${rev}.pom").text = pom.toString()
+ }
+}