diff --git a/src/main/java/org/apache/groovy/util/HiddenClassDefiner.java b/src/main/java/org/apache/groovy/util/HiddenClassDefiner.java new file mode 100644 index 00000000000..d7d602ece5a --- /dev/null +++ b/src/main/java/org/apache/groovy/util/HiddenClassDefiner.java @@ -0,0 +1,315 @@ +/* + * 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 org.apache.groovy.util; + +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.commons.ClassRemapper; +import org.objectweb.asm.commons.SimpleRemapper; + +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodHandles.Lookup; +import java.lang.reflect.Constructor; + +/** + * Central facility for defining hidden classes + * (JEP 371). + * + *

Why a single entry point

+ *

Every dynamic class generator in Groovy (proxies, reflectors, per-class + * meta-method artifacts, …) should obtain hidden classes through this type so + * that: + *

+ * + *

Preferred usage (host-based)

+ *
{@code
+ * // host determines: defining loader, run-time package, protection domain, nest
+ * Class hidden = HiddenClassDefiner.tryDefineNestmate(hostClass, bytecode, false);
+ * if (hidden == null) {
+ *     // fall back to ClassLoader.defineClass(...)
+ * }
+ * }
+ * + *

The host is obtained via {@link MethodHandles#privateLookupIn(Class, Lookup)} + * using a full-privilege lookup captured inside this (Java) class. That makes + * the result independent of Groovy's indy / {@code $$InjectedInvoker} + * caller-sensitive quirks. + * + *

Lifecycle

+ * + * + *

Kill switch

+ *

{@code -Dgroovy.hidden.classes.disable=true} forces every {@code try*} + * method to return {@code null} (and makes {@link #isEnabled()} false) so + * diagnostics and legacy environments can fall back without code changes. + * + * @since 6.0.0 + * @see Lookup#defineHiddenClass(byte[], boolean, Lookup.ClassOption...) + */ +public final class HiddenClassDefiner { + + /** System property that disables hidden-class definitions. */ + public static final String PROPERTY_DISABLE = "groovy.hidden.classes.disable"; + + /** + * {@code true} when hidden-class definitions are globally disabled. + * Evaluated once at class-init so hot paths pay no property-lookup cost. + */ + public static final boolean HIDDEN_CLASSES_DISABLED = + SystemUtil.getBooleanSafe(PROPERTY_DISABLE, false); + + /** + * Full-privilege lookup for this Java class, captured during + * {@code }. Used solely as the caller argument to + * {@link MethodHandles#privateLookupIn(Class, Lookup)}; it is never used + * as the nest host of user-generated classes. + */ + private static final Lookup TRUSTED_LOOKUP = MethodHandles.lookup(); + + // Pre-allocated option arrays — defineHiddenClass is on the meta-class hot path. + private static final Lookup.ClassOption[] OPT_NESTMATE = + new Lookup.ClassOption[]{Lookup.ClassOption.NESTMATE}; + private static final Lookup.ClassOption[] OPT_STRONG = + new Lookup.ClassOption[]{Lookup.ClassOption.STRONG}; + private static final Lookup.ClassOption[] OPT_NESTMATE_STRONG = + new Lookup.ClassOption[]{Lookup.ClassOption.NESTMATE, Lookup.ClassOption.STRONG}; + private static final Lookup.ClassOption[] OPT_NONE = new Lookup.ClassOption[0]; + + private HiddenClassDefiner() { + throw new AssertionError("HiddenClassDefiner is a utility class"); + } + + // ------------------------------------------------------------------------- + // Status + // ------------------------------------------------------------------------- + + /** + * @return {@code true} when hidden-class definition is enabled + * (the default unless {@value #PROPERTY_DISABLE} is set) + */ + public static boolean isEnabled() { + return !HIDDEN_CLASSES_DISABLED; + } + + // ------------------------------------------------------------------------- + // Soft (best-effort) API — preferred by production call sites + // ------------------------------------------------------------------------- + + /** + * Attempts to define {@code bytes} as a hidden nestmate of + * {@code host} with a weak (eager-unloading) lifecycle. + * + *

On success the returned class: + *

+ * + *

Returns {@code null} when hidden classes are disabled, {@code host} + * is unsuitable (null / primitive / array / hidden), private lookup is + * refused, the class file is invalid, or linkage fails (for example the + * host loader cannot resolve a supertype referenced by the bytecode). + * Callers are expected to fall back to {@link ClassLoader#defineClass}. + * + * @param host nest host and class-loader / package donor; must be a + * normal (non-hidden) reference type + * @param bytes class-file bytes; {@code this_class} is rewritten into + * {@code host}'s package when needed + * @param initialize {@code true} to run {@code } immediately + * @return the hidden class, or {@code null} if definition is not possible + */ + public static Class tryDefineNestmate( + final Class host, + final byte[] bytes, + final boolean initialize) { + if (HIDDEN_CLASSES_DISABLED || !isUsableHost(host) || bytes == null) { + return null; + } + try { + final Lookup hostLookup = MethodHandles.privateLookupIn(host, TRUSTED_LOOKUP); + final byte[] aligned = alignPackage(bytes, host); + return hostLookup.defineHiddenClass(aligned, initialize, OPT_NESTMATE).lookupClass(); + } catch (IllegalAccessException | IllegalArgumentException | SecurityException | LinkageError e) { + return null; + } catch (RuntimeException e) { + // ASM rewrite failures, unexpected JVM checks, etc. + return null; + } + } + + /** + * Soft variant of {@link #define(Lookup, byte[], boolean, boolean, boolean)}. + * Returns {@code null} instead of throwing for the expected failure modes. + * + *

The bytecode package is aligned to {@code lookup.lookupClass()} before + * definition. The lookup itself is not replaced — callers that need a + * specific nest host should obtain it via + * {@link MethodHandles#privateLookupIn(Class, Lookup)} (or use + * {@link #tryDefineNestmate(Class, byte[], boolean)}). + */ + public static Class tryDefine( + final Lookup lookup, + final byte[] bytes, + final boolean initialize, + final boolean nestmate, + final boolean strong) { + if (HIDDEN_CLASSES_DISABLED || lookup == null || bytes == null) { + return null; + } + try { + return define(lookup, bytes, initialize, nestmate, strong); + } catch (IllegalAccessException | IllegalArgumentException | SecurityException | LinkageError e) { + return null; + } catch (RuntimeException e) { + return null; + } + } + + // ------------------------------------------------------------------------- + // Strict API — for tests and callers that want the original exception + // ------------------------------------------------------------------------- + + /** + * Defines a hidden class with the given options. + * + *

The class-file's {@code this_class} package is rewritten to match + * {@code lookup.lookupClass()} when they differ — this is required by + * {@link Lookup#defineHiddenClass}. + * + * @param lookup full-privilege lookup whose lookup-class supplies the + * defining loader, package, protection domain and + * (when {@code nestmate}) nest + * @param bytes class-file bytes + * @param initialize whether to initialize the class immediately + * @param nestmate whether to inject the class into the lookup class's nest + * @param strong whether the class's lifetime is tied to the loader + * @return the defined hidden class + * @throws IllegalAccessException if the lookup lacks the required access + * @throws IllegalArgumentException if the bytes are not a valid class file + * (after package alignment) + * @throws LinkageError if a dependency of the new class cannot + * be resolved in the lookup class's loader + */ + public static Class define( + final Lookup lookup, + final byte[] bytes, + final boolean initialize, + final boolean nestmate, + final boolean strong) throws IllegalAccessException { + final byte[] aligned = alignPackage(bytes, lookup.lookupClass()); + return lookup.defineHiddenClass(aligned, initialize, options(nestmate, strong)).lookupClass(); + } + + /** + * Strict nestmate + weak convenience overload. + * + * @see #define(Lookup, byte[], boolean, boolean, boolean) + */ + public static Class defineNestmate( + final Lookup lookup, + final byte[] bytes, + final boolean initialize) throws IllegalAccessException { + return define(lookup, bytes, initialize, true, false); + } + + /** + * Strict non-nestmate + strong convenience overload. + * + * @see #define(Lookup, byte[], boolean, boolean, boolean) + */ + public static Class defineStrong( + final Lookup lookup, + final byte[] bytes, + final boolean initialize) throws IllegalAccessException { + return define(lookup, bytes, initialize, false, true); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * Returns a declared constructor of {@code type}, wrapping a missing + * constructor as {@link IllegalStateException} (bytecode-generation bug). + */ + @SuppressWarnings("unchecked") + public static Constructor findConstructor( + final Class type, + final Class... parameterTypes) { + try { + return type.getDeclaredConstructor(parameterTypes); + } catch (NoSuchMethodException e) { + throw new IllegalStateException( + "Class " + type.getName() + " is missing the expected constructor", e); + } + } + + /** + * Rewrites {@code this_class} (and all internal references to it) so the + * class lives in {@code host}'s run-time package. No-op when already aligned. + * + *

Visible for testing. + */ + static byte[] alignPackage(final byte[] bytes, final Class host) { + final String hostPkg = host.getPackageName(); + final ClassReader reader = new ClassReader(bytes); + final String oldInternal = reader.getClassName(); + final int slash = oldInternal.lastIndexOf('/'); + final String simple = slash < 0 ? oldInternal : oldInternal.substring(slash + 1); + final String newInternal = hostPkg.isEmpty() + ? simple + : hostPkg.replace('.', '/') + '/' + simple; + if (oldInternal.equals(newInternal)) { + return bytes; + } + final ClassWriter writer = new ClassWriter(reader, 0); + reader.accept(new ClassRemapper(writer, new SimpleRemapper(oldInternal, newInternal)), 0); + return writer.toByteArray(); + } + + private static boolean isUsableHost(final Class host) { + return host != null + && !host.isPrimitive() + && !host.isArray() + && !host.isHidden(); + } + + private static Lookup.ClassOption[] options(final boolean nestmate, final boolean strong) { + if (nestmate) { + return strong ? OPT_NESTMATE_STRONG : OPT_NESTMATE; + } + return strong ? OPT_STRONG : OPT_NONE; + } +} diff --git a/src/main/java/org/codehaus/groovy/reflection/ClassLoaderForClassArtifacts.java b/src/main/java/org/codehaus/groovy/reflection/ClassLoaderForClassArtifacts.java index 874495f64b0..066b5558d70 100644 --- a/src/main/java/org/codehaus/groovy/reflection/ClassLoaderForClassArtifacts.java +++ b/src/main/java/org/codehaus/groovy/reflection/ClassLoaderForClassArtifacts.java @@ -18,71 +18,146 @@ */ package org.codehaus.groovy.reflection; +import org.apache.groovy.util.HiddenClassDefiner; + import java.lang.ref.SoftReference; import java.lang.reflect.Constructor; import java.lang.reflect.Method; import java.util.concurrent.atomic.AtomicInteger; +/** + * A specialized {@link ClassLoader} used to define per-class artifact + * classes such as generated meta-method dispatchers. + * + *

Since Groovy 6.0 this loader first attempts to define each artifact as a + * hidden nestmate of the target class ({@link HiddenClassDefiner}): + *

+ * + *

If hidden-class definition is disabled or fails (module not open, target + * is a hidden/primitive/array type, linkage error, …) the loader falls back to + * the traditional {@link ClassLoader#defineClass} path transparently. + */ public class ClassLoaderForClassArtifacts extends ClassLoader { + + /** Soft reference to the class for which artifacts are generated. */ public final SoftReference klazz; + + /** + * Counter used to ensure unique class names when multiple artifacts are + * generated for the same method name. + */ private final AtomicInteger classNamesCounter = new AtomicInteger(-1); - public ClassLoaderForClassArtifacts(Class klazz) { + /** + * Creates a new artifact class loader for the specified class. + * + * @param klazz the class whose artifact classes are to be defined via this loader + */ + public ClassLoaderForClassArtifacts(final Class klazz) { super(klazz.getClassLoader()); this.klazz = new SoftReference<>(klazz); } - public Class define(String name, byte[] bytes) { - Class cls = defineClass(name, bytes, 0, bytes.length, klazz.get().getProtectionDomain()); + // ------------------------------------------------------------------------- + // Class definition + // ------------------------------------------------------------------------- + + /** + * Defines a class from bytecode, preferring a hidden nestmate of the target. + * + * @param name the binary name used for the fallback (visible-class) path + * @param bytes the class-file bytes + * @return the defined class + */ + public Class define(final String name, final byte[] bytes) { + final Class host = klazz.get(); + if (host != null) { + final Class hidden = HiddenClassDefiner.tryDefineNestmate(host, bytes, false); + if (hidden != null) { + return hidden; + } + } + + // Fallback: visible class with the target's protection domain + final Class cls = defineClass( + name, bytes, 0, bytes.length, + host != null ? host.getProtectionDomain() : null); resolveClass(cls); return cls; } + /** + * Defines a class from bytecode and returns the constructor matching the + * given parameter types, or {@code null} if definition or lookup fails. + * + * @param name the binary name (for fallback visible-class definition) + * @param bytes the class-file bytes + * @param parameterTypes the constructor parameter types to look up + * @return the matching constructor, or {@code null} + */ + public Constructor defineClassAndGetConstructor( + final String name, + final byte[] bytes, + final Class... parameterTypes) { + try { + final Class cls = define(name, bytes); + return cls.getDeclaredConstructor(parameterTypes); + } catch (NoSuchMethodException e) { + return null; + } + } + + /** {@inheritDoc} */ @Override - public Class loadClass(String name) throws ClassNotFoundException { - Class cls = findLoadedClass(name); - if (cls != null) + public Class loadClass(final String name) throws ClassNotFoundException { + final Class cls = findLoadedClass(name); + if (cls != null) { return cls; - + } return super.loadClass(name); } - public String createClassName(Method method) { - return createClassName(method.getName()); - } - - public String createClassName(String methodName) { - final String name; - final String clsName = klazz.get().getName(); - if (clsName.startsWith("java.")) - name = clsName.replace('.', '_') + "$" + methodName; - else - name = clsName + "$" + methodName; - int suffix = classNamesCounter.getAndIncrement(); - return suffix == -1 ? name : name + "$" + suffix; - } + // ------------------------------------------------------------------------- + // Name generation + // ------------------------------------------------------------------------- /** - * Defines a class from bytecode and returns a constructor matching {@code parameterTypes}. + * Generates a unique class name for an artifact associated with the given method. * - * @param name the binary name of the class to define - * @param bytes the class file bytes - * @param parameterTypes the constructor parameter types to look up - * @return the matching constructor, or {@code null} if definition or lookup fails + * @param method the method for which the artifact is generated + * @return a unique class name */ - public Constructor defineClassAndGetConstructor(final String name, final byte[] bytes, final Class... parameterTypes) { - final Class cls = definePrivileged(name, bytes); - - if (cls != null) { - try { - return cls.getConstructor(parameterTypes); - } catch (NoSuchMethodException e) { // - } - } - return null; + public String createClassName(final Method method) { + return createClassName(method.getName()); } - private Class definePrivileged(String name, byte[] bytes) { - return define(name, bytes); + /** + * Generates a unique class name for an artifact associated with the given + * method name. + * + *

For classes in the {@code java.*} package hierarchy the name is + * prefixed to avoid the restricted {@code java.} namespace. The counter + * suffix ensures uniqueness when multiple artifacts share the same logical + * name. + * + * @param methodName the method name component of the artifact class name + * @return a unique class name + */ + public String createClassName(final String methodName) { + final Class host = klazz.get(); + final String clsName = host != null ? host.getName() : "unknown"; + final String base = clsName.startsWith("java.") + ? clsName.replace('.', '_') + "$" + methodName + : clsName + "$" + methodName; + final int suffix = classNamesCounter.getAndIncrement(); + return suffix == -1 ? base : base + "$" + suffix; } } diff --git a/src/main/java/org/codehaus/groovy/runtime/ProxyGeneratorAdapter.java b/src/main/java/org/codehaus/groovy/runtime/ProxyGeneratorAdapter.java index dd6d6708db8..5e55a169884 100644 --- a/src/main/java/org/codehaus/groovy/runtime/ProxyGeneratorAdapter.java +++ b/src/main/java/org/codehaus/groovy/runtime/ProxyGeneratorAdapter.java @@ -23,6 +23,7 @@ import groovy.lang.GroovyClassLoader; import groovy.lang.GroovyObject; import groovy.lang.GroovyRuntimeException; +import org.apache.groovy.util.HiddenClassDefiner; import org.codehaus.groovy.ast.ClassHelper; import org.codehaus.groovy.ast.ClassNode; import org.codehaus.groovy.classgen.asm.BytecodeHelper; @@ -139,7 +140,18 @@ public class ProxyGeneratorAdapter extends ClassVisitor { private final String proxyName; private final Class superClass; private final Class delegateClass; + /** + * Class loader used for intermediate Groovy compilation (e.g. trait adapter + * classes in {@link #adjustSuperClass}) and as a fallback when hidden-class + * definition is not possible. + */ private final InnerLoader innerLoader; + /** + * Whether the final proxy class was defined as a hidden class. + * {@code true} means {@link #cachedClass} is a hidden nestmate; + * {@code false} means it was defined via the classic {@link ClassLoader} path. + */ + private final boolean proxyIsHidden; private final Set implClasses; private final Set visitedMethods; private final Set objectDelegateMethods; @@ -208,23 +220,204 @@ public ProxyGeneratorAdapter( if (interfaces != null) { Collections.addAll(this.implClasses, interfaces); } - this.proxyName = proxyName(); + this.proxyName = computeProxyName(); this.emptyBody = emptyBody; // generate bytecode ClassWriter writer = (ClassWriter) cv; this.visit(CompilerConfiguration.DEFAULT.getBytecodeVersion(), ACC_PUBLIC, proxyName, null, null, null); - byte[] b = writer.toByteArray(); - cachedClass = innerLoader.defineClass(proxyName.replace('/', '.'), b); - // cache no-arg constructor - Class[] args = generateDelegateField ? new Class[]{Map.class, delegateClass} : new Class[]{Map.class}; - Constructor constructor; + final byte[] b = writer.toByteArray(); + + // Prefer a hidden nestmate when safe (see {@link #shouldDefineAsHiddenClass()}). + // Host selection order (first success wins): + // 1. concrete superClass (right ClassLoader for script / app types); + // 2. delegateClass; + // 3. implemented interfaces (trait adapters, user markers, …); + // 4. ProxyGeneratorAdapter itself (only if it can resolve every type + // named by the proxy bytecode — never when $delegate is a class + // defined by a child ClassLoader). + // A candidate is accepted only when define succeeds *and* the expected + // constructor can be resolved (defineHiddenClass may return a Class + // whose remaining linkage fails on first reflective use). + // Fall back to InnerLoader.defineClass otherwise. + final Class[] ctorArgs = generateDelegateField + ? new Class[]{Map.class, delegateClass} + : new Class[]{Map.class}; + + Class definedClass = null; + boolean hidden = false; + if (shouldDefineAsHiddenClass()) { + for (Class host : nestHostCandidates()) { + final Class candidate = HiddenClassDefiner.tryDefineNestmate(host, b, true); + if (candidate != null && resolveConstructor(candidate, ctorArgs) != null) { + definedClass = candidate; + hidden = true; + break; + } + } + } + if (definedClass == null) { + definedClass = innerLoader.defineClass(proxyName.replace('/', '.'), b); + } + this.proxyIsHidden = hidden; + cachedClass = definedClass; + cachedNoArgConstructor = resolveConstructor(definedClass, ctorArgs); + } + + /** + * Looks up a constructor; returns {@code null} when missing or when the + * class cannot be fully linked (e.g. a hidden class whose nest host loader + * cannot see a {@code $delegate} type from a child ClassLoader). + */ + private static Constructor resolveConstructor(final Class type, final Class[] args) { try { - constructor = cachedClass.getConstructor(args); - } catch (NoSuchMethodException e) { - constructor = null; + return type.getDeclaredConstructor(args); + } catch (NoSuchMethodException | LinkageError e) { + return null; + } + } + + /** + * Returns {@code true} if the generated proxy class was defined as a hidden + * class (non-discoverable by name, eligible for eager unloading). + * + * @return {@code true} when the proxy is a hidden nestmate + */ + public boolean isProxyHidden() { + return proxyIsHidden; + } + + /** + * Whether this proxy may be defined as a hidden class. + * + *

JEP 371 hidden classes cannot appear as nominal types (superclass, + * field type, method parameter/return) in other class files. Groovy's + * {@code MockFor}/{@code StubFor} routinely build a second proxy whose + * {@code $delegate} field is the first proxy's class — that first class + * must therefore remain a normal, nameable class. The same restriction + * applies if any dependency of this proxy is already hidden. + * + *

Policy: + *

    + *
  • disabled when the kill-switch is set;
  • + *
  • disabled when super / delegate / an implemented type is hidden;
  • + *
  • disabled for interface-style aggregates ({@code Object} super, + * no typed delegate) — the common MockFor first step;
  • + *
  • enabled otherwise (concrete superclasses, typed delegates, …).
  • + *
+ */ + private boolean shouldDefineAsHiddenClass() { + if (!HiddenClassDefiner.isEnabled()) { + return false; + } + if (isUnusableNamedType(superClass) || isUnusableNamedType(delegateClass)) { + return false; + } + if (implClasses != null) { + for (Class impl : implClasses) { + if (isUnusableNamedType(impl)) { + return false; + } + } + } + // Interface aggregates: super is normalised to Object, there is no typed + // $delegate, and one or more user interfaces are implemented. MockFor / + // StubFor re-wrap these instances and need a nameable class. + if (delegateClass == null && superClass == Object.class && hasUserInterfaces()) { + return false; + } + return true; + } + + /** {@code true} when implClasses contains a type other than Object / GroovyObject. */ + private boolean hasUserInterfaces() { + if (implClasses == null) { + return false; + } + for (Class impl : implClasses) { + if (impl != null && impl != Object.class && impl != GroovyObject.class) { + return true; + } + } + return false; + } + + /** + * Ordered nest-host candidates for hidden-class definition. The defining + * loader of the chosen host must be able to resolve every type referenced + * by the proxy bytecode (child loaders can see parents; parents cannot see + * children). Candidates that fail this visibility check are skipped. + */ + private Class[] nestHostCandidates() { + final LinkedHashSet> hosts = new LinkedHashSet<>(); + addNestHostCandidate(hosts, superClass); + addNestHostCandidate(hosts, delegateClass); + if (implClasses != null) { + for (Class impl : implClasses) { + addNestHostCandidate(hosts, impl); + } + } + addNestHostCandidate(hosts, ProxyGeneratorAdapter.class); + return hosts.toArray(new Class[0]); + } + + private void addNestHostCandidate(final Set> hosts, final Class type) { + if (type == null || type == Object.class || isUnusableNamedType(type) || type.isSealed()) { + return; + } + if (!hostCanResolveDependencies(type)) { + return; } - cachedNoArgConstructor = constructor; + hosts.add(type); + } + + /** + * A hidden class is defined in the nest host's ClassLoader. Every type the + * proxy bytecode names (super, delegate, interfaces) must be resolvable + * from that loader — i.e. the type's defining loader must be the host + * loader or an ancestor of it. + */ + private boolean hostCanResolveDependencies(final Class host) { + if (!loaderCanResolve(host, superClass)) { + return false; + } + if (!loaderCanResolve(host, delegateClass)) { + return false; + } + if (implClasses != null) { + for (Class impl : implClasses) { + if (!loaderCanResolve(host, impl)) { + return false; + } + } + } + return true; + } + + /** + * {@code true} when {@code type} is visible to {@code host}'s defining loader + * (bootstrap types and {@code null} are always visible). + */ + private static boolean loaderCanResolve(final Class host, final Class type) { + if (type == null || type.isPrimitive()) { + return true; + } + final ClassLoader defining = type.getClassLoader(); // null ⇒ bootstrap + if (defining == null) { + return true; + } + // type is visible iff its defining loader is host's loader or an ancestor + for (ClassLoader cl = host.getClassLoader(); cl != null; cl = cl.getParent()) { + if (cl == defining) { + return true; + } + } + return false; + } + + /** Types that must not appear as nest hosts or as named binary dependencies. */ + private static boolean isUnusableNamedType(final Class type) { + return type != null && (type.isPrimitive() || type.isArray() || type.isHidden()); } private Class adjustSuperClass(final Class superClass, Class[] interfaces) { @@ -486,16 +679,31 @@ private void addDelegateFields() { } } - private String proxyName() { + private String computeProxyName() { String name = delegateClass != null ? delegateClass.getName() : superClass.getName(); if (name.startsWith("[") && name.endsWith(";")) { name = name.substring(1, name.length() - 1) + "_array"; } int index = name.lastIndexOf('.'); + // The proxy name is used as the internal class name in the generated + // bytecode. When the proxy ends up defined as a hidden class the JVM + // appends its own synthetic suffix, so the logical name below is only + // used for the visible-class (InnerLoader) fallback path and for + // diagnostics (e.g. stack traces via Class.getName()). if (index == -1) return name + PROXY_COUNTER.incrementAndGet() + "_groovyProxy"; return name.substring(index + 1) + PROXY_COUNTER.incrementAndGet() + "_groovyProxy"; } + /** + * Returns the proxy name used in the generated bytecode (internal form). + * This is also the name visible in stack traces for visible-class proxies. + * + * @return the proxy name + */ + public String proxyName() { + return proxyName; + } + private static boolean isImplemented(final Class clazz, final String name, final String desc) { Method[] methods = clazz.getDeclaredMethods(); for (Method method : methods) { diff --git a/src/main/java/org/codehaus/groovy/runtime/metaclass/ReflectorLoader.java b/src/main/java/org/codehaus/groovy/runtime/metaclass/ReflectorLoader.java index 33dbacadcfc..955c88482f0 100644 --- a/src/main/java/org/codehaus/groovy/runtime/metaclass/ReflectorLoader.java +++ b/src/main/java/org/codehaus/groovy/runtime/metaclass/ReflectorLoader.java @@ -18,6 +18,7 @@ */ package org.codehaus.groovy.runtime.metaclass; +import org.apache.groovy.util.HiddenClassDefiner; import org.codehaus.groovy.runtime.Reflector; import java.security.ProtectionDomain; @@ -25,20 +26,34 @@ import java.util.Map; /** - * Reflector creation helper. This class is used to define the Reflector classes. - * For each ClassLoader such a loader will be created by MetaClass. - * Special about this loader is, that it knows the classes form the - * Groovy Runtime. The Reflector class is resolved in different ways: During - * the definition of a class Reflector will resolve to the Reflector class of - * the runtime, even if there is another Reflector class in the parent loader. - * After the new class is defined Reflector will resolve like other Groovy - * classes. This loader is able to resolve all Groovy classes even if the - * parent does not know them, but the parent serves first (Reflector during a - * class definition is different). + * Reflector creation helper. This class is used to define the {@link Reflector} classes. + * + *

For each {@link ClassLoader} such a loader will be created by {@code MetaClass}. + * Special about this loader is that it knows the classes from the Groovy runtime. + * The {@link Reflector} class is resolved in different ways: during the definition + * of a class {@link Reflector} will resolve to the {@link Reflector} class of the + * runtime, even if there is another {@link Reflector} class in the parent loader. + * After the new class is defined {@link Reflector} will resolve like other Groovy + * classes. This loader is able to resolve all Groovy classes even if the parent + * does not know them, but the parent serves first (Reflector during a class + * definition is different). + * + *

Since Groovy 6.0 this loader preferentially defines each generated + * Reflector class as a hidden nestmate of {@link Reflector} + * ({@link HiddenClassDefiner}): + *

    + *
  • non-discoverable by name;
  • + *
  • eager unloading once the {@link Class} object is unreachable;
  • + *
  • nestmate of {@link Reflector} (mutual private access within that nest).
  • + *
+ * When the host loader of {@link Reflector} cannot see a type referenced by the + * generated bytecode (or hidden classes are disabled), the classic + * {@link ClassLoader#defineClass} path is used transparently. */ public class ReflectorLoader extends ClassLoader { + private boolean inDefine = false; - private final Map loadedClasses = new HashMap(); + private final Map> loadedClasses = new HashMap<>(); private final ClassLoader delegatationLoader; private static final String REFLECTOR = Reflector.class.getName(); @@ -51,25 +66,25 @@ public class ReflectorLoader extends ClassLoader { * @throws ClassNotFoundException if the class cannot be found */ @Override - protected Class findClass(String name) throws ClassNotFoundException { - if (delegatationLoader==null) return super.findClass(name); + protected Class findClass(String name) throws ClassNotFoundException { + if (delegatationLoader == null) return super.findClass(name); return delegatationLoader.loadClass(name); } /** - * Loads a class per name. Unlike a normal loadClass this version - * behaves different during a class definition. In that case it - * checks if the class we want to load is Reflector and returns - * class if the check is successful. If it is not during a class - * definition it just calls the super class version of loadClass. - * - * @param name of the class to load - * @param resolve is true if the class should be resolved + * Loads a class per name. Unlike a normal {@code loadClass} this version + * behaves differently during a class definition. In that case it checks + * if the class we want to load is {@link Reflector} and returns that + * class if the check is successful. If it is not during a class definition + * it just calls the super class version of {@code loadClass}. + * + * @param name of the class to load + * @param resolve is {@code true} if the class should be resolved * @see Reflector * @see ClassLoader#loadClass(String, boolean) */ @Override - protected synchronized Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + protected synchronized Class loadClass(String name, boolean resolve) throws ClassNotFoundException { if (inDefine) { if (name.equals(REFLECTOR)) return Reflector.class; } @@ -77,22 +92,36 @@ protected synchronized Class loadClass(String name, boolean resolve) throws Clas } /** - * Helper method to define Reflector classes. This method sets the inDefine flag to true - * during class definition to ensure Reflector is resolved correctly, then resolves the - * newly defined class and stores it in the loadedClasses cache. + * Helper method to define Reflector classes. * - * @param name the fully qualified name of the Reflector class + *

Prefers a hidden nestmate of {@link Reflector} and falls back to the + * classic {@link ClassLoader#defineClass} path when that is not possible. + * + *

This method sets the {@code inDefine} flag to {@code true} during + * class definition to ensure {@link Reflector} is resolved correctly. + * + * @param name the fully qualified binary name of the Reflector class * @param bytecode the bytecode of the Reflector class - * @param domain the protection domain for the class + * @param domain the protection domain for the fallback visible-class + * definition; not used when the hidden-class path succeeds * @return the newly defined class */ - public synchronized Class defineClass(String name, byte[] bytecode, ProtectionDomain domain) { + public synchronized Class defineClass( + final String name, + final byte[] bytecode, + final ProtectionDomain domain) { inDefine = true; - Class c = defineClass(name, bytecode, 0, bytecode.length, domain); - loadedClasses.put(name,c); - resolveClass(c); - inDefine = false; - return c; + try { + final Class cls = defineReflectorClass(name, bytecode, domain); + loadedClasses.put(name, cls); + if (!cls.isHidden()) { + // Hidden classes do not need resolveClass(); visible classes do. + resolveClass(cls); + } + return cls; + } finally { + inDefine = false; + } } /** @@ -100,7 +129,7 @@ public synchronized Class defineClass(String name, byte[] bytecode, ProtectionDo * This loader is responsible for defining Reflector classes that can resolve * the Reflector class from the Groovy runtime correctly. * - * @param parent the parent class loader (should never be null) + * @param parent the parent class loader (should never be {@code null}) */ public ReflectorLoader(ClassLoader parent) { super(parent); @@ -111,54 +140,69 @@ public ReflectorLoader(ClassLoader parent) { * Retrieves a previously defined Reflector class by name from the cache. * * @param name the fully qualified name of the Reflector class - * @return the Reflector class if it has been defined, or null otherwise + * @return the Reflector class if it has been defined, or {@code null} otherwise */ - public synchronized Class getLoadedClass(String name) { - return (Class)loadedClasses.get(name); + public synchronized Class getLoadedClass(String name) { + return loadedClasses.get(name); } /** * Generates the fully qualified name of a Reflector class for the given class. - * For java.* classes, the name is prefixed with "gjdk."; otherwise the package - * and class name are used. Array types are handled specially with "_GroovyReflectorArray" - * suffix and nesting level indicators. + * + *

For {@code java.*} classes the name is prefixed with {@code "gjdk."} to + * avoid the restricted {@code java.} package namespace. Array types are + * handled specially with a {@code "_GroovyReflectorArray"} suffix and nesting + * level indicators. * * @param theClass the class for which to generate the Reflector name * @return the fully qualified name of the Reflector class */ - static String getReflectorName(Class theClass) { + static String getReflectorName(Class theClass) { String className = theClass.getName(); if (className.startsWith("java.")) { String packagePrefix = "gjdk."; String name = packagePrefix + className + "_GroovyReflector"; if (theClass.isArray()) { - Class clazz = theClass; - name = packagePrefix; - int level = 0; - while (clazz.isArray()) { - clazz = clazz.getComponentType(); - level++; - } + Class clazz = theClass; + int level = 0; + while (clazz.isArray()) { + clazz = clazz.getComponentType(); + level++; + } String componentName = clazz.getName(); name = packagePrefix + componentName + "_GroovyReflectorArray"; - if (level>1) name += level; + if (level > 1) name += level; } return name; - } - else { - String name = className.replace('$','_') + "_GroovyReflector"; + } else { + String name = className.replace('$', '_') + "_GroovyReflector"; if (theClass.isArray()) { - Class clazz = theClass; - int level = 0; - while (clazz.isArray()) { - clazz = clazz.getComponentType(); - level++; - } + Class clazz = theClass; + int level = 0; + while (clazz.isArray()) { + clazz = clazz.getComponentType(); + level++; + } String componentName = clazz.getName(); - name = componentName.replace('$','_') + "_GroovyReflectorArray"; - if (level>1) name += level; + name = componentName.replace('$', '_') + "_GroovyReflectorArray"; + if (level > 1) name += level; } return name; } } + + /** + * Attempts to define the Reflector class as a hidden nestmate of + * {@link Reflector}. Falls back to a visible-class definition otherwise. + */ + private Class defineReflectorClass( + final String name, + final byte[] bytecode, + final ProtectionDomain domain) { + final Class hidden = HiddenClassDefiner.tryDefineNestmate(Reflector.class, bytecode, false); + if (hidden != null) { + return hidden; + } + return defineClass(name, bytecode, 0, bytecode.length, domain); + } } diff --git a/src/test/groovy/groovy/util/ProxyGeneratorAdapterTest.groovy b/src/test/groovy/groovy/util/ProxyGeneratorAdapterTest.groovy index 9086715547f..927820a6356 100644 --- a/src/test/groovy/groovy/util/ProxyGeneratorAdapterTest.groovy +++ b/src/test/groovy/groovy/util/ProxyGeneratorAdapterTest.groovy @@ -18,10 +18,12 @@ */ package groovy.util +import org.apache.groovy.util.HiddenClassDefiner import org.codehaus.groovy.runtime.ProxyGeneratorAdapter import org.junit.jupiter.api.Test import static groovy.test.GroovyAssert.assertScript +import static org.junit.jupiter.api.Assertions.* class ProxyGeneratorAdapterTest { @Test @@ -35,7 +37,7 @@ class ProxyGeneratorAdapterTest { @Test void testShouldCreateProxyWithArrayDelegate() { - def adapter = new ProxyGeneratorAdapter([:], Map$Entry, [Map$Entry] as Class[], null, false, String[]) + def adapter = new ProxyGeneratorAdapter([:], Map.Entry, [Map.Entry] as Class[], null, false, String[]) assert adapter.proxyName() =~ /String_array\d+_groovyProxy/ } @@ -283,4 +285,130 @@ class ProxyGeneratorAdapterTest { proxy.run() assert calls == 2 } + + // ------------------------------------------------------------------------- + // Hidden-class-specific tests (since Groovy 6.0 / JEP 371) + // ------------------------------------------------------------------------- + + /** + * Concrete abstract superclasses only reference types visible from the + * host loader, so the hidden nestmate path must succeed when enabled. + */ + @Test + void testProxyIsDefinedAsHiddenClass() { + if (!HiddenClassDefiner.isEnabled()) return + + def map = ['bar': { }] + ProxyGeneratorAdapter adapter = new ProxyGeneratorAdapter(map, Bar, null, this.class.classLoader, false, null) + assertTrue(adapter.isProxyHidden(), + 'Concrete-super proxy must be a hidden class when hidden classes are enabled') + assert adapter.proxy(map) instanceof Bar + } + + /** + * Interface aggregates (Object super + user interfaces, no typed delegate) + * must stay visible: MockFor/StubFor re-wrap them and need a + * nameable binary type for the {@code $delegate} field. + */ + @Test + void testInterfaceAggregateIsNotHidden() { + if (!HiddenClassDefiner.isEnabled()) return + + def map = [:] + ProxyGeneratorAdapter adapter = new ProxyGeneratorAdapter( + map, Object, [Iterator] as Class[], this.class.classLoader, false, null) + assertFalse(adapter.isProxyHidden(), + 'Interface aggregates must remain nameable for MockFor re-wrapping') + def obj = adapter.proxy(map) + assert obj instanceof Iterator + assertFalse(obj.getClass().isHidden()) + } + + /** + * A proxy defined as a hidden class must report {@link Class#isHidden()} as + * {@code true} and must not be discoverable via {@code Class.forName()}. + */ + @Test + void testHiddenProxyIsNotDiscoverableByName() { + if (!HiddenClassDefiner.isEnabled()) return + + def map = ['bar': { }] + ProxyGeneratorAdapter adapter = new ProxyGeneratorAdapter(map, Bar, null, this.class.classLoader, false, null) + if (!adapter.isProxyHidden()) return + + Class proxyCls = adapter.proxy(map).getClass() + assertTrue(proxyCls.isHidden(), 'Proxy class must report isHidden() == true') + assertThrows(ClassNotFoundException) { + Class.forName(proxyCls.getName()) + } + } + + /** + * MockFor-style re-wrap: interface aggregate (visible) then a delegating + * proxy whose {@code $delegate} field names that class. Must not throw + * during class definition (the original regression for hidden proxies). + */ + @Test + void testDelegatingProxyOverInterfaceAggregate() { + def closures = [hasNext: { false }, next: { null }] + def aggregateAdapter = new ProxyGeneratorAdapter( + closures, Object, [Iterator] as Class[], this.class.classLoader, false, null) + def aggregate = aggregateAdapter.proxy(closures) + assert aggregate instanceof Iterator + assertFalse(aggregate.getClass().isHidden()) + + def wrapAdapter = new ProxyGeneratorAdapter( + closures, Object, [Iterator] as Class[], + aggregate.getClass().classLoader, false, aggregate.getClass()) + def wrapped = wrapAdapter.delegatingProxy(aggregate, closures) + assert wrapped instanceof Iterator + assert !wrapped.hasNext() + } + + /** + * A hidden proxy must implement the same interfaces and expose the same + * method behaviour as a visible (fallback) proxy. + */ + @Test + void testHiddenProxyBehaviourIsIdenticalToVisibleProxy() { + def x = null + def map = ['bar': { x = 'HELLO_HIDDEN' }] + ProxyGeneratorAdapter adapter = new ProxyGeneratorAdapter(map, Bar, null, this.class.classLoader, false, null) + def obj = adapter.proxy(map) + + assert obj instanceof GroovyObject + assert obj instanceof Bar + assert x == null + obj.bar() + assert x == 'HELLO_HIDDEN' + // Bar is loadable from this test's class loader → hidden path preferred + if (HiddenClassDefiner.isEnabled()) { + assertTrue(adapter.isProxyHidden()) + assertTrue(obj.getClass().isHidden()) + } + } + + /** + * Proxies that extend a user type must still work: the nest host must be + * the user type (or another type sharing its ClassLoader), not a Groovy-core + * class whose loader cannot see the user type. Covered end-to-end by + * {@code testShouldNotThrowVerifyErrorBecauseOfStackSize} and + * {@code testTraitFromDifferentClassloader}; this focuses on a local type. + */ + @Test + void testProxyOverUserSuperclassRemainsFunctional() { + def called = false + def map = ['bar': { called = true }] + ProxyGeneratorAdapter adapter = new ProxyGeneratorAdapter(map, Bar, null, this.class.classLoader, false, null) + def obj = adapter.proxy(map) + assert obj instanceof Bar + obj.bar() + assert called + if (HiddenClassDefiner.isEnabled()) { + assertTrue(adapter.isProxyHidden()) + assertTrue(obj.getClass().isHidden()) + // Nest host is Bar (or its nest host), not a Groovy-core class alone + assertEquals(Bar.nestHost, obj.getClass().nestHost) + } + } } diff --git a/src/test/groovy/org/apache/groovy/util/HiddenClassDefinerTest.groovy b/src/test/groovy/org/apache/groovy/util/HiddenClassDefinerTest.groovy new file mode 100644 index 00000000000..f5710d922ae --- /dev/null +++ b/src/test/groovy/org/apache/groovy/util/HiddenClassDefinerTest.groovy @@ -0,0 +1,262 @@ +/* + * 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 org.apache.groovy.util + +import org.junit.jupiter.api.Test +import org.objectweb.asm.ClassReader +import org.objectweb.asm.ClassWriter + +import java.lang.invoke.MethodHandles +import java.lang.invoke.MethodHandles.Lookup + +import static org.junit.jupiter.api.Assertions.assertEquals +import static org.junit.jupiter.api.Assertions.assertFalse +import static org.junit.jupiter.api.Assertions.assertNotNull +import static org.junit.jupiter.api.Assertions.assertNull +import static org.junit.jupiter.api.Assertions.assertSame +import static org.junit.jupiter.api.Assertions.assertThrows +import static org.junit.jupiter.api.Assertions.assertTrue +import static org.objectweb.asm.Opcodes.ACC_PUBLIC +import static org.objectweb.asm.Opcodes.ALOAD +import static org.objectweb.asm.Opcodes.INVOKESPECIAL +import static org.objectweb.asm.Opcodes.RETURN +import static org.objectweb.asm.Opcodes.V17 + +/** + * Unit tests for {@link HiddenClassDefiner}. + * + *

The host-based {@code tryDefineNestmate} path is the production API and + * is exercised thoroughly. Strict Lookup-based overloads are covered as well, + * using a lookup pinned to this class via {@code privateLookupIn} so the + * result is stable on every supported JDK (including JDK 17, where a bare + * {@code MethodHandles.lookup()} reached through Groovy/indy can resolve to a + * synthetic {@code $$InjectedInvoker} hidden class). + * + * @since 6.0.0 + */ +class HiddenClassDefinerTest { + + /** Lookup pinned to this test class — independent of @CallerSensitive quirks. */ + private static final Lookup LOOKUP = MethodHandles.privateLookupIn( + HiddenClassDefinerTest, MethodHandles.lookup()) + + // ------------------------------------------------------------------------- + // Bytecode helper + // ------------------------------------------------------------------------- + + /** + * Minimal public class extending Object with a no-arg constructor. + * + * @param internalName slash-separated internal name, e.g. {@code org/example/Foo} + */ + private static byte[] minimalClassBytes(String internalName) { + def cw = new ClassWriter(0) + cw.visit(V17, ACC_PUBLIC, internalName, null, 'java/lang/Object', null) + def mv = cw.visitMethod(ACC_PUBLIC, '', '()V', null, null) + mv.visitCode() + mv.visitVarInsn(ALOAD, 0) + mv.visitMethodInsn(INVOKESPECIAL, 'java/lang/Object', '', '()V', false) + mv.visitInsn(RETURN) + mv.visitMaxs(1, 1) + mv.visitEnd() + cw.visitEnd() + cw.toByteArray() + } + + // ------------------------------------------------------------------------- + // Status / kill-switch + // ------------------------------------------------------------------------- + + @Test + void testHiddenClassesEnabledByDefault() { + assertTrue(HiddenClassDefiner.isEnabled()) + assertFalse(HiddenClassDefiner.HIDDEN_CLASSES_DISABLED) + } + + // ------------------------------------------------------------------------- + // Host-based soft API (production path) + // ------------------------------------------------------------------------- + + @Test + void testTryDefineNestmateReturnsHiddenClass() { + byte[] bytes = minimalClassBytes('org/apache/groovy/util/HostNestmate1') + Class hidden = HiddenClassDefiner.tryDefineNestmate(HiddenClassDefinerTest, bytes, true) + assertNotNull(hidden) + assertTrue(hidden.isHidden()) + assertTrue(hidden.name.contains('/')) + } + + @Test + void testTryDefineNestmateSharesNestWithHost() { + byte[] bytes = minimalClassBytes('org/apache/groovy/util/HostNestmate2') + Class hidden = HiddenClassDefiner.tryDefineNestmate(HiddenClassDefinerTest, bytes, true) + assertNotNull(hidden) + assertEquals(HiddenClassDefinerTest.nestHost, hidden.nestHost) + assertTrue(HiddenClassDefinerTest.isNestmateOf(hidden)) + } + + @Test + void testTryDefineNestmateUsesHostClassLoaderAndPackage() { + byte[] bytes = minimalClassBytes('unrelated/pkg/SomeTemplate') + Class hidden = HiddenClassDefiner.tryDefineNestmate(HiddenClassDefinerTest, bytes, true) + assertNotNull(hidden) + // Defining loader is the host's loader + assertSame(HiddenClassDefinerTest.classLoader, hidden.classLoader) + // Package aligned to the host even though the template name was elsewhere + assertEquals(HiddenClassDefinerTest.packageName, hidden.packageName) + } + + @Test + void testTryDefineNestmateIsNotDiscoverable() { + byte[] bytes = minimalClassBytes('org/apache/groovy/util/HostNestmate3') + Class hidden = HiddenClassDefiner.tryDefineNestmate(HiddenClassDefinerTest, bytes, true) + assertNotNull(hidden) + assertThrows(ClassNotFoundException) { + Class.forName(hidden.name) + } + assertThrows(ClassNotFoundException) { + hidden.classLoader.loadClass(hidden.name) + } + } + + @Test + void testTryDefineNestmateCanBeInstantiated() { + byte[] bytes = minimalClassBytes('org/apache/groovy/util/HostNestmate4') + Class hidden = HiddenClassDefiner.tryDefineNestmate(HiddenClassDefinerTest, bytes, true) + assertNotNull(hidden.getDeclaredConstructor().newInstance()) + } + + @Test + void testTryDefineNestmateRejectsUnusableHosts() { + byte[] bytes = minimalClassBytes('org/apache/groovy/util/HostReject') + assertNull(HiddenClassDefiner.tryDefineNestmate(null, bytes, true)) + assertNull(HiddenClassDefiner.tryDefineNestmate(Integer.TYPE, bytes, true)) + assertNull(HiddenClassDefiner.tryDefineNestmate(String[].class, bytes, true)) + + // A hidden class cannot itself host further nestmates via this API + Class hidden = HiddenClassDefiner.tryDefineNestmate(HiddenClassDefinerTest, bytes, true) + assertNotNull(hidden) + assertNull(HiddenClassDefiner.tryDefineNestmate(hidden, bytes, true)) + } + + @Test + void testTryDefineNestmateRejectsNullBytes() { + assertNull(HiddenClassDefiner.tryDefineNestmate(HiddenClassDefinerTest, null, true)) + } + + @Test + void testTryDefineNestmateReturnsNullOnInvalidBytecode() { + // truncated / garbage class file → IllegalArgumentException path → null + assertNull(HiddenClassDefiner.tryDefineNestmate( + HiddenClassDefinerTest, new byte[]{0, 1, 2, 3, 4}, true)) + } + + // ------------------------------------------------------------------------- + // Lookup-based strict / soft API + // ------------------------------------------------------------------------- + + @Test + void testDefineNestmateViaLookup() { + byte[] bytes = minimalClassBytes('org/apache/groovy/util/LookupNestmate1') + Class hidden = HiddenClassDefiner.defineNestmate(LOOKUP, bytes, true) + assertTrue(hidden.isHidden()) + assertEquals(HiddenClassDefinerTest.nestHost, hidden.nestHost) + } + + @Test + void testDefineStrongIsOwnNestHost() { + byte[] bytes = minimalClassBytes('org/apache/groovy/util/LookupStrong1') + Class hidden = HiddenClassDefiner.defineStrong(LOOKUP, bytes, true) + assertTrue(hidden.isHidden()) + assertEquals(hidden, hidden.nestHost) + } + + @Test + void testDefineWithAllOptionCombinations() { + // nestmate + weak + assertTrue(HiddenClassDefiner.define(LOOKUP, + minimalClassBytes('org/apache/groovy/util/OptNW'), true, true, false).isHidden()) + // nestmate + strong + assertTrue(HiddenClassDefiner.define(LOOKUP, + minimalClassBytes('org/apache/groovy/util/OptNS'), true, true, true).isHidden()) + // non-nestmate + weak + Class weak = HiddenClassDefiner.define(LOOKUP, + minimalClassBytes('org/apache/groovy/util/OptW'), true, false, false) + assertTrue(weak.isHidden()) + assertEquals(weak, weak.nestHost) + // non-nestmate + strong + Class strong = HiddenClassDefiner.define(LOOKUP, + minimalClassBytes('org/apache/groovy/util/OptS'), true, false, true) + assertTrue(strong.isHidden()) + assertEquals(strong, strong.nestHost) + } + + @Test + void testTryDefineSoftPath() { + Class hidden = HiddenClassDefiner.tryDefine(LOOKUP, + minimalClassBytes('org/apache/groovy/util/TryDef1'), true, true, false) + assertNotNull(hidden) + assertTrue(hidden.isHidden()) + + assertNull(HiddenClassDefiner.tryDefine(null, + minimalClassBytes('org/apache/groovy/util/TryDef2'), true, true, false)) + assertNull(HiddenClassDefiner.tryDefine(LOOKUP, null, true, true, false)) + assertNull(HiddenClassDefiner.tryDefine(LOOKUP, new byte[]{0xCA, 0xFE}, true, true, false)) + } + + // ------------------------------------------------------------------------- + // Package alignment + // ------------------------------------------------------------------------- + + @Test + void testAlignPackageRewritesThisClass() { + byte[] original = minimalClassBytes('com/example/Elsewhere') + byte[] aligned = HiddenClassDefiner.alignPackage(original, HiddenClassDefinerTest) + assertEquals( + 'org/apache/groovy/util/Elsewhere', + new ClassReader(aligned).className) + // Idempotent when already aligned + byte[] again = HiddenClassDefiner.alignPackage(aligned, HiddenClassDefinerTest) + assertEquals( + 'org/apache/groovy/util/Elsewhere', + new ClassReader(again).className) + } + + @Test + void testAlignPackageNoOpWhenAlreadyMatching() { + byte[] original = minimalClassBytes('org/apache/groovy/util/AlreadyHere') + byte[] aligned = HiddenClassDefiner.alignPackage(original, HiddenClassDefinerTest) + // Same content (or at least same this_class) — no rewrite needed + assertEquals(new ClassReader(original).className, new ClassReader(aligned).className) + } + + // ------------------------------------------------------------------------- + // findConstructor helper + // ------------------------------------------------------------------------- + + @Test + void testFindConstructor() { + byte[] bytes = minimalClassBytes('org/apache/groovy/util/CtorHost') + Class hidden = HiddenClassDefiner.tryDefineNestmate(HiddenClassDefinerTest, bytes, true) + assertNotNull(HiddenClassDefiner.findConstructor(hidden)) + assertThrows(IllegalStateException) { + HiddenClassDefiner.findConstructor(hidden, String) + } + } +} diff --git a/src/test/groovy/org/codehaus/groovy/reflection/ClassLoaderForClassArtifactsTest.groovy b/src/test/groovy/org/codehaus/groovy/reflection/ClassLoaderForClassArtifactsTest.groovy new file mode 100644 index 00000000000..cd44bd079fd --- /dev/null +++ b/src/test/groovy/org/codehaus/groovy/reflection/ClassLoaderForClassArtifactsTest.groovy @@ -0,0 +1,101 @@ +/* + * 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 org.codehaus.groovy.reflection + +import org.apache.groovy.util.HiddenClassDefiner +import org.junit.jupiter.api.Test +import org.objectweb.asm.ClassWriter + +import static org.junit.jupiter.api.Assertions.assertEquals +import static org.junit.jupiter.api.Assertions.assertFalse +import static org.junit.jupiter.api.Assertions.assertNotNull +import static org.junit.jupiter.api.Assertions.assertNull +import static org.junit.jupiter.api.Assertions.assertTrue +import static org.objectweb.asm.Opcodes.ACC_PUBLIC +import static org.objectweb.asm.Opcodes.ALOAD +import static org.objectweb.asm.Opcodes.INVOKESPECIAL +import static org.objectweb.asm.Opcodes.RETURN +import static org.objectweb.asm.Opcodes.V17 + +/** + * Covers the hidden-class path in {@link ClassLoaderForClassArtifacts}. + */ +class ClassLoaderForClassArtifactsTest { + + static class Host { + // nest host for generated artifacts + } + + private static byte[] minimalBytes(String internalName) { + def cw = new ClassWriter(0) + cw.visit(V17, ACC_PUBLIC, internalName, null, 'java/lang/Object', null) + def mv = cw.visitMethod(ACC_PUBLIC, '', '()V', null, null) + mv.visitCode() + mv.visitVarInsn(ALOAD, 0) + mv.visitMethodInsn(INVOKESPECIAL, 'java/lang/Object', '', '()V', false) + mv.visitInsn(RETURN) + mv.visitMaxs(1, 1) + mv.visitEnd() + cw.visitEnd() + cw.toByteArray() + } + + @Test + void testDefinePrefersHiddenNestmateOfTarget() { + def loader = new ClassLoaderForClassArtifacts(Host) + String name = loader.createClassName('artifact') + Class cls = loader.define(name, minimalBytes(name.replace('.', '/'))) + assertNotNull(cls) + if (HiddenClassDefiner.isEnabled()) { + assertTrue(cls.isHidden()) + assertEquals(Host.nestHost, cls.nestHost) + assertEquals(Host.packageName, cls.packageName) + } + assertNotNull(cls.getDeclaredConstructor().newInstance()) + } + + @Test + void testDefineClassAndGetConstructor() { + def loader = new ClassLoaderForClassArtifacts(Host) + String name = loader.createClassName('withCtor') + def ctor = loader.defineClassAndGetConstructor(name, minimalBytes(name.replace('.', '/'))) + assertNotNull(ctor) + assertNotNull(ctor.newInstance()) + // Missing constructor signature → null + assertNull(loader.defineClassAndGetConstructor( + loader.createClassName('missing'), + minimalBytes('org/codehaus/groovy/reflection/Missing'), + String)) + } + + @Test + void testCreateClassNameUniquenessAndJavaPrefix() { + def loader = new ClassLoaderForClassArtifacts(Host) + String first = loader.createClassName('m') + String second = loader.createClassName('m') + assertTrue(first.contains(Host.name)) + assertTrue(first != second || second.endsWith('$0') || second.contains('$')) + + def javaLoader = new ClassLoaderForClassArtifacts(String) + String javaName = javaLoader.createClassName('length') + assertFalse(javaName.startsWith('java.'), + 'java.* artifacts must be renamed out of the restricted package') + assertTrue(javaName.contains('java_lang_String') || javaName.startsWith('java_lang_String')) + } +} diff --git a/src/test/groovy/org/codehaus/groovy/runtime/metaclass/ReflectorLoaderTest.groovy b/src/test/groovy/org/codehaus/groovy/runtime/metaclass/ReflectorLoaderTest.groovy new file mode 100644 index 00000000000..58379e6bd2c --- /dev/null +++ b/src/test/groovy/org/codehaus/groovy/runtime/metaclass/ReflectorLoaderTest.groovy @@ -0,0 +1,81 @@ +/* + * 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 org.codehaus.groovy.runtime.metaclass + +import org.apache.groovy.util.HiddenClassDefiner +import org.codehaus.groovy.runtime.Reflector +import org.junit.jupiter.api.Test +import org.objectweb.asm.ClassWriter + +import static org.junit.jupiter.api.Assertions.assertEquals +import static org.junit.jupiter.api.Assertions.assertNotNull +import static org.junit.jupiter.api.Assertions.assertTrue +import static org.objectweb.asm.Opcodes.ACC_PUBLIC +import static org.objectweb.asm.Opcodes.ALOAD +import static org.objectweb.asm.Opcodes.INVOKESPECIAL +import static org.objectweb.asm.Opcodes.RETURN +import static org.objectweb.asm.Opcodes.V17 + +/** + * Covers the hidden-class path in {@link ReflectorLoader}. + */ +class ReflectorLoaderTest { + + private static byte[] reflectorSubclassBytes(String internalName) { + def cw = new ClassWriter(0) + // Subclass of Reflector so the generated type is a valid Reflector + cw.visit(V17, ACC_PUBLIC, internalName, null, + Reflector.name.replace('.', '/'), null) + def mv = cw.visitMethod(ACC_PUBLIC, '', '()V', null, null) + mv.visitCode() + mv.visitVarInsn(ALOAD, 0) + mv.visitMethodInsn(INVOKESPECIAL, + Reflector.name.replace('.', '/'), '', '()V', false) + mv.visitInsn(RETURN) + mv.visitMaxs(1, 1) + mv.visitEnd() + cw.visitEnd() + cw.toByteArray() + } + + @Test + void testDefineClassPrefersHiddenNestmateOfReflector() { + def loader = new ReflectorLoader(this.class.classLoader) + String name = ReflectorLoader.getReflectorName(StringBuilder) + Class cls = loader.defineClass( + name, + reflectorSubclassBytes(name.replace('.', '/')), + this.class.protectionDomain) + assertNotNull(cls) + assertTrue(Reflector.isAssignableFrom(cls)) + if (HiddenClassDefiner.isEnabled()) { + assertTrue(cls.isHidden()) + assertEquals(Reflector.nestHost, cls.nestHost) + } + assertNotNull(cls.getDeclaredConstructor().newInstance()) + assertEquals(cls, loader.getLoadedClass(name)) + } + + @Test + void testGetReflectorNameForJavaAndUserTypes() { + assertTrue(ReflectorLoader.getReflectorName(String).startsWith('gjdk.')) + assertTrue(ReflectorLoader.getReflectorName(String[]).contains('Array')) + assertTrue(ReflectorLoader.getReflectorName(ReflectorLoaderTest).endsWith('_GroovyReflector')) + } +}