Samrat002 commented on code in PR #29422:
URL: https://github.com/apache/flink/pull/29422#discussion_r4397431277


##########
flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableSerializerTest.java:
##########
@@ -148,4 +152,320 @@ void testVersion1BytesWithTailKeyAtBucketRoot() throws 
IOException {
         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();
+    }
+
+    @Test
+    void testCurrentFormatNotMistakenForLegacyFormat() throws IOException {
+        NativeS3RecoverableSerializer serializer = 
NativeS3RecoverableSerializer.INSTANCE;
+
+        List<NativeS3Recoverable.PartETag> parts = new ArrayList<>();
+        parts.add(new NativeS3Recoverable.PartETag(1, "etag1"));
+        NativeS3Recoverable original =
+                new NativeS3Recoverable("test-object-key", "test-upload-id", 
parts, 12345L);
+
+        byte[] serialized = serializer.serialize(original);
+        NativeS3Recoverable deserialized =
+                serializer.deserialize(serializer.getVersion(), serialized);
+
+        
assertThat(deserialized.getObjectName()).isEqualTo(original.getObjectName());
+        assertThat(deserialized.uploadId()).isEqualTo(original.uploadId());
+        
assertThat(deserialized.numBytesInParts()).isEqualTo(original.numBytesInParts());
+        assertThat(deserialized.parts()).hasSize(1);
+    }
+
+    /**
+     * Golden test against 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), 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(
+                                "3214769816000000"
+                                        + "6469722f74657374"
+                                        + "2d6b65792dc3a4e6"
+                                        + "97a5f09f94910c00"
+                                        + "000075706c6f6164"
+                                        + "2dc3a42d69640200"
+                                        + "0000010000000800"
+                                        + "0000657461672d6f"
+                                        + "6e65020000000800"
+                                        + "0000657461672d74"
+                                        + "776f393000000000"
+                                        + "000011000000696e"
+                                        + "636f6d706c657465"
+                                        + "2d6b65792dc3a400"
+                                        + "04000000000000"));
+
+        
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(
+                        "3214769816000000"
+                                + "6469722f74657374"
+                                + "2d6b65792dc3a4e6"
+                                + "97a5f09f94910c00"
+                                + "000075706c6f6164"
+                                + "2dc3a42d69640200");
+        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 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() {
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        assertThatThrownBy(
+                        () -> {
+                            putIntLE(out, 
NativeS3RecoverableSerializer.LEGACY_MAGIC_NUMBER);
+                            putIntLE(out, -1);
+                            NativeS3RecoverableSerializer.INSTANCE.deserialize(
+                                    
NativeS3RecoverableSerializer.INSTANCE.getVersion(),
+                                    out.toByteArray());
+                        })
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("invalid field length");
+    }
+
+    @Test
+    void testDeserializeLegacyWithHugeLengthThrowsIOException() {

Review Comment:
   Same as above



##########
flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableSerializerTest.java:
##########
@@ -148,4 +152,320 @@ void testVersion1BytesWithTailKeyAtBucketRoot() throws 
IOException {
         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();
+    }
+
+    @Test
+    void testCurrentFormatNotMistakenForLegacyFormat() throws IOException {

Review Comment:
   This is the same as the existing round-trip tests and doesn't test the 
format check. Either drop it, or build current-format bytes starting with 0x32 
0x14 (UTF length 12820)  and assert they don't go down the legacy path.



##########
flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableSerializer.java:
##########
@@ -108,6 +133,12 @@ public NativeS3Recoverable deserialize(int version, byte[] 
serialized) throws IO
         if (version != CURRENT_VERSION) {
             throw new IOException("Unsupported version: " + version);
         }
+        if (isLegacyFormat(serialized)) {

Review Comment:
   Hadoop's S3Committer.commit() / commitAfterRecovery() do nothing when 
totalLength == 0. NativeS3Committer.commit() throws "Cannot commit empty 
multipart upload" when parts is empty. So a Hadoop committable with no parts 
will now decode, then fail on every restore. Could we either skip the commit 
for parts.isEmpty() && numBytesInParts == 0, or list this as a known limitation 
in the docs note?



##########
flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableSerializer.java:
##########
@@ -135,4 +166,119 @@ public NativeS3Recoverable deserialize(int version, 
byte[] serialized) throws IO
                 incompleteObjectName,
                 incompleteObjectLength);
     }
+
+    /**
+     * Checks whether the given serialized state was written by the {@code 
S3RecoverableSerializer}
+     * of flink-s3-fs-base (used by {@code flink-s3-fs-hadoop}).
+     *
+     * <p>The detection is unambiguous: the current format starts with a 
{@code writeUTF} length
+     * (two bytes, big-endian) for the object name, so mistaking it for the 
legacy magic number
+     * would require an object name of 0x3214 (12820) bytes, far beyond the 
1024-byte S3 key limit.
+     * It is also structurally impossible: read as the little-endian magic 
number, the current
+     * format's first four bytes would have to end in 0x98, which is never a 
valid UTF-8 character
+     * start byte.
+     */
+    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: magic number, then length-prefixed UTF-8 object name 
and upload id, part
+     * count with {partNumber, length-prefixed eTag} pairs, numBytesInParts, 
length-prefixed
+     * incomplete object name (0 length meaning none), and incomplete object 
length.
+     */
+    private static NativeS3Recoverable deserializeLegacy(byte[] serialized) 
throws IOException {
+        try {
+            return decodeLegacy(serialized);
+        } catch (BufferUnderflowException
+                | NegativeArraySizeException
+                | IllegalArgumentException e) {
+            throw new IOException(
+                    "Corrupt or truncated S3 multipart upload state in legacy 
format "
+                            + "(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

Review Comment:
   nit: this check can't fail, since isLegacyFormat just checked the same 
thing. Fine to keep for parity.
   Just noting it. 



##########
flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableSerializer.java:
##########
@@ -135,4 +166,119 @@ public NativeS3Recoverable deserialize(int version, 
byte[] serialized) throws IO
                 incompleteObjectName,
                 incompleteObjectLength);
     }
+
+    /**
+     * Checks whether the given serialized state was written by the {@code 
S3RecoverableSerializer}

Review Comment:
   Overwhelming desciption 



##########
flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableSerializer.java:
##########
@@ -135,4 +166,119 @@ public NativeS3Recoverable deserialize(int version, 
byte[] serialized) throws IO
                 incompleteObjectName,
                 incompleteObjectLength);
     }
+
+    /**
+     * Checks whether the given serialized state was written by the {@code 
S3RecoverableSerializer}
+     * of flink-s3-fs-base (used by {@code flink-s3-fs-hadoop}).
+     *
+     * <p>The detection is unambiguous: the current format starts with a 
{@code writeUTF} length
+     * (two bytes, big-endian) for the object name, so mistaking it for the 
legacy magic number
+     * would require an object name of 0x3214 (12820) bytes, far beyond the 
1024-byte S3 key limit.
+     * It is also structurally impossible: read as the little-endian magic 
number, the current
+     * format's first four bytes would have to end in 0x98, which is never a 
valid UTF-8 character
+     * start byte.
+     */
+    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: magic number, then length-prefixed UTF-8 object name 
and upload id, part
+     * count with {partNumber, length-prefixed eTag} pairs, numBytesInParts, 
length-prefixed
+     * incomplete object name (0 length meaning none), and incomplete object 
length.
+     */

Review Comment:
   Overwhelming desciption



##########
flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableSerializerTest.java:
##########
@@ -148,4 +152,320 @@ void testVersion1BytesWithTailKeyAtBucketRoot() throws 
IOException {
         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();
+    }
+
+    @Test
+    void testCurrentFormatNotMistakenForLegacyFormat() throws IOException {
+        NativeS3RecoverableSerializer serializer = 
NativeS3RecoverableSerializer.INSTANCE;
+
+        List<NativeS3Recoverable.PartETag> parts = new ArrayList<>();
+        parts.add(new NativeS3Recoverable.PartETag(1, "etag1"));
+        NativeS3Recoverable original =
+                new NativeS3Recoverable("test-object-key", "test-upload-id", 
parts, 12345L);
+
+        byte[] serialized = serializer.serialize(original);
+        NativeS3Recoverable deserialized =
+                serializer.deserialize(serializer.getVersion(), serialized);
+
+        
assertThat(deserialized.getObjectName()).isEqualTo(original.getObjectName());
+        assertThat(deserialized.uploadId()).isEqualTo(original.uploadId());
+        
assertThat(deserialized.numBytesInParts()).isEqualTo(original.numBytesInParts());
+        assertThat(deserialized.parts()).hasSize(1);
+    }
+
+    /**
+     * Golden test against 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), 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(
+                                "3214769816000000"
+                                        + "6469722f74657374"
+                                        + "2d6b65792dc3a4e6"
+                                        + "97a5f09f94910c00"
+                                        + "000075706c6f6164"
+                                        + "2dc3a42d69640200"
+                                        + "0000010000000800"
+                                        + "0000657461672d6f"
+                                        + "6e65020000000800"
+                                        + "0000657461672d74"
+                                        + "776f393000000000"
+                                        + "000011000000696e"
+                                        + "636f6d706c657465"
+                                        + "2d6b65792dc3a400"
+                                        + "04000000000000"));
+
+        
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(
+                        "3214769816000000"
+                                + "6469722f74657374"
+                                + "2d6b65792dc3a4e6"
+                                + "97a5f09f94910c00"
+                                + "000075706c6f6164"
+                                + "2dc3a42d69640200");
+        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 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() {
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        assertThatThrownBy(
+                        () -> {
+                            putIntLE(out, 
NativeS3RecoverableSerializer.LEGACY_MAGIC_NUMBER);
+                            putIntLE(out, -1);
+                            NativeS3RecoverableSerializer.INSTANCE.deserialize(
+                                    
NativeS3RecoverableSerializer.INSTANCE.getVersion(),
+                                    out.toByteArray());
+                        })
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("invalid field length");
+    }
+
+    @Test
+    void testDeserializeLegacyWithHugeLengthThrowsIOException() {
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        assertThatThrownBy(
+                        () -> {
+                            putIntLE(out, 
NativeS3RecoverableSerializer.LEGACY_MAGIC_NUMBER);
+                            putIntLE(out, Integer.MAX_VALUE);
+                            NativeS3RecoverableSerializer.INSTANCE.deserialize(
+                                    
NativeS3RecoverableSerializer.INSTANCE.getVersion(),
+                                    out.toByteArray());
+                        })
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("invalid field length");
+    }
+
+    @Test
+    void testDeserializeLegacyWithHugePartCountThrowsIOException() {

Review Comment:
   Same as above



##########
flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableSerializerTest.java:
##########
@@ -148,4 +152,320 @@ void testVersion1BytesWithTailKeyAtBucketRoot() throws 
IOException {
         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();
+    }
+
+    @Test
+    void testCurrentFormatNotMistakenForLegacyFormat() throws IOException {
+        NativeS3RecoverableSerializer serializer = 
NativeS3RecoverableSerializer.INSTANCE;
+
+        List<NativeS3Recoverable.PartETag> parts = new ArrayList<>();
+        parts.add(new NativeS3Recoverable.PartETag(1, "etag1"));
+        NativeS3Recoverable original =
+                new NativeS3Recoverable("test-object-key", "test-upload-id", 
parts, 12345L);
+
+        byte[] serialized = serializer.serialize(original);
+        NativeS3Recoverable deserialized =
+                serializer.deserialize(serializer.getVersion(), serialized);
+
+        
assertThat(deserialized.getObjectName()).isEqualTo(original.getObjectName());
+        assertThat(deserialized.uploadId()).isEqualTo(original.uploadId());
+        
assertThat(deserialized.numBytesInParts()).isEqualTo(original.numBytesInParts());
+        assertThat(deserialized.parts()).hasSize(1);
+    }
+
+    /**
+     * Golden test against 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), 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(
+                                "3214769816000000"
+                                        + "6469722f74657374"
+                                        + "2d6b65792dc3a4e6"
+                                        + "97a5f09f94910c00"
+                                        + "000075706c6f6164"
+                                        + "2dc3a42d69640200"
+                                        + "0000010000000800"
+                                        + "0000657461672d6f"
+                                        + "6e65020000000800"
+                                        + "0000657461672d74"
+                                        + "776f393000000000"
+                                        + "000011000000696e"
+                                        + "636f6d706c657465"
+                                        + "2d6b65792dc3a400"
+                                        + "04000000000000"));
+
+        
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(
+                        "3214769816000000"
+                                + "6469722f74657374"
+                                + "2d6b65792dc3a4e6"
+                                + "97a5f09f94910c00"
+                                + "000075706c6f6164"
+                                + "2dc3a42d69640200");
+        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 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() {

Review Comment:
   [NIT] setup runs inside assertThatThrownBy, so an IOException from setup 
would also satisfy the IOException assertion. Could the bytes be built before 
the assertion, so the lambda only calls deserialize?



##########
flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableSerializer.java:
##########
@@ -135,4 +166,119 @@ public NativeS3Recoverable deserialize(int version, 
byte[] serialized) throws IO
                 incompleteObjectName,
                 incompleteObjectLength);
     }
+
+    /**
+     * Checks whether the given serialized state was written by the {@code 
S3RecoverableSerializer}
+     * of flink-s3-fs-base (used by {@code flink-s3-fs-hadoop}).
+     *
+     * <p>The detection is unambiguous: the current format starts with a 
{@code writeUTF} length
+     * (two bytes, big-endian) for the object name, so mistaking it for the 
legacy magic number
+     * would require an object name of 0x3214 (12820) bytes, far beyond the 
1024-byte S3 key limit.
+     * It is also structurally impossible: read as the little-endian magic 
number, the current
+     * format's first four bytes would have to end in 0x98, which is never a 
valid UTF-8 character
+     * start byte.
+     */
+    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: magic number, then length-prefixed UTF-8 object name 
and upload id, part
+     * count with {partNumber, length-prefixed eTag} pairs, numBytesInParts, 
length-prefixed
+     * incomplete object name (0 length meaning none), and incomplete object 
length.
+     */
+    private static NativeS3Recoverable deserializeLegacy(byte[] serialized) 
throws IOException {
+        try {
+            return decodeLegacy(serialized);
+        } catch (BufferUnderflowException
+                | NegativeArraySizeException
+                | IllegalArgumentException e) {
+            throw new IOException(
+                    "Corrupt or truncated S3 multipart upload state in legacy 
format "

Review Comment:
   nit: the other errors use "Corrupt legacy S3 recoverable state: ...". Could 
this use the same prefix, so callers and tests can match one message? 
     



##########
flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableSerializer.java:
##########
@@ -135,4 +166,119 @@ public NativeS3Recoverable deserialize(int version, 
byte[] serialized) throws IO
                 incompleteObjectName,
                 incompleteObjectLength);
     }
+
+    /**
+     * Checks whether the given serialized state was written by the {@code 
S3RecoverableSerializer}
+     * of flink-s3-fs-base (used by {@code flink-s3-fs-hadoop}).
+     *
+     * <p>The detection is unambiguous: the current format starts with a 
{@code writeUTF} length
+     * (two bytes, big-endian) for the object name, so mistaking it for the 
legacy magic number
+     * would require an object name of 0x3214 (12820) bytes, far beyond the 
1024-byte S3 key limit.
+     * It is also structurally impossible: read as the little-endian magic 
number, the current
+     * format's first four bytes would have to end in 0x98, which is never a 
valid UTF-8 character
+     * start byte.
+     */
+    private static boolean isLegacyFormat(byte[] serialized) {
+        if (serialized.length < Integer.BYTES) {
+            return false;
+        }
+        return 
ByteBuffer.wrap(serialized).order(ByteOrder.LITTLE_ENDIAN).getInt()

Review Comment:
   nit, optional: comparing the first 4 bytes directly (0x32, 0x14, 0x76, 0x98) 
would skip allocating a buffer. Not on a hot path, so feel free to ignore.



##########
flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableSerializer.java:
##########


Review Comment:
   This comment sits between the field and the constructor and isn't attached 
to anything. The description also says the base decoder must stay untouched for 
backports. Could we drop this change ? 



##########
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.
+

Review Comment:
   Why this is required ?



##########
flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/writer/NativeS3RecoverableSerializerTest.java:
##########
@@ -148,4 +152,320 @@ void testVersion1BytesWithTailKeyAtBucketRoot() throws 
IOException {
         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();
+    }
+
+    @Test
+    void testCurrentFormatNotMistakenForLegacyFormat() throws IOException {
+        NativeS3RecoverableSerializer serializer = 
NativeS3RecoverableSerializer.INSTANCE;
+
+        List<NativeS3Recoverable.PartETag> parts = new ArrayList<>();
+        parts.add(new NativeS3Recoverable.PartETag(1, "etag1"));
+        NativeS3Recoverable original =
+                new NativeS3Recoverable("test-object-key", "test-upload-id", 
parts, 12345L);
+
+        byte[] serialized = serializer.serialize(original);
+        NativeS3Recoverable deserialized =
+                serializer.deserialize(serializer.getVersion(), serialized);
+
+        
assertThat(deserialized.getObjectName()).isEqualTo(original.getObjectName());
+        assertThat(deserialized.uploadId()).isEqualTo(original.uploadId());
+        
assertThat(deserialized.numBytesInParts()).isEqualTo(original.numBytesInParts());
+        assertThat(deserialized.parts()).hasSize(1);
+    }
+
+    /**
+     * Golden test against 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), 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(
+                                "3214769816000000"
+                                        + "6469722f74657374"
+                                        + "2d6b65792dc3a4e6"
+                                        + "97a5f09f94910c00"
+                                        + "000075706c6f6164"
+                                        + "2dc3a42d69640200"
+                                        + "0000010000000800"
+                                        + "0000657461672d6f"
+                                        + "6e65020000000800"
+                                        + "0000657461672d74"
+                                        + "776f393000000000"
+                                        + "000011000000696e"
+                                        + "636f6d706c657465"
+                                        + "2d6b65792dc3a400"
+                                        + "04000000000000"));
+
+        
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() {

Review Comment:
   This input fails in readBoundedLength (upload-id length 12 > remaining), so 
the BufferUnderflowException / IllegalArgumentException wrapping in 
deserializeLegacy is never tested. Could you add:                               
                                                                                
                                          
   - the golden bytes with the last byte cut off (underflow while reading the 
final long)                                                                     
                    
   - legacy state with an incomplete name but incompleteObjectLength <= 0 (the 
constructor's checkArgument should come back as an IOException)



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to