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 + * <ibiblio> 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)
