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

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


The following commit(s) were added to refs/heads/master by this push:
     new 2ea9ccbc3e3 [FLINK-40943][filesystems] Restore FileSink state written 
with flink-s3-fs-hadoop in flink-s3-fs-native
2ea9ccbc3e3 is described below

commit 2ea9ccbc3e3436f320a1345794c0e3a3028f7f3b
Author: Joao Boto <[email protected]>
AuthorDate: Tue Nov 3 16:32:01 2026 +0100

    [FLINK-40943][filesystems] Restore FileSink state written with 
flink-s3-fs-hadoop in flink-s3-fs-native
---
 docs/content.zh/docs/deployment/filesystems/s3.md  |   8 +
 docs/content/docs/deployment/filesystems/s3.md     |   8 +
 .../s3/common/writer/S3RecoverableSerializer.java  |   3 +
 .../writer/NativeS3RecoverableSerializer.java      | 127 +++++++
 .../writer/NativeS3RecoverableSerializerTest.java  | 367 +++++++++++++++++++++
 5 files changed, 513 insertions(+)

diff --git a/docs/content.zh/docs/deployment/filesystems/s3.md 
b/docs/content.zh/docs/deployment/filesystems/s3.md
index 27d4a77bde2..642d25226e6 100644
--- a/docs/content.zh/docs/deployment/filesystems/s3.md
+++ b/docs/content.zh/docs/deployment/filesystems/s3.md
@@ -153,6 +153,14 @@ The legacy configuration key `s3.path.style.access` is 
still supported as a fall
 
 The Native S3 FileSystem is a pure-Java implementation built on the AWS SDK v2 
completely removing the dependency on Hadoop. It is registered under the 
schemes *s3://* and *s3a://*. It provides a drop-in replacement for the Presto 
and Hadoop implementations, supporting checkpointing, the [FileSink]({{< ref 
"docs/connectors/datastream/filesystem" >}}) (via `RecoverableWriter`), 
server-side encryption (SSE-S3, SSE-KMS), cross-account access via IAM role 
assumption, entropy injection, and b [...]
 
+{{< hint info >}}
+**Switching from the Hadoop implementation:** jobs with pending or in-progress 
`FileSink` part files in their state can be restored with the Native S3 
FileSystem, which detects and decodes the state written by the Hadoop 
implementation.
+
+This is a one-time migration path. The Hadoop implementation is being left 
behind and later changes to its state format are not followed. The reverse 
direction is not supported: state written by the Native S3 FileSystem cannot be 
restored with the Hadoop implementation.
+
+Known gap: a part file that was closed with zero bytes in the Hadoop format 
has no committed parts. The Native S3 FileSystem cannot commit it, so restoring 
such state fails.
+{{< /hint >}}
+
 #### Cleaning Up Unfinished Uploads
 
 The Native S3 FileSystem never aborts a multipart upload on its own. An upload 
that is in progress may be referenced by a checkpoint or savepoint, and Flink 
cannot tell whether that is the case when a stream is closed, cancelled, or 
fails to commit. Aborting such an upload would make the referencing checkpoint 
or savepoint unrecoverable. Uploads that are no longer needed (for example 
after a job is cancelled and never restored, or after a failed commit) 
therefore remain in the bucket and [...]
diff --git a/docs/content/docs/deployment/filesystems/s3.md 
b/docs/content/docs/deployment/filesystems/s3.md
index 40756c5d95c..07903d08b56 100644
--- a/docs/content/docs/deployment/filesystems/s3.md
+++ b/docs/content/docs/deployment/filesystems/s3.md
@@ -153,6 +153,14 @@ The legacy configuration key `s3.path.style.access` is 
still supported as a fall
 
 The Native S3 FileSystem is a pure-Java implementation built on the AWS SDK v2 
completely removing the dependency on Hadoop. It is registered under the 
schemes *s3://* and *s3a://*. It provides a drop-in replacement for the Presto 
and Hadoop implementations, supporting checkpointing, the [FileSink]({{< ref 
"docs/connectors/datastream/filesystem" >}}) (via `RecoverableWriter`), 
server-side encryption (SSE-S3, SSE-KMS), cross-account access via IAM role 
assumption, entropy injection, and b [...]
 
