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

spmallette pushed a commit to branch tinkergraph-storage
in repository https://gitbox.apache.org/repos/asf/tinkerpop.git


The following commit(s) were added to refs/heads/tinkergraph-storage by this 
push:
     new 9a6255a1f6 Bound decoder allocations and dictionary refs in 
TinkerStorageGraph storage
9a6255a1f6 is described below

commit 9a6255a1f6e8bcd6b6e78420c9b81be91dfb7b60
Author: Stephen Mallette <[email protected]>
AuthorDate: Tue Sep 8 13:26:38 2026 -0400

    Bound decoder allocations and dictionary refs in TinkerStorageGraph storage
    
    A corrupt storage frame could escape the corrupt-frame contract and surface 
as
    an unchecked error instead of an IOException. readString allocated from a
    declared length before checking it against the record, so a small frame 
could
    demand gigabytes. readVarInt had no shift bound, so an over-long encoding
    wrapped and was silently accepted. Dictionary refs were dereferenced 
straight
    into the backing list, so a ref naming an undefined entry raised
    IndexOutOfBoundsException. All three now report corruption.
    
    Assisted-by: Claude Code:claude-opus-5
    Claude-Session: https://claude.ai/code/session_01KgH2VCpRw57sbFg5GoAiVV
---
 .../structure/storage/GraphBinaryStorage.java      | 47 +++++++++---
 .../structure/storage/GraphBinaryStorageTest.java  | 89 ++++++++++++++++++++++
 2 files changed, 127 insertions(+), 9 deletions(-)

diff --git 
a/tinkergraph-gremlin/src/main/java/org/apache/tinkerpop/gremlin/tinkergraph/structure/storage/GraphBinaryStorage.java
 
b/tinkergraph-gremlin/src/main/java/org/apache/tinkerpop/gremlin/tinkergraph/structure/storage/GraphBinaryStorage.java
index e99e3c8192..a5c4707409 100644
--- 
a/tinkergraph-gremlin/src/main/java/org/apache/tinkerpop/gremlin/tinkergraph/structure/storage/GraphBinaryStorage.java
+++ 
b/tinkergraph-gremlin/src/main/java/org/apache/tinkerpop/gremlin/tinkergraph/structure/storage/GraphBinaryStorage.java
@@ -326,17 +326,17 @@ public final class GraphBinaryStorage extends 
AbstractLogStorage {
         final DetachedVertex.Builder b = DetachedVertex.build().setId(id);
         final int labelCount = readVarInt(buf);
         if (labelCount == 1) {
-            b.setLabel(idToKey.get(readVarInt(buf)));
+            b.setLabel(resolveKey(buf));
         } else if (labelCount > 1) {
             final Set<String> labels = new LinkedHashSet<>();
             for (int i = 0; i < labelCount; i++)
-                labels.add(idToKey.get(readVarInt(buf)));
+                labels.add(resolveKey(buf));
             b.setLabels(labels);
         }
         final boolean hasVpIds = buf.readByte() != 0;
         final int keyGroupCount = readVarInt(buf);
         for (int g = 0; g < keyGroupCount; g++) {
-            final String key = idToKey.get(readVarInt(buf));
+            final String key = resolveKey(buf);
             final int valueCount = readVarInt(buf);
             for (int j = 0; j < valueCount; j++) {
                 final Object value = readScalar(buf);
@@ -345,7 +345,7 @@ public final class GraphBinaryStorage extends 
AbstractLogStorage {
                     vpb.setId(readScalar(buf));
                 final int metaCount = readVarInt(buf);
                 for (int m = 0; m < metaCount; m++) {
-                    final String metaKey = idToKey.get(readVarInt(buf));
+                    final String metaKey = resolveKey(buf);
                     final Object metaValue = readScalar(buf);
                     vpb.addProperty(new DetachedProperty<>(metaKey, 
metaValue));
                 }
@@ -357,7 +357,7 @@ public final class GraphBinaryStorage extends 
AbstractLogStorage {
 
     private DetachedEdge readEdgeRecord(final ByteBufferBuffer buf) throws 
IOException {
         final Object id = readScalar(buf);
-        final String label = idToKey.get(readVarInt(buf));
+        final String label = resolveKey(buf);
         final Object outVId = readScalar(buf);
         final Object inVId = readScalar(buf);
         final DetachedEdge.Builder b = 
DetachedEdge.build().setId(id).setLabel(label)
@@ -365,7 +365,7 @@ public final class GraphBinaryStorage extends 
AbstractLogStorage {
                 .setInV(DetachedVertex.build().setId(inVId).create());
         final int propCount = readVarInt(buf);
         for (int i = 0; i < propCount; i++) {
-            final String key = idToKey.get(readVarInt(buf));
+            final String key = resolveKey(buf);
             final Object value = readScalar(buf);
             b.addProperty(new DetachedProperty<>(key, value));
         }
@@ -407,8 +407,30 @@ public final class GraphBinaryStorage extends 
AbstractLogStorage {
         buf.writeBytes(bytes);
     }
 
-    private static String readString(final ByteBufferBuffer buf) {
-        final byte[] bytes = new byte[readVarInt(buf)];
+    /**
+     * Resolve the next dictionary ref in {@code buf} to its string. A ref 
that names an entry the dictionary does not
+     * hold is corruption, and is reported as such rather than raised as an 
{@code IndexOutOfBoundsException} from the
+     * backing list.
+     */
+    private String resolveKey(final ByteBufferBuffer buf) throws IOException {
+        final int id = readVarInt(buf);
+        if (id >= idToKey.size())
+            throw new IOException(String.format(
+                    "Corrupt storage frame: dictionary ref %d with only %d 
entries defined", id, idToKey.size()));
+        return idToKey.get(id);
+    }
+
+    private static String readString(final ByteBufferBuffer buf) throws 
IOException {
+        final int length = readVarInt(buf);
+        // check the declared length against what the frame actually holds 
before allocating. The frame itself is
+        // already bounded against the file by AbstractLogStorage.readFrame, 
but a length inside the frame is not,
+        // so an unchecked allocation here would let a small corrupt record 
demand gigabytes and raise
+        // OutOfMemoryError instead of the IOException a corrupt frame is 
contracted to produce.
+        if (length > buf.readableBytes())
+            throw new IOException(String.format(
+                    "Corrupt storage frame: string of %d bytes declared with 
only %d readable in the record",
+                    length, buf.readableBytes()));
+        final byte[] bytes = new byte[length];
         buf.readBytes(bytes);
         return new String(bytes, StandardCharsets.UTF_8);
     }
@@ -426,15 +448,22 @@ public final class GraphBinaryStorage extends 
AbstractLogStorage {
         buf.writeByte(v & 0x7F);
     }
 
-    private static int readVarInt(final ByteBufferBuffer buf) {
+    private static int readVarInt(final ByteBufferBuffer buf) throws 
IOException {
         int result = 0;
         int shift = 0;
         byte b;
         do {
+            // Java masks a shift count to five bits, so without this bound an 
over-long encoding wraps around and
+            // yields an arbitrary (possibly negative) value rather than 
failing. Every count, length and dictionary
+            // ref in the format is non-negative, so anything that does not 
fit in five groups is corruption.
+            if (shift >= Integer.SIZE)
+                throw new IOException("Corrupt storage frame: over-long varint 
encoding");
             b = buf.readByte();
             result |= (b & 0x7F) << shift;
             shift += 7;
         } while ((b & 0x80) != 0);
+        if (result < 0)
+            throw new IOException("Corrupt storage frame: negative varint 
value " + result);
         return result;
     }
 }
diff --git 
a/tinkergraph-gremlin/src/test/java/org/apache/tinkerpop/gremlin/tinkergraph/structure/storage/GraphBinaryStorageTest.java
 
b/tinkergraph-gremlin/src/test/java/org/apache/tinkerpop/gremlin/tinkergraph/structure/storage/GraphBinaryStorageTest.java
index 346e6a50c4..c001d94afd 100644
--- 
a/tinkergraph-gremlin/src/test/java/org/apache/tinkerpop/gremlin/tinkergraph/structure/storage/GraphBinaryStorageTest.java
+++ 
b/tinkergraph-gremlin/src/test/java/org/apache/tinkerpop/gremlin/tinkergraph/structure/storage/GraphBinaryStorageTest.java
@@ -32,10 +32,12 @@ import java.io.DataInputStream;
 import java.io.File;
 import java.io.IOException;
 import java.io.RandomAccessFile;
+import java.nio.ByteBuffer;
 import java.nio.file.Files;
 import java.util.HashMap;
 import java.util.Iterator;
 import java.util.Map;
+import java.util.zip.CRC32;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertTrue;
@@ -341,6 +343,93 @@ public class GraphBinaryStorageTest extends 
AbstractTinkerStorageConformanceTest
         }
     }
 
+    @Test
+    public void shouldFailOnStringLongerThanItsFrame() throws Exception {
+        // a dictionary-append entry declaring a ~2GB string in a record 
holding no such bytes. The declared length
+        // must be checked before the array is allocated, otherwise this is an 
OutOfMemoryError rather than a
+        // reportable corrupt frame.
+        final byte[] payload = new byte[] {
+                0x01,                                     // entry count = 1
+                0x05,                                     // OP_DICT_APPEND
+                0x00,                                     // dictionary id = 0
+                (byte) 0xF0, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, 0x07  // 
varint length = 0x7FFFFFF0
+        };
+
+        try {
+            openWithSyntheticFrame(payload);
+            fail("expected reopen to fail on a string longer than its frame");
+        } catch (Exception expected) {
+            assertTrue("cause should report corruption: " + 
rootMessage(expected),
+                    rootMessage(expected).contains("string of"));
+        }
+    }
+
+    @Test
+    public void shouldFailOnOverLongVarInt() throws Exception {
+        // a varint whose continuation bits run past the width of an int. Java 
masks a shift count to five bits, so
+        // without a bound this wraps and yields an arbitrary, possibly 
negative, value instead of failing.
+        final byte[] payload = new byte[] {
+                (byte) 0x80, (byte) 0x80, (byte) 0x80, (byte) 0x80,
+                (byte) 0x80, (byte) 0x80, (byte) 0x80, 0x00
+        };
+
+        try {
+            openWithSyntheticFrame(payload);
+            fail("expected reopen to fail on an over-long varint");
+        } catch (Exception expected) {
+            assertTrue("cause should report corruption: " + 
rootMessage(expected),
+                    rootMessage(expected).contains("over-long varint"));
+        }
+    }
+
+    @Test
+    public void shouldFailOnDictionaryRefWithNoSuchEntry() throws Exception {
+        // a vertex record whose label names dictionary entry 5 when the 
dictionary is empty. The ref must be
+        // validated rather than dereferenced straight into the backing list.
+        final byte[] payload = new byte[] {
+                0x01,                                     // entry count = 1
+                0x01,                                     // OP_PUT_VERTEX
+                0x01, 0x00, 0x00, 0x00, 0x2A,             // id: GraphBinary 
INT tag then 42
+                0x01,                                     // label count = 1
+                0x05                                      // dictionary ref = 
5, nothing defined
+        };
+
+        try {
+            openWithSyntheticFrame(payload);
+            fail("expected reopen to fail on an undefined dictionary ref");
+        } catch (Exception expected) {
+            assertTrue("cause should report corruption: " + 
rootMessage(expected),
+                    rootMessage(expected).contains("dictionary ref"));
+        }
+    }
+
+    /**
+     * Replace the store's log with a single well-formed frame (correct length 
prefix and CRC) carrying {@code
+     * payload}, then reopen. The framing must be valid so that replay reaches 
the codec rather than stopping at the
+     * frame checks, which are covered separately.
+     */
+    private void openWithSyntheticFrame(final byte[] payload) throws Exception 
{
+        TinkerStorageGraph graph = open();
+        final String location = 
graph.configuration().getString(TinkerGraph.GREMLIN_TINKERGRAPH_STORAGE_DIRECTORY);
+        graph.addVertex(T.id, 1);
+        graph.tx().commit();
+        graph.tx().close();
+        graph.close();
+
+        final CRC32 crc = new CRC32();
+        crc.update(payload);
+        final ByteBuffer frame = 
ByteBuffer.allocate(GraphBinaryStorage.HEADER_SIZE + 2 * Integer.BYTES + 
payload.length);
+        frame.put(GraphBinaryStorage.MAGIC);
+        frame.putInt(payload.length);
+        frame.putInt((int) crc.getValue());
+        frame.put(payload);
+
+        Files.deleteIfExists(new File(location, 
GraphBinaryStorage.SNAPSHOT_FILE).toPath());
+        Files.write(new File(location, GraphBinaryStorage.LOG_FILE).toPath(), 
frame.array());
+
+        open().close();
+    }
+
     @Test
     public void shouldFailOnForeignFileWithBadMagic() throws Exception {
         TinkerStorageGraph graph = open();

Reply via email to