This is an automated email from the ASF dual-hosted git repository.
garydgregory pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/commons-lang.git
The following commit(s) were added to refs/heads/master by this push:
new 7e5906fb0 [LANG-1815] Fix AnnotationUtils.equals for package-private
annotations. (#1760)
7e5906fb0 is described below
commit 7e5906fb0cb679d630527fc1bc18018f935518dc
Author: Maksym Korshun <[email protected]>
AuthorDate: Thu Jul 30 14:04:27 2026 +0200
[LANG-1815] Fix AnnotationUtils.equals for package-private annotations.
(#1760)
* [LANG-1815] Fix AnnotationUtils.equals for package-private annotations.
* style: fix to follow style convention
* fix: AbstractReflection.setAccessible handle null safely, ensure
accessability for AnnotationUtils hashCode and toString
In addition, improve code coverage and update docs
* Add @since tag to getForceAccessible method
---------
Co-authored-by: Gary Gregory <[email protected]>
---
.../org/apache/commons/lang3/AnnotationUtils.java | 6 +-
.../commons/lang3/builder/AbstractReflection.java | 54 ++++++-----
.../AbstractReflectionSetAccessibleTest.java | 60 ++++++++++++
.../lang3/external/AnnotationEqualsTest.java | 105 +++++++++++++++++++++
4 files changed, 201 insertions(+), 24 deletions(-)
diff --git a/src/main/java/org/apache/commons/lang3/AnnotationUtils.java
b/src/main/java/org/apache/commons/lang3/AnnotationUtils.java
index 3b21b0f64..bdaf76433 100644
--- a/src/main/java/org/apache/commons/lang3/AnnotationUtils.java
+++ b/src/main/java/org/apache/commons/lang3/AnnotationUtils.java
@@ -20,6 +20,7 @@
import java.lang.reflect.Method;
import java.util.Arrays;
+import org.apache.commons.lang3.builder.AbstractReflection;
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.commons.lang3.builder.ToStringStyle;
import org.apache.commons.lang3.exception.UncheckedException;
@@ -212,6 +213,7 @@ public static boolean equals(final Annotation a1, final
Annotation a2) {
for (final Method m : type1.getDeclaredMethods()) {
if (m.getParameterTypes().length == 0
&& isValidAnnotationMemberType(m.getReturnType())) {
+
AbstractReflection.setAccessible(AbstractReflection.getForceAccessible(), m);
final Object v1 = m.invoke(a1);
final Object v2 = m.invoke(a2);
if (!memberEquals(m.getReturnType(), v1, v2)) {
@@ -220,7 +222,7 @@ && isValidAnnotationMemberType(m.getReturnType())) {
}
}
} catch (final ReflectiveOperationException ex) {
- return false;
+ throw new IllegalStateException(ex);
}
return true;
}
@@ -242,6 +244,7 @@ public static int hashCode(final Annotation a) {
final Class<? extends Annotation> type = a.annotationType();
for (final Method m : type.getDeclaredMethods()) {
try {
+
AbstractReflection.setAccessible(AbstractReflection.getForceAccessible(), m);
final Object value = m.invoke(a);
if (value == null) {
throw new IllegalStateException(String.format("Annotation
method %s returned null", m));
@@ -336,6 +339,7 @@ public static String toString(final Annotation a) {
continue; // what?
}
try {
+
AbstractReflection.setAccessible(AbstractReflection.getForceAccessible(), m);
builder.append(m.getName(), m.invoke(a));
} catch (final ReflectiveOperationException ex) {
throw new UncheckedException(ex);
diff --git
a/src/main/java/org/apache/commons/lang3/builder/AbstractReflection.java
b/src/main/java/org/apache/commons/lang3/builder/AbstractReflection.java
index 49ce0a96c..e422d56bf 100644
--- a/src/main/java/org/apache/commons/lang3/builder/AbstractReflection.java
+++ b/src/main/java/org/apache/commons/lang3/builder/AbstractReflection.java
@@ -108,8 +108,9 @@ public B setForceAccessible(final boolean forceAccessible) {
*
* @return whether the system property {@code
"AbstractReflection.forceAccessible"} is set to true with true as the default.
* @see Boolean#parseBoolean(String)
+ * @since 3.21.0
*/
- static boolean getForceAccessible() {
+ public static boolean getForceAccessible() {
return SystemProperties.getBoolean(AbstractReflection.class,
"forceAccessible", () -> true);
}
@@ -124,37 +125,44 @@ static void register(final Object lhs, final Object rhs,
final Set<Pair<IDKey, I
}
/**
- * If {@code forceAccessible} flag is true, then the field is made
accessible by calling {@link AccessibleObject#setAccessible(boolean)
- * AccessibleObject#setAccessible(true)} but <em>only</em> if a field is
not already accessible.
+ * If {@code forceAccessible} is true, makes {@code accessibleObject}
accessible by calling
+ * {@link AccessibleObject#setAccessible(boolean)
AccessibleObject#setAccessible(true)} but <em>only</em> if it is not already
accessible.
*
- * @param forceAccessible Whether to call {@link
AccessibleObject#setAccessible(boolean)} if a field is not already accessible.
- * @param field The field to set.
- * @return true if the field is accessible, false otherwise.
- * @throws SecurityException Thrown if {@code forceAccessible} flag is
true and the request is denied.
+ * @param forceAccessible Whether to call {@link
AccessibleObject#setAccessible(boolean)} if the object is not already
accessible.
+ * @param accessibleObject The accessible object to set; may be {@code
null}.
+ * @return {@code true} if {@code accessibleObject} is non-null and
accessible after this call; {@code false} otherwise
+ * (including when {@code accessibleObject} is {@code null}, or
when it is inaccessible and {@code forceAccessible} is {@code false}).
+ * @throws SecurityException Thrown if {@code forceAccessible} is true and
the request is denied.
* @see AccessibleObject#setAccessible(boolean)
* @see SecurityManager#checkPermission
*/
- static boolean setAccessible(final boolean forceAccessible, final Field
field) {
- return !field.isAccessible() && forceAccessible &&
setAccessibleTrue(field);
+ public static boolean setAccessible(final boolean forceAccessible, final
AccessibleObject accessibleObject) {
+ if (accessibleObject == null) {
+ return false;
+ }
+ if (accessibleObject.isAccessible()) {
+ return true;
+ }
+ return forceAccessible && setAccessibleTrue(accessibleObject);
}
/**
- * Sets the field as accessible by calling {@link
AccessibleObject#setAccessible(boolean) AccessibleObject#setAccessible(true)}
but <em>only</em> if a field
- * is not already accessible.
+ * Sets the accessible object as accessible by calling {@link
AccessibleObject#setAccessible(boolean) AccessibleObject#setAccessible(true)}
but
+ * <em>only</em> if it is not already accessible.
*
- * @param field The field to set, may be null.
- * @return true if the field is accessible, false otherwise.
- * @throws SecurityException Thrown if {@code forceAccessible} flag is
true and the request is denied.
+ * @param accessibleObject The accessible object to set, may be {@code
null}.
+ * @return {@code true} if {@code accessibleObject} is non-null and
accessible after this call; {@code false} otherwise.
+ * @throws SecurityException Thrown if the request is denied.
* @see AccessibleObject#setAccessible(boolean)
* @see SecurityManager#checkPermission
*/
- private static boolean setAccessibleTrue(final Field field) {
- if (field != null) {
+ private static boolean setAccessibleTrue(final AccessibleObject
accessibleObject) {
+ if (accessibleObject != null) {
// Test isAccessible() to avoid the permission check.
- if (!field.isAccessible()) {
- field.setAccessible(true);
+ if (!accessibleObject.isAccessible()) {
+ accessibleObject.setAccessible(true);
}
- return field.isAccessible();
+ return accessibleObject.isAccessible();
}
return false;
}
@@ -202,12 +210,12 @@ protected boolean isForceAccessible() {
}
/**
- * If {@code forceAccessible} flag is true, each field in the given array
is made accessible by calling {@link AccessibleObject#setAccessible(boolean)
- * AccessibleObject#setAccessible(true)} but <em>only</em> if a field is
not already accessible.
+ * If {@code forceAccessible} flag is true, the field is made accessible
by calling {@link AccessibleObject#setAccessible(boolean)
+ * AccessibleObject#setAccessible(true)} but <em>only</em> if the field is
not already accessible.
*
- * @param field The fields to set.
+ * @param field The field to set; may be {@code null}.
+ * @return {@code true} if {@code field} is non-null and accessible after
this call; {@code false} otherwise.
* @throws SecurityException Thrown if {@code forceAccessible} flag is
true and the request is denied.
- * @return true if the field is accessible, false otherwise.
* @see AccessibleObject#setAccessible(boolean)
* @see SecurityManager#checkPermission
*/
diff --git
a/src/test/java/org/apache/commons/lang3/builder/AbstractReflectionSetAccessibleTest.java
b/src/test/java/org/apache/commons/lang3/builder/AbstractReflectionSetAccessibleTest.java
new file mode 100644
index 000000000..b6a379100
--- /dev/null
+++
b/src/test/java/org/apache/commons/lang3/builder/AbstractReflectionSetAccessibleTest.java
@@ -0,0 +1,60 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.lang3.builder;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.lang.reflect.Field;
+
+import org.apache.commons.lang3.AbstractLangTest;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests {@link AbstractReflection#setAccessible(boolean,
java.lang.reflect.AccessibleObject)}.
+ */
+class AbstractReflectionSetAccessibleTest extends AbstractLangTest {
+
+ @SuppressWarnings("unused")
+ private final String privateField = "value";
+
+ @Test
+ void setAccessibleHandlesNull() {
+ assertFalse(AbstractReflection.setAccessible(true, null));
+ assertFalse(AbstractReflection.setAccessible(false, null));
+ }
+
+ @Test
+ void setAccessibleReturnsTrueWhenAlreadyAccessible() throws Exception {
+ final Field field = getClass().getDeclaredField("privateField");
+ field.setAccessible(true);
+ assertTrue(field.isAccessible());
+ assertTrue(AbstractReflection.setAccessible(false, field));
+ assertTrue(AbstractReflection.setAccessible(true, field));
+ }
+
+ @Test
+ void setAccessibleRespectsForceFlag() throws Exception {
+ final Field field = getClass().getDeclaredField("privateField");
+ field.setAccessible(false);
+ assertFalse(field.isAccessible());
+ assertFalse(AbstractReflection.setAccessible(false, field));
+ assertFalse(field.isAccessible());
+ assertTrue(AbstractReflection.setAccessible(true, field));
+ assertTrue(field.isAccessible());
+ }
+}
diff --git
a/src/test/java/org/apache/commons/lang3/external/AnnotationEqualsTest.java
b/src/test/java/org/apache/commons/lang3/external/AnnotationEqualsTest.java
new file mode 100644
index 000000000..9f215e6ef
--- /dev/null
+++ b/src/test/java/org/apache/commons/lang3/external/AnnotationEqualsTest.java
@@ -0,0 +1,105 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.commons.lang3.external;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.lang.annotation.Annotation;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.reflect.InvocationTargetException;
+
+import org.apache.commons.lang3.AnnotationUtils;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Regression test for <a
href="https://issues.apache.org/jira/browse/LANG-1815">LANG-1815</a>.
+ * <p>
+ * Verifies that {@link AnnotationUtils} can reflectively read package-private
annotation members
+ * for {@code equals}, {@code hashCode}, and {@code toString}, and that
reflective invocation failures
+ * are not treated as inequality.
+ * </p>
+ *
+ * <h2>Important</h2>
+ * <p>
+ * This test relies on reflective access rules that differ depending on the
caller's package.
+ * To reproduce the original bug, this class <strong>must remain
outside</strong> the
+ * {@code org.apache.commons.lang3} package.
+ * </p>
+ * <p>
+ * Do <strong>not</strong> move this class into {@code
org.apache.commons.lang3},
+ * otherwise the test may no longer exercise the failing scenario from
LANG-1815.
+ * </p>
+ */
+public class AnnotationEqualsTest {
+ @Retention(RetentionPolicy.RUNTIME)
+ @interface Tag {
+ String value();
+ }
+
+ static class ThrowingTag implements Tag {
+ @Override
+ public String value() {
+ throw new IllegalArgumentException("boom");
+ }
+
+ @Override
+ public Class<? extends Annotation> annotationType() {
+ return Tag.class;
+ }
+ }
+
+ @Tag("value")
+ private final Object a = new Object();
+ @Tag("value")
+ private final Object b = new Object();
+
+ @Test
+ void equalsWorksOnPackagePrivateAnnotations() throws Exception {
+ final Tag tagA =
getClass().getDeclaredField("a").getAnnotation(Tag.class);
+ final Tag tagB =
getClass().getDeclaredField("b").getAnnotation(Tag.class);
+ assertTrue(AnnotationUtils.equals(tagA, tagB));
+ }
+
+ @Test
+ void equalsWrapsReflectiveOperationException() throws Exception {
+ final Tag tagA = new ThrowingTag();
+ final Tag tagB =
getClass().getDeclaredField("b").getAnnotation(Tag.class);
+
+ final IllegalStateException ex =
+ assertThrows(IllegalStateException.class, () ->
AnnotationUtils.equals(tagA, tagB));
+ assertInstanceOf(InvocationTargetException.class, ex.getCause());
+ assertEquals("boom", ((InvocationTargetException)
ex.getCause()).getTargetException().getMessage());
+ }
+
+ @Test
+ void hashCodeWorksOnPackagePrivateAnnotations() throws Exception {
+ final Tag tag =
getClass().getDeclaredField("a").getAnnotation(Tag.class);
+ assertEquals(tag.hashCode(), AnnotationUtils.hashCode(tag));
+ }
+
+ @Test
+ void toStringWorksOnPackagePrivateAnnotations() throws Exception {
+ final Tag tag =
getClass().getDeclaredField("a").getAnnotation(Tag.class);
+ final String text = AnnotationUtils.toString(tag);
+ assertTrue(text.contains("value=value"), text);
+ }
+
+}