+{{< hint info >}}
+**Switching from the Hadoop implementation:** jobs with pending or in-progress 
`FileSink` part files in their state can be restored with the Native S3 
FileSystem, which detects and decodes the state written by the Hadoop 
implementation.
+
+This is a one-time migration path. The Hadoop implementation is being left 
behind and later changes to its state format are not followed. The reverse 
direction is not supported: state written by the Native S3 FileSystem cannot be 
restored with the Hadoop implementation.
+
+Known gap: a part file that was closed with zero bytes in the Hadoop format 
has no committed parts. The Native S3 FileSystem cannot commit it, so restoring 
such state fails.
+{{< /hint >}}
+
 #### Cleaning Up Unfinished Uploads
 
 The Native S3 FileSystem never aborts a multipart upload on its own. An upload 
that is in progress may be referenced by a checkpoint or savepoint, and Flink 
cannot tell whether that is the case when a stream is closed, cancelled, or 
fails to commit. Aborting such an upload would make the referencing checkpoint 
or savepoint unrecoverable. Uploads that are no longer needed (for example 
after a job is cancelled and never restored, or after a failed commit) 
therefore remain in the bucket and [...]
diff --git 
a/flink-filesystems/flink-s3-fs-base/src/main/java/org/apache/flink/fs/s3/common/writer/S3RecoverableSerializer.java
 
b/flink-filesystems/flink-s3-fs-base/src/main/java/org/apache/flink/fs/s3/common/writer/S3RecoverableSerializer.java
index a72e99be831..d477057bdd4 100644
--- 
a/flink-filesystems/flink-s3-fs-base/src/main/java/org/apache/flink/fs/s3/common/writer/S3RecoverableSerializer.java
+++ 
b/flink-filesystems/flink-s3-fs-base/src/main/java/org/apache/flink/fs/s3/common/writer/S3RecoverableSerializer.java
@@ -41,6 +41,9 @@ final class S3RecoverableSerializer implements 
SimpleVersionedSerializer<S3Recov
 
     private static final int MAGIC_NUMBER = 0x98761432;
 
+    // One-time copy: flink-s3-fs-native duplicates this decoder (FLINK-40943).
+    // Later format changes are not mirrored there.
+
     /** Do not instantiate, use reusable {@link #INSTANCE} instead. */
     private S3RecoverableSerializer() {}
 
diff --git 
a/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableSerializer.java
 
b/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableSerializer.java
index 5f9c28fdb8e..bf8a1fb4546 100644
--- 
a/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableSerializer.java
+++ 
b/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableSerializer.java
@@ -18,14 +18,23 @@
 
 package org.apache.flink.fs.s3native.writer;
 
+import org.apache.flink.annotation.VisibleForTesting;
 import org.apache.flink.core.io.SimpleVersionedSerializer;
 import org.apache.flink.fs.s3native.writer.NativeS3Recoverable.PartETag;
 
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
 import java.io.DataInputStream;
 import java.io.DataOutputStream;
 import java.io.IOException;
+import java.nio.BufferUnderflowException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.List;
 
@@ -68,11 +77,21 @@ import java.util.List;
  */
 class NativeS3RecoverableSerializer implements 
