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