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()
+    }
+}

Reply via email to