SimpleVersionedSerializer<NativeS3Recoverable> {
 
+    private static final Logger LOG = 
LoggerFactory.getLogger(NativeS3RecoverableSerializer.class);
+
     public static final NativeS3RecoverableSerializer INSTANCE =
             new NativeS3RecoverableSerializer();
 
     private static final int CURRENT_VERSION = 1;
 
+    private static final Charset CHARSET = StandardCharsets.UTF_8;
+
+    /**
+     * Magic number written at the beginning of the legacy ({@code 
flink-s3-fs-hadoop}) serialized
+     * form, as found in {@code S3RecoverableSerializer} of flink-s3-fs-base.
+     */
+    @VisibleForTesting static final int LEGACY_MAGIC_NUMBER = 0x98761432;
+
     @Override
     public int getVersion() {
         return CURRENT_VERSION;
@@ -108,6 +127,12 @@ class NativeS3RecoverableSerializer implements 
SimpleVersionedSerializer<NativeS
         if (version != CURRENT_VERSION) {
             throw new IOException("Unsupported version: " + version);
         }
+        if (isLegacyFormat(serialized)) {
+            LOG.debug(
+                    "Detected S3 multipart upload state written by 
flink-s3-fs-hadoop "
+                            + "(legacy format); restoring it with the native 
S3 filesystem.");
+            return deserializeLegacy(serialized);
+        }
         ByteArrayInputStream inputStream = new 
ByteArrayInputStream(serialized);
         DataInputStream in = new DataInputStream(inputStream);
         String objectName = in.readUTF();
@@ -135,4 +160,106 @@ class NativeS3RecoverableSerializer implements 
SimpleVersionedSerializer<NativeS
                 incompleteObjectName,
                 incompleteObjectLength);
     }
+
+    /**
+     * Returns whether the state was written by flink-s3-fs-base's {@code 
S3RecoverableSerializer}.
+     */
+    private static boolean isLegacyFormat(byte[] serialized) {
+        if (serialized.length < Integer.BYTES) {
+            return false;
+        }
+        return 
ByteBuffer.wrap(serialized).order(ByteOrder.LITTLE_ENDIAN).getInt()
+                == LEGACY_MAGIC_NUMBER;
+    }
+
+    /**
+     * Decodes the legacy little-endian format written by {@code 
S3RecoverableSerializer} in
+     * flink-s3-fs-base, wrapping any decode error of corrupt state in an 
{@link IOException}.
+     */
+    private static NativeS3Recoverable deserializeLegacy(byte[] serialized) 
throws IOException {
+        try {
+            return decodeLegacy(serialized);
+        } catch (BufferUnderflowException
+                | NegativeArraySizeException
+                | IllegalArgumentException e) {
+            throw new IOException(
+                    "Corrupt legacy S3 recoverable state: truncated or invalid 
data "
+                            + "(written by flink-s3-fs-hadoop).",
+                    e);
+        }
+    }
+
+    private static NativeS3Recoverable decodeLegacy(byte[] serialized) throws 
IOException {
+        final ByteBuffer bb = 
ByteBuffer.wrap(serialized).order(ByteOrder.LITTLE_ENDIAN);
+
+        // re-check kept so this decoder stays a faithful copy of the 
authoritative
+        // S3RecoverableSerializer.deserializeV1 in flink-s3-fs-base
+        if (bb.getInt() != LEGACY_MAGIC_NUMBER) {
+            throw new IOException("Corrupt data: Unexpected magic number.");
+        }
+
+        final byte[] keyBytes = new byte[readBoundedLength(bb)];
+        bb.get(keyBytes);
+
+        final byte[] uploadIdBytes = new byte[readBoundedLength(bb)];
+        bb.get(uploadIdBytes);
+
+        final int numParts = readBoundedPartCount(bb);
+        final List<PartETag> parts = new ArrayList<>(numParts);
+        for (int i = 0; i < numParts; i++) {
+            final int partNum = bb.getInt();
+            final byte[] buffer = new byte[readBoundedLength(bb)];
+            bb.get(buffer);
+            parts.add(new PartETag(partNum, new String(buffer, CHARSET)));
+        }
+
+        final long numBytes = bb.getLong();
+
+        final String lastPart;
+        final int lastObjectArraySize = readBoundedLength(bb);
+        if (lastObjectArraySize == 0) {
+            lastPart = null;
+        } else {
+            final byte[] lastPartBytes = new byte[lastObjectArraySize];
+            bb.get(lastPartBytes);
+            lastPart = new String(lastPartBytes, CHARSET);
+        }
+
+        final long lastPartLength = bb.getLong();
+
+        if (bb.hasRemaining()) {
+            throw new IOException(
+                    "Corrupt legacy S3 recoverable state: unexpected trailing 
bytes.");
+        }
+
+        return new NativeS3Recoverable(
+                new String(keyBytes, CHARSET),
+                new String(uploadIdBytes, CHARSET),
+                parts,
+                numBytes,
+                lastPart,
+                lastPartLength);
+    }
+
+    /** Reads a field length, rejecting negative values and values beyond the 
remaining buffer. */
+    private static int readBoundedLength(ByteBuffer bb) throws IOException {
+        final int length = bb.getInt();
+        if (length < 0 || length > bb.remaining()) {
+            throw new IOException(
+                    "Corrupt legacy S3 recoverable state: invalid field length 
" + length);
+        }
+        return length;
+    }
+
+    /** Reads the part count, bounded so that a corrupt value cannot trigger a 
huge allocation. */
+    private static int readBoundedPartCount(ByteBuffer bb) throws IOException {
+        final int numParts = bb.getInt();
+        // every part needs at least part number + eTag length (2 ints); 
remaining also holds the
+        // trailing fields, so this bound is conservative but proportional to 
the buffer size
+        if (numParts < 0 || (long) numParts * 2 * Integer.BYTES > 
bb.remaining()) {
+            throw new IOException(
+                    "Corrupt legacy S3 recoverable state: invalid part count " 
+ numParts);
+        }
+        return numParts;
+    }
 }
diff --git 
a/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableSerializerTest.java
 
b/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableSerializerTest.java
index c9b38954f70..74318fbcc7e 100644
--- 
a/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableSerializerTest.java
+++ 
b/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableSerializerTest.java
@@ -23,14 +23,40 @@ import org.junit.jupiter.api.Test;
 import java.io.ByteArrayOutputStream;
 import java.io.DataOutputStream;
 import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.List;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 /** Tests for {@link NativeS3RecoverableSerializer}. */
 class NativeS3RecoverableSerializerTest {
 
+    /**
+     * Bytes captured once from the real {@code 
S3RecoverableSerializer#serialize(S3Recoverable)} in
+     * flink-s3-fs-base (object name {@code dir/test-key-ä日🔑}, upload id 
{@code upload-ä-id}, 2
+     * parts, incomplete part with a multi-byte name).
+     */
+    private static final String GOLDEN_LEGACY_HEX =
+            "3214769816000000"
+                    + "6469722f74657374"
+                    + "2d6b65792dc3a4e6"
+                    + "97a5f09f94910c00"
+                    + "000075706c6f6164"
+                    + "2dc3a42d69640200"
+                    + "0000010000000800"
+                    + "0000657461672d6f"
+                    + "6e65020000000800"
+                    + "0000657461672d74"
+                    + "776f393000000000"
+                    + "000011000000696e"
+                    + "636f6d706c657465"
+                    + "2d6b65792dc3a400"
+                    + "04000000000000";
+
     @Test
     void testSerializeAndDeserializeWithParts() throws IOException {
         NativeS3RecoverableSerializer serializer = 
NativeS3RecoverableSerializer.INSTANCE;
@@ -148,4 +174,345 @@ class NativeS3RecoverableSerializerTest {
         assertThat(deserialized.incompleteObjectLength()).isEqualTo(1024L);
         assertThat(serializer.serialize(deserialized)).isEqualTo(version1);
     }
+
+    @Test
+    void testUnsupportedVersionThrows() throws IOException {
+        NativeS3RecoverableSerializer serializer = 
NativeS3RecoverableSerializer.INSTANCE;
+        NativeS3Recoverable original =
+                new NativeS3Recoverable("key", "upload-id", new ArrayList<>(), 
0L);
+        byte[] serialized = serializer.serialize(original);
+
+        assertThatThrownBy(() -> serializer.deserialize(2, serialized))
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("Unsupported version");
+    }
+
+    @Test
+    void testDeserializeLegacyFormatWithIncompletePart() throws IOException {
+        NativeS3Recoverable deserialized =
+                NativeS3RecoverableSerializer.INSTANCE.deserialize(
+                        NativeS3RecoverableSerializer.INSTANCE.getVersion(),
+                        serializeLegacy(
+                                "test-object-key",
+                                "test-upload-id",
+                                new int[] {1, 2},
+                                new String[] {"etag1", "etag2"},
+                                12345L,
+                                "incomplete-object-key",
+                                1024L));
+
+        assertThat(deserialized.getObjectName()).isEqualTo("test-object-key");
+        assertThat(deserialized.uploadId()).isEqualTo("test-upload-id");
+        assertThat(deserialized.numBytesInParts()).isEqualTo(12345L);
+        assertThat(deserialized.parts()).hasSize(2);
+        assertThat(deserialized.parts().get(0).getPartNumber()).isEqualTo(1);
+        assertThat(deserialized.parts().get(0).getETag()).isEqualTo("etag1");
+        assertThat(deserialized.parts().get(1).getPartNumber()).isEqualTo(2);
+        assertThat(deserialized.parts().get(1).getETag()).isEqualTo("etag2");
+        
assertThat(deserialized.incompleteObjectName()).isEqualTo("incomplete-object-key");
+        assertThat(deserialized.incompleteObjectLength()).isEqualTo(1024L);
+    }
+
+    @Test
+    void testDeserializeLegacyFormatWithoutIncompletePart() throws IOException 
{
+        NativeS3Recoverable deserialized =
+                NativeS3RecoverableSerializer.INSTANCE.deserialize(
+                        NativeS3RecoverableSerializer.INSTANCE.getVersion(),
+                        serializeLegacy(
+                                "test-object-key",
+                                "test-upload-id",
+                                new int[] {1},
+                                new String[] {"etag1"},
+                                54321L,
+                                null,
+                                -1L));
+
+        assertThat(deserialized.getObjectName()).isEqualTo("test-object-key");
+        assertThat(deserialized.uploadId()).isEqualTo("test-upload-id");
+        assertThat(deserialized.numBytesInParts()).isEqualTo(54321L);
+        assertThat(deserialized.parts()).hasSize(1);
+        assertThat(deserialized.incompleteObjectName()).isNull();
+        assertThat(deserialized.incompleteObjectLength()).isEqualTo(-1L);
+    }
+
+    @Test
+    void testLegacyFormatWithoutParts() throws IOException {
+        NativeS3Recoverable deserialized =
+                NativeS3RecoverableSerializer.INSTANCE.deserialize(
+                        NativeS3RecoverableSerializer.INSTANCE.getVersion(),
+                        serializeLegacy(
+                                "key", "upload-id", new int[0], new String[0], 
0L, null, -1L));
+
+        assertThat(deserialized.getObjectName()).isEqualTo("key");
+        assertThat(deserialized.parts()).isEmpty();
+    }
+
+    /**
+     * The current format's leading {@code writeUTF} length could numerically 
collide with the
+     * legacy magic number's first two bytes for an object name of 0x3214 
(12820) bytes. Even then
+     * the state must not go down the legacy path: it is decoded by the 
current-format reader.
+     */
+    @Test
+    void testCurrentFormatNotMistakenForLegacyFormat() throws IOException {
+        NativeS3RecoverableSerializer serializer = 
NativeS3RecoverableSerializer.INSTANCE;
+        String longObjectName = new String(new char[0x3214]).replace('\0', 
'a');
+
+        ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+        DataOutputStream out = new DataOutputStream(bytes);
+        out.writeUTF(longObjectName);
+        out.writeUTF("test-upload-id");
+        out.writeLong(12345L);
+        out.writeInt(0);
+        out.writeBoolean(false);
+        out.flush();
+        byte[] serialized = bytes.toByteArray();
+
+        assertThat(serialized[0]).isEqualTo((byte) 0x32);
+        assertThat(serialized[1]).isEqualTo((byte) 0x14);
+
+        NativeS3Recoverable deserialized =
+                serializer.deserialize(serializer.getVersion(), serialized);
+
+        assertThat(deserialized.getObjectName()).isEqualTo(longObjectName);
+        assertThat(deserialized.uploadId()).isEqualTo("test-upload-id");
+        assertThat(deserialized.numBytesInParts()).isEqualTo(12345L);
+    }
+
+    /**
+     * Golden test against bytes captured once from the real {@code
+     * S3RecoverableSerializer#serialize(S3Recoverable)} in flink-s3-fs-base, 
pinning the legacy
+     * wire format independently of the test-side re-encoder below.
+     */
+    @Test
+    void testDeserializeGoldenLegacyBytes() throws IOException {
+        NativeS3Recoverable deserialized =
+                NativeS3RecoverableSerializer.INSTANCE.deserialize(
+                        NativeS3RecoverableSerializer.INSTANCE.getVersion(),
+                        hexToBytes(GOLDEN_LEGACY_HEX));
+
+        
assertThat(deserialized.getObjectName()).isEqualTo("dir/test-key-\u00e4\u65e5\uD83D\uDD11");
+        assertThat(deserialized.uploadId()).isEqualTo("upload-\u00e4-id");
+        assertThat(deserialized.numBytesInParts()).isEqualTo(12345L);
+        assertThat(deserialized.parts()).hasSize(2);
+        assertThat(deserialized.parts().get(0).getPartNumber()).isEqualTo(1);
+        
assertThat(deserialized.parts().get(0).getETag()).isEqualTo("etag-one");
+        assertThat(deserialized.parts().get(1).getPartNumber()).isEqualTo(2);
+        
assertThat(deserialized.parts().get(1).getETag()).isEqualTo("etag-two");
+        
assertThat(deserialized.incompleteObjectName()).isEqualTo("incomplete-key-\u00e4");
+        assertThat(deserialized.incompleteObjectLength()).isEqualTo(1024L);
+    }
+
+    @Test
+    void testDeserializeLegacyWithMultiByteNames() throws IOException {
+        NativeS3Recoverable deserialized =
+                NativeS3RecoverableSerializer.INSTANCE.deserialize(
+                        NativeS3RecoverableSerializer.INSTANCE.getVersion(),
+                        serializeLegacy(
+                                "dir/ключ-\u00e4日\uD83D\uDD11",
+                                "upload-id-\u00e9",
+                                new int[] {7},
+                                new String[] {"etag-ä"},
+                                99L,
+                                "trailing-\u00fc",
+                                7L));
+
+        
assertThat(deserialized.getObjectName()).isEqualTo("dir/ключ-\u00e4日\uD83D\uDD11");
+        assertThat(deserialized.uploadId()).isEqualTo("upload-id-\u00e9");
+        assertThat(deserialized.parts().get(0).getETag()).isEqualTo("etag-ä");
+        
assertThat(deserialized.incompleteObjectName()).isEqualTo("trailing-\u00fc");
+    }
+
+    @Test
+    void testDeserializeLegacyTruncatedThrowsIOException() {
+        byte[] legacy = hexToBytes(GOLDEN_LEGACY_HEX.substring(0, 64)); // 
cuts off mid upload id
+        byte[] truncated = new byte[legacy.length - 5];
+        System.arraycopy(legacy, 0, truncated, 0, truncated.length);
+
+        assertThatThrownBy(
+                        () ->
+                                
NativeS3RecoverableSerializer.INSTANCE.deserialize(
+                                        
NativeS3RecoverableSerializer.INSTANCE.getVersion(),
+                                        truncated))
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("Corrupt legacy");
+    }
+
+    @Test
+    void testDeserializeLegacyUnderflowOnFinalLengthThrowsIOException() {
+        byte[] golden = hexToBytes(GOLDEN_LEGACY_HEX);
+        byte[] truncated = new byte[golden.length - 1]; // underflows reading 
the final long
+        System.arraycopy(golden, 0, truncated, 0, truncated.length);
+
+        assertThatThrownBy(
+                        () ->
+                                
NativeS3RecoverableSerializer.INSTANCE.deserialize(
+                                        
NativeS3RecoverableSerializer.INSTANCE.getVersion(),
+                                        truncated))
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("Corrupt legacy");
+    }
+
+    @Test
+    void 
testDeserializeLegacyWithNonPositiveIncompleteLengthThrowsIOException()
+            throws IOException {
+        // an incomplete-part name with length <= 0 violates the recoverable's 
invariants, so the
+        // constructor's checkArgument must surface as an IOException
+        byte[] legacy =
+                serializeLegacy(
+                        "key", "upload-id", new int[0], new String[0], 0L, 
"incomplete-key", 0L);
+
+        assertThatThrownBy(
+                        () ->
+                                
NativeS3RecoverableSerializer.INSTANCE.deserialize(
+                                        
NativeS3RecoverableSerializer.INSTANCE.getVersion(),
+                                        legacy))
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("Corrupt legacy");
+    }
+
+    @Test
+    void testDeserializeLegacyWithTrailingBytesThrowsIOException() {
+        byte[] legacy = serializeLegacyQuietly("key", "upload-id", 0L);
+        byte[] withTrailing = new byte[legacy.length + 1];
+        System.arraycopy(legacy, 0, withTrailing, 0, legacy.length);
+
+        assertThatThrownBy(
+                        () ->
+                                
NativeS3RecoverableSerializer.INSTANCE.deserialize(
+                                        
NativeS3RecoverableSerializer.INSTANCE.getVersion(),
+                                        withTrailing))
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("trailing bytes");
+    }
+
+    @Test
+    void testDeserializeLegacyWithNegativeLengthThrowsIOException() throws 
IOException {
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        putIntLE(out, NativeS3RecoverableSerializer.LEGACY_MAGIC_NUMBER);
+        putIntLE(out, -1);
+        byte[] legacy = out.toByteArray();
+
+        assertThatThrownBy(
+                        () ->
+                                
NativeS3RecoverableSerializer.INSTANCE.deserialize(
+                                        
NativeS3RecoverableSerializer.INSTANCE.getVersion(),
+                                        legacy))
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("invalid field length");
+    }
+
+    @Test
+    void testDeserializeLegacyWithHugeLengthThrowsIOException() throws 
IOException {
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        putIntLE(out, NativeS3RecoverableSerializer.LEGACY_MAGIC_NUMBER);
+        putIntLE(out, Integer.MAX_VALUE);
+        byte[] legacy = out.toByteArray();
+
+        assertThatThrownBy(
+                        () ->
+                                
NativeS3RecoverableSerializer.INSTANCE.deserialize(
+                                        
NativeS3RecoverableSerializer.INSTANCE.getVersion(),
+                                        legacy))
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("invalid field length");
+    }
+
+    @Test
+    void testDeserializeLegacyWithHugePartCountThrowsIOException() throws 
IOException {
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        putIntLE(out, NativeS3RecoverableSerializer.LEGACY_MAGIC_NUMBER);
+        putIntLE(out, 0); // object name length
+        putIntLE(out, 0); // upload id length
+        putIntLE(out, Integer.MAX_VALUE); // part count
+        byte[] legacy = out.toByteArray();
+
+        assertThatThrownBy(
+                        () ->
+                                
NativeS3RecoverableSerializer.INSTANCE.deserialize(
+                                        
NativeS3RecoverableSerializer.INSTANCE.getVersion(),
+                                        legacy))
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("invalid part count");
+    }
+
+    private static byte[] serializeLegacyQuietly(
+            String objectName, String uploadId, long numBytesInParts) {
+        try {
+            return serializeLegacy(
+                    objectName, uploadId, new int[0], new String[0], 
numBytesInParts, null, -1L);
+        } catch (IOException e) {
+            throw new AssertionError(e);
+        }
+    }
+
+    private static byte[] hexToBytes(String hex) {
+        String normalized = hex.replace(" ", "");
+        byte[] bytes = new byte[normalized.length() / 2];
+        for (int i = 0; i < bytes.length; i++) {
+            int hi = Character.digit(normalized.charAt(2 * i), 16);
+            int lo = Character.digit(normalized.charAt(2 * i + 1), 16);
+            bytes[i] = (byte) ((hi << 4) + lo);
+        }
+        return bytes;
+    }
+
+    /**
+     * Reproduces the binary format written by {@code S3RecoverableSerializer} 
(version 1) of
+     * flink-s3-fs-base, used by flink-s3-fs-hadoop: little-endian, leading 
magic number, then
+     * length-prefixed UTF-8 strings.
+     */
+    private static byte[] serializeLegacy(
+            String objectName,
+            String uploadId,
+            int[] partNumbers,
+            String[] eTags,
+            long numBytesInParts,
+            String incompleteObjectName,
+            long incompleteObjectLength)
+            throws IOException {
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        putIntLE(out, NativeS3RecoverableSerializer.LEGACY_MAGIC_NUMBER);
+        putLegacyString(out, objectName);
+        putLegacyString(out, uploadId);
+
+        putIntLE(out, partNumbers.length);
+        for (int i = 0; i < partNumbers.length; i++) {
+            putIntLE(out, partNumbers[i]);
+            putLegacyString(out, eTags[i]);
+        }
+
+        putLongLE(out, numBytesInParts);
+
+        if (incompleteObjectName == null) {
+            putIntLE(out, 0);
+        } else {
+            putLegacyString(out, incompleteObjectName);
+        }
+        putLongLE(out, incompleteObjectLength);
+
+        return out.toByteArray();
+    }
+
+    private static void putLegacyString(ByteArrayOutputStream out, String 
value)
+            throws IOException {
+        byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
+        putIntLE(out, bytes.length);
+        out.write(bytes);
+    }
+
+    private static void putIntLE(ByteArrayOutputStream out, int value) throws 
IOException {
+        out.write(
+                ByteBuffer.allocate(Integer.BYTES)
+                        .order(ByteOrder.LITTLE_ENDIAN)
+                        .putInt(value)
+                        .array());
+    }
+
+    private static void putLongLE(ByteArrayOutputStream out, long value) 
throws IOException {
+        out.write(
+                ByteBuffer.allocate(Long.BYTES)
+                        .order(ByteOrder.LITTLE_ENDIAN)
+                        .putLong(value)
+                        .array());
+    }
 }

Reply via email to