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 d1e60e04f Align ReflectionDiffBuilder with AbstractReflection and add 
cycle detection (#1778)
d1e60e04f is described below

commit d1e60e04f7ddfd51772fbf0859c12407d17e600b
Author: gaurav kumar pandey <[email protected]>
AuthorDate: Mon Aug 24 04:38:56 2026 +0530

    Align ReflectionDiffBuilder with AbstractReflection and add cycle detection 
(#1778)
---
 src/changes/changes.xml                            |   1 +
 .../apache/commons/lang3/builder/DiffBuilder.java  |  15 +-
 .../lang3/builder/ReflectionDiffBuilder.java       | 124 +++++++++++++----
 .../lang3/builder/ReflectionDiffBuilderTest.java   | 151 +++++++++++++++++++++
 4 files changed, 261 insertions(+), 30 deletions(-)

diff --git a/src/changes/changes.xml b/src/changes/changes.xml
index 021fab1b8..264f3a821 100644
--- a/src/changes/changes.xml
+++ b/src/changes/changes.xml
@@ -46,6 +46,7 @@ The <action> type attribute can be add,update,fix,remove.
   <body>
   <release version="3.21.0" date="YYY-MM-DD" description="This is a feature 
and maintenance release. Java 8 or later is required.">
     <!-- FIX -->
+    <action                   type="fix" dev="ggregory" due-to="Gaurav Pandey, 
Gary Gregory">Align ReflectionDiffBuilder with AbstractReflection and add cycle 
detection to prevent StackOverflowError on cyclic object graphs.</action>
     <action                   type="fix" dev="ggregory" due-to="Javid Khan, 
Gary Gregory">Stop ExtendedMessageFormat seekNonWs reading past the pattern 
end.</action>
     <action                   type="fix" dev="ggregory" due-to="ThrawnCA">Fix 
spelling and grammar in StringUtils #1486.</action>
     <action                   type="fix" dev="ggregory" due-to="Michael 
Hausegger, Gary Gregory">Add ConversionTest assertions to increase coverage 
#1489.</action>
diff --git a/src/main/java/org/apache/commons/lang3/builder/DiffBuilder.java 
b/src/main/java/org/apache/commons/lang3/builder/DiffBuilder.java
index 45e0b2777..368cc0ec8 100644
--- a/src/main/java/org/apache/commons/lang3/builder/DiffBuilder.java
+++ b/src/main/java/org/apache/commons/lang3/builder/DiffBuilder.java
@@ -270,7 +270,20 @@ private DiffBuilder(final T left, final T right, final 
ToStringStyle style, fina
         this.toStringFormat = toStringFormat;
         this.style = style != null ? style : ToStringStyle.DEFAULT_STYLE;
         // Don't compare any fields if objects equal
-        this.equals = testObjectsEquals && Objects.equals(left, right);
+        if (testObjectsEquals) {
+            if (left == right || ReflectionDiffBuilder.isRegistered(left, 
right)) {
+                this.equals = true;
+            } else {
+                try {
+                    ReflectionDiffBuilder.register(left, right);
+                    this.equals = Objects.equals(left, right);
+                } finally {
+                    ReflectionDiffBuilder.unregister(left, right);
+                }
+            }
+        } else {
+            this.equals = false;
+        }
     }
 
     private <F> DiffBuilder<T> add(final String fieldName, final 
SerializableSupplier<F> left, final SerializableSupplier<F> right, final 
Class<F> type) {
diff --git 
a/src/main/java/org/apache/commons/lang3/builder/ReflectionDiffBuilder.java 
b/src/main/java/org/apache/commons/lang3/builder/ReflectionDiffBuilder.java
index baf78bf22..e2209ccfc 100644
--- a/src/main/java/org/apache/commons/lang3/builder/ReflectionDiffBuilder.java
+++ b/src/main/java/org/apache/commons/lang3/builder/ReflectionDiffBuilder.java
@@ -19,12 +19,15 @@
 import java.lang.reflect.Field;
 import java.lang.reflect.Modifier;
 import java.util.Arrays;
+import java.util.HashSet;
 import java.util.Objects;
+import java.util.Set;
 
 import org.apache.commons.lang3.ArraySorter;
 import org.apache.commons.lang3.ArrayUtils;
 import org.apache.commons.lang3.ClassUtils;
 import org.apache.commons.lang3.reflect.FieldUtils;
+import org.apache.commons.lang3.tuple.Pair;
 
 /**
  * Assists in implementing {@link Diffable#diff(Object)} methods.
@@ -73,9 +76,10 @@
  * @see DiffResult
  * @see ToStringStyle
  * @see DiffBuilder
+ * @see AbstractBuilder#setForceAccessible(boolean)
  * @since 3.6
  */
-public class ReflectionDiffBuilder<T> implements Builder<DiffResult<T>> {
+public class ReflectionDiffBuilder<T> extends AbstractReflection implements 
Builder<DiffResult<T>> {
 
     /**
      * Constructs a new instance.
@@ -83,7 +87,7 @@ public class ReflectionDiffBuilder<T> implements 
Builder<DiffResult<T>> {
      * @param <T> type of the left and right object.
      * @since 3.15.0
      */
-    public static final class Builder<T> {
+    public static final class Builder<T> extends AbstractBuilder<Builder<T>> {
 
         private String[] excludeFieldNames = ArrayUtils.EMPTY_STRING_ARRAY;
         private DiffBuilder<T> diffBuilder;
@@ -101,7 +105,12 @@ public Builder() {
          * @return A new configured {@link ReflectionDiffBuilder}.
          */
         public ReflectionDiffBuilder<T> build() {
-            return new ReflectionDiffBuilder<>(diffBuilder, excludeFieldNames);
+            return new ReflectionDiffBuilder<>(this);
+        }
+
+        @Override
+        public ReflectionDiffBuilder<T> get() {
+            return build();
         }
 
         /**
@@ -128,6 +137,62 @@ public Builder<T> setExcludeFieldNames(final String... 
excludeFieldNames) {
 
     }
 
+    /**
+     * A registry of objects to detect cyclical object references, avoid 
infinite loops, and stack overflows.
+     */
+    private static final ThreadLocal<Set<Pair<IDKey, IDKey>>> REGISTRY = 
ThreadLocal.withInitial(HashSet::new);
+
+    /**
+     * Gets the registry of object pairs being traversed by the reflection
+     * methods in the current thread.
+     *
+     * @return Set the registry of objects being traversed
+     */
+    static Set<Pair<IDKey, IDKey>> getRegistry() {
+        return REGISTRY.get();
+    }
+
+    /**
+     * Tests whether the registry contains the given object pair.
+     * <p>
+     * Used by the reflection methods to avoid infinite loops.
+     * Objects might be swapped therefore a check is needed if the object pair
+     * is registered in the given or swapped order.
+     * </p>
+     *
+     * @param lhs {@code this} object to lookup in registry
+     * @param rhs The other object to lookup on registry
+     * @return boolean {@code true} if the registry contains the given object.
+     */
+    static boolean isRegistered(final Object lhs, final Object rhs) {
+        return isRegistered(lhs, rhs, getRegistry());
+    }
+
+    /**
+     * Registers the given object pair.
+     * Used by the reflection methods to avoid infinite loops.
+     *
+     * @param lhs {@code this} object to register
+     * @param rhs the other object to register
+     */
+    static void register(final Object lhs, final Object rhs) {
+        register(lhs, rhs, getRegistry());
+    }
+
+    /**
+     * Unregisters the given object pair.
+     *
+     * <p>
+     * Used by the reflection methods to avoid infinite loops.
+     * </p>
+     *
+     * @param lhs {@code this} object to unregister
+     * @param rhs the other object to unregister
+     */
+    static void unregister(final Object lhs, final Object rhs) {
+        unregister(lhs, rhs, getRegistry(), REGISTRY);
+    }
+
     /**
      * Constructs a new {@link Builder}.
      *
@@ -154,16 +219,27 @@ private static String[] toExcludeFieldNames(final 
String[] excludeFieldNames) {
      */
     private String[] excludeFieldNames;
 
+    /**
+     * Constructs a new instance.
+     *
+     * @param builder A non-null Builder.
+     * @throws NullPointerException Thrown on null input.
+     */
+    private ReflectionDiffBuilder(final Builder<T> builder) {
+        super(Objects.requireNonNull(builder, "builder"));
+        this.diffBuilder = Objects.requireNonNull(builder.diffBuilder, 
"diffBuilder");
+        this.excludeFieldNames = 
Objects.requireNonNull(builder.excludeFieldNames, "excludeFieldNames");
+    }
+
     /**
      * Constructs a new instance.
      *
      * @param diffBuilder A non-null DiffBuilder.
      * @param excludeFieldNames A non-null String array.
-     * @throw NullPointerException Thrown on null input.
+     * @throws NullPointerException Thrown on null input.
      */
     private ReflectionDiffBuilder(final DiffBuilder<T> diffBuilder, final 
String[] excludeFieldNames) {
-        this.diffBuilder = Objects.requireNonNull(diffBuilder, "diffBuilder");
-        this.excludeFieldNames = Objects.requireNonNull(excludeFieldNames, 
"excludeFieldNames");
+        
this(ReflectionDiffBuilder.<T>builder().setDiffBuilder(diffBuilder).setExcludeFieldNames(excludeFieldNames));
     }
 
     /**
@@ -204,11 +280,11 @@ private void appendFields(final Class<?> clazz) {
         for (final Field field : FieldUtils.getAllFields(clazz)) {
             if (accept(field)) {
                 try {
-                    diffBuilder.append(field.getName(), readField(field, 
getLeft()), readField(field, getRight()));
-                } catch (final IllegalAccessException e) {
-                    // this can't happen. Would get a Security exception 
instead
-                    // throw a runtime exception in case the impossible 
happens.
-                    throw new IllegalArgumentException("Unexpected 
IllegalAccessException: " + e.getMessage(), e);
+                    if (setAccessible(field)) {
+                        diffBuilder.append(field.getName(), 
Reflection.getUnchecked(field, getLeft()), Reflection.getUnchecked(field, 
getRight()));
+                    }
+                } catch (final RuntimeException e) {
+                    // Ignored as per AccessibleObject / SecurityManager / 
InaccessibleObjectException
                 }
             }
         }
@@ -222,11 +298,16 @@ private void appendFields(final Class<?> clazz) {
      */
     @Override
     public DiffResult<T> build() {
-        if (getLeft().equals(getRight())) {
+        if (getLeft() == getRight() || isRegistered(getLeft(), getRight())) {
             return diffBuilder.build();
         }
-        appendFields(getLeft().getClass());
-        return diffBuilder.build();
+        try {
+            register(getLeft(), getRight());
+            appendFields(getLeft().getClass());
+            return diffBuilder.build();
+        } finally {
+            unregister(getLeft(), getRight());
+        }
     }
 
     /**
@@ -247,21 +328,6 @@ private T getRight() {
         return diffBuilder.getRight();
     }
 
-    /**
-     * Reads a {@link Field}, forcing access if needed.
-     *
-     * @param field  The field to use.
-     * @param target The object to call on, may be {@code null} for {@code 
static} fields.
-     * @return The field value.
-     * @throws NullPointerException   if the field is {@code null}.
-     * @throws IllegalAccessException if the field is not made accessible.
-     * @throws SecurityException      if an underlying accessible object's 
method denies the request.
-     * @see SecurityManager#checkPermission
-     */
-    private Object readField(final Field field, final Object target) throws 
IllegalAccessException {
-        return FieldUtils.readField(field, target, true);
-    }
-
     /**
      * Sets the field names to exclude.
      *
diff --git 
a/src/test/java/org/apache/commons/lang3/builder/ReflectionDiffBuilderTest.java 
b/src/test/java/org/apache/commons/lang3/builder/ReflectionDiffBuilderTest.java
index c350b4302..20c3aa6ce 100644
--- 
a/src/test/java/org/apache/commons/lang3/builder/ReflectionDiffBuilderTest.java
+++ 
b/src/test/java/org/apache/commons/lang3/builder/ReflectionDiffBuilderTest.java
@@ -21,6 +21,7 @@
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 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 java.math.BigDecimal;
 import java.math.BigInteger;
@@ -545,4 +546,154 @@ void testTransientFieldDifference() {
         assertEquals(0, list.getNumberOfDiffs());
     }
 
+    private static final class CycleDiffableNode implements 
Diffable<CycleDiffableNode> {
+        @SuppressWarnings("unused")
+        private CycleDiffableNode self;
+        @SuppressWarnings("unused")
+        private final String value;
+
+        CycleDiffableNode(final String value) {
+            this.value = value;
+        }
+
+        @Override
+        public boolean equals(final Object obj) {
+            return EqualsBuilder.reflectionEquals(this, obj);
+        }
+
+        @Override
+        public int hashCode() {
+            return HashCodeBuilder.reflectionHashCode(this);
+        }
+
+        @Override
+        public DiffResult<CycleDiffableNode> diff(final CycleDiffableNode obj) 
{
+            return ReflectionDiffBuilder.<CycleDiffableNode>builder()
+                    .setDiffBuilder(DiffBuilder.<CycleDiffableNode>builder()
+                            .setLeft(this)
+                            .setRight(obj)
+                            .setStyle(ToStringStyle.SHORT_PREFIX_STYLE)
+                            .build())
+                    .build()
+                    .build();
+        }
+    }
+
+    private static final class MutualDiffableNode implements 
Diffable<MutualDiffableNode> {
+        @SuppressWarnings("unused")
+        private MutualDiffableNode other;
+        @SuppressWarnings("unused")
+        private final String name;
+
+        MutualDiffableNode(final String name) {
+            this.name = name;
+        }
+
+        @Override
+        public boolean equals(final Object obj) {
+            return EqualsBuilder.reflectionEquals(this, obj);
+        }
+
+        @Override
+        public int hashCode() {
+            return HashCodeBuilder.reflectionHashCode(this);
+        }
+
+        @Override
+        public DiffResult<MutualDiffableNode> diff(final MutualDiffableNode 
obj) {
+            return ReflectionDiffBuilder.<MutualDiffableNode>builder()
+                    .setDiffBuilder(DiffBuilder.<MutualDiffableNode>builder()
+                            .setLeft(this)
+                            .setRight(obj)
+                            .setStyle(ToStringStyle.SHORT_PREFIX_STYLE)
+                            .build())
+                    .build()
+                    .build();
+        }
+    }
+
+    @Test
+    void testCycleSelfReferential() {
+        final CycleDiffableNode first = new CycleDiffableNode("a");
+        final CycleDiffableNode second = new CycleDiffableNode("a");
+        first.self = first;
+        second.self = second;
+
+        final DiffResult<CycleDiffableNode> result = first.diff(second);
+        assertEquals(0, result.getNumberOfDiffs());
+        assertTrue(ReflectionDiffBuilder.getRegistry().isEmpty(), "Registry 
must be empty after diff");
+    }
+
+    @Test
+    void testCycleSelfReferentialWithDifference() {
+        final CycleDiffableNode first = new CycleDiffableNode("a");
+        final CycleDiffableNode second = new CycleDiffableNode("b");
+        first.self = first;
+        second.self = second;
+
+        final DiffResult<CycleDiffableNode> result = first.diff(second);
+        assertEquals(2, result.getNumberOfDiffs());
+        assertTrue(ReflectionDiffBuilder.getRegistry().isEmpty(), "Registry 
must be empty after diff");
+    }
+
+    @Test
+    void testCycleMutuallyReferential() {
+        final MutualDiffableNode a = new MutualDiffableNode("node");
+        final MutualDiffableNode b = new MutualDiffableNode("node");
+        a.other = b;
+        b.other = a;
+
+        final MutualDiffableNode c = new MutualDiffableNode("node");
+        final MutualDiffableNode d = new MutualDiffableNode("node");
+        c.other = d;
+        d.other = c;
+
+        final DiffResult<MutualDiffableNode> result = a.diff(c);
+        assertEquals(0, result.getNumberOfDiffs());
+        assertTrue(ReflectionDiffBuilder.getRegistry().isEmpty(), "Registry 
must be empty after diff");
+    }
+
+    @Test
+    void testCycleMutuallyReferentialWithDifference() {
+        final MutualDiffableNode a = new MutualDiffableNode("nodeA");
+        final MutualDiffableNode b = new MutualDiffableNode("nodeB");
+        a.other = b;
+        b.other = a;
+
+        final MutualDiffableNode c = new MutualDiffableNode("nodeA");
+        final MutualDiffableNode d = new MutualDiffableNode("nodeChanged");
+        c.other = d;
+        d.other = c;
+
+        final DiffResult<MutualDiffableNode> result = a.diff(c);
+        assertEquals(1, result.getNumberOfDiffs());
+        assertTrue(ReflectionDiffBuilder.getRegistry().isEmpty(), "Registry 
must be empty after diff");
+    }
+
+    @Test
+    void testCycleAsymmetric() {
+        final CycleDiffableNode first = new CycleDiffableNode("a");
+        final CycleDiffableNode second = new CycleDiffableNode("a");
+        first.self = first;
+        second.self = null;
+
+        final DiffResult<CycleDiffableNode> result = first.diff(second);
+        assertEquals(1, result.getNumberOfDiffs());
+        assertEquals("self", result.getDiffs().get(0).getFieldName());
+        assertTrue(ReflectionDiffBuilder.getRegistry().isEmpty(), "Registry 
must be empty after diff");
+    }
+
+    @Test
+    void testBuilderGetAndSetForceAccessible() {
+        final TypeTestClass first = new TypeTestClass();
+        final TypeTestClass second = new TypeTestClass();
+        final ReflectionDiffBuilder.Builder<TypeTestClass> builder = 
ReflectionDiffBuilder.<TypeTestClass>builder()
+                
.setDiffBuilder(DiffBuilder.<TypeTestClass>builder().setLeft(first).setRight(second).build())
+                .setForceAccessible(true);
+        final ReflectionDiffBuilder<TypeTestClass> diffBuilder = builder.get();
+        assertNotNull(diffBuilder);
+        assertTrue(diffBuilder.isForceAccessible());
+        assertEquals(0, diffBuilder.build().getNumberOfDiffs());
+    }
+
 }

Reply via email to