You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@commons.apache.org by gg...@apache.org on 2026/07/27 20:03:12 UTC

(commons-beanutils) branch 1.X updated: Add tests to avoid breaking the binary format of serialized objects.

This is an automated email from the ASF dual-hosted git repository.

garydgregory pushed a commit to branch 1.X
in repository https://gitbox.apache.org/repos/asf/commons-beanutils.git


The following commit(s) were added to refs/heads/1.X by this push:
     new 3c0f9a9b Add tests to avoid breaking the binary format of serialized objects.
3c0f9a9b is described below

commit 3c0f9a9b8683a843d9379202b2be2aa42a3b9f84
Author: Gary Gregory <ga...@gmail.com>
AuthorDate: Mon Jul 27 20:03:06 2026 +0000

    Add tests to avoid breaking the binary format of serialized objects.
---
 .../beanutils/DynaPropertySerializationTest.java   | 141 +++++++++++++++++++++
 1 file changed, 141 insertions(+)

diff --git a/src/test/java/org/apache/commons/beanutils/DynaPropertySerializationTest.java b/src/test/java/org/apache/commons/beanutils/DynaPropertySerializationTest.java
index 65387bed..a8dff23a 100644
--- a/src/test/java/org/apache/commons/beanutils/DynaPropertySerializationTest.java
+++ b/src/test/java/org/apache/commons/beanutils/DynaPropertySerializationTest.java
@@ -23,6 +23,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNotSame;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
@@ -30,6 +31,7 @@ import java.io.IOException;
 import java.io.ObjectInputStream;
 import java.io.ObjectOutputStream;
 import java.io.StreamCorruptedException;
+import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
@@ -340,4 +342,143 @@ class DynaPropertySerializationTest {
                 "Expected StreamCorruptedException for unrecognised primitive contentType constant");
         assertInstanceOf(StreamCorruptedException.class, thrown, "Root cause must be StreamCorruptedException");
     }
