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)