William Degrange created LANG-1835:
--------------------------------------

             Summary: AnnotationUtils equals(), hashCode() and toString() 
invoke the synthetic methods of the annotation type, such as lambda bodies
                 Key: LANG-1835
                 URL: https://issues.apache.org/jira/browse/LANG-1835
             Project: Commons Lang
          Issue Type: Bug
          Components: lang.*
    Affects Versions: 3.21.0
         Environment: OpenJDK 26.0.2 (not JDK specific: javac generates these 
synthetic methods since Java 8)
            Reporter: William Degrange


h3. Problem

{{AnnotationUtils.equals(Annotation, Annotation)}}, 
{{AnnotationUtils.hashCode(Annotation)}} and 
{{AnnotationUtils.toString(Annotation)}} consider every method returned by 
{{annotationType().getDeclaredMethods()}} as an annotation member ({{equals}} 
and {{toString}} only skip the methods with parameters). But an annotation type 
can declare methods which are not members:
* the compiler generates a private static synthetic method for each lambda 
expression used in a constant of the annotation type: a {{Runnable}} constant 
initialized with a lambda gives {{private static void lambda$static$0()}};
* bytecode instrumentation can add more synthetic methods to such an annotation 
type, e.g. JaCoCo adds a {{$jacocoInit}} method to instrument the lambda body.

Before 3.21.0, invoking these private methods failed with an 
{{IllegalAccessException}}: {{hashCode}} and {{toString}} threw an 
{{UncheckedException}}, and {{equals}} returned {{false}}. Since 3.21.0, the 
methods are made accessible before being invoked (LANG-1815), so these methods 
are now *executed*:
* {{hashCode}} throws an {{IllegalStateException}} ("Annotation method ... 
returned null") when such a method returns {{void}}, like the one of a 
{{Runnable}} lambda;
* {{hashCode}} throws an {{IllegalArgumentException}} ("wrong number of 
arguments") when such a method has parameters, like the one of a {{Function}} 
lambda, since {{hashCode}} does not check the number of parameters;
* otherwise, the returned value is hashed and printed as if it were the value 
of a member: {{AnnotationUtils.hashCode(a)}} differs from {{a.hashCode()}}, 
although the Javadoc of {{hashCode(Annotation)}} refers to the algorithm of 
{{Annotation.hashCode()}};
* in every case, the body of the lambda expression runs during {{equals}}, 
{{hashCode}} and {{toString}}.

As a result, a dynamic proxy implementing an annotation with 
{{AnnotationUtils}} (the use case described in its Javadoc) is not consistent 
with the JDK annotations: its {{hashCode}} throws, or differs from the one of 
an equal JDK annotation (e.g. in a {{HashSet}}).

h3. Reproducer

{code:java}
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.function.Supplier;

import org.apache.commons.lang3.AnnotationUtils;
import org.junit.jupiter.api.Test;

class AnnotationUtilsSyntheticMethodTest {

    @Retention(RetentionPolicy.RUNTIME)
    public @interface WithRunnable {
        // javac generates: private static synthetic void lambda$static$0()
        Runnable RUNNABLE = () -> { };

        String value();
    }

    @Retention(RetentionPolicy.RUNTIME)
    public @interface WithSupplier {
        // javac generates: private static synthetic String lambda$static$0()
        Supplier<String> SUPPLIER = () -> "not a member";

        String value();
    }

    @WithRunnable("a")
    @WithSupplier("a")
    static class Annotated {
    }

    @Test
    void testHashCodeWithVoidSyntheticMethod() {
        final WithRunnable annotation = 
Annotated.class.getAnnotation(WithRunnable.class);
        assertEquals(annotation.hashCode(), 
AnnotationUtils.hashCode(annotation));
    }

    @Test
    void testHashCodeWithSyntheticMethod() {
        final WithSupplier annotation = 
Annotated.class.getAnnotation(WithSupplier.class);
        assertEquals(annotation.hashCode(), 
AnnotationUtils.hashCode(annotation));
    }

    @Test
    void testToStringWithSyntheticMethod() {
        final WithSupplier annotation = 
Annotated.class.getAnnotation(WithSupplier.class);
        assertFalse(AnnotationUtils.toString(annotation).contains("lambda$"));
    }

}
{code}

With 3.21.0, the three tests fail:
* {{testHashCodeWithVoidSyntheticMethod}}: {{IllegalStateException: Annotation 
method private static void 
AnnotationUtilsSyntheticMethodTest$WithRunnable.lambda$static$0() returned 
null}};
* {{testHashCodeWithSyntheticMethod}}: {{expected: <1335633774> but was: 
<-1572135523>}}, the value returned by {{lambda$static$0()}} being included in 
the hash code;
* {{testToStringWithSyntheticMethod}}: the string contains 
{{lambda$static$0=not a member}}.

With 3.20.0, the three tests fail with an {{UncheckedException}} wrapping an 
{{IllegalAccessException}}.

h3. Expected behavior

Only the members of the annotation type are considered, as the JDK does: 
{{sun.reflect.annotation.AnnotationType}} only treats the public, abstract and 
non-synthetic methods as members, and 
{{AnnotationInvocationHandler.validateAnnotationMethods}} explicitly skips the 
synthetic private or static methods (its comment mentions the static 
initializer of a lambda stored in an interface field).

h3. Suggested fix

Filter the declared methods as {{AnnotationType}} does, in the loops of 
{{equals}}, {{hashCode}} and {{toString}}:
{code:java}
private static boolean isMember(final Method method) {
    final int modifiers = method.getModifiers();
    return Modifier.isPublic(modifiers) && Modifier.isAbstract(modifiers) && 
!method.isSynthetic();
}
{code}



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

Reply via email to