+
+    /**
+     * Returns the byte-offset of the first occurrence of {@code needle} inside {@code haystack}, or {@code -1} if not found.
+     */
+    private static int findSequence(final byte[] haystack, final byte[] needle) {
+        outer: for (int i = 0; i <= haystack.length - needle.length; i++) {
+            for (int j = 0; j < needle.length; j++) {
+                if (haystack[i + j] != needle[j]) {
+                    continue outer;
+                }
+            }
+            return i;
+        }
+        return -1;
+    }
+
+    /**
+     * Proves that {@link DynaProperty#writeObject(ObjectOutputStream)} calls {@code writeAnyClass} (which encodes the {@code type} field) <em>before</em>
+     * {@code defaultWriteObject} (which encodes the {@code name} field) for <strong>primitive types</strong>.
+     * <p>
+     * Strategy: serialise two {@link DynaProperty} instances that have an identical {@code name} but different primitive types. Because the name is identical,
+     * both byte streams are the same from the class-descriptor through to the end of the default-field data. The streams diverge only at the
+     * {@code writeAnyClass} output (the primitive-type integer constant). If that divergence point is located <em>before</em> the name bytes in the stream, it
+     * proves that {@code writeAnyClass} is called first.
+     * </p>
+     */
+    @Test
+    void testWireFormatPrimitiveTypeDataPrecedesNameField() throws Exception {
+        final String sharedName = "sharedPrimitiveName";
+        // Boolean.TYPE encodes as writeBoolean(true) + writeInt(BOOLEAN_TYPE=1).
+        // Byte.TYPE encodes as writeBoolean(true) + writeInt(BYTE_TYPE=2).
+        // The two streams are byte-for-byte identical except at the primitive-type int.
+        final byte[] boolBytes = serialize(new DynaProperty(sharedName, Boolean.TYPE));
+        final byte[] byteBytes = serialize(new DynaProperty(sharedName, Byte.TYPE));
+        // Locate the shared name in the boolean-type stream.
+        final byte[] nameBytes = sharedName.getBytes(StandardCharsets.UTF_8);
+        final int namePosition = findSequence(boolBytes, nameBytes);
+        assertTrue(namePosition > 0, "Property name must be present in the serialized stream");
+        // The name must occupy the same position in both streams (identical name, identical class).
+        assertEquals(namePosition, findSequence(byteBytes, nameBytes), "Name must be at the same byte position in both streams");
+        // Find the first byte where the two streams diverge: this is inside the
+        // writeAnyClass output (the primitive-type integer constant differs: 1 vs 2).
+        int firstDiff = -1;
+        for (int i = 0; i < boolBytes.length; i++) {
+            if (boolBytes[i] != byteBytes[i]) {
+                firstDiff = i;
+                break;
+            }
+        }
+        assertTrue(firstDiff >= 0, "Streams must diverge at the primitive-type constant");
+        // CRITICAL assertion: the divergence point (type data from writeAnyClass) must
+        // come BEFORE the name field (from defaultWriteObject).
+        assertTrue(firstDiff < namePosition, "writeAnyClass must be invoked before defaultWriteObject: " + "type-data divergence at byte " + firstDiff
+                + " must precede name field at byte " + namePosition);
+        // Sanity-check: both properties still round-trip correctly.
+        assertRoundTrip(new DynaProperty(sharedName, Boolean.TYPE));
+        assertRoundTrip(new DynaProperty(sharedName, Byte.TYPE));
+    }
+
+    /**
+     * Same proof as {@link #testWireFormatPrimitiveTypeDataPrecedesNameField()} but for <strong>object (non-primitive) types</strong>.
+     * <p>
+     * For object types {@code writeAnyClass} emits {@code writeBoolean(false)} followed by {@code writeObject(clazz)}. Two properties with the same name but
+     * different object types ({@code String.class} vs {@code Integer.class}) differ in the class object written by {@code writeAnyClass}; the shared name is
+     * written later by {@code defaultWriteObject}.
+     * </p>
+     */
+    @Test
+    void testWireFormatObjectTypeDataPrecedesNameField() throws Exception {
+        final String sharedName = "sharedObjectName";
+        // writeAnyClass for object type: writeBoolean(false) + writeObject(clazz).
+        // String.class and Integer.class are serialised differently, so streams diverge
+        // at the class-object position (inside writeAnyClass output).
+        final byte[] stringTypeBytes = serialize(new DynaProperty(sharedName, String.class));
+        final byte[] integerTypeBytes = serialize(new DynaProperty(sharedName, Integer.class));
+        // Find the shared name in both streams.
+        final byte[] nameBytes = sharedName.getBytes(StandardCharsets.UTF_8);
+        final int namePositionInString = findSequence(stringTypeBytes, nameBytes);
+        final int namePositionInInteger = findSequence(integerTypeBytes, nameBytes);
+        assertTrue(namePositionInString > 0, "Name must be present in the String-type stream");
+        assertTrue(namePositionInInteger > 0, "Name must be present in the Integer-type stream");
+        // Find the first byte where the streams diverge (inside writeAnyClass output,
+        // where the serialised form of String.class differs from Integer.class).
+        int firstDiff = -1;
+        for (int i = 0; i < stringTypeBytes.length; i++) {
+            if (stringTypeBytes[i] != integerTypeBytes[i]) {
+                firstDiff = i;
+                break;
+            }
+        }
+        assertTrue(firstDiff >= 0, "Streams must diverge where the class objects differ");
+        // The divergence (type class data from writeAnyClass) must precede the name
+        // (from defaultWriteObject) in BOTH streams.
+        assertTrue(firstDiff < namePositionInString, "writeAnyClass must be invoked before defaultWriteObject (String-type stream): "
+                + "type divergence at byte " + firstDiff + " must precede name at byte " + namePositionInString);
+        assertTrue(firstDiff < namePositionInInteger, "writeAnyClass must be invoked before defaultWriteObject (Integer-type stream): "
+                + "type divergence at byte " + firstDiff + " must precede name at byte " + namePositionInInteger);
+        assertRoundTrip(new DynaProperty(sharedName, String.class));
+        assertRoundTrip(new DynaProperty(sharedName, Integer.class));
+    }
+
+    /**
+     * Proves that for <strong>indexed and mapped properties</strong> the second {@code writeAnyClass} call (for {@code contentType}) also appears in the byte
+     * stream <em>before</em> the {@code name} field written by {@code defaultWriteObject}.
+     * <p>
+     * Two mapped properties share the same name and the same {@code Map.class} type but differ in their {@code contentType} ({@code Boolean.TYPE} vs
+     * {@code Byte.TYPE}). Because the type ({@code Map.class}) is identical, the streams are the same through the first {@code writeAnyClass} call. They
+     * diverge at the second {@code writeAnyClass} call (contentType constant), which must still precede the name.
+     * </p>
+     */
+    @Test
+    void testWireFormatContentTypeDataPrecedesNameFieldForMappedProperty() throws Exception {
+        final String sharedName = "sharedMappedName";
+        // Both use Map.class as the type (identical first writeAnyClass output).
+        // They differ only in contentType: Boolean.TYPE (constant 1) vs Byte.TYPE (constant 2).
+        final byte[] boolContentBytes = serialize(new DynaProperty(sharedName, Map.class, Boolean.TYPE));
+        final byte[] byteContentBytes = serialize(new DynaProperty(sharedName, Map.class, Byte.TYPE));
+        final byte[] nameBytes = sharedName.getBytes(StandardCharsets.UTF_8);
+        final int namePosition = findSequence(boolContentBytes, nameBytes);
+        assertTrue(namePosition > 0, "Name must be present in the serialized stream");
+        assertEquals(namePosition, findSequence(byteContentBytes, nameBytes), "Name must be at the same byte position in both streams");
+        // The streams are identical up to (and including) the type encoding of Map.class.
+        // They diverge at the contentType constant written by the second writeAnyClass call.
+        int firstDiff = -1;
+        for (int i = 0; i < boolContentBytes.length; i++) {
+            if (boolContentBytes[i] != byteContentBytes[i]) {
+                firstDiff = i;
+                break;
+            }
+        }
+        assertTrue(firstDiff >= 0, "Streams must diverge at the contentType primitive-type constant");
+        // The second writeAnyClass output (contentType) must still precede the name
+        // (defaultWriteObject), confirming that both writeAnyClass calls happen before
+        // defaultWriteObject is invoked.
+        assertTrue(firstDiff < namePosition, "The second writeAnyClass call (contentType) must precede defaultWriteObject: "
+                + "content-type divergence at byte " + firstDiff + " must precede name at byte " + namePosition);
+        assertRoundTrip(new DynaProperty(sharedName, Map.class, Boolean.TYPE));
+        assertRoundTrip(new DynaProperty(sharedName, Map.class, Byte.TYPE));
+    }
 }