You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@groovy.apache.org by su...@apache.org on 2026/08/03 15:49:38 UTC
(groovy) branch GROOVY-12223 updated (58063bed35 -> c35ffe88d4)
This is an automated email from the ASF dual-hosted git repository.
asf-gitbox-commits pushed a change to branch GROOVY-12223
in repository https://gitbox.apache.org/repos/asf/groovy.git
omit 58063bed35 GROOVY-12223: Introduce hidden class support
new c35ffe88d4 GROOVY-12223: Introduce hidden class support
This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version. This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:
* -- * -- B -- O -- O -- O (58063bed35)
\
N -- N -- N refs/heads/GROOVY-12223 (c35ffe88d4)
You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.
Any revisions marked "omit" are not gone; other references still
refer to them. Any revisions marked "discard" are gone forever.
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
.../org/apache/groovy/util/HiddenClassDefiner.java | 269 +++++++--------------
.../reflection/ClassLoaderForClassArtifacts.java | 66 ++---
.../codehaus/groovy/runtime/ProxyClassDefiner.java | 262 ++++++++++++++++++++
.../groovy/runtime/ProxyGeneratorAdapter.java | 225 +++--------------
.../groovy/runtime/metaclass/ReflectorLoader.java | 48 ++--
.../groovy/util/ProxyGeneratorAdapterTest.groovy | 7 +-
.../groovy/util/HiddenClassDefinerTest.groovy | 205 +++++-----------
.../ClassLoaderForClassArtifactsTest.groovy | 10 +-
.../groovy/runtime/ProxyClassDefinerTest.groovy | 151 ++++++++++++
.../runtime/metaclass/ReflectorLoaderTest.groovy | 7 +-
10 files changed, 682 insertions(+), 568 deletions(-)
create mode 100644 src/main/java/org/codehaus/groovy/runtime/ProxyClassDefiner.java
create mode 100644 src/test/groovy/org/codehaus/groovy/runtime/ProxyClassDefinerTest.groovy
(groovy) 01/01: GROOVY-12223: Introduce hidden class support
Posted by su...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
asf-gitbox-commits pushed a commit to branch GROOVY-12223
in repository https://gitbox.apache.org/repos/asf/groovy.git
commit c35ffe88d4934aa93fd61cbbf582a545f66c78c6
Author: Daniel Sun <su...@apache.org>
AuthorDate: Sun Aug 2 17:33:37 2026 +0900
GROOVY-12223: Introduce hidden class support
---
.../org/apache/groovy/util/HiddenClassDefiner.java | 214 +++++++++++++++++
.../reflection/ClassLoaderForClassArtifacts.java | 151 +++++++++---
.../codehaus/groovy/runtime/ProxyClassDefiner.java | 262 +++++++++++++++++++++
.../groovy/runtime/ProxyGeneratorAdapter.java | 97 ++++++--
.../groovy/runtime/metaclass/ReflectorLoader.java | 180 +++++++++-----
.../groovy/util/ProxyGeneratorAdapterTest.groovy | 135 ++++++++++-
.../groovy/util/HiddenClassDefinerTest.groovy | 187 +++++++++++++++
.../ClassLoaderForClassArtifactsTest.groovy | 103 ++++++++
.../groovy/runtime/ProxyClassDefinerTest.groovy | 151 ++++++++++++
.../runtime/metaclass/ReflectorLoaderTest.groovy | 82 +++++++
10 files changed, 1445 insertions(+), 117 deletions(-)
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 0000000000..9582a53aa2
--- /dev/null
+++ b/src/main/java/org/apache/groovy/util/HiddenClassDefiner.java
@@ -0,0 +1,214 @@
+/*
+ * 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.util.Collections;
+
+/**
+ * Central facility for defining <em>hidden classes</em>
+ * (<a href="https://openjdk.org/jeps/371">JEP 371</a>).
+ *
+ * <h2>Lookup ownership (read this first)</h2>
+ * <p>{@link MethodHandles#lookup()} is <em>caller-sensitive</em>: it returns a
+ * full-privilege lookup only for the class that literally contains the call.
+ * A lookup captured in this utility therefore only has full privilege for
+ * {@code HiddenClassDefiner} itself — never for arbitrary foreign classes
+ * (for example {@code java.lang.String} in {@code java.base}).
+ *
+ * <p>Consequently the <strong>preferred</strong> entry point is
+ * {@link #tryDefineNestmate(Lookup, byte[], boolean)}, where the caller
+ * supplies a {@link Lookup} obtained inside the intended nest-host class:
+ * <pre>{@code
+ * // Inside the class that should host the hidden nestmate:
+ * private static final MethodHandles.Lookup LOOKUP = MethodHandles.lookup();
+ *
+ * Class<?> hidden = HiddenClassDefiner.tryDefineNestmate(LOOKUP, bytecode, false);
+ * if (hidden == null) {
+ * // fall back to ClassLoader.defineClass(...)
+ * }
+ * }</pre>
+ *
+ * <p>The overload {@link #tryDefineNestmate(Class, byte[], boolean)} is a
+ * <em>best-effort</em> helper for foreign hosts (user classes Groovy does not
+ * control). It uses {@link MethodHandles#privateLookupIn(Class, Lookup)} from
+ * this class and therefore succeeds only when the host's package is accessible
+ * to Groovy's module — typically true for unnamed-module application classes,
+ * and typically false for sealed / unopened packages such as those in
+ * {@code java.base}. Callers must always handle a {@code null} result.
+ *
+ * <h2>What this utility centralises</h2>
+ * <ul>
+ * <li>{@code NESTMATE} + weak-lifecycle policy for dynamic Groovy classes;</li>
+ * <li>rewriting {@code this_class} into the lookup class's package (required
+ * by {@link Lookup#defineHiddenClass});</li>
+ * <li>a soft API that returns {@code null} on the <em>expected</em> failure
+ * modes ({@link IllegalAccessException}, {@link IllegalArgumentException},
+ * {@link SecurityException}, {@link LinkageError}) so call sites fall back
+ * to {@link ClassLoader#defineClass} with one null check. Unexpected
+ * failures (e.g. programming errors in callers) are not swallowed as a
+ * blanket {@link RuntimeException}.</li>
+ * </ul>
+ *
+ * <h2>Kill switch</h2>
+ * <p>{@code -Dgroovy.hidden.classes.disable=true} forces every {@code try*}
+ * method to return {@code null}.
+ *
+ * @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);
+
+ /**
+ * Lookup for <em>this</em> class only — used exclusively as the caller
+ * argument to {@link MethodHandles#privateLookupIn(Class, Lookup)} in the
+ * foreign-host overload. It is never used as a nest host for user code.
+ */
+ private static final Lookup LOOKUP = MethodHandles.lookup();
+
+ /** Nestmate + weak (eager unloading) — the only option set production uses. */
+ private static final Lookup.ClassOption[] NESTMATE_WEAK =
+ new Lookup.ClassOption[]{Lookup.ClassOption.NESTMATE};
+
+ private HiddenClassDefiner() {
+ }
+
+ /**
+ * @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;
+ }
+
+ /**
+ * Defines {@code bytes} as a hidden nestmate of {@code lookup.lookupClass()}
+ * with a weak lifecycle.
+ *
+ * <p>The lookup must have been obtained via {@link MethodHandles#lookup()}
+ * inside the intended nest-host class (or otherwise carry full privilege
+ * for that class). The class-file package is rewritten to match the lookup
+ * class before definition.
+ *
+ * @param lookup full-privilege lookup for the nest host
+ * @param bytes class-file bytes
+ * @param initialize {@code true} to run {@code <clinit>} immediately
+ * @return the hidden class, or {@code null} if definition is not possible
+ */
+ public static Class<?> tryDefineNestmate(
+ final Lookup lookup,
+ final byte[] bytes,
+ final boolean initialize) {
+ if (HIDDEN_CLASSES_DISABLED || lookup == null || bytes == null) {
+ return null;
+ }
+ try {
+ final byte[] aligned = alignPackage(bytes, lookup.lookupClass());
+ return lookup.defineHiddenClass(aligned, initialize, NESTMATE_WEAK).lookupClass();
+ } catch (IllegalAccessException | SecurityException | LinkageError e) {
+ return null;
+ } catch (IllegalArgumentException e) {
+ // Invalid / unaligned class-file bytes (including ASM ClassReader).
+ return null;
+ } catch (IndexOutOfBoundsException e) {
+ // Corrupt bytes can surface as bounds errors from ClassReader.
+ return null;
+ }
+ }
+
+ /**
+ * Best-effort definition of a hidden nestmate of a <em>foreign</em> host
+ * class (one whose source Groovy does not control).
+ *
+ * <p>Obtains a host lookup via
+ * {@code MethodHandles.privateLookupIn(host, }lookup captured in this class)
+ * and then delegates to {@link #tryDefineNestmate(Lookup, byte[], boolean)}.
+ * This succeeds only when the host's package is accessible to Groovy's
+ * module; it is <strong>not</strong> a substitute for a lookup created
+ * inside the host.
+ *
+ * @param host nest host and class-loader / package donor; must be a
+ * normal (non-hidden) reference type
+ * @param bytes class-file bytes
+ * @param initialize {@code true} to run {@code <clinit>} immediately
+ * @return the hidden class, or {@code null} if private lookup or definition fails
+ */
+ 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, LOOKUP);
+ return tryDefineNestmate(hostLookup, bytes, initialize);
+ } catch (IllegalAccessException | SecurityException e) {
+ return null;
+ }
+ }
+
+ /**
+ * Rewrites {@code this_class} (and internal references to it) into
+ * {@code host}'s run-time package. No-op when already aligned.
+ *
+ * <p>ASM may throw {@link IllegalArgumentException} for corrupt bytes; callers
+ * of {@link #tryDefineNestmate(Lookup, byte[], boolean)} treat that as soft-fail.
+ */
+ private 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(Collections.singletonMap(oldInternal, newInternal))), 0);
+ return writer.toByteArray();
+ }
+
+ private static boolean isUsableHost(final Class<?> host) {
+ return host != null
+ && !host.isPrimitive()
+ && !host.isArray()
+ && !host.isHidden();
+ }
+}
diff --git a/src/main/java/org/codehaus/groovy/reflection/ClassLoaderForClassArtifacts.java b/src/main/java/org/codehaus/groovy/reflection/ClassLoaderForClassArtifacts.java
index 874495f64b..6e1e17f112 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.security.ProtectionDomain;
import java.util.concurrent.atomic.AtomicInteger;
+/**
+ * A specialized {@link ClassLoader} used to define per-class <em>artifact</em>
+ * classes such as generated meta-method dispatchers.
+ *
+ * <p>Since Groovy 6.0 this loader first attempts to define each artifact as a
+ * <em>hidden nestmate</em> of the target class via
+ * {@link HiddenClassDefiner#tryDefineNestmate(Class, byte[], boolean)} (best-effort
+ * {@code privateLookupIn} into the target). Benefits when that succeeds:
+ * <ul>
+ * <li>non-discoverable by name;</li>
+ * <li>same defining loader / package / protection domain as the target;</li>
+ * <li>nestmate of the target;</li>
+ * <li>weak lifecycle — eligible for eager unloading once the {@link Class}
+ * object is unreachable.</li>
+ * </ul>
+ *
+ * <p>If hidden-class definition is refused (module not open, target unsuitable,
+ * …) the loader falls back to {@link ClassLoader#defineClass} with the
+ * protection domain captured at construction time.
+ */
public class ClassLoaderForClassArtifacts extends ClassLoader {
+
+ /** Soft reference to the class for which artifacts are generated. */
public final SoftReference<Class> klazz;
+
+ /** Binary name of the target, retained even if {@link #klazz} is cleared. */
+ private final String className;
+
+ /**
+ * Protection domain of the target, captured strongly so fallback
+ * {@code defineClass} stays deterministic if the soft reference is cleared.
+ */
+ private final ProtectionDomain protectionDomain;
+
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);
+ this.className = klazz.getName();
+ this.protectionDomain = klazz.getProtectionDomain();
}
- public Class define(String name, byte[] bytes) {
- Class cls = defineClass(name, bytes, 0, bytes.length, klazz.get().getProtectionDomain());
+ /**
+ * 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) {
+ // Foreign host: best-effort privateLookupIn (see HiddenClassDefiner).
+ final Class<?> hidden = HiddenClassDefiner.tryDefineNestmate(host, bytes, false);
+ if (hidden != null) {
+ return hidden;
+ }
+ }
+
+ final Class<?> cls = defineClass(name, bytes, 0, bytes.length, protectionDomain);
resolveClass(cls);
return cls;
}
+ /**
+ * Defines a class from bytecode and returns the <em>public</em> constructor
+ * matching the given parameter types, or {@code null} if definition or
+ * lookup fails.
+ *
+ * <p>Uses {@link Class#getConstructor(Class[])} so the returned constructor
+ * is always publicly accessible (same contract as before hidden-class support).
+ *
+ * @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 public 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.getConstructor(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) {
+ /**
+ * Generates a unique class name for an artifact associated with the given method.
+ *
+ * @param method the method for which the artifact is generated
+ * @return a unique class name
+ */
+ public String createClassName(final 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;
- }
-
/**
- * 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 name.
*
- * @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
+ * <p>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 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;
- }
-
- private Class definePrivileged(String name, byte[] bytes) {
- return define(name, bytes);
+ public String createClassName(final String methodName) {
+ final String base = className.startsWith("java.")
+ ? className.replace('.', '_') + "$" + methodName
+ : className + "$" + methodName;
+ final int suffix = classNamesCounter.getAndIncrement();
+ return suffix == -1 ? base : base + "$" + suffix;
}
}
diff --git a/src/main/java/org/codehaus/groovy/runtime/ProxyClassDefiner.java b/src/main/java/org/codehaus/groovy/runtime/ProxyClassDefiner.java
new file mode 100644
index 0000000000..2d1c18d0cf
--- /dev/null
+++ b/src/main/java/org/codehaus/groovy/runtime/ProxyClassDefiner.java
@@ -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.codehaus.groovy.runtime;
+
+import groovy.lang.GroovyObject;
+import org.apache.groovy.util.HiddenClassDefiner;
+
+import java.lang.invoke.MethodHandles.Lookup;
+import java.lang.reflect.Constructor;
+import java.util.Collection;
+import java.util.function.BiFunction;
+
+/**
+ * Defines proxy classes for {@link ProxyGeneratorAdapter}, preferring a JEP 371
+ * hidden nestmate when safe and falling back to a visible {@link ClassLoader}
+ * definition otherwise.
+ *
+ * <p>Extracted from {@link ProxyGeneratorAdapter} so that class keeps focused on
+ * bytecode generation while nest-host policy and define/fallback live here.
+ *
+ * <h2>When a proxy stays visible</h2>
+ * <ul>
+ * <li>hidden classes are disabled;</li>
+ * <li>any named dependency (super / delegate / implemented type) is itself
+ * hidden — hidden types cannot appear as nominal field/super types;</li>
+ * <li>interface-style aggregates ({@code Object} super, no typed delegate,
+ * at least one user interface) — these are routinely re-wrapped (e.g. by
+ * MockFor) and must remain loadable by binary name.</li>
+ * </ul>
+ *
+ * <h2>Nest host selection (at most one foreign try, then own Lookup)</h2>
+ * <ol>
+ * <li>{@code delegateClass} if usable and its loader can see every dependency;</li>
+ * <li>else concrete {@code superClass} (not {@code Object}) under the same rule;</li>
+ * <li>else the caller-owned {@link Lookup} from {@link ProxyGeneratorAdapter}
+ * when that loader can see every dependency.</li>
+ * </ol>
+ * Interfaces are not used as nest hosts: they add trials without helping the
+ * common cases and complicate class-loader reasoning.
+ *
+ * @since 6.0.0
+ */
+final class ProxyClassDefiner {
+
+ private ProxyClassDefiner() {
+ }
+
+ /**
+ * Result of a define attempt.
+ */
+ static final class Result {
+ final Class<?> type;
+ final boolean hidden;
+ final Constructor<?> constructor;
+
+ Result(final Class<?> type, final boolean hidden, final Constructor<?> constructor) {
+ this.type = type;
+ this.hidden = hidden;
+ this.constructor = constructor;
+ }
+ }
+
+ /**
+ * Defines {@code bytecode} as a hidden nestmate when policy allows, else via
+ * {@code visibleDefine}.
+ *
+ * @param bytecode generated class-file bytes
+ * @param binaryName binary name for the visible fallback path
+ * @param superClass proxy superclass (already normalised; may be Object)
+ * @param delegateClass typed {@code $delegate} field type, or {@code null}
+ * @param implClasses super + interfaces + related types named by the bytecode
+ * @param ownLookup {@link MethodHandles#lookup()} from {@link ProxyGeneratorAdapter}
+ * @param visibleDefine {@code (binaryName, bytes) -> Class} fallback (e.g. InnerLoader)
+ * @param ctorArgs constructor parameter types to resolve after define
+ * @return defined class, whether it is hidden, and the matching public ctor (or null)
+ */
+ static Result define(
+ final byte[] bytecode,
+ final String binaryName,
+ final Class<?> superClass,
+ final Class<?> delegateClass,
+ final Collection<? extends Class<?>> implClasses,
+ final Lookup ownLookup,
+ final BiFunction<String, byte[], Class<?>> visibleDefine,
+ final Class<?>[] ctorArgs) {
+
+ Class<?> type = null;
+
+ if (mayDefineHidden(superClass, delegateClass, implClasses)) {
+ final Class<?> foreign = preferredForeignHost(superClass, delegateClass, implClasses);
+ if (foreign != null) {
+ type = accept(HiddenClassDefiner.tryDefineNestmate(foreign, bytecode, true), ctorArgs);
+ }
+ if (type == null && canResolveAll(ownLookup.lookupClass(), superClass, delegateClass, implClasses)) {
+ type = accept(HiddenClassDefiner.tryDefineNestmate(ownLookup, bytecode, true), ctorArgs);
+ }
+ if (type != null) {
+ return new Result(type, true, resolvePublicConstructor(type, ctorArgs));
+ }
+ }
+
+ // Visible fallback — never mark as hidden.
+ type = visibleDefine.apply(binaryName, bytecode);
+ return new Result(type, false, resolvePublicConstructor(type, ctorArgs));
+ }
+
+ // -------------------------------------------------------------------------
+ // Policy
+ // -------------------------------------------------------------------------
+
+ /**
+ * {@code true} when attempting a hidden definition is worthwhile.
+ */
+ static boolean mayDefineHidden(
+ final Class<?> superClass,
+ final Class<?> delegateClass,
+ final Collection<? extends Class<?>> implClasses) {
+ 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 must stay nameable for re-wrapping (MockFor, etc.).
+ return delegateClass != null || superClass != Object.class || !hasUserInterface(implClasses);
+ }
+
+ /**
+ * Single preferred foreign nest host, or {@code null} to skip to own Lookup.
+ */
+ static Class<?> preferredForeignHost(
+ final Class<?> superClass,
+ final Class<?> delegateClass,
+ final Collection<? extends Class<?>> implClasses) {
+ if (isCandidateHost(delegateClass)
+ && canResolveAll(delegateClass, superClass, delegateClass, implClasses)) {
+ return delegateClass;
+ }
+ if (superClass != Object.class
+ && isCandidateHost(superClass)
+ && canResolveAll(superClass, superClass, delegateClass, implClasses)) {
+ return superClass;
+ }
+ return null;
+ }
+
+ // -------------------------------------------------------------------------
+ // Loader visibility
+ // -------------------------------------------------------------------------
+
+ /**
+ * Every type the proxy bytecode names must be resolvable from {@code host}'s
+ * defining loader (child loaders see parents; parents do not see children).
+ */
+ static boolean canResolveAll(
+ final Class<?> host,
+ final Class<?> superClass,
+ final Class<?> delegateClass,
+ final Collection<? extends Class<?>> implClasses) {
+ if (!loaderCanResolve(host, superClass) || !loaderCanResolve(host, delegateClass)) {
+ return false;
+ }
+ if (implClasses != null) {
+ for (Class<?> impl : implClasses) {
+ if (!loaderCanResolve(host, impl)) {
+ return false;
+ }
+ }
+ }
+ return true;
+ }
+
+ 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;
+ }
+ for (ClassLoader cl = host.getClassLoader(); cl != null; cl = cl.getParent()) {
+ if (cl == defining) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ // -------------------------------------------------------------------------
+ // Helpers
+ // -------------------------------------------------------------------------
+
+ private static Class<?> accept(final Class<?> candidate, final Class<?>[] ctorArgs) {
+ if (candidate == null) {
+ return null;
+ }
+ // Ensure deferred linkage succeeds before we commit to the hidden path.
+ if (resolvePublicConstructor(candidate, ctorArgs) == null) {
+ return null;
+ }
+ return candidate;
+ }
+
+ /**
+ * Public constructor only — preserves the historical
+ * {@link Class#getConstructor(Class[])} contract used by proxy instantiation.
+ */
+ static Constructor<?> resolvePublicConstructor(final Class<?> type, final Class<?>[] args) {
+ try {
+ return type.getConstructor(args);
+ } catch (NoSuchMethodException | LinkageError e) {
+ return null;
+ }
+ }
+
+ private static boolean isCandidateHost(final Class<?> type) {
+ return type != null
+ && type != Object.class
+ && type != ProxyGeneratorAdapter.class
+ && !isUnusableNamedType(type)
+ && !type.isSealed();
+ }
+
+ private static boolean isUnusableNamedType(final Class<?> type) {
+ return type != null && (type.isPrimitive() || type.isArray() || type.isHidden());
+ }
+
+ private static boolean hasUserInterface(final Collection<? extends Class<?>> implClasses) {
+ if (implClasses == null) {
+ return false;
+ }
+ for (Class<?> impl : implClasses) {
+ if (impl != null && impl != Object.class && impl != GroovyObject.class) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/src/main/java/org/codehaus/groovy/runtime/ProxyGeneratorAdapter.java b/src/main/java/org/codehaus/groovy/runtime/ProxyGeneratorAdapter.java
index dd6d6708db..09c2d1a0c4 100644
--- a/src/main/java/org/codehaus/groovy/runtime/ProxyGeneratorAdapter.java
+++ b/src/main/java/org/codehaus/groovy/runtime/ProxyGeneratorAdapter.java
@@ -41,6 +41,8 @@ import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Type;
import java.io.Serial;
+import java.lang.invoke.MethodHandles;
+import java.lang.invoke.MethodHandles.Lookup;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
@@ -125,6 +127,13 @@ public class ProxyGeneratorAdapter extends ClassVisitor {
private static final String DELEGATE_OBJECT_FIELD = "$delegate";
private static final AtomicLong PROXY_COUNTER = new AtomicLong();
+ /**
+ * Full-privilege lookup for this class (caller-sensitive, captured here).
+ * Used as the last-resort nest host when no foreign host is suitable —
+ * never replaced by {@code privateLookupIn} on a utility class.
+ */
+ private static final Lookup LOOKUP = MethodHandles.lookup();
+
private static final List<Method> OBJECT_METHODS = getInheritedMethods(Object.class, new ArrayList<>());
private static final List<Method> GROOVYOBJECT_METHODS = getInheritedMethods(GroovyObject.class, new ArrayList<>());
private static final Set<String> GROOVYOBJECT_METHOD_NAMES;
@@ -139,7 +148,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<Class > implClasses;
private final Set<Object> visitedMethods;
private final Set<String> objectDelegateMethods;
@@ -167,13 +187,15 @@ public class ProxyGeneratorAdapter extends ClassVisitor {
*/
public ProxyGeneratorAdapter(
final Map<Object, Object> closureMap,
- final Class superClass,
+ final Class requestedSuperClass,
final Class[] interfaces,
final ClassLoader proxyLoader,
final boolean emptyBody,
final Class delegateClass) {
super(ASM_API_VERSION, new ClassWriter(ClassWriter.COMPUTE_MAXS | ClassWriter.COMPUTE_FRAMES));
- this.innerLoader = proxyLoader != null ? createInnerLoader(proxyLoader, interfaces) : findClassLoader(superClass, interfaces);
+ this.innerLoader = proxyLoader != null
+ ? createInnerLoader(proxyLoader, interfaces)
+ : findClassLoader(requestedSuperClass, interfaces);
this.visitedMethods = new LinkedHashSet<>();
this.delegatedClosures = closureMap.isEmpty() ? EMPTY_DELEGATECLOSURE_MAP : new HashMap<>();
boolean wildcard = false;
@@ -187,11 +209,15 @@ public class ProxyGeneratorAdapter extends ClassVisitor {
this.hasWildcard = wildcard;
this.delegateClass = delegateClass;
- Class<?> fixedSuperClass = adjustSuperClass(superClass, interfaces);
+ // Normalise interface supers to a concrete class (Object or trait adapter).
+ // Parameter is named requestedSuperClass so it cannot shadow this.superClass.
+ Class<?> fixedSuperClass = adjustSuperClass(requestedSuperClass, interfaces);
// if we have to delegate to another object, generate the appropriate delegate field
// and collect the name of the methods for which delegation is active
this.generateDelegateField = delegateClass != null;
- this.objectDelegateMethods = generateDelegateField ? createDelegateMethodList(fixedSuperClass, delegateClass, interfaces) : Collections.emptySet();
+ this.objectDelegateMethods = generateDelegateField
+ ? createDelegateMethodList(fixedSuperClass, delegateClass, interfaces)
+ : Collections.emptySet();
// a proxy is supposed to be a concrete class, so it cannot extend an interface.
// If the provided superclass is an interface, then we replace the superclass with Object
@@ -200,7 +226,7 @@ public class ProxyGeneratorAdapter extends ClassVisitor {
// collect classes which have possible methods to be overloaded
this.implClasses = new LinkedHashSet<>();
- this.implClasses.add(superClass);
+ this.implClasses.add(requestedSuperClass);
if (generateDelegateField) {
implClasses.add(delegateClass);
Collections.addAll(this.implClasses, Arrays.stream(delegateClass.getInterfaces()).filter(c -> !c.isSealed()).toArray(Class[]::new));
@@ -208,23 +234,43 @@ public class ProxyGeneratorAdapter extends ClassVisitor {
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;
- try {
- constructor = cachedClass.getConstructor(args);
- } catch (NoSuchMethodException e) {
- constructor = null;
- }
- cachedNoArgConstructor = constructor;
+ final byte[] b = writer.toByteArray();
+
+ final Class<?>[] ctorArgs = generateDelegateField
+ ? new Class<?>[]{Map.class, delegateClass}
+ : new Class<?>[]{Map.class};
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ final Collection<? extends Class<?>> namedTypes = (Collection) implClasses;
+ // Use this.superClass (normalised concrete super), not the constructor
+ // parameter which still holds the pre-adjustSuperClass value (often an interface).
+ final ProxyClassDefiner.Result defined = ProxyClassDefiner.define(
+ b,
+ proxyName.replace('/', '.'),
+ this.superClass,
+ this.delegateClass,
+ namedTypes,
+ LOOKUP,
+ innerLoader::defineClass,
+ ctorArgs);
+ this.proxyIsHidden = defined.hidden;
+ this.cachedClass = defined.type;
+ this.cachedNoArgConstructor = defined.constructor;
+ }
+
+ /**
+ * Diagnostic: {@code true} if this adapter's proxy was defined as a hidden
+ * nestmate (JEP 371). Not part of the stable user-facing proxy contract.
+ *
+ * @return {@code true} when the proxy class is hidden
+ */
+ public boolean isProxyHidden() {
+ return proxyIsHidden;
}
private Class<?> adjustSuperClass(final Class<?> superClass, Class<?>[] interfaces) {
@@ -486,16 +532,31 @@ public class ProxyGeneratorAdapter extends ClassVisitor {
}
}
- 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 33dbacadcf..db526d6201 100644
--- a/src/main/java/org/codehaus/groovy/runtime/metaclass/ReflectorLoader.java
+++ b/src/main/java/org/codehaus/groovy/runtime/metaclass/ReflectorLoader.java
@@ -18,27 +18,50 @@
*/
package org.codehaus.groovy.runtime.metaclass;
+import org.apache.groovy.util.HiddenClassDefiner;
import org.codehaus.groovy.runtime.Reflector;
+import java.lang.invoke.MethodHandles;
+import java.lang.invoke.MethodHandles.Lookup;
import java.security.ProtectionDomain;
import java.util.HashMap;
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.
+ *
+ * <p>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).
+ *
+ * <p>Since Groovy 6.0 this loader preferentially defines each generated
+ * Reflector class as a <em>hidden nestmate</em> of {@code ReflectorLoader}
+ * itself (via a {@link Lookup} captured in this class — the correct
+ * caller-sensitive way to obtain full privilege). Hidden classes are
+ * non-discoverable and eligible for eager unloading. When that fails the
+ * classic {@link ClassLoader#defineClass} path is used transparently.
+ *
+ * <p>{@code name} passed to {@link #defineClass(String, byte[], ProtectionDomain)}
+ * is the cache / fallback binary name only: a successful hidden definition
+ * yields a JVM-assigned name (with a {@code /} suffix) that is not loadable
+ * by {@code name}.
*/
public class ReflectorLoader extends ClassLoader {
+
+ /**
+ * Full-privilege lookup for this class, captured at class-init time.
+ * Used as the nest host for hidden Reflector subclasses.
+ */
+ private static final Lookup LOOKUP = MethodHandles.lookup();
+
private boolean inDefine = false;
- private final Map loadedClasses = new HashMap();
+ private final Map<String, Class<?>> loadedClasses = new HashMap<>();
private final ClassLoader delegatationLoader;
private static final String REFLECTOR = Reflector.class.getName();
@@ -51,25 +74,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 +100,40 @@ public class ReflectorLoader extends ClassLoader {
}
/**
- * 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
+ * <p>Prefers a hidden nestmate of {@code ReflectorLoader} and falls back to
+ * the classic {@link ClassLoader#defineClass} path when that is not possible.
+ *
+ * <p>{@code name} is stored in the per-loader cache and used for the visible
+ * fallback definition; it is <em>not</em> the runtime name of a successfully
+ * defined hidden class.
+ *
+ * <p>This method sets the {@code inDefine} flag to {@code true} during
+ * class definition to ensure {@link Reflector} is resolved correctly.
+ *
+ * @param name cache key / fallback 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 +141,7 @@ public class ReflectorLoader extends ClassLoader {
* 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);
@@ -110,55 +151,74 @@ public class ReflectorLoader extends ClassLoader {
/**
* Retrieves a previously defined Reflector class by name from the cache.
*
+ * <p>The key is the logical binary name passed to
+ * {@link #defineClass(String, byte[], ProtectionDomain)}, not the
+ * JVM-assigned hidden-class name.
+ *
* @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.
+ *
+ * <p>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;
}
}
+
+ /**
+ * Prefers a hidden nestmate of this loader class (caller-owned
+ * {@link #LOOKUP}); falls back to a visible class under {@code name}.
+ */
+ private Class<?> defineReflectorClass(
+ final String name,
+ final byte[] bytecode,
+ final ProtectionDomain domain) {
+ final Class<?> hidden = HiddenClassDefiner.tryDefineNestmate(LOOKUP, 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 9086715547..59dae19793 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,135 @@ 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 <em>visible</em>: 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')
+ // Non-discoverability: the binary-name prefix (before '/') must not load
+ String binaryPrefix = proxyCls.name.substring(0, proxyCls.name.indexOf('/'))
+ assertThrows(ClassNotFoundException) {
+ Class.forName(binaryPrefix)
+ }
+ assertThrows(ClassNotFoundException) {
+ proxyCls.classLoader.loadClass(binaryPrefix)
+ }
+ }
+
+ /**
+ * 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 0000000000..d8911d4e6b
--- /dev/null
+++ b/src/test/groovy/org/apache/groovy/util/HiddenClassDefinerTest.groovy
@@ -0,0 +1,187 @@
+/*
+ * 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.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}.
+ *
+ * <p>The preferred production path is {@code tryDefineNestmate(Lookup, ...)}
+ * with a lookup captured in this test class itself (caller-sensitive). The
+ * foreign-host overload is covered separately as a best-effort path.
+ *
+ * @since 6.0.0
+ */
+class HiddenClassDefinerTest {
+
+ /**
+ * Full-privilege lookup for this class. A bare {@code MethodHandles.lookup()}
+ * reached through Groovy's indy path can resolve to a synthetic
+ * {@code $$InjectedInvoker} hidden class on some JDKs; pin to this class
+ * with {@code privateLookupIn} so nest-host assertions are stable.
+ * Production Java call sites capture {@code MethodHandles.lookup()} in a
+ * {@code static final} field of the nest-host class itself (see
+ * {@code ReflectorLoader}, {@code ProxyGeneratorAdapter}).
+ */
+ private static final Lookup LOOKUP = MethodHandles.privateLookupIn(
+ HiddenClassDefinerTest, MethodHandles.lookup())
+
+ 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, '<init>', '()V', null, null)
+ mv.visitCode()
+ mv.visitVarInsn(ALOAD, 0)
+ mv.visitMethodInsn(INVOKESPECIAL, 'java/lang/Object', '<init>', '()V', false)
+ mv.visitInsn(RETURN)
+ mv.visitMaxs(1, 1)
+ mv.visitEnd()
+ cw.visitEnd()
+ cw.toByteArray()
+ }
+
+ // -------------------------------------------------------------------------
+ // Status
+ // -------------------------------------------------------------------------
+
+ @Test
+ void testHiddenClassesEnabledByDefault() {
+ assertTrue(HiddenClassDefiner.isEnabled())
+ assertFalse(HiddenClassDefiner.HIDDEN_CLASSES_DISABLED)
+ }
+
+ // -------------------------------------------------------------------------
+ // Preferred API: caller-owned Lookup
+ // -------------------------------------------------------------------------
+
+ @Test
+ void testTryDefineNestmateWithLookup() {
+ byte[] bytes = minimalClassBytes('org/apache/groovy/util/LookupNest1')
+ Class<?> hidden = HiddenClassDefiner.tryDefineNestmate(LOOKUP, bytes, true)
+ assertNotNull(hidden)
+ assertTrue(hidden.isHidden())
+ assertTrue(hidden.name.contains('/'))
+ assertEquals(HiddenClassDefinerTest.nestHost, hidden.nestHost)
+ assertTrue(HiddenClassDefinerTest.isNestmateOf(hidden))
+ }
+
+ @Test
+ void testLookupPathUsesLookupClassLoaderAndPackage() {
+ // Template package differs from host package — must be rewritten
+ byte[] bytes = minimalClassBytes('unrelated/pkg/TemplateName')
+ Class<?> hidden = HiddenClassDefiner.tryDefineNestmate(LOOKUP, bytes, true)
+ assertNotNull(hidden)
+ assertSame(HiddenClassDefinerTest.classLoader, hidden.classLoader)
+ assertEquals(HiddenClassDefinerTest.packageName, hidden.packageName)
+ }
+
+ @Test
+ void testHiddenClassIsNotDiscoverable() {
+ byte[] bytes = minimalClassBytes('org/apache/groovy/util/LookupNest2')
+ Class<?> hidden = HiddenClassDefiner.tryDefineNestmate(LOOKUP, bytes, true)
+ assertNotNull(hidden)
+
+ // Binary-name prefix (before the JVM '/' suffix) must not resolve either
+ String binaryPrefix = hidden.name.substring(0, hidden.name.indexOf('/'))
+ assertThrows(ClassNotFoundException) {
+ Class.forName(binaryPrefix)
+ }
+ assertThrows(ClassNotFoundException) {
+ hidden.classLoader.loadClass(binaryPrefix)
+ }
+ assertThrows(ClassNotFoundException) {
+ Class.forName(hidden.name)
+ }
+ }
+
+ @Test
+ void testHiddenClassCanBeInstantiated() {
+ byte[] bytes = minimalClassBytes('org/apache/groovy/util/LookupNest3')
+ Class<?> hidden = HiddenClassDefiner.tryDefineNestmate(LOOKUP, bytes, true)
+ assertNotNull(hidden.getDeclaredConstructor().newInstance())
+ }
+
+ @Test
+ void testTryDefineNestmateWithLookupRejectsNulls() {
+ byte[] bytes = minimalClassBytes('org/apache/groovy/util/LookupNull')
+ assertNull(HiddenClassDefiner.tryDefineNestmate((Lookup) null, bytes, true))
+ assertNull(HiddenClassDefiner.tryDefineNestmate(LOOKUP, null, true))
+ // Corrupt class-file bytes: soft API must not throw (ClassReader may
+ // raise IllegalArgumentException or similar on garbage input).
+ assertNull(HiddenClassDefiner.tryDefineNestmate(LOOKUP, new byte[]{0, 1, 2, 3, 4, 5, 6, 7}, true))
+ }
+
+ // -------------------------------------------------------------------------
+ // Best-effort API: foreign host
+ // -------------------------------------------------------------------------
+
+ @Test
+ void testTryDefineNestmateWithForeignHost() {
+ // Same-module unnamed class — privateLookupIn from HiddenClassDefiner works
+ byte[] bytes = minimalClassBytes('org/apache/groovy/util/ForeignHost1')
+ Class<?> hidden = HiddenClassDefiner.tryDefineNestmate(HiddenClassDefinerTest, bytes, true)
+ assertNotNull(hidden)
+ assertTrue(hidden.isHidden())
+ assertEquals(HiddenClassDefinerTest.nestHost, hidden.nestHost)
+ }
+
+ @Test
+ void testForeignHostRejectsUnusableTypes() {
+ byte[] bytes = minimalClassBytes('org/apache/groovy/util/ForeignReject')
+ assertNull(HiddenClassDefiner.tryDefineNestmate((Class) null, bytes, true))
+ assertNull(HiddenClassDefiner.tryDefineNestmate(Integer.TYPE, bytes, true))
+ assertNull(HiddenClassDefiner.tryDefineNestmate(String[].class, bytes, true))
+
+ Class<?> hidden = HiddenClassDefiner.tryDefineNestmate(LOOKUP, bytes, true)
+ assertNotNull(hidden)
+ // Hidden class cannot host further nestmates via the Class overload
+ assertNull(HiddenClassDefiner.tryDefineNestmate(hidden, bytes, true))
+ }
+
+ @Test
+ void testForeignHostIntoJavaBaseTypicallyFails() {
+ // java.lang is not open to unnamed modules → privateLookupIn fails → null
+ // (unless the JVM was launched with --add-opens). Soft API must not throw.
+ byte[] bytes = minimalClassBytes('java/lang/ShouldNotAppear')
+ Class<?> result = HiddenClassDefiner.tryDefineNestmate(String, bytes, true)
+ // On a stock JDK this is null; if the environment opens java.lang, a
+ // hidden class is still a valid outcome. Never throw.
+ if (result != null) {
+ assertTrue(result.isHidden())
+ }
+ }
+}
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 0000000000..0f257c4363
--- /dev/null
+++ b/src/test/groovy/org/codehaus/groovy/reflection/ClassLoaderForClassArtifactsTest.groovy
@@ -0,0 +1,103 @@
+/*
+ * 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.assertNotEquals
+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 {
+ }
+
+ 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, '<init>', '()V', null, null)
+ mv.visitCode()
+ mv.visitVarInsn(ALOAD, 0)
+ mv.visitMethodInsn(INVOKESPECIAL, 'java/lang/Object', '<init>', '()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')
+ // Public no-arg constructor — getConstructor contract
+ def ctor = loader.defineClassAndGetConstructor(name, minimalBytes(name.replace('.', '/')))
+ assertNotNull(ctor)
+ assertNotNull(ctor.newInstance())
+
+ 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))
+ assertNotEquals(first, second)
+ assertTrue(second.endsWith('$0'), "second name should carry counter suffix, was: $second")
+
+ 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.startsWith('java_lang_String'))
+ }
+}
diff --git a/src/test/groovy/org/codehaus/groovy/runtime/ProxyClassDefinerTest.groovy b/src/test/groovy/org/codehaus/groovy/runtime/ProxyClassDefinerTest.groovy
new file mode 100644
index 0000000000..75fed94b35
--- /dev/null
+++ b/src/test/groovy/org/codehaus/groovy/runtime/ProxyClassDefinerTest.groovy
@@ -0,0 +1,151 @@
+/*
+ * 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
+
+import org.apache.groovy.util.HiddenClassDefiner
+import org.junit.jupiter.api.Test
+import org.objectweb.asm.ClassWriter
+
+import java.lang.invoke.MethodHandles
+
+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
+
+/**
+ * Unit tests for {@link ProxyClassDefiner} policy and define path.
+ */
+class ProxyClassDefinerTest {
+
+ abstract static class ConcreteHost {
+ abstract void m()
+ }
+
+ private static byte[] publicNoArgClass(String internalName) {
+ def cw = new ClassWriter(0)
+ cw.visit(V17, ACC_PUBLIC, internalName, null, 'java/lang/Object', null)
+ def mv = cw.visitMethod(ACC_PUBLIC, '<init>', '()V', null, null)
+ mv.visitCode()
+ mv.visitVarInsn(ALOAD, 0)
+ mv.visitMethodInsn(INVOKESPECIAL, 'java/lang/Object', '<init>', '()V', false)
+ mv.visitInsn(RETURN)
+ mv.visitMaxs(1, 1)
+ mv.visitEnd()
+ cw.visitEnd()
+ cw.toByteArray()
+ }
+
+ @Test
+ void testMayDefineHiddenRejectsInterfaceAggregates() {
+ // Object super + user interface + no delegate → must stay nameable
+ assertFalse(ProxyClassDefiner.mayDefineHidden(
+ Object, null, [Object, Iterator] as Set))
+ }
+
+ @Test
+ void testMayDefineHiddenAllowsConcreteSuper() {
+ if (!HiddenClassDefiner.isEnabled()) return
+ assertTrue(ProxyClassDefiner.mayDefineHidden(
+ ConcreteHost, null, [ConcreteHost] as Set))
+ }
+
+ @Test
+ void testMayDefineHiddenAllowsTypedDelegate() {
+ if (!HiddenClassDefiner.isEnabled()) return
+ assertTrue(ProxyClassDefiner.mayDefineHidden(
+ Object, ArrayList, [Object, List, ArrayList] as Set))
+ }
+
+ @Test
+ void testPreferredForeignHostPrefersDelegateThenSuper() {
+ assertEquals(ArrayList, ProxyClassDefiner.preferredForeignHost(
+ Object, ArrayList, [Object, ArrayList] as Set))
+ assertEquals(ConcreteHost, ProxyClassDefiner.preferredForeignHost(
+ ConcreteHost, null, [ConcreteHost] as Set))
+ assertNull(ProxyClassDefiner.preferredForeignHost(
+ Object, null, [Object, Iterator] as Set))
+ }
+
+ @Test
+ void testLoaderCanResolveParentButNotChild() {
+ def childLoader = new GroovyClassLoader(this.class.classLoader)
+ Class<?> childType = childLoader.parseClass('class ChildType {}')
+ // Parent loader cannot see a type defined by a child loader
+ assertFalse(ProxyClassDefiner.loaderCanResolve(String, childType))
+ // Child type's loader can see itself
+ assertTrue(ProxyClassDefiner.loaderCanResolve(childType, childType))
+ // Bootstrap types always visible
+ assertTrue(ProxyClassDefiner.loaderCanResolve(childType, String))
+ }
+
+ @Test
+ void testDefineFallsBackToVisibleWhenHiddenDisabledOrUnsafe() {
+ def bytes = publicNoArgClass('org/codehaus/groovy/runtime/VisibleOnly')
+ def fallback = { String name, byte[] b ->
+ def cl = new GroovyClassLoader(this.class.classLoader)
+ cl.defineClass(name, b)
+ }
+ // Interface-aggregate shape → visible path
+ def result = ProxyClassDefiner.define(
+ bytes,
+ 'org.codehaus.groovy.runtime.VisibleOnly',
+ Object,
+ null,
+ [Object, Iterator] as Set,
+ MethodHandles.lookup(),
+ fallback,
+ new Class<?>[0])
+ assertNotNull(result.type)
+ assertFalse(result.hidden)
+ assertNotNull(result.constructor)
+ }
+
+ @Test
+ void testDefineCanProduceHiddenForConcreteSuper() {
+ if (!HiddenClassDefiner.isEnabled()) return
+ def bytes = publicNoArgClass('org/codehaus/groovy/runtime/MaybeHidden')
+ def fallback = { String name, byte[] b ->
+ new GroovyClassLoader(this.class.classLoader).defineClass(name, b)
+ }
+ def result = ProxyClassDefiner.define(
+ bytes,
+ 'org.codehaus.groovy.runtime.MaybeHidden',
+ ConcreteHost,
+ null,
+ [ConcreteHost] as Set,
+ MethodHandles.lookup(),
+ fallback,
+ new Class<?>[0])
+ assertNotNull(result.type)
+ // Own LOOKUP (this test class) may not host ConcreteHost package;
+ // foreign host ConcreteHost should succeed when privateLookupIn works.
+ if (result.hidden) {
+ assertTrue(result.type.isHidden())
+ assertEquals(ConcreteHost.nestHost, result.type.nestHost)
+ }
+ assertNotNull(result.constructor)
+ }
+}
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 0000000000..2837839ab7
--- /dev/null
+++ b/src/test/groovy/org/codehaus/groovy/runtime/metaclass/ReflectorLoaderTest.groovy
@@ -0,0 +1,82 @@
+/*
+ * 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)
+ cw.visit(V17, ACC_PUBLIC, internalName, null,
+ Reflector.name.replace('.', '/'), null)
+ def mv = cw.visitMethod(ACC_PUBLIC, '<init>', '()V', null, null)
+ mv.visitCode()
+ mv.visitVarInsn(ALOAD, 0)
+ mv.visitMethodInsn(INVOKESPECIAL,
+ Reflector.name.replace('.', '/'), '<init>', '()V', false)
+ mv.visitInsn(RETURN)
+ mv.visitMaxs(1, 1)
+ mv.visitEnd()
+ cw.visitEnd()
+ cw.toByteArray()
+ }
+
+ @Test
+ void testDefineClassPrefersHiddenNestmateOfReflectorLoader() {
+ 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())
+ // Nest host is ReflectorLoader (caller-owned LOOKUP), not Reflector
+ assertEquals(ReflectorLoader.nestHost, cls.nestHost)
+ }
+ assertNotNull(cls.getDeclaredConstructor().newInstance())
+ // Cache key is the logical name, not the hidden runtime name
+ 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'))
+ }
+}