diff --git a/grails-core/src/main/groovy/grails/boot/config/ApplicationArtefactScanner.groovy b/grails-core/src/main/groovy/grails/boot/config/ApplicationArtefactScanner.groovy index a69cfef692b..c43483e2f41 100644 --- a/grails-core/src/main/groovy/grails/boot/config/ApplicationArtefactScanner.groovy +++ b/grails-core/src/main/groovy/grails/boot/config/ApplicationArtefactScanner.groovy @@ -60,8 +60,14 @@ final class ApplicationArtefactScanner { * @return The classes that constitute the Grails application */ static Collection scanApplicationClasses(Class applicationClass, Collection packageNames) { - Collection classes = new HashSet<>() - classes.addAll(new ClassPathScanner().scan(applicationClass, packageNames)) + Collection classes = new LinkedHashSet<>() + Collection indexedClasses = ArtefactIndexReader.read(applicationClass, packageNames) + if (indexedClasses == null) { + classes.addAll(new ClassPathScanner().scan(applicationClass, packageNames)) + } + else { + classes.addAll(indexedClasses) + } classes.addAll(loadTransformedClasses(applicationClass.classLoader)) return classes } diff --git a/grails-core/src/main/groovy/grails/boot/config/ArtefactIndexReader.java b/grails-core/src/main/groovy/grails/boot/config/ArtefactIndexReader.java new file mode 100644 index 00000000000..05829e27880 --- /dev/null +++ b/grails-core/src/main/groovy/grails/boot/config/ArtefactIndexReader.java @@ -0,0 +1,114 @@ +/* + * 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 + * + * https://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 grails.boot.config; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.Set; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import grails.io.IOUtils; + +/** + * Reads the optional application artefact index. + * + *

The index is UTF-8 text at {@value #RESOURCE_NAME}, with one fully qualified + * class name per line; blank lines are ignored. Any entry that fails to resolve to a + * loadable class, or any failure while locating or reading the index itself, rejects + * the complete index so callers fall back to their normal classpath scan. Entries that + * do not match the given package names are silently excluded from the result without + * invalidating the index.

+ * + *

Unlike {@link grails.boot.config.tools.ClassPathScanner}, which this reader + * substitutes when a usable index is present, entries are trusted without an artefact + * annotation check, and the given package names are honored exactly as given rather + * than also excluding {@code ClassPathScanner}'s default ignored root packages (such as + * {@code com}, {@code org} and {@code net}). An index producer must reproduce + * {@code ClassPathScanner}'s selection semantics to keep behavior equivalent to + * classpath scanning.

+ * + *

Reading the index can be forced off, in favor of classpath scanning, by setting + * the {@value #DISABLED_PROPERTY} system property to {@code true}.

+ */ +final class ArtefactIndexReader { + + static final String RESOURCE_NAME = "META-INF/grails/artefacts.idx"; + + static final String DISABLED_PROPERTY = "grails.artefactIndex.disabled"; + + private static final Logger log = LoggerFactory.getLogger(ArtefactIndexReader.class); + + private ArtefactIndexReader() { + } + + static Collection read(Class applicationClass, Collection packageNames) { + if (Boolean.getBoolean(DISABLED_PROPERTY)) { + log.debug("Artefact index reading is disabled via the '{}' system property; using classpath scanning", DISABLED_PROPERTY); + return null; + } + try { + URL resource = new URL(IOUtils.findRootResource(applicationClass), RESOURCE_NAME); + Set classes = new LinkedHashSet<>(); + if (readResource(resource, applicationClass.getClassLoader(), packageNames, classes)) { + log.debug("Using artefact index at {} ({} classes)", resource, classes.size()); + return classes; + } + return null; + } catch (IOException | RuntimeException e) { + log.debug("Artefact index for {} could not be read; falling back to classpath scanning", applicationClass.getName(), e); + return null; + } + } + + private static boolean readResource(URL resource, ClassLoader classLoader, Collection packageNames, Set classes) { + try (InputStream inputStream = resource.openStream(); + BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) { + String className; + while ((className = reader.readLine()) != null) { + if (className.isEmpty()) { + continue; + } + if (isInPackage(className, packageNames)) { + classes.add(classLoader.loadClass(className)); + } + } + return true; + } catch (IOException | ClassNotFoundException | LinkageError e) { + log.debug("Artefact index at {} is invalid; falling back to classpath scanning", resource, e); + return false; + } + } + + private static boolean isInPackage(String className, Collection packageNames) { + for (String packageName : packageNames) { + if (packageName != null && (packageName.isEmpty() ? !className.contains(".") : className.startsWith(packageName + "."))) { + return true; + } + } + return false; + } +} diff --git a/grails-core/src/test/groovy/DefaultPackageArtefact.groovy b/grails-core/src/test/groovy/DefaultPackageArtefact.groovy new file mode 100644 index 00000000000..14b209bec5b --- /dev/null +++ b/grails-core/src/test/groovy/DefaultPackageArtefact.groovy @@ -0,0 +1,20 @@ +/* + * 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 + * + * https://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. + */ +class DefaultPackageArtefact { +} diff --git a/grails-core/src/test/groovy/grails/boot/config/ApplicationArtefactScannerSpec.groovy b/grails-core/src/test/groovy/grails/boot/config/ApplicationArtefactScannerSpec.groovy new file mode 100644 index 00000000000..d07a85c6123 --- /dev/null +++ b/grails-core/src/test/groovy/grails/boot/config/ApplicationArtefactScannerSpec.groovy @@ -0,0 +1,379 @@ +/* + * 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 + * + * https://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 grails.boot.config + +import java.io.InputStream +import java.lang.annotation.Retention +import java.lang.annotation.RetentionPolicy +import java.lang.reflect.Field +import java.net.URL +import java.net.URLClassLoader + +import grails.boot.config.excluded.ExcludedArtefact +import grails.boot.config.indexed.IncludedArtefact +import org.grails.compiler.injection.AbstractGrailsArtefactTransformer +import spock.lang.Specification + +class ApplicationArtefactScannerSpec extends Specification { + + private final List temporaryDirectories = [] + private final List classLoaders = [] + private Collection transformedClassNames + + void setup() { + transformedClassNames = new ArrayList<>(knownTransformedClassNames()) + } + + void 'uses indexed artefacts in declaration order'() { + given: + Class applicationClass = applicationClass(''' +grails.boot.config.IndexedSecondArtefact +grails.boot.config.IndexedFirstArtefact +''') + + when: + Collection classes = ApplicationArtefactScanner.scanApplicationClasses(applicationClass) + + then: + indexedClassNames(classes) == [ + IndexedSecondArtefact.name, + IndexedFirstArtefact.name + ] + } + + void 'deduplicates indexed artefacts while preserving the first declaration'() { + given: + Class applicationClass = applicationClass(''' +grails.boot.config.IndexedSecondArtefact +grails.boot.config.IndexedFirstArtefact +grails.boot.config.IndexedSecondArtefact +''') + + when: + Collection classes = ApplicationArtefactScanner.scanApplicationClasses(applicationClass) + + then: + indexedClassNames(classes) == [ + IndexedSecondArtefact.name, + IndexedFirstArtefact.name + ] + } + + void 'falls back to classpath scanning when no index is present'() { + given: + Class applicationClass = applicationClass(null) + + when: + Collection classes = ApplicationArtefactScanner.scanApplicationClasses(applicationClass) + + then: + classes*.name.contains(FallbackArtefact.name) + } + + void 'skips blank lines when reading the index'() { + given: + Class applicationClass = applicationClass(''' +grails.boot.config.IndexedFirstArtefact + +grails.boot.config.IndexedSecondArtefact +''') + + when: + Collection classes = ApplicationArtefactScanner.scanApplicationClasses(applicationClass) + + then: + indexedClassNames(classes) == [ + IndexedFirstArtefact.name, + IndexedSecondArtefact.name + ] + !classes*.name.contains(FallbackArtefact.name) + } + + void 'forces classpath scanning when the artefact index is disabled via system property'() { + given: + System.setProperty(ArtefactIndexReader.DISABLED_PROPERTY, 'true') + Class applicationClass = applicationClass(IndexedFirstArtefact.name) + + when: + Collection classes = ApplicationArtefactScanner.scanApplicationClasses(applicationClass) + + then: + classes*.name.contains(FallbackArtefact.name) + !classes*.name.contains(IndexedFirstArtefact.name) + + cleanup: + System.clearProperty(ArtefactIndexReader.DISABLED_PROPERTY) + } + + void 'falls back to classpath scanning when reading an indexed class throws a runtime exception'() { + given: + Class applicationClass = applicationClass('grails.boot.config.RestrictedArtefact', null, null, 'grails.boot.config.RestrictedArtefact') + + when: + Collection classes = ApplicationArtefactScanner.scanApplicationClasses(applicationClass) + + then: + classes*.name.contains(FallbackArtefact.name) + } + + void 'falls back to application scanning when only a dependency index is present'() { + given: + Class applicationClass = applicationClass(null, ForeignArtefact.name) + + when: + Collection classes = ApplicationArtefactScanner.scanApplicationClasses(applicationClass) + + then: + classes*.name.contains(FallbackArtefact.name) + !classes*.name.contains(ForeignArtefact.name) + } + + void 'does not include entries from a dependency index'() { + given: + Class applicationClass = applicationClass(IndexedFirstArtefact.name, ForeignArtefact.name) + + when: + Collection classes = ApplicationArtefactScanner.scanApplicationClasses(applicationClass) + + then: + classes*.name.contains(IndexedFirstArtefact.name) + !classes*.name.contains(ForeignArtefact.name) + } + + void 'filters indexed artefacts by package names'() { + given: + Class applicationClass = applicationClass(""" +${IncludedArtefact.name} +${ExcludedArtefact.name} +""") + + when: + Collection classes = ApplicationArtefactScanner.scanApplicationClasses(applicationClass, [IncludedArtefact.package.name]) + + then: + classes*.name.contains(IncludedArtefact.name) + !classes*.name.contains(ExcludedArtefact.name) + } + + void 'filters indexed artefacts declared in the default package'() { + given: + Class applicationClass = applicationClass(""" +DefaultPackageArtefact +${IndexedFirstArtefact.name} +""") + + when: + Collection classes = ApplicationArtefactScanner.scanApplicationClasses(applicationClass, ['']) + + then: + classes*.name.contains('DefaultPackageArtefact') + !classes*.name.contains(IndexedFirstArtefact.name) + } + + void 'ignores null package names when filtering indexed artefacts'() { + given: + Class applicationClass = applicationClass(IndexedFirstArtefact.name) + + when: + Collection classes = ApplicationArtefactScanner.scanApplicationClasses(applicationClass, [null, IndexedFirstArtefact.package.name]) + + then: + classes*.name.contains(IndexedFirstArtefact.name) + } + + void 'falls back to classpath scanning when an indexed class is missing'() { + given: + Class applicationClass = applicationClass('grails.boot.config.MissingArtefact') + + when: + Collection classes = ApplicationArtefactScanner.scanApplicationClasses(applicationClass) + + then: + classes*.name.contains(FallbackArtefact.name) + } + + void 'falls back to classpath scanning when an indexed class has a linkage error'() { + given: + Class applicationClass = applicationClass('grails.boot.config.LinkageErrorArtefact', null, 'grails.boot.config.LinkageErrorArtefact') + + when: + Collection classes = ApplicationArtefactScanner.scanApplicationClasses(applicationClass) + + then: + classes*.name.contains(FallbackArtefact.name) + } + + void 'appends transformed artefacts to indexed artefacts without duplicates'() { + given: + AbstractGrailsArtefactTransformer.addToTransformedClasses(IndexedFirstArtefact.name) + AbstractGrailsArtefactTransformer.addToTransformedClasses(IndexedSecondArtefact.name) + Class applicationClass = applicationClass(IndexedFirstArtefact.name) + + when: + Collection classes = ApplicationArtefactScanner.scanApplicationClasses(applicationClass) + + then: + indexedClassNames(classes) == [ + IndexedFirstArtefact.name, + IndexedSecondArtefact.name + ] + } + + void cleanup() { + classLoaders.each { URLClassLoader classLoader -> classLoader.close() } + temporaryDirectories.each { File directory -> directory.deleteDir() } + Collection knownTransformedClassNames = knownTransformedClassNames() + knownTransformedClassNames.clear() + knownTransformedClassNames.addAll(transformedClassNames) + } + + private Class applicationClass(String index, String dependencyIndex = null, String linkageErrorClassName = null, String runtimeExceptionClassName = null) { + File directory = File.createTempDir() + temporaryDirectories << directory + [IndexedApplication, IndexedFirstArtefact, IndexedSecondArtefact, FallbackArtefact, ArtefactMarker, IncludedArtefact, ExcludedArtefact].each { + Class type -> copyClass(type, directory) + } + copyClass(Class.forName('DefaultPackageArtefact', false, ApplicationArtefactScannerSpec.classLoader), directory) + if (index != null) { + writeIndex(directory, index) + } + ClassLoader parent = ApplicationArtefactScannerSpec.classLoader + if (dependencyIndex != null) { + File dependencyDirectory = File.createTempDir() + temporaryDirectories << dependencyDirectory + copyClass(ForeignArtefact, dependencyDirectory) + writeIndex(dependencyDirectory, dependencyIndex) + URLClassLoader dependencyClassLoader = new URLClassLoader([dependencyDirectory.toURI().toURL()] as URL[], parent) + classLoaders << dependencyClassLoader + parent = dependencyClassLoader + } + URLClassLoader classLoader = new TestApplicationClassLoader([directory.toURI().toURL()] as URL[], parent, linkageErrorClassName, runtimeExceptionClassName) + classLoaders << classLoader + return classLoader.loadClass(IndexedApplication.name) + } + + private static void writeIndex(File directory, String index) { + File indexFile = new File(directory, ArtefactIndexReader.RESOURCE_NAME) + indexFile.parentFile.mkdirs() + indexFile.text = index.stripIndent().trim() + } + + private static Collection knownTransformedClassNames() { + Field field = AbstractGrailsArtefactTransformer.getDeclaredField('KNOWN_TRANSFORMED_CLASSES') + field.accessible = true + (Collection) field.get(null) + } + + private static void copyClass(Class type, File directory) { + String resourceName = type.name.replace('.', '/') + '.class' + InputStream inputStream = type.classLoader.getResourceAsStream(resourceName) + try { + File classFile = new File(directory, resourceName) + classFile.parentFile.mkdirs() + classFile.bytes = inputStream.bytes + } finally { + inputStream.close() + } + } + + private static List indexedClassNames(Collection classes) { + classes*.name.findAll { String className -> + className == IndexedFirstArtefact.name || className == IndexedSecondArtefact.name + } + } +} + +class IndexedApplication { +} + +class IndexedFirstArtefact { +} + +class IndexedSecondArtefact { +} + +class ForeignArtefact { +} + +@ArtefactMarker +class FallbackArtefact { +} + +@Retention(RetentionPolicy.RUNTIME) +@interface ArtefactMarker { +} + +class TestApplicationClassLoader extends URLClassLoader { + + private final String linkageErrorClassName + private final String runtimeExceptionClassName + + TestApplicationClassLoader(URL[] urls, ClassLoader parent, String linkageErrorClassName, String runtimeExceptionClassName = null) { + super(urls, parent) + this.linkageErrorClassName = linkageErrorClassName + this.runtimeExceptionClassName = runtimeExceptionClassName + } + + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (name == linkageErrorClassName) { + throw new NoClassDefFoundError(name) + } + if (name == runtimeExceptionClassName) { + throw new SecurityException(name) + } + if (!isFixtureClass(name)) { + return super.loadClass(name, resolve) + } + + synchronized (getClassLoadingLock(name)) { + Class loadedClass = findLoadedClass(name) + if (loadedClass == null) { + try { + loadedClass = findClass(name) + } catch (ClassNotFoundException ignored) { + return super.loadClass(name, resolve) + } + } + if (resolve) { + resolveClass(loadedClass) + } + return loadedClass + } + } + + @Override + URL getResource(String name) { + if (isFixtureClassResource(name)) { + URL resource = findResource(name) + if (resource != null) { + return resource + } + } + return super.getResource(name) + } + + private static boolean isFixtureClass(String name) { + name.startsWith('grails.boot.config.Indexed') || name.startsWith('grails.boot.config.indexed.') || name.startsWith('grails.boot.config.excluded.') || name == FallbackArtefact.name || name == ArtefactMarker.name || name == 'DefaultPackageArtefact' + } + + private static boolean isFixtureClassResource(String name) { + name.endsWith('.class') && isFixtureClass(name.substring(0, name.length() - '.class'.length()).replace('/', '.')) + } +} diff --git a/grails-core/src/test/groovy/grails/boot/config/excluded/ExcludedArtefact.groovy b/grails-core/src/test/groovy/grails/boot/config/excluded/ExcludedArtefact.groovy new file mode 100644 index 00000000000..a475a442c5e --- /dev/null +++ b/grails-core/src/test/groovy/grails/boot/config/excluded/ExcludedArtefact.groovy @@ -0,0 +1,25 @@ +/* + * 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 + * + * https://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 grails.boot.config.excluded + +import grails.boot.config.ArtefactMarker + +@ArtefactMarker +class ExcludedArtefact { +} diff --git a/grails-core/src/test/groovy/grails/boot/config/indexed/IncludedArtefact.groovy b/grails-core/src/test/groovy/grails/boot/config/indexed/IncludedArtefact.groovy new file mode 100644 index 00000000000..6cfac753feb --- /dev/null +++ b/grails-core/src/test/groovy/grails/boot/config/indexed/IncludedArtefact.groovy @@ -0,0 +1,25 @@ +/* + * 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 + * + * https://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 grails.boot.config.indexed + +import grails.boot.config.ArtefactMarker + +@ArtefactMarker +class IncludedArtefact { +}