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

commit 26f5fe44b0a5b74a031b3385679f7fc1290648c8
Author: Paul King <[email protected]>
AuthorDate: Tue May 12 08:08:54 2026 +1000

    GROOVY-12005: Harden Grape against cache corruption and CDN throttling 
(cont'd)
---
 .../main/groovy/org.apache.groovy-tested.gradle    |   7 +-
 .../main/groovy/groovy/grape/ivy/GrapeIvy.groovy   |  94 +++++++++++++++--
 .../grape/ivy/StrictCachedGrapesResolver.groovy    | 115 +++++++++++++++++----
 .../groovy/grape/ivy/StrictLocalM2Resolver.groovy  |  27 ++---
 .../groovy/grape/ivy/relaxedGrapeConfig.xml        |  44 ++++++++
 .../grape/ivy/GrapeConfigResolutionTest.groovy     |  74 +++++++++++++
 .../ivy/StrictCachedGrapesResolverTest.groovy      |  67 +++++++++---
 .../grape/ivy/StrictLocalM2ResolverTest.groovy     |  20 ----
 8 files changed, 358 insertions(+), 90 deletions(-)

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 b0e908caa8..afc0cf7a22 100644
--- a/build-logic/src/main/groovy/org.apache.groovy-tested.gradle
+++ b/build-logic/src/main/groovy/org.apache.groovy-tested.gradle
@@ -85,10 +85,9 @@ tasks.withType(Test).configureEach {
         systemProperty 'groovy.testdb.props', testdb
     }
     // Forward any groovy.grape.* property set on the Gradle command line (-P 
or -D)
-    // to the test JVM. Lets users override Grape behaviour — strict-localm2, 
debug
-    // flags, future settings — without adding one-off plumbing per flag. 
Project
-    // properties (-P) win over system properties (-D) when both are set, 
matching
-    // Gradle convention.
+    // to the test JVM. Lets users override Grape behaviour — debug flags, 
future
+    // settings — without adding one-off plumbing per flag. Project properties 
(-P)
+    // win over system properties (-D) when both are set, matching Gradle 
convention.
     def grapeProps = new LinkedHashMap<String, String>()
     project.properties.each { k, v ->
         if (k.toString().startsWith('groovy.grape.') && v != null) {
diff --git 
a/subprojects/groovy-grape-ivy/src/main/groovy/groovy/grape/ivy/GrapeIvy.groovy 
b/subprojects/groovy-grape-ivy/src/main/groovy/groovy/grape/ivy/GrapeIvy.groovy
index 4d952722de..df3345515c 100644
--- 
a/subprojects/groovy-grape-ivy/src/main/groovy/groovy/grape/ivy/GrapeIvy.groovy
+++ 
b/subprojects/groovy-grape-ivy/src/main/groovy/groovy/grape/ivy/GrapeIvy.groovy
@@ -27,6 +27,7 @@ import groovy.transform.CompileStatic
 import groovy.transform.EqualsAndHashCode
 import groovy.transform.NamedParam
 import groovy.transform.NamedParams
+import groovy.transform.PackageScope
 import org.apache.ivy.Ivy
 import org.apache.ivy.core.IvyContext
 import org.apache.ivy.core.event.IvyListener
@@ -58,6 +59,8 @@ import javax.xml.XMLConstants
 import javax.xml.parsers.DocumentBuilderFactory
 import javax.xml.parsers.ParserConfigurationException
 import java.text.ParseException
+import java.util.logging.Level
+import java.util.logging.Logger
 import java.util.regex.Pattern
 
 /**
@@ -94,6 +97,8 @@ class GrapeIvy implements GrapeEngine {
         }
     }
 
+    private static final Logger LOGGER = Logger.getLogger('groovy.grape.ivy')
+
     private static final List<String> DEFAULT_CONF = 
Collections.singletonList('default')
     private static final Map<String, Set<String>> MUTUALLY_EXCLUSIVE_KEYS = 
processGrabArgs([
             ['group', 'groupId', 'organisation', 'organization', 'org'],
@@ -133,16 +138,14 @@ class GrapeIvy implements GrapeEngine {
         settings = new IvySettings()
         def url = new File(System.getProperty('user.home')).toURI().toURL() as 
String
         settings.setVariable('user.home.url', url.endsWith("/") ? url[0..-2] : 
url)
-        File grapeConfig = getLocalGrapeConfig()
-        if (grapeConfig.exists()) {
-            try {
-                settings.load(grapeConfig)
-            } catch (ParseException e) {
-                System.err.println("Local Ivy config file 
'${grapeConfig.getCanonicalPath()}' appears corrupt - ignoring it and using 
default config instead\nError was: ${e.getMessage()}")
-                settings.load(GrapeIvy.getResource('defaultGrapeConfig.xml'))
-            }
-        } else {
-            settings.load(GrapeIvy.getResource('defaultGrapeConfig.xml'))
+        URL defaultConfig = GrapeIvy.getResource('defaultGrapeConfig.xml')
+        URL effective = resolveGrapeConfigUrl() ?: defaultConfig
+        try {
+            settings.load(effective)
+        } catch (ParseException e) {
+            LOGGER.log(Level.WARNING,
+                "Ivy config '${effective}' appears corrupt - ignoring and 
using default config. Error: ${e.message}", e)
+            settings.load(defaultConfig)
         }
         settings.setDefaultCache(getGrapeCacheDir())
         settings.setVariable('ivy.default.configuration.m2compatible', 'true')
@@ -209,7 +212,13 @@ class GrapeIvy implements GrapeEngine {
     /**
      * Returns the local Ivy grape configuration file.
      *
-     * @return the grape configuration file
+     * <p>Retained for backwards compatibility — only honors filesystem-path 
forms
+     * of {@code -Dgrape.config}. The runtime now resolves the active config
+     * through {@link #resolveGrapeConfigUrl}, which additionally accepts the
+     * {@code relaxed}, {@code default}, and {@code classpath:<resource>} 
shorthand
+     * forms.</p>
+     *
+     * @return the grape configuration file (may not exist on disk)
      */
     File getLocalGrapeConfig() {
         String grapeConfig = System.getProperty('grape.config')
@@ -220,6 +229,69 @@ class GrapeIvy implements GrapeEngine {
         }
     }
 
+    /**
+     * Resolves the active Grape Ivy config URL.
+     *
+     * <p>Precedence:</p>
+     * <ol>
+     *   <li>{@code -Dgrape.config=<value>} — see {@link 
#resolveExplicitConfig}.
+     *       If the value is set but cannot be resolved, a {@code WARNING} is
+     *       logged and the method returns null so the caller falls back to
+     *       {@code defaultGrapeConfig.xml}. (This matches the existing 
semantics
+     *       where a missing sys-prop file bypasses {@code 
~/.groovy/grapeConfig.xml}.)</li>
+     *   <li>{@code ~/.groovy/grapeConfig.xml} (or the configured grape 
dir).</li>
+     *   <li>{@code null} — caller is expected to fall back to {@code 
defaultGrapeConfig.xml}.</li>
+     * </ol>
+     *
+     * @return the resolved config URL, or null if no override was selected
+     */
+    URL resolveGrapeConfigUrl() {
+        String prop = System.getProperty('grape.config')
+        if (prop) {
+            URL fromProp = resolveExplicitConfig(prop)
+            if (fromProp != null) {
+                LOGGER.log(Level.FINE, "Using grape.config='${prop}' 
(${fromProp})")
+                return fromProp
+            }
+            LOGGER.log(Level.WARNING,
+                "grape.config='${prop}' could not be resolved; falling back to 
defaultGrapeConfig.xml")
+            return null
+        }
+        File userConfig = new File(getGrapeDir(), 'grapeConfig.xml')
+        if (userConfig.exists()) {
+            return userConfig.toURI().toURL()
+        }
+        return null
+    }
+
+    /**
+     * Visible for testing: resolves a {@code -Dgrape.config=<value>} setting 
to a
+     * URL. Accepted forms:
+     * <ul>
+     *   <li>{@code relaxed} — packaged {@code relaxedGrapeConfig.xml} (plain
+     *       {@code <filesystem>} / {@code <ibiblio>} resolvers, no 
strictness).</li>
+     *   <li>{@code default} — packaged {@code defaultGrapeConfig.xml} 
(explicit
+     *       selection of the shipped default; useful for symmetry).</li>
+     *   <li>{@code classpath:<resource>} — arbitrary classpath resource 
looked up
+     *       via the GrapeIvy classloader, then the thread context 
classloader.</li>
+     *   <li>any other value — interpreted as a filesystem path.</li>
+     * </ul>
+     *
+     * @return the resolved URL, or null if the value could not be located
+     */
+    @PackageScope static URL resolveExplicitConfig(String prop) {
+        if (prop == 'relaxed') return 
GrapeIvy.getResource('relaxedGrapeConfig.xml')
+        if (prop == 'default') return 
GrapeIvy.getResource('defaultGrapeConfig.xml')
+        if (prop.startsWith('classpath:')) {
+            String name = prop.substring('classpath:'.length())
+            URL res = GrapeIvy.classLoader?.getResource(name)
+            if (res != null) return res
+            return Thread.currentThread().contextClassLoader?.getResource(name)
+        }
+        File f = new File(prop)
+        return f.exists() ? f.toURI().toURL() : null
+    }
+
     /**
      * Chooses the target class loader for a grab operation.
      *
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
index 0af709e50f..7d6b3cdd04 100644
--- 
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
@@ -25,6 +25,12 @@ import org.apache.ivy.core.resolve.ResolveData
 import org.apache.ivy.plugins.resolver.FileSystemResolver
 import org.apache.ivy.plugins.resolver.util.ResolvedResource
 
+import java.nio.file.Files
+import java.util.jar.JarFile
+import java.util.logging.Level
+import java.util.logging.Logger
+import java.util.zip.ZipException
+
 /**
  * {@link FileSystemResolver} subclass that hardens Grape's {@code 
cachedGrapes}
  * resolver against stub-shaped synthetic descriptors.
@@ -38,31 +44,39 @@ import org.apache.ivy.plugins.resolver.util.ResolvedResource
  * 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>
+ * <p>This resolver guards three failure modes:</p>
  * <ol>
- *   <li>Sets {@code allownomd=false} in the constructor — prevents 
<em>new</em>
+ *   <li>Sets {@code descriptor=required} 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>
+ *   <li>Overrides {@link #findIvyFileRef} to reject existing stubs — 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>
+ *   <li>Validates the cached primary JAR's zip integrity. If the JAR exists 
but
+ *       {@link JarFile} can't open it (e.g. truncated download from a CDN 429 
or
+ *       interrupted process), evicts the JAR + descriptor + {@code .original}
+ *       and returns null. The chain falls through to localm2/ibiblio and Ivy
+ *       re-downloads a fresh copy in the same {@code @Grab} call — no second
+ *       grab needed. Catching corruption here, rather than at classloading 
time,
+ *       keeps corrupt JARs off the classpath entirely.</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>
+ * <p>Strict behaviour is unconditional — if this resolver is in use, both 
checks
+ * run. Operators who don't want them can simply not declare this resolver in
+ * their {@code ~/.groovy/grapeConfig.xml}, dropping back to the stock Ivy
+ * {@code <filesystem>} resolver. Stub-rejection skips snapshot revisions
+ * automatically (Maven snapshot caches use timestamped filenames); integrity
+ * validation does not — corruption is corruption.</p>
  */
 @CompileStatic
 class StrictCachedGrapesResolver extends FileSystemResolver {
 
-    private static final String ENABLE_PROPERTY = 
'groovy.grape.strict-cached-grapes'
+    private static final Logger LOGGER = 
Logger.getLogger(StrictCachedGrapesResolver.name)
 
     StrictCachedGrapesResolver() {
         // Prevent the in-memory synthesis path that creates stubs from
@@ -81,17 +95,20 @@ class StrictCachedGrapesResolver extends FileSystemResolver 
{
         if (shouldRejectAsStub(mrid, ivyFile)) {
             return null
         }
+        if (shouldRejectAsCorruptArtifact(mrid, ivyFile)) {
+            invalidateCacheEntry(ivyFile, mrid)
+            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.
+     * stub and reject it. Returns true iff 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
@@ -100,8 +117,25 @@ class StrictCachedGrapesResolver extends 
FileSystemResolver {
         return !original.exists()
     }
 
-    private boolean shouldEnforce() {
-        Boolean.parseBoolean(System.getProperty(ENABLE_PROPERTY, 'true'))
+    /**
+     * Visible for testing: decide whether to treat a cached module as having a
+     * corrupt primary JAR (truncated, zero bytes, garbage). Returns true iff 
the
+     * JAR is locatable on disk in the cachedGrapes layout and opening it as a
+     * {@link JarFile} raises {@link ZipException}. Unrelated I/O errors (e.g.
+     * permissions) return false — we only reject on clear zip-format 
corruption.
+     */
+    boolean shouldRejectAsCorruptArtifact(ModuleRevisionId mrid, File ivyFile) 
{
+        if (mrid == null) return false
+        File jar = locatePrimaryJar(ivyFile, mrid)
+        if (jar == null || !jar.isFile()) return false
+        try (JarFile jf = new JarFile(jar)) {
+            jf.entries()
+            return false
+        } catch (ZipException ignored) {
+            return true
+        } catch (IOException ignored) {
+            return false
+        }
     }
 
     /**
@@ -120,4 +154,43 @@ class StrictCachedGrapesResolver extends 
FileSystemResolver {
         }
         null
     }
+
+    /**
+     * Visible for testing: locate the primary JAR for a module revision in the
+     * cachedGrapes layout ({@code <module-dir>/jars/<name>-<rev>.jar}), given 
the
+     * ivy descriptor file. Returns null if any layout assumption fails.
+     */
+    static File locatePrimaryJar(File ivyFile, ModuleRevisionId mrid) {
+        if (ivyFile == null || mrid == null) return null
+        File parent = ivyFile.parentFile
+        if (parent == null) return null
+        File jarsDir = new File(parent, 'jars')
+        if (!jarsDir.isDirectory()) return null
+        new File(jarsDir, "${mrid.name}-${mrid.revision}.jar")
+    }
+
+    private static void invalidateCacheEntry(File ivyFile, ModuleRevisionId 
mrid) {
+        File jar = locatePrimaryJar(ivyFile, mrid)
+        if (jar != null && jar.exists()) {
+            LOGGER.log(Level.WARNING,
+                'Evicting corrupt cached JAR (next resolve will re-download): 
' + jar)
+            tryDelete(jar)
+        }
+        // Drop the descriptor + .original too, so the chain doesn't bind back 
to
+        // cachedGrapes on the next pass — the next chain run finds nothing 
here
+        // and falls through to localm2/ibiblio for a fresh download.
+        tryDelete(ivyFile)
+        if (ivyFile != null) {
+            tryDelete(new File(ivyFile.parentFile, ivyFile.name + '.original'))
+        }
+    }
+
+    private static void tryDelete(File f) {
+        if (f == null || !f.exists()) return
+        try {
+            Files.deleteIfExists(f.toPath())
+        } catch (Exception e) {
+            LOGGER.log(Level.FINE, 'Could not delete cached file: ' + f, e)
+        }
+    }
 }
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
index b0a48d2f5d..27fd275aa2 100644
--- 
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
@@ -39,18 +39,16 @@ import java.util.regex.Pattern
  * poisoning: returning null at descriptor-lookup time prevents Ivy from 
caching
  * the descriptor with resolver=localm2.
  *
- * Strictness is enabled by default. Disable with 
-Dgroovy.grape.strict-localm2=false
- * if you maintain a custom resolver setup where rejected POM-only entries 
cause
- * legitimate problems (the resolver will still skip strictness automatically
- * for snapshot revisions — Maven uses timestamp-suffixed filenames there — for
- * non-m2-compatible configurations, and when the resolver root is not a file 
URL,
- * so the opt-out should rarely be needed).
+ * Strict behaviour is unconditional — if this resolver is in use, the check
+ * runs. Operators who don't want it can simply not declare this resolver in
+ * their ~/.groovy/grapeConfig.xml, dropping back to the stock Ivy
+ * &lt;ibiblio&gt; resolver. Strictness is still skipped automatically 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',
@@ -77,12 +75,12 @@ class StrictLocalM2Resolver extends IBiblioResolver {
 
     /**
      * 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.
+     * half-populated. Returns true iff the resolver is m2-compatible, 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 (!isM2compatible()) return false
         if (mrid == null) return false
         String rev = mrid.revision
         if (rev != null && rev.endsWith('-SNAPSHOT')) return false
@@ -97,11 +95,6 @@ class StrictLocalM2Resolver extends IBiblioResolver {
         return !primary.exists()
     }
 
-    private boolean shouldEnforce() {
-        if (!isM2compatible()) return false
-        Boolean.parseBoolean(System.getProperty(ENABLE_PROPERTY, 'true'))
-    }
-
     /**
      * Visible for testing: compute the local m2 directory for a module
      * revision based on the resolver's root.
diff --git 
a/subprojects/groovy-grape-ivy/src/main/resources/groovy/grape/ivy/relaxedGrapeConfig.xml
 
b/subprojects/groovy-grape-ivy/src/main/resources/groovy/grape/ivy/relaxedGrapeConfig.xml
new file mode 100644
index 0000000000..d790c7c37c
--- /dev/null
+++ 
b/subprojects/groovy-grape-ivy/src/main/resources/groovy/grape/ivy/relaxedGrapeConfig.xml
@@ -0,0 +1,44 @@
+<!--
+
+     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.
+
+-->
+<!--
+  Alternative ("relaxed") Grape Ivy configuration. Uses the stock Ivy
+  <filesystem> and <ibiblio> resolvers for cachedGrapes and localm2, without
+  the strict stub-rejection / JAR-integrity / half-populated-localm2 checks
+  performed by the default config. Intended for users with trusted caches who
+  prefer the pre-6.0 resolution semantics.
+
+  To opt in, copy this file to ~/.groovy/grapeConfig.xml (or point
+  -Dgrape.config at it on the JVM command line).
+-->
+<ivysettings>
+  <settings defaultResolver="downloadGrapes"/>
+  <caches lockStrategy="artifact-lock-nio"/>
+  <resolvers>
+    <chain name="downloadGrapes" returnFirst="true">
+      <filesystem 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>
+      <ibiblio name="localm2" root="${user.home.url}/.m2/repository/" 
checkmodified="true" changingPattern=".*" changingMatcher="regexp" 
m2compatible="true"/>
+      <ibiblio name="ibiblio" m2compatible="true"/>
+    </chain>
+  </resolvers>
+</ivysettings>
diff --git 
a/subprojects/groovy-grape-ivy/src/test/groovy/groovy/grape/ivy/GrapeConfigResolutionTest.groovy
 
b/subprojects/groovy-grape-ivy/src/test/groovy/groovy/grape/ivy/GrapeConfigResolutionTest.groovy
new file mode 100644
index 0000000000..d7b8aa95f2
--- /dev/null
+++ 
b/subprojects/groovy-grape-ivy/src/test/groovy/groovy/grape/ivy/GrapeConfigResolutionTest.groovy
@@ -0,0 +1,74 @@
+/*
+ *  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.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.assertNotNull
+import static org.junit.jupiter.api.Assertions.assertNull
+import static org.junit.jupiter.api.Assertions.assertTrue
+
+/**
+ * Unit tests for {@link GrapeIvy#resolveExplicitConfig(String)} — the 
dispatcher
+ * for {@code -Dgrape.config=<value>}.
+ */
+final class GrapeConfigResolutionTest {
+
+    @Test
+    void resolves_relaxed_shorthand_to_packaged_xml() {
+        URL url = GrapeIvy.resolveExplicitConfig('relaxed')
+        assertNotNull url
+        assertTrue url.toString().endsWith('relaxedGrapeConfig.xml')
+    }
+
+    @Test
+    void resolves_default_shorthand_to_packaged_xml() {
+        URL url = GrapeIvy.resolveExplicitConfig('default')
+        assertNotNull url
+        assertTrue url.toString().endsWith('defaultGrapeConfig.xml')
+    }
+
+    @Test
+    void resolves_classpath_prefix_via_classloader() {
+        URL url = 
GrapeIvy.resolveExplicitConfig('classpath:groovy/grape/ivy/relaxedGrapeConfig.xml')
+        assertNotNull url
+        assertTrue url.toString().endsWith('relaxedGrapeConfig.xml')
+    }
+
+    @Test
+    void returns_null_for_missing_classpath_resource() {
+        assertNull 
GrapeIvy.resolveExplicitConfig('classpath:does/not/exist.xml')
+    }
+
+    @Test
+    void resolves_existing_filesystem_path(@TempDir File tmp) {
+        File f = new File(tmp, 'custom-grape.xml')
+        f << '<ivysettings/>'
+        URL url = GrapeIvy.resolveExplicitConfig(f.absolutePath)
+        assertNotNull url
+        assertEquals f.toURI().toURL(), url
+    }
+
+    @Test
+    void returns_null_for_missing_filesystem_path() {
+        assertNull 
GrapeIvy.resolveExplicitConfig('/nonexistent/no/such/file-grape.xml')
+    }
+}
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
index 3a433bd62b..f188269673 100644
--- 
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
@@ -19,7 +19,6 @@
 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
@@ -29,8 +28,6 @@ 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
 
@@ -39,12 +36,6 @@ final class StrictCachedGrapesResolverTest {
     @BeforeEach
     void setUp() {
         resolver = new StrictCachedGrapesResolver()
-        System.clearProperty(ENABLE_PROPERTY)  // exercise the default-on path
-    }
-
-    @AfterEach
-    void tearDown() {
-        System.clearProperty(ENABLE_PROPERTY)
     }
 
     @Test
@@ -79,28 +70,47 @@ final class StrictCachedGrapesResolverTest {
     }
 
     @Test
-    void accepts_when_disabled_via_system_property() {
-        System.setProperty(ENABLE_PROPERTY, 'false')
+    void accepts_when_mrid_null() {
         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(null, ivy)
+    }
+
+    @Test
+    void accepts_when_ivyFile_null() {
         assertFalse resolver.shouldRejectAsStub(
+            ModuleRevisionId.newInstance('com.example', 'foo', '1.0'),
+            null)
+    }
+
+    @Test
+    void rejects_corrupt_primary_jar() {
+        File moduleDir = layout('com.example', 'foo', '1.0')
+        File ivy = writeIvyStub(moduleDir, 'foo', '1.0')
+        writeCorruptJar(moduleDir, 'foo', '1.0')
+        assertTrue resolver.shouldRejectAsCorruptArtifact(
             ModuleRevisionId.newInstance('com.example', 'foo', '1.0'),
             ivy)
     }
 
     @Test
-    void accepts_when_mrid_null() {
+    void accepts_valid_primary_jar() {
         File moduleDir = layout('com.example', 'foo', '1.0')
         File ivy = writeIvyStub(moduleDir, 'foo', '1.0')
-        assertFalse resolver.shouldRejectAsStub(null, ivy)
+        writeValidJar(moduleDir, 'foo', '1.0')
+        assertFalse resolver.shouldRejectAsCorruptArtifact(
+            ModuleRevisionId.newInstance('com.example', 'foo', '1.0'),
+            ivy)
     }
 
     @Test
-    void accepts_when_ivyFile_null() {
-        assertFalse resolver.shouldRejectAsStub(
+    void accepts_when_no_jar_present() {
+        File moduleDir = layout('com.example', 'foo', '1.0')
+        File ivy = writeIvyStub(moduleDir, 'foo', '1.0')
+        // no jars/ subdir at all — no JAR to validate
+        assertFalse resolver.shouldRejectAsCorruptArtifact(
             ModuleRevisionId.newInstance('com.example', 'foo', '1.0'),
-            null)
+            ivy)
     }
 
     private File layout(String org, String mod, String rev) {
@@ -109,6 +119,29 @@ final class StrictCachedGrapesResolverTest {
         dir
     }
 
+    private static File writeCorruptJar(File moduleDir, String mod, String 
rev) {
+        File jarsDir = new File(moduleDir, 'jars')
+        jarsDir.mkdirs()
+        File jar = new File(jarsDir, "${mod}-${rev}.jar")
+        jar.bytes = [0x00, 0x01, 0x02, 0x03] as byte[]
+        jar
+    }
+
+    private static File writeValidJar(File moduleDir, String mod, String rev) {
+        File jarsDir = new File(moduleDir, 'jars')
+        jarsDir.mkdirs()
+        File jar = new File(jarsDir, "${mod}-${rev}.jar")
+        def jos = new java.util.jar.JarOutputStream(new FileOutputStream(jar))
+        try {
+            jos.putNextEntry(new 
java.util.zip.ZipEntry('META-INF/MANIFEST.MF'))
+            jos.write('Manifest-Version: 1.0\n'.bytes)
+            jos.closeEntry()
+        } finally {
+            jos.close()
+        }
+        jar
+    }
+
     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"?>
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
index 4fa46dd7e2..eda91953b4 100644
--- 
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
@@ -19,7 +19,6 @@
 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
@@ -31,8 +30,6 @@ import static org.junit.jupiter.api.Assertions.assertTrue
 
 final class StrictLocalM2ResolverTest {
 
-    private static final String ENABLE_PROPERTY = 'groovy.grape.strict-localm2'
-
     @TempDir
     File m2
 
@@ -43,12 +40,6 @@ final class StrictLocalM2ResolverTest {
         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
@@ -132,17 +123,6 @@ final class StrictLocalM2ResolverTest {
             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)

Reply via email to