This is an automated email from the ASF dual-hosted git repository.
chaokunyang pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/fory.git
The following commit(s) were added to refs/heads/main by this push:
new adba10156 fix(java): use REPLACE_STUB_ID for unregistered writeReplace
classes to prevent cross-JVM ClassNotFoundException (#3638)
adba10156 is described below
commit adba101563b021574953c77a31b780366b7e033f
Author: wakilurislam <[email protected]>
AuthorDate: Tue May 5 14:49:01 2026 +0600
fix(java): use REPLACE_STUB_ID for unregistered writeReplace classes to
prevent cross-JVM ClassNotFoundException (#3638)
# PR Description
## Why?
When Fory serializes an unregistered class with `writeReplace()`
returning a different type (e.g., a Hibernate/ByteBuddy proxy), the
outer type info writes the **proxy class name** to the byte stream. On a
different JVM where that proxy class doesn't exist, deserialization
fails with `ClassNotFoundException`.
## What does this PR do?
Adds an `instanceof ReplaceResolveSerializer` check in
`ClassResolver.buildUnregisteredTypeId()` to return `REPLACE_STUB_ID`
instead of `NAMED_EXT`. This writes a compact 1-byte internal type ID
without the class name, matching the existing pattern for lambdas
(`LAMBDA_STUB_ID`) and JDK proxies (`JDK_PROXY_STUB_ID`).
The original `NAMED_EXT` fallback for `serializer == null` (before
serializer creation) is preserved unchanged.
### Changes
**`ClassResolver.java`**:
1. `buildUnregisteredTypeId()` — return `REPLACE_STUB_ID` when
serializer is `ReplaceResolveSerializer`:
```diff
protected int buildUnregisteredTypeId(Class<?> cls, Serializer<?>
serializer) {
- if (serializer == null && !cls.isEnum() &&
useReplaceResolveSerializer(cls)) {
- return Types.NAMED_EXT;
+ if (!cls.isEnum()) {
+ if (serializer instanceof ReplaceResolveSerializer) {
+ return REPLACE_STUB_ID;
+ }
+ if (serializer == null && useReplaceResolveSerializer(cls)) {
+ return Types.NAMED_EXT;
+ }
}
```
2. `addSerializer()` — guard against overwriting
`typeIdToTypeInfo[REPLACE_STUB_ID]`:
```diff
+ if (typeId == REPLACE_STUB_ID) {
+ classInfoMap.put(type, typeInfo);
+ } else {
+ updateTypeInfo(type, typeInfo);
+ }
```
**`WriteReplaceCrossJvmTest.java`** (new): 6 test methods × 4
configurations = 24 tests covering cross-JVM proxy, same-JVM regression,
same-type replace, round-trip, and nested proxy in DTO.
## Related issues
- Fixes #3635
## AI Contribution Checklist
- [x] Substantial AI assistance was used in this PR: `yes`
```text
AI Usage Disclosure
- substantial_ai_assistance: yes
- scope: code drafting, tests, design analysis
- affected_files_or_subsystems: java/fory-core ClassResolver.java,
WriteReplaceCrossJvmTest.java (new)
- ai_review: two fresh AI reviewers on current HEAD — both found 0
actionable issues.
- human_verification: full fory-core test suite (1820 tests, 0 failures).
ObjectStreamSerializerTest (82), URLSerializerTest (9),
ReplaceResolveSerializerTest (58) all pass. Cross-JVM test fails without fix (4
failures), passes with fix (24/24).
- performance_verification: N/A — one-time cold-path change only
- provenance_license_confirmation: Apache-2.0 compatible, all original work.
```
## Does this PR introduce any user-facing change?
No public API or binary protocol changes. Wire format changes only for
unregistered `ReplaceResolveSerializer` classes (bug fix — previous
format was broken for cross-JVM).
## Benchmark
N/A — one-time `buildUnregisteredTypeId()` call, not per-element.
[review_1_with_skill.md](https://github.com/user-attachments/files/27203956/review_1_with_skill.md)
[review_2_without_skill.md](https://github.com/user-attachments/files/27203958/review_2_without_skill.md)
---
.../org/apache/fory/resolver/ClassResolver.java | 15 +-
.../fory/serializer/WriteReplaceCrossJvmTest.java | 408 +++++++++++++++++++++
2 files changed, 420 insertions(+), 3 deletions(-)
diff --git
a/java/fory-core/src/main/java/org/apache/fory/resolver/ClassResolver.java
b/java/fory-core/src/main/java/org/apache/fory/resolver/ClassResolver.java
index 6705de531..c1d7493ff 100644
--- a/java/fory-core/src/main/java/org/apache/fory/resolver/ClassResolver.java
+++ b/java/fory-core/src/main/java/org/apache/fory/resolver/ClassResolver.java
@@ -692,8 +692,13 @@ public class ClassResolver extends TypeResolver {
@Override
protected int buildUnregisteredTypeId(Class<?> cls, Serializer<?>
serializer) {
- if (serializer == null && !cls.isEnum() &&
useReplaceResolveSerializer(cls)) {
- return Types.NAMED_EXT;
+ if (!cls.isEnum()) {
+ if (serializer instanceof ReplaceResolveSerializer) {
+ return REPLACE_STUB_ID;
+ }
+ if (serializer == null && useReplaceResolveSerializer(cls)) {
+ return Types.NAMED_EXT;
+ }
}
return super.buildUnregisteredTypeId(cls, serializer);
}
@@ -1221,7 +1226,11 @@ public class ClassResolver extends TypeResolver {
} else {
typeInfo = typeInfo.copy(typeId);
}
- updateTypeInfo(type, typeInfo);
+ if (typeId == REPLACE_STUB_ID) {
+ classInfoMap.put(type, typeInfo);
+ } else {
+ updateTypeInfo(type, typeInfo);
+ }
// Add to compositeNameBytes2TypeInfo for unregistered classes so that
// readTypeInfo can find the TypeInfo by name bytes during
deserialization.
// This is important for dynamically created classes that can't be
loaded by name.
diff --git
a/java/fory-core/src/test/java/org/apache/fory/serializer/WriteReplaceCrossJvmTest.java
b/java/fory-core/src/test/java/org/apache/fory/serializer/WriteReplaceCrossJvmTest.java
new file mode 100644
index 000000000..fe651649e
--- /dev/null
+++
b/java/fory-core/src/test/java/org/apache/fory/serializer/WriteReplaceCrossJvmTest.java
@@ -0,0 +1,408 @@
+/*
+ * 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.fory.serializer;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertNotNull;
+import static org.testng.Assert.assertTrue;
+
+import java.io.Serializable;
+import java.util.Objects;
+import org.apache.fory.Fory;
+import org.apache.fory.ForyTestBase;
+import org.apache.fory.codegen.JaninoUtils;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+/**
+ * Tests that writeReplace-based serialization works correctly when the proxy
class is unavailable
+ * on the deserializing JVM. This reproduces the Hibernate proxy cross-JVM
ClassNotFoundException
+ * bug.
+ *
+ * <p>The root cause: when Fory serializes an unregistered class that has
writeReplace(), it writes
+ * the original class name in the outer type info (as NAMED_EXT). On a
different JVM where that
+ * class doesn't exist, deserialization fails with ClassNotFoundException
before the
+ * ReplaceResolveSerializer even gets a chance to read the replacement object.
+ */
+public class WriteReplaceCrossJvmTest extends ForyTestBase {
+
+ /** A simple entity class that both JVMs know about. */
+ public static class RealEntity implements Serializable {
+ private static final long serialVersionUID = 1L;
+ public String name;
+ public int value;
+
+ public RealEntity() {}
+
+ public RealEntity(String name, int value) {
+ this.name = name;
+ this.value = value;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ RealEntity that = (RealEntity) o;
+ return value == that.value && Objects.equals(name, that.name);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(name, value);
+ }
+ }
+
+ /**
+ * A proxy class that simulates a Hibernate proxy. It extends RealEntity and
has writeReplace()
+ * that returns a RealEntity. This class only exists on the "serializing
JVM".
+ */
+ public static class ProxyEntity extends RealEntity {
+ private static final long serialVersionUID = 1L;
+
+ public ProxyEntity() {}
+
+ public ProxyEntity(String name, int value) {
+ super(name, value);
+ }
+
+ private Object writeReplace() {
+ return new RealEntity(name, value);
+ }
+ }
+
+ @DataProvider
+ public static Object[][] referenceTrackingAndCodegen() {
+ return new Object[][] {
+ {false, false},
+ {true, false},
+ {false, true},
+ {true, true},
+ };
+ }
+
+ /**
+ * Tests that a proxy object serialized on one JVM can be deserialized on
another JVM that doesn't
+ * have the proxy class.
+ *
+ * <p>This simulates the Hibernate proxy scenario: - JVM A has both
ProxyEntity and RealEntity -
+ * JVM B only has RealEntity - ProxyEntity.writeReplace() returns a
RealEntity
+ *
+ * <p>Before the fix, this test fails with ClassNotFoundException because
the proxy class name is
+ * written in the outer type info.
+ */
+ @Test(dataProvider = "referenceTrackingAndCodegen")
+ public void testWriteReplaceCrossJvm(boolean refTracking, boolean codegen) {
+ // fory1: "JVM A" - knows both ProxyEntity and RealEntity
+ Fory fory1 =
+ builder()
+ .withRefTracking(refTracking)
+ .withCodegen(codegen)
+ .requireClassRegistration(false)
+ .build();
+
+ ProxyEntity proxy = new ProxyEntity("test-entity", 42);
+ byte[] bytes = fory1.serialize(proxy);
+
+ // fory2: "JVM B" - only knows RealEntity (uses parent classloader which
+ // can't see ProxyEntity since we use a filtering classloader)
+ ClassLoader restrictedLoader =
+ new ClassLoader(getClass().getClassLoader()) {
+ @Override
+ public Class<?> loadClass(String name, boolean resolve) throws
ClassNotFoundException {
+ if (name.equals(ProxyEntity.class.getName())) {
+ throw new ClassNotFoundException("Simulated: " + name + " not on
this JVM");
+ }
+ return super.loadClass(name, resolve);
+ }
+ };
+
+ Fory fory2 =
+ builder()
+ .withRefTracking(refTracking)
+ .withCodegen(codegen)
+ .requireClassRegistration(false)
+ .withClassLoader(restrictedLoader)
+ .build();
+
+ Object result = fory2.deserialize(bytes);
+ assertNotNull(result);
+ assertEquals(result.getClass(), RealEntity.class);
+ RealEntity entity = (RealEntity) result;
+ assertEquals(entity.name, "test-entity");
+ assertEquals(entity.value, 42);
+ }
+
+ /**
+ * Tests cross-JVM writeReplace with a dynamically generated proxy class.
This more closely
+ * simulates how Hibernate creates ByteBuddy proxy classes that don't exist
on the remote JVM.
+ */
+ @Test(dataProvider = "referenceTrackingAndCodegen")
+ public void testWriteReplaceCrossJvmDynamicProxy(boolean refTracking,
boolean codegen) {
+ // Compile a dynamic "proxy" class that extends RealEntity with
writeReplace.
+ // Use protected (not private) because Janino mangles private methods with
$ suffix.
+ String proxyCode =
+ "import
org.apache.fory.serializer.WriteReplaceCrossJvmTest.RealEntity;\n"
+ + "public class DynamicProxy extends RealEntity {\n"
+ + " public DynamicProxy() {}\n"
+ + " public DynamicProxy(String name, int value) { super(name,
value); }\n"
+ + " protected Object writeReplace() { return new RealEntity(name,
value); }\n"
+ + "}";
+
+ Class<?> proxyClass =
+ JaninoUtils.compileClass(getClass().getClassLoader(), "",
"DynamicProxy", proxyCode);
+
+ // Create a proxy instance via reflection
+ Object proxy;
+ try {
+ proxy = proxyClass.getConstructor(String.class,
int.class).newInstance("dynamic-entity", 99);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+
+ // fory1: serialize using the classloader that knows the proxy
+ Fory fory1 =
+ builder()
+ .withRefTracking(refTracking)
+ .withCodegen(codegen)
+ .requireClassRegistration(false)
+ .withClassLoader(proxyClass.getClassLoader())
+ .build();
+ byte[] bytes = fory1.serialize(proxy);
+
+ // fory2: deserialize using this class's classloader (can't see
DynamicProxy)
+ Fory fory2 =
+ builder()
+ .withRefTracking(refTracking)
+ .withCodegen(codegen)
+ .requireClassRegistration(false)
+ .withClassLoader(getClass().getClassLoader())
+ .build();
+
+ Object result = fory2.deserialize(bytes);
+ assertNotNull(result);
+ assertEquals(result.getClass(), RealEntity.class);
+ RealEntity entity = (RealEntity) result;
+ assertEquals(entity.name, "dynamic-entity");
+ assertEquals(entity.value, 99);
+ }
+
+ /**
+ * Regression test: same-JVM writeReplace still works correctly. When the
proxy class IS
+ * available, deserialization should still unwrap to the real entity via
readResolve.
+ */
+ @Test(dataProvider = "referenceTrackingAndCodegen")
+ public void testWriteReplaceSameJvmStillWorks(boolean refTracking, boolean
codegen) {
+ Fory fory =
+ builder()
+ .withRefTracking(refTracking)
+ .withCodegen(codegen)
+ .requireClassRegistration(false)
+ .build();
+
+ ProxyEntity proxy = new ProxyEntity("same-jvm", 7);
+ byte[] bytes = fory.serialize(proxy);
+ Object result = fory.deserialize(bytes);
+
+ assertNotNull(result);
+ // writeReplace returns RealEntity, so deserialized type should be
RealEntity
+ assertEquals(result.getClass(), RealEntity.class);
+ RealEntity entity = (RealEntity) result;
+ assertEquals(entity.name, "same-jvm");
+ assertEquals(entity.value, 7);
+ }
+
+ /**
+ * Tests that writeReplace returning the same type (but different instance)
still works correctly
+ * across JVMs.
+ */
+ public static class SameTypeReplaceEntity implements Serializable {
+ private static final long serialVersionUID = 1L;
+ public String data;
+ public boolean replaced;
+
+ public SameTypeReplaceEntity() {}
+
+ public SameTypeReplaceEntity(String data, boolean replaced) {
+ this.data = data;
+ this.replaced = replaced;
+ }
+
+ private Object writeReplace() {
+ return new SameTypeReplaceEntity(data, true);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ SameTypeReplaceEntity that = (SameTypeReplaceEntity) o;
+ return replaced == that.replaced && Objects.equals(data, that.data);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(data, replaced);
+ }
+ }
+
+ @Test(dataProvider = "referenceTrackingAndCodegen")
+ public void testWriteReplaceSameType(boolean refTracking, boolean codegen) {
+ Fory fory =
+ builder()
+ .withRefTracking(refTracking)
+ .withCodegen(codegen)
+ .requireClassRegistration(false)
+ .build();
+
+ SameTypeReplaceEntity original = new SameTypeReplaceEntity("hello", false);
+ byte[] bytes = fory.serialize(original);
+ SameTypeReplaceEntity result = (SameTypeReplaceEntity)
fory.deserialize(bytes);
+
+ assertEquals(result.data, "hello");
+ assertTrue(result.replaced, "writeReplace should have set replaced=true");
+ }
+
+ /**
+ * Tests cross-JVM round trip: serialize proxy on JVM A, deserialize on JVM
B, re-serialize on JVM
+ * B, deserialize back on JVM A.
+ */
+ @Test(dataProvider = "referenceTrackingAndCodegen")
+ public void testWriteReplaceCrossJvmRoundTrip(boolean refTracking, boolean
codegen) {
+ Fory fory1 =
+ builder()
+ .withRefTracking(refTracking)
+ .withCodegen(codegen)
+ .requireClassRegistration(false)
+ .build();
+
+ ClassLoader restrictedLoader =
+ new ClassLoader(getClass().getClassLoader()) {
+ @Override
+ public Class<?> loadClass(String name, boolean resolve) throws
ClassNotFoundException {
+ if (name.equals(ProxyEntity.class.getName())) {
+ throw new ClassNotFoundException("Simulated: " + name + " not on
this JVM");
+ }
+ return super.loadClass(name, resolve);
+ }
+ };
+
+ Fory fory2 =
+ builder()
+ .withRefTracking(refTracking)
+ .withCodegen(codegen)
+ .requireClassRegistration(false)
+ .withClassLoader(restrictedLoader)
+ .build();
+
+ // Step 1: Serialize proxy on "JVM A"
+ ProxyEntity proxy = new ProxyEntity("round-trip", 100);
+ byte[] bytes1 = fory1.serialize(proxy);
+
+ // Step 2: Deserialize on "JVM B" (no ProxyEntity class)
+ Object intermediate = fory2.deserialize(bytes1);
+ assertEquals(intermediate.getClass(), RealEntity.class);
+
+ // Step 3: Re-serialize on "JVM B"
+ byte[] bytes2 = fory2.serialize(intermediate);
+
+ // Step 4: Deserialize back on "JVM A"
+ Object result = fory1.deserialize(bytes2);
+ assertEquals(result.getClass(), RealEntity.class);
+ RealEntity entity = (RealEntity) result;
+ assertEquals(entity.name, "round-trip");
+ assertEquals(entity.value, 100);
+ }
+
+ /**
+ * Tests that a proxy object nested inside another object works correctly
across JVMs. This
+ * simulates having a Hibernate proxy entity as a field in a DTO.
+ */
+ public static class DtoWithProxy implements Serializable {
+ private static final long serialVersionUID = 1L;
+ public String label;
+ public RealEntity entity;
+
+ public DtoWithProxy() {}
+
+ public DtoWithProxy(String label, RealEntity entity) {
+ this.label = label;
+ this.entity = entity;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ DtoWithProxy that = (DtoWithProxy) o;
+ return Objects.equals(label, that.label) && Objects.equals(entity,
that.entity);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(label, entity);
+ }
+ }
+
+ @Test(dataProvider = "referenceTrackingAndCodegen")
+ public void testWriteReplaceNestedProxyCrossJvm(boolean refTracking, boolean
codegen) {
+ Fory fory1 =
+ builder()
+ .withRefTracking(refTracking)
+ .withCodegen(codegen)
+ .requireClassRegistration(false)
+ .build();
+
+ ClassLoader restrictedLoader =
+ new ClassLoader(getClass().getClassLoader()) {
+ @Override
+ public Class<?> loadClass(String name, boolean resolve) throws
ClassNotFoundException {
+ if (name.equals(ProxyEntity.class.getName())) {
+ throw new ClassNotFoundException("Simulated: " + name + " not on
this JVM");
+ }
+ return super.loadClass(name, resolve);
+ }
+ };
+
+ Fory fory2 =
+ builder()
+ .withRefTracking(refTracking)
+ .withCodegen(codegen)
+ .requireClassRegistration(false)
+ .withClassLoader(restrictedLoader)
+ .build();
+
+ // Nest a ProxyEntity inside a DTO
+ ProxyEntity proxy = new ProxyEntity("nested", 55);
+ DtoWithProxy dto = new DtoWithProxy("my-dto", proxy);
+ byte[] bytes = fory1.serialize(dto);
+
+ // Deserialize on "JVM B" — DTO should deserialize, inner proxy should
unwrap to RealEntity
+ Object result = fory2.deserialize(bytes);
+ assertNotNull(result);
+ assertEquals(result.getClass(), DtoWithProxy.class);
+ DtoWithProxy resultDto = (DtoWithProxy) result;
+ assertEquals(resultDto.label, "my-dto");
+ assertNotNull(resultDto.entity);
+ assertEquals(resultDto.entity.getClass(), RealEntity.class);
+ assertEquals(resultDto.entity.name, "nested");
+ assertEquals(resultDto.entity.value, 55);
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]