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

JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new 4dba8f84f1 [format] Validate blob file footer and index metadata 
(#9223)
4dba8f84f1 is described below

commit 4dba8f84f1ec87bfecfb308d3afb927acab10af0
Author: Wenchao Wu <[email protected]>
AuthorDate: Sat Aug 15 20:15:18 2026 +0800

    [format] Validate blob file footer and index metadata (#9223)
---
 .../apache/paimon/format/blob/BlobFileMeta.java    |  72 ++++++-
 .../paimon/format/blob/BlobFileMetaTest.java       | 209 +++++++++++++++++++++
 2 files changed, 273 insertions(+), 8 deletions(-)

diff --git 
a/paimon-format/src/main/java/org/apache/paimon/format/blob/BlobFileMeta.java 
b/paimon-format/src/main/java/org/apache/paimon/format/blob/BlobFileMeta.java
index 24fe3a764d..7de594fc0a 100644
--- 
a/paimon-format/src/main/java/org/apache/paimon/format/blob/BlobFileMeta.java
+++ 
b/paimon-format/src/main/java/org/apache/paimon/format/blob/BlobFileMeta.java
@@ -32,46 +32,102 @@ import java.util.Iterator;
 /** Metadata of blob file. */
 public class BlobFileMeta {
 
+    private static final int FILE_FOOTER_LENGTH = Integer.BYTES + Byte.BYTES;
+    private static final int MIN_RECORD_LENGTH = Integer.BYTES + Long.BYTES + 
Integer.BYTES;
+
     private final long[] blobLengths;
     private final long[] blobOffsets;
     private final @Nullable int[] returnedPositions;
 
     public BlobFileMeta(SeekableInputStream in, long fileSize, @Nullable 
RoaringBitmap32 selection)
             throws IOException {
-        in.seek(fileSize - 5);
-        byte[] header = new byte[5];
+        if (fileSize < FILE_FOOTER_LENGTH) {
+            throw new IOException(
+                    String.format(
+                            "Corrupt blob file: file size %s is smaller than 
footer size %s.",
+                            fileSize, FILE_FOOTER_LENGTH));
+        }
+
+        in.seek(fileSize - FILE_FOOTER_LENGTH);
+        byte[] header = new byte[FILE_FOOTER_LENGTH];
         IOUtils.readFully(in, header);
-        byte version = header[4];
+        byte version = header[Integer.BYTES];
         if (version != BlobFormatWriter.VERSION) {
             throw new IOException("Unsupported version: " + version);
         }
         int indexLength = BytesUtils.getInt(header, 0);
+        long maximumIndexLength = fileSize - FILE_FOOTER_LENGTH;
+        if (indexLength < 0 || indexLength > maximumIndexLength) {
+            throw new IOException(
+                    String.format(
+                            "Corrupt blob file: invalid index length %s for 
file size %s.",
+                            indexLength, fileSize));
+        }
 
-        in.seek(fileSize - 5 - indexLength);
+        long indexStart = maximumIndexLength - indexLength;
+        in.seek(indexStart);
         byte[] indexBytes = new byte[indexLength];
         IOUtils.readFully(in, indexBytes);
 
-        long[] blobLengths = DeltaVarintCompressor.decompress(indexBytes);
+        long[] blobLengths;
+        try {
+            blobLengths = DeltaVarintCompressor.decompress(indexBytes);
+        } catch (RuntimeException e) {
+            throw new IOException("Corrupt blob file: invalid index.", e);
+        }
         long[] blobOffsets = new long[blobLengths.length];
         long offset = 0;
         for (int i = 0; i < blobLengths.length; i++) {
-            if (blobLengths[i] < 0) {
+            long blobLength = blobLengths[i];
+            if (blobLength == BlobFormatWriter.NULL_LENGTH
+                    || blobLength == BlobFormatWriter.PLACE_HOLDER_LENGTH) {
                 blobOffsets[i] = -1;
             } else {
+                if (blobLength < MIN_RECORD_LENGTH) {
+                    throw new IOException(
+                            String.format(
+                                    "Corrupt blob file: invalid record length 
%s at position %s.",
+                                    blobLength, i));
+                }
+                if (blobLength > indexStart - offset) {
+                    throw new IOException(
+                            String.format(
+                                    "Corrupt blob file: record length %s at 
position %s exceeds the data region.",
+                                    blobLength, i));
+                }
                 blobOffsets[i] = offset;
-                offset += blobLengths[i];
+                offset += blobLength;
             }
         }
+        if (offset != indexStart) {
+            throw new IOException(
+                    String.format(
+                            "Corrupt blob file: indexed records use %s bytes, 
but data region contains %s bytes.",
+                            offset, indexStart));
+        }
 
         int[] returnedPositions = null;
         if (selection != null) {
-            int cardinality = (int) selection.getCardinality();
+            long selectionCardinality = selection.getCardinality();
+            if (selectionCardinality > blobLengths.length) {
+                throw new IOException(
+                        String.format(
+                                "Invalid blob selection: cardinality %s 
exceeds record count %s.",
+                                selectionCardinality, blobLengths.length));
+            }
+            int cardinality = (int) selectionCardinality;
             returnedPositions = new int[cardinality];
             long[] newLengths = new long[cardinality];
             long[] newOffsets = new long[cardinality];
             Iterator<Integer> iterator = selection.iterator();
             for (int i = 0; i < cardinality; i++) {
                 Integer next = iterator.next();
+                if (next < 0 || next >= blobLengths.length) {
+                    throw new IOException(
+                            String.format(
+                                    "Invalid blob selection: position %s is 
outside record count %s.",
+                                    next, blobLengths.length));
+                }
                 newLengths[i] = blobLengths[next];
                 newOffsets[i] = blobOffsets[next];
                 returnedPositions[i] = next;
diff --git 
a/paimon-format/src/test/java/org/apache/paimon/format/blob/BlobFileMetaTest.java
 
b/paimon-format/src/test/java/org/apache/paimon/format/blob/BlobFileMetaTest.java
new file mode 100644
index 0000000000..3b3f7a1cc8
--- /dev/null
+++ 
b/paimon-format/src/test/java/org/apache/paimon/format/blob/BlobFileMetaTest.java
@@ -0,0 +1,209 @@
+/*
+ * 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
+ *
+ *     http://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.paimon.format.blob;
+
+import org.apache.paimon.fs.FileIO;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.SeekableInputStream;
+import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.utils.DeltaVarintCompressor;
+import org.apache.paimon.utils.RoaringBitmap32;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.UUID;
+
+import static org.apache.paimon.utils.StreamUtils.intToLittleEndian;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for {@link BlobFileMeta}. */
+public class BlobFileMetaTest {
+
+    private static final int RECORD_LENGTH = Integer.BYTES + Long.BYTES + 
Integer.BYTES;
+
+    @TempDir java.nio.file.Path tempPath;
+
+    private FileIO fileIO;
+    private Path file;
+
+    @BeforeEach
+    public void beforeEach() {
+        fileIO = LocalFileIO.create();
+        file = new Path(new Path(tempPath.toUri()), 
UUID.randomUUID().toString());
+    }
+
+    @Test
+    public void testRejectsFileSmallerThanFooter() throws IOException {
+        for (int size = 0; size < Integer.BYTES + Byte.BYTES; size++) {
+            writeFile(new byte[size]);
+            assertThatThrownBy(() -> readMeta(null))
+                    .isInstanceOf(IOException.class)
+                    .hasMessageContaining("smaller than footer");
+        }
+    }
+
+    @Test
+    public void testRejectsInvalidIndexLength() throws IOException {
+        writeFile(blobFileBytes(0, new byte[0], -1, BlobFormatWriter.VERSION));
+        assertThatThrownBy(() -> readMeta(null))
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("invalid index length -1");
+
+        writeFile(blobFileBytes(0, new byte[0], 1, BlobFormatWriter.VERSION));
+        assertThatThrownBy(() -> readMeta(null))
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("invalid index length 1");
+    }
+
+    @Test
+    public void testRejectsUnsupportedVersionAndMalformedIndex() throws 
IOException {
+        writeFile(blobFileBytes(0, new byte[0], 0, (byte) 
(BlobFormatWriter.VERSION + 1)));
+        assertThatThrownBy(() -> readMeta(null))
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("Unsupported version");
+
+        byte[] malformedIndex = new byte[] {(byte) 0x80};
+        writeFile(
+                blobFileBytes(0, malformedIndex, malformedIndex.length, 
BlobFormatWriter.VERSION));
+        assertThatThrownBy(() -> readMeta(null))
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("invalid index")
+                .hasCauseInstanceOf(RuntimeException.class);
+    }
+
+    @Test
+    public void testRejectsInvalidRecordLength() throws IOException {
+        assertInvalidRecordLength(-3, 0);
+        assertInvalidRecordLength(0, 0);
+        assertInvalidRecordLength(RECORD_LENGTH - 1, RECORD_LENGTH - 1);
+    }
+
+    @Test
+    public void testRejectsRecordLengthSumMismatch() throws IOException {
+        writeFile(blobFileBytes(RECORD_LENGTH - 1, RECORD_LENGTH));
+        assertThatThrownBy(() -> readMeta(null))
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("exceeds the data region");
+
+        writeFile(blobFileBytes(RECORD_LENGTH + 1, RECORD_LENGTH));
+        assertThatThrownBy(() -> readMeta(null))
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("indexed records use")
+                .hasMessageContaining("data region contains");
+    }
+
+    @Test
+    public void testAcceptsEmptyFileAndKnownSentinels() throws IOException {
+        writeFile(blobFileBytes(0));
+        assertThat(readMeta(null).recordNumber()).isZero();
+
+        writeFile(
+                blobFileBytes(
+                        RECORD_LENGTH,
+                        BlobFormatWriter.NULL_LENGTH,
+                        RECORD_LENGTH,
+                        BlobFormatWriter.PLACE_HOLDER_LENGTH));
+        BlobFileMeta meta = readMeta(null);
+        assertThat(meta.recordNumber()).isEqualTo(3);
+        assertThat(meta.isNull(0)).isTrue();
+        assertThat(meta.blobOffset(0)).isEqualTo(-1);
+        assertThat(meta.blobOffset(1)).isZero();
+        assertThat(meta.isPlaceHolder(2)).isTrue();
+        assertThat(meta.blobOffset(2)).isEqualTo(-1);
+    }
+
+    @Test
+    public void testValidatesSelectionPositions() throws IOException {
+        writeFile(blobFileBytes(RECORD_LENGTH, RECORD_LENGTH));
+
+        RoaringBitmap32 tooMany = new RoaringBitmap32();
+        tooMany.add(0);
+        tooMany.add(1);
+        assertThatThrownBy(() -> readMeta(tooMany))
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("cardinality 2 exceeds record count 1");
+
+        RoaringBitmap32 outOfRange = new RoaringBitmap32();
+        outOfRange.add(1);
+        assertThatThrownBy(() -> readMeta(outOfRange))
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("position 1 is outside record count 1");
+    }
+
+    @Test
+    public void testNonContiguousAndEmptySelection() throws IOException {
+        writeFile(blobFileBytes(RECORD_LENGTH * 3, RECORD_LENGTH, 
RECORD_LENGTH, RECORD_LENGTH));
+
+        RoaringBitmap32 selection = new RoaringBitmap32();
+        selection.add(0);
+        selection.add(2);
+        BlobFileMeta selected = readMeta(selection);
+        assertThat(selected.recordNumber()).isEqualTo(2);
+        assertThat(selected.blobOffset(0)).isZero();
+        assertThat(selected.blobOffset(1)).isEqualTo(RECORD_LENGTH * 2L);
+        assertThat(selected.returnedPosition(1)).isZero();
+        assertThat(selected.returnedPosition(2)).isEqualTo(2);
+
+        BlobFileMeta empty = readMeta(new RoaringBitmap32());
+        assertThat(empty.recordNumber()).isZero();
+    }
+
+    private void assertInvalidRecordLength(long recordLength, int dataLength) 
throws IOException {
+        writeFile(blobFileBytes(dataLength, recordLength));
+        assertThatThrownBy(() -> readMeta(null))
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("invalid record length " + recordLength);
+    }
+
+    private BlobFileMeta readMeta(RoaringBitmap32 selection) throws 
IOException {
+        try (SeekableInputStream in = fileIO.newInputStream(file)) {
+            return new BlobFileMeta(in, fileIO.getFileSize(file), selection);
+        }
+    }
+
+    private void writeFile(byte[] bytes) throws IOException {
+        Files.write(Paths.get(file.toUri()), bytes);
+    }
+
+    private static byte[] blobFileBytes(int dataLength, long... recordLengths) 
{
+        byte[] index = DeltaVarintCompressor.compress(recordLengths);
+        return blobFileBytes(dataLength, index, index.length, 
BlobFormatWriter.VERSION);
+    }
+
+    private static byte[] blobFileBytes(
+            int dataLength, byte[] index, int declaredIndexLength, byte 
version) {
+        byte[] bytes = new byte[dataLength + index.length + Integer.BYTES + 
Byte.BYTES];
+        System.arraycopy(index, 0, bytes, dataLength, index.length);
+        byte[] indexLengthBytes = intToLittleEndian(declaredIndexLength);
+        System.arraycopy(
+                indexLengthBytes,
+                0,
+                bytes,
+                bytes.length - Integer.BYTES - Byte.BYTES,
+                indexLengthBytes.length);
+        bytes[bytes.length - 1] = version;
+        return bytes;
+    }
+}

Reply via email to