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 df8fc81071 test additional user agent workarounds
df8fc81071 is described below

commit df8fc8107157e05da85e7e44bfb3b2d1045aaf5c
Author: Paul King <[email protected]>
AuthorDate: Mon May 11 20:47:09 2026 +1000

    test additional user agent workarounds
---
 .../grape/ivy/StrictCachedGrapesResolver.groovy    | 123 ++++++++++++++++++++
 .../groovy/grape/ivy/defaultGrapeConfig.xml        |   6 +-
 .../ivy/StrictCachedGrapesResolverTest.groovy      | 124 +++++++++++++++++++++
 3 files changed, 250 insertions(+), 3 deletions(-)

diff --git 
a/subprojects/groovy-grape-ivy/src/main/groovy/groovy/grape/ivy/StrictCachedGrapesResolver.groovy
 
b/subprojects/groovy-grape-ivy/src/main/groovy/groovy/grape/ivy/StrictCachedGrapesResolver.groovy
new file mode 100644
index 0000000000..0af709e50f
--- /dev/null
+++ 
b/subprojects/groovy-grape-ivy/src/main/groovy/groovy/grape/ivy/StrictCachedGrapesResolver.groovy
@@ -0,0 +1,123 @@
+/*
+ *  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.FileSystemResolver
+import org.apache.ivy.plugins.resolver.util.ResolvedResource
+
+/**
+ * {@link FileSystemResolver} subclass that hardens Grape's {@code 
cachedGrapes}
+ * resolver against stub-shaped synthetic descriptors.
+ *
+ * <p>Grape's default {@code cachedGrapes} (a plain {@code <filesystem>}) 
synthesises
+ * a minimal ivy descriptor when it finds a JAR but no ivy file alongside —
+ * {@code <publications>} only, no {@code <dependencies>}. The synthetic 
descriptor
+ * is then written back to disk for next time, so the first artifact-only state
+ * perpetuates itself. Any module hit via the chain after that returns only the
+ * JAR (no transitive deps), causing runtime classloading failures — e.g. when
+ * log4j-core's JAR is present but log4j-api is missing, calls into the log4j 
API
+ * raise {@code NoClassDefFoundError} / {@code MissingPropertyException}.</p>
+ *
+ * <p>This resolver guards both directions:</p>
+ * <ol>
+ *   <li>Sets {@code allownomd=false} in the constructor — prevents 
<em>new</em>
+ *       stub synthesis. If the JAR exists but no ivy descriptor, the resolver
+ *       returns null and the chain falls through to localm2/ibiblio where the
+ *       real POM gets parsed.</li>
+ *   <li>Overrides {@link #findIvyFileRef} — if an ivy descriptor exists but 
its
+ *       companion {@code ivy-<rev>.xml.original} (the original POM) does not,
+ *       treats the descriptor as if not present. The {@code .original} file is
+ *       Ivy's record that the descriptor was downloaded from a real repo; its
+ *       absence is the load-bearing signal that we're looking at a synthesised
+ *       stub rather than a real cached descriptor. This silently self-heals
+ *       existing stub corruption.</li>
+ * </ol>
+ *
+ * <p>Strictness is enabled by default. Disable with
+ * {@code -Dgroovy.grape.strict-cached-grapes=false} if you maintain a custom
+ * cache where hand-installed JAR + IVY descriptors legitimately lack the
+ * {@code .original} POM. Strictness is also automatically skipped for snapshot
+ * revisions, since Maven snapshot caches use timestamped filenames.</p>
+ */
+@CompileStatic
+class StrictCachedGrapesResolver extends FileSystemResolver {
+
+    private static final String ENABLE_PROPERTY = 
'groovy.grape.strict-cached-grapes'
+
+    StrictCachedGrapesResolver() {
+        // Prevent the in-memory synthesis path that creates stubs from
+        // artifact-only state. With descriptor="required", a missing ivy file
+        // makes the resolver return null and the chain proceeds to the next
+        // resolver. (This replaces the deprecated allownomd=false setter.)
+        setDescriptor(DESCRIPTOR_REQUIRED)
+    }
+
+    @Override
+    ResolvedResource findIvyFileRef(DependencyDescriptor dd, ResolveData data) 
{
+        ResolvedResource ref = super.findIvyFileRef(dd, data)
+        if (ref == null) return null
+        ModuleRevisionId mrid = dd.getDependencyRevisionId()
+        File ivyFile = resolvedRefAsFile(ref)
+        if (shouldRejectAsStub(mrid, ivyFile)) {
+            return null
+        }
+        return ref
+    }
+
+    /**
+     * Visible for testing: decide whether to treat a cached ivy descriptor as 
a
+     * stub and reject it. Returns true iff strictness is enabled, the revision
+     * is not a snapshot, the ivy file's location is known, and no companion
+     * {@code ivy-<rev>.xml.original} file exists alongside it.
+     */
+    boolean shouldRejectAsStub(ModuleRevisionId mrid, File ivyFile) {
+        if (!shouldEnforce()) return false
+        if (mrid == null) return false
+        String rev = mrid.revision
+        if (rev != null && rev.endsWith('-SNAPSHOT')) return false
+        if (ivyFile == null) return false
+        File original = new File(ivyFile.parentFile, ivyFile.name + 
'.original')
+        return !original.exists()
+    }
+
+    private boolean shouldEnforce() {
+        Boolean.parseBoolean(System.getProperty(ENABLE_PROPERTY, 'true'))
+    }
+
+    /**
+     * Visible for testing: extract the on-disk File for a resolved ivy 
resource,
+     * or null if the resource is not file-backed.
+     */
+    static File resolvedRefAsFile(ResolvedResource ref) {
+        try {
+            String name = ref.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/main/resources/groovy/grape/ivy/defaultGrapeConfig.xml
 
b/subprojects/groovy-grape-ivy/src/main/resources/groovy/grape/ivy/defaultGrapeConfig.xml
index 7fc507514e..60b705fe26 100644
--- 
a/subprojects/groovy-grape-ivy/src/main/resources/groovy/grape/ivy/defaultGrapeConfig.xml
+++ 
b/subprojects/groovy-grape-ivy/src/main/resources/groovy/grape/ivy/defaultGrapeConfig.xml
@@ -22,14 +22,14 @@
   <settings defaultResolver="downloadGrapes"/>
   <caches lockStrategy="artifact-lock-nio"/>
   <typedef name="strict-localm2" 
classname="groovy.grape.ivy.StrictLocalM2Resolver"/>
+  <typedef name="strict-cached-grapes" 
classname="groovy.grape.ivy.StrictCachedGrapesResolver"/>
   <resolvers>
     <chain name="downloadGrapes" returnFirst="true">
-      <filesystem name="cachedGrapes">
+      <strict-cached-grapes name="cachedGrapes">
         <ivy 
pattern="${user.home}/.groovy/grapes/[organisation]/[module]/ivy-[revision].xml"/>
         <artifact 
pattern="${user.home}/.groovy/grapes/[organisation]/[module]/[type]s/[artifact]-[revision](-[classifier]).[ext]"/>
-      </filesystem>
+      </strict-cached-grapes>
       <strict-localm2 name="localm2" root="${user.home.url}/.m2/repository/" 
checkmodified="true" changingPattern=".*" changingMatcher="regexp" 
m2compatible="true"/>
-      <!-- TODO: add 'endorsed groovy extensions' resolver here -->
       <ibiblio name="ibiblio" m2compatible="true"/>
     </chain>
   </resolvers>
diff --git 
a/subprojects/groovy-grape-ivy/src/test/groovy/groovy/grape/ivy/StrictCachedGrapesResolverTest.groovy
 
b/subprojects/groovy-grape-ivy/src/test/groovy/groovy/grape/ivy/StrictCachedGrapesResolverTest.groovy
new file mode 100644
index 0000000000..3a433bd62b
--- /dev/null
+++ 
b/subprojects/groovy-grape-ivy/src/test/groovy/groovy/grape/ivy/StrictCachedGrapesResolverTest.groovy
@@ -0,0 +1,124 @@
+/*
+ *  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.assertFalse
+import static org.junit.jupiter.api.Assertions.assertTrue
+
+final class StrictCachedGrapesResolverTest {
+
+    private static final String ENABLE_PROPERTY = 
'groovy.grape.strict-cached-grapes'
+
+    @TempDir
+    File grapesRoot
+
+    StrictCachedGrapesResolver resolver
+
+    @BeforeEach
+    void setUp() {
+        resolver = new StrictCachedGrapesResolver()
+        System.clearProperty(ENABLE_PROPERTY)  // exercise the default-on path
+    }
+
+    @AfterEach
+    void tearDown() {
+        System.clearProperty(ENABLE_PROPERTY)
+    }
+
+    @Test
+    void rejects_ivy_without_original_companion() {
+        File moduleDir = layout('com.example', 'foo', '1.0')
+        File ivy = writeIvyStub(moduleDir, 'foo', '1.0')
+        // no .original companion
+        assertTrue resolver.shouldRejectAsStub(
+            ModuleRevisionId.newInstance('com.example', 'foo', '1.0'),
+            ivy)
+    }
+
+    @Test
+    void accepts_ivy_with_original_companion() {
+        File moduleDir = layout('com.example', 'foo', '1.0')
+        File ivy = writeIvyStub(moduleDir, 'foo', '1.0')
+        new File(moduleDir, "foo-1.0.pom") // unrelated POM, not the .original
+        new File(moduleDir, ivy.name + '.original') << '<project/>' // the 
.original
+        assertFalse resolver.shouldRejectAsStub(
+            ModuleRevisionId.newInstance('com.example', 'foo', '1.0'),
+            ivy)
+    }
+
+    @Test
+    void accepts_snapshot_revision_regardless() {
+        File moduleDir = layout('com.example', 'foo', '1.0-SNAPSHOT')
+        File ivy = writeIvyStub(moduleDir, 'foo', '1.0-SNAPSHOT')
+        // no .original — but it's a snapshot, so strictness is skipped
+        assertFalse resolver.shouldRejectAsStub(
+            ModuleRevisionId.newInstance('com.example', 'foo', '1.0-SNAPSHOT'),
+            ivy)
+    }
+
+    @Test
+    void accepts_when_disabled_via_system_property() {
+        System.setProperty(ENABLE_PROPERTY, 'false')
+        File moduleDir = layout('com.example', 'foo', '1.0')
+        File ivy = writeIvyStub(moduleDir, 'foo', '1.0')
+        // no .original — would normally be rejected, but strictness is off
+        assertFalse resolver.shouldRejectAsStub(
+            ModuleRevisionId.newInstance('com.example', 'foo', '1.0'),
+            ivy)
+    }
+
+    @Test
+    void accepts_when_mrid_null() {
+        File moduleDir = layout('com.example', 'foo', '1.0')
+        File ivy = writeIvyStub(moduleDir, 'foo', '1.0')
+        assertFalse resolver.shouldRejectAsStub(null, ivy)
+    }
+
+    @Test
+    void accepts_when_ivyFile_null() {
+        assertFalse resolver.shouldRejectAsStub(
+            ModuleRevisionId.newInstance('com.example', 'foo', '1.0'),
+            null)
+    }
+
+    private File layout(String org, String mod, String rev) {
+        File dir = new File(grapesRoot, "${org}/${mod}")
+        dir.mkdirs()
+        dir
+    }
+
+    private static File writeIvyStub(File dir, String mod, String rev) {
+        File ivy = new File(dir, "ivy-${rev}.xml")
+        ivy << """<?xml version="1.0" encoding="UTF-8"?>
+<ivy-module version="2.0">
+  <info organisation="com.example" module="${mod}" revision="${rev}" 
status="release"/>
+  <publications>
+    <artifact name="${mod}" type="jar" ext="jar"/>
+  </publications>
+</ivy-module>
+"""
+        ivy
+    }
+}

Reply via email to