A Rust implementation of the Java Object Serialization Stream Protocol — the byte format produced by
java.io.ObjectOutputStream (magic AC ED 00 05).
It lets Rust code emit byte streams that a stock JVM can read back with ObjectInputStream, with no JVM and no JNI
involved. Useful when a Rust service has to talk to something that only speaks Java serialization: an RPC endpoint, a
cache, a message payload, a persisted blob.
Status: early (
0.0.1), write-only. There is no deserializer — this crate encodes Rust values into the Java format, it does not decode Java streams. See Limitations.
Not published to crates.io yet. Depend on it by git or path:
[dependencies]
serde-java = { git = "<repo-url>" }Requires a Rust toolchain supporting edition 2024.
#[derive(JavaSerialize)] comes from the default-on derive feature. If you only want the hand-written path, drop it
with default-features = false.
Given this Java class:
package com.example;
public class Demo implements Serializable {
private static final long serialVersionUID = 5151422842377556126L;
private int i;
private String message;
}Describe its shape in Rust and serialize:
use serde_java::*;
#[derive(JavaSerialize)]
#[java(class = "com.example.Demo", serial_version_uid = 5151422842377556126)]
struct Demo {
i: i32,
message: String,
}
fn main() -> std::io::Result<()> {
let demo = Demo { i: 42, message: "helloWorld".to_string() };
let bytes = demo.to_bytes()?; // Vec<u8>, byte-identical to ObjectOutputStream
demo.to_file("demo.ser")?; // or straight to disk
Ok(())
}bytes is exactly what a JVM writes for the same object:
aced0005 // stream header
73 72 0010 636f6d2e6578616d706c652e44656d6f // TC_OBJECT, TC_CLASSDESC, "com.example.Demo"
477d87c81fbf509e 02 0002 // serialVersionUID, SC_SERIALIZABLE, 2 fields
49 0001 69 // int i
4c 0007 6d657373616765 7400124c6a…537472696e673b // String message, "Ljava/lang/String;"
78 70 // TC_ENDBLOCKDATA, null superclass
0000002a 74000a68656c6c6f576f726c64 // 42, "helloWorld"
Feed that to an ObjectInputStream and you get a real com.example.Demo back.
Both container attributes are required. The class name is what the receiving JVM matches on, and
serial_version_uid cannot be derived from the Rust side — Java's default algorithm hashes method and constructor
signatures, which don't exist here. Read it off the Java class, or compute it (see
Computing serialVersionUID).
Note that the struct declares i and message in the Java class's order; the derive re-sorts them at expansion time
into the order the JVM expects (see Field order is not declaration order).
The derive writes two impls, and you can write them yourself instead — the JVM cannot tell the two apart, the byte streams are identical:
JavaObject::class()— the schema: the Java class's name,serialVersionUID, and ordered field list. Build it once in astatic Lazy<Class>;ClassisArc-backed, so handing out clones is cheap.JavaSerializable::write_object(&self, w)— the instance: write each field's value throughw, in the same order as the schema. Values only — theTC_OBJECTtag, the class descriptor and the handle bookkeeping are not your job here.
A third trait, JavaWriteable, is what you actually call. It is blanket-implemented for every
JavaObject + JavaSerializable, and its write_to(w) is what emits TC_OBJECT + the class descriptor and then
delegates to write_object; to_bytes() and to_file() sit on top of it. It is also implemented directly for the
Rust primitives and String/str, where it writes the bare value with no object header — which is what makes
self.i.write_to(w)? below work.
By hand, that same Demo is:
use once_cell::sync::Lazy;
use serde_java::*;
use std::io;
struct Demo {
i: i32,
message: String,
}
impl JavaObject for Demo {
fn class() -> Class {
static CLASS: Lazy<Class> = Lazy::new(|| {
Class::builder("com.example.Demo", 5151422842377556126)
.field(Field::builder("i").int())
.field(Field::builder("message").string())
.build()
});
Clone::clone(&CLASS)
}
}
impl JavaSerializable for Demo {
fn write_object(&self, w: &mut ObjectWriter<&mut dyn io::Write>) -> io::Result<()> {
self.i.write_to(w)?; // or, equivalently: w.write_int(self.i)?
self.message.write_to(w)?; // or: w.write_string(&self.message)?
Ok(())
}
}A nested object is just self.address.write_to(w)? (that one does write a full TC_OBJECT), and a null reference
is w.write_null()?.
to_bytes() / to_file() come free with JavaWriteable. To stream into an arbitrary io::Write instead:
let mut w = ObjectWriter::new(&mut sink)?; // writes the stream header
w.with_dyn(|w| demo.write_to(w))?;with_dyn hands write_to the type-erased ObjectWriter<&mut dyn io::Write> it expects, over the same sink, keeping
handle allocation and the string/class back-reference tables in sync.
Hand-writing is the fallback for what the derive doesn't cover — superclass chains
(Object::<Child, Parent>::builder(class).this(&child).extends(&parent)), custom writeObject, generic structs — and
there it's on you to keep write_object() in step with class(): nothing validates the count, the order, or the
types.
Field attributes:
#[java(rename = "...")]— changes the Java-side field name (and therefore its sort position)#[java(skip)]— Javatransient; omitted from both schema and value#[java(signature = "...")]— overrides the declared descriptor signature only; the value side still follows the Rust type#[java(with = "path::to::SomeLayout")]— overrides the value side: the field is written asSomeLayout::layout(&self.field).write_to(w), whereSomeLayout: serde_java::Layout. This bypasses the Rust→Java mapping table entirely, so it must be paired with an explicitsignature, and it always sorts into the object group regardless of the field's Rust type. For anOption<T>field, pairwithwith aLayout<Input = T>—Nonewritesnulldirectly and onlySome(v)goes throughLayout::layout. This is howserde-java-ext'sArrayList/LinkedList/Boolean/boxed numbers attach to a plainVec<T>/bool/primitive field — see Using them as fields.
Supported field types: the Java primitives (bool, i8, u8, u16, i16, i32, i64, f32, f64),
String/&str, primitive arrays (Vec<u8|i16|i32|i64|f32|f64> or the equivalent &[T] slices), object arrays
(Vec<T>), Option<T> for non-primitive T, and nested types implementing JavaObject. Struct lifetime
parameters are fine; type/const generics, tuple/unit structs, enums, and unions are rejected at compile time, as are
char, the wider integer types (u32/u64/usize/isize/i128/u128),
Vec<String>/Vec<bool>/Vec<u16>/Vec<char>/Vec<i8> (use Vec<u8>), Option<primitive>, and doubly-nested
collections (Vec<Vec<T>>, Vec<Option<T>>, Option<Option<T>> — but Option<Vec<T>> is fine). Every rejection is
a compile-time syn::Error at the offending span, not a runtime surprise.
This is the single easiest thing to get wrong by hand. ObjectStreamClass sorts fields the way the JVM does: all
primitive fields first, then all object/array fields, each group sorted alphabetically by name. Your Class builder
must list them in that order, not in the order the Java source declares them.
#[derive(JavaSerialize)] does this for you at expansion time — declare the fields in whatever order reads best
(matching the Java source is the obvious choice) and the generated class() and write_object() are sorted
together, from the same list. The rest of this section is about the hand-written path.
So this Java class:
public class User implements Serializable {
private long id;
private String name;
private int age;
private Address[] addresses;
private ExtInfo ext1;
private ExtInfo ext2;
}is described in Rust as age, id (primitives, alphabetical), then addresses, ext1, ext2, name (objects,
alphabetical):
Class::builder("com.example.User", 4956385333250593913)
.field(Field::builder("age").int())
.field(Field::builder("id").long())
.field(Field::builder("addresses").array(Address::class().signature()))
.field(Field::builder("ext1").object("Lcom/example/ExtInfo;"))
.field(Field::builder("ext2").object("Lcom/example/ExtInfo;"))
.field(Field::builder("name").string())
.build()write_object() must then write the values in that same order. Nothing validates this — not the count, not the
order, not the types. A mismatch yields a stream the JVM rejects or, worse, silently mis-binds.
The derived version of the same class declares its fields in the Java source's order and needs neither the sort nor
the [Lcom/example/Address; array-class magic number:
#[derive(JavaSerialize)]
#[java(class = "com.example.User", serial_version_uid = 4956385333250593913)]
struct User {
id: i64,
name: String,
age: i32,
addresses: Vec<Address>,
ext1: ExtInfo,
ext2: ExtInfo,
}examples/ carries both, paired with the Java class they target (examples/example.java). They emit byte-identical
streams:
cargo run --example example # hand-written impls
cargo run --example example_derive # the same stream, derivedEvery schema needs a serial_version_uid. The easy path is to read it off the Java class (private static final long serialVersionUID) or run serialver com.example.Demo. When the Java class doesn't declare one, the JVM derives it
from the class's structure — and the receiving ObjectInputStream will reject the stream unless your Rust side
produces the exact same number.
suid::compute_default_suid reproduces that derivation. You describe the Java class the way ObjectStreamClass sees
it — name, modifiers, interfaces, fields, constructors, methods — and it returns the same i64 the JVM would compute:
use serde_java::suid::*;
let meta = ClassMetadata {
class_name: "com.example.ExtInfo",
class_modifiers: PUBLIC,
is_interface: false,
interfaces: vec!["java.io.Serializable"],
fields: vec![
FieldSig { name: "id", modifiers: PRIVATE, type_sig: "I" },
FieldSig { name: "key", modifiers: PRIVATE, type_sig: "Ljava/lang/String;" },
FieldSig { name: "value", modifiers: PRIVATE, type_sig: "Ljava/lang/String;" },
],
has_static_initializer: false,
constructors: vec![
MethodSig { name: "<init>", modifiers: PUBLIC, descriptor: "()V" },
MethodSig { name: "<init>", modifiers: PUBLIC,
descriptor: "(ILjava/lang/String;Ljava/lang/String;)V" },
],
methods: vec![
MethodSig { name: "getId", modifiers: PUBLIC, descriptor: "()I" },
MethodSig { name: "getKey", modifiers: PUBLIC, descriptor: "()Ljava/lang/String;" },
MethodSig { name: "getValue", modifiers: PUBLIC, descriptor: "()Ljava/lang/String;" },
],
};
assert_eq!(650544313874690833, compute_default_suid(&meta));The algorithm follows the spec: write the class name, the masked class modifiers, the sorted interface
names, the surviving fields, <clinit>, the constructors and the methods into one DataOutputStream-style buffer
(2-byte length + modified UTF-8 per string, big-endian i32 per modifier set), SHA-1 it, then fold the first 8 bytes
back little-endian into an i64. The fiddly parts it handles for you:
- Modifier masking. Only
PUBLIC | FINAL | INTERFACE | ABSTRACTcount for the class; fields keepPUBLIC | PRIVATE | PROTECTED | STATIC | FINAL | VOLATILE | TRANSIENT; methods and constructors keepPUBLIC | PRIVATE | PROTECTED | STATIC | FINAL | SYNCHRONIZED | NATIVE | ABSTRACT | STRICT. - Interface
ABSTRACTfixup. For an interface,ABSTRACTis forced on when it declares methods and cleared when it doesn't. - Filtering.
private staticandprivate transientfields are dropped, as are allprivateconstructors and methods. - Sort order. Interfaces by name; fields by name; constructors by descriptor; methods by name then descriptor.
- Descriptor rewriting. Constructor and method descriptors are hashed with
/replaced by., matching the JVM.
Modifier constants (PUBLIC, PRIVATE, STATIC, TRANSIENT, …) are plain i32s in the same module, meant to be
OR'd together: modifiers: PUBLIC | STATIC | FINAL.
Two caveats. First, this is your description of the Java class, not reflection over a real one — if you forget a
Lombok-generated equals/hashCode/toString or get a descriptor wrong, you get a different (wrong) UID with no
warning. Cross-check against serialver when you can. Second, the module is declared mod suid; in src/lib.rs, so
it is currently internal — make it pub mod suid; (or re-export the items) before using it from another crate.
This is the whole mapping. It is a closed table (derive/src/ty.rs), so #[derive(JavaSerialize)] picks the row
straight from the Rust type — and the last two columns are what you write by hand for the same field:
| Rust | Java | Descriptor | Schema — Field::builder(name) |
Value — inside write_object |
|---|---|---|---|---|
bool |
boolean |
Z |
.boolean() |
w.write_bool(v) |
i8 |
byte |
B |
.byte() |
w.write_byte(v as u8) |
u8 |
byte |
B |
.byte() |
w.write_byte(v) |
u16 |
short |
S |
.short() |
w.write(v) |
i16 |
short |
S |
.short() |
w.write_short(v) |
char |
char |
C |
.char() |
w.write(v) |
i32 |
int |
I |
.int() |
w.write_int(v) |
i64 |
long |
J |
.long() |
w.write_long(v) |
f32 |
float |
F |
.float() |
w.write_float(v) |
f64 |
double |
D |
.double() |
w.write_double(v) |
String, &str |
java.lang.String |
Ljava/lang/String; |
.string() |
w.write_string(&v) |
T: JavaObject |
T's Java class |
Lcom/example/Foo; |
.object(T::class().signature()) |
v.write_to(w) |
Option<T> = None |
null |
— (same as T) |
same as T |
w.write_null() |
Vec<u8>, &[u8] |
byte[] |
[B |
.byte_array() |
w.write_byte_array(&v) |
Vec<i16>, &[i16] |
short[] |
[S |
.short_array() |
w.write_short_array(&v) |
Vec<i32>, &[i32] |
int[] |
[I |
.int_array() |
w.write_int_array(&v) |
Vec<i64>, &[i64] |
long[] |
[J |
.long_array() |
w.write_long_array(&v) |
Vec<f32>, &[f32] |
float[] |
[F |
.float_array() |
w.write_float_array(&v) |
Vec<f64>, &[f64] |
double[] |
[D |
.double_array() |
w.write_double_array(&v) |
Vec<T: JavaObject> |
T[] |
[Lcom/example/Foo; |
.array(T::class().signature()) |
v.write_to(w) |
Vec<String> † |
String[] |
[Ljava/lang/String; |
.string_array() |
v.write_to(w) |
Vec<bool> † |
boolean[] |
[Z |
.boolean_array() |
w.write_boolean_array(&v) |
† hand-written path only — the derive rejects these two (see below), the encoder itself handles them fine.
Every row above also works as plain v.write_to(w)?: JavaWriteable is implemented for the Rust primitives,
String/str, the primitive slices, Vec<String> and Vec<T: JavaObject>, so a hand-written write_object can be
uniformly self.field.write_to(w)? instead of picking the matching w.write_* call. For an object array it is the
only sane form — it derives the array's own class descriptor (Class::class_of_array(&T::class()), serialVersionUID
included) and loops the elements, which by hand is w.begin_array(&cls, len)? plus one write_to per element.
Every one of these is a compile-time syn::Error at the offending span, not a runtime surprise:
| Rust | Why | Use instead |
|---|---|---|
u32, u64, i128, u128 |
no Java equivalent ‡ | i32 / i64 |
Option<primitive> |
Java primitives cannot be null | drop the Option, or box it |
Vec<i8> |
write_byte_array takes &[u8] |
Vec<u8> |
Vec<char> |
no char[] writer in ObjectWriter yet |
— |
Vec<String>, Vec<bool> |
writers exist, just not wired into the derive's table | hand-written impl |
Vec<Vec<T>>, Vec<Option<T>>, Option<Option<T>> |
no Java equivalent (Option<Vec<T>> is fine) |
flatten it |
‡ u32 and u64 do have JavaWriteable impls on the hand-written path (they write int and long after an as
cast); it is only the derive that refuses to guess for them. isize/usize are not in this table — the derive maps
them to long, same as i64/u64.
Any other type path — HashMap<K, V>, a type from another crate, one of the serde-java-ext types below — is
treated as an opaque JavaObject, and its descriptor comes from <T as JavaObject>::class().signature(). If it does
not implement JavaObject, that is the error you get.
Strings go out as Java modified UTF-8, and repeated strings and class descriptors become TC_REFERENCE
back-references, matching what the JVM writes.
Descriptions of common JDK classes, already written and fixture-verified, in the separate serde-java-ext
crate — it depends on serde-java, never the reverse, so they are reached as serde_java_ext::Integer:
| Java | Rust | Notes |
|---|---|---|
java.lang.Boolean |
Boolean(pub bool) |
|
java.lang.Byte / Short / Integer / Long / Float / Double |
Byte, Short, Integer, Long, Float, Double |
each carries its java.lang.Number superclass |
java.util.ArrayList<E> |
ArrayList<'a, T>(pub &'a [T]) |
Layout over Vec<T>; custom writeObject block data |
java.util.LinkedList<E> |
LinkedList<'a, T>(pub &'a [T]) |
Layout over Vec<T>; custom writeObject block data |
java.lang.Throwable |
Throwable |
partial — see below |
java.lang.StackTraceElement |
StackTraceElement |
built via StackTraceElement::builder(..) |
java.util.Map is not implemented yet (ext/src/map.rs is an empty placeholder).
Every box is From<prim> / Into<prim>, plus Display and Debug:
use serde_java::JavaWriteable;
use serde_java_ext::{Boolean, Double, Integer};
let bytes = Integer::from(0x01020304).to_bytes()?;
let n: i32 = Integer::from(42).into();
Double::from(std::f64::consts::PI).to_file("pi.ser")?;
Boolean::from(true).to_bytes()?;The six numeric boxes extend java.lang.Number, and the stream says so — the superclass descriptor is emitted for you:
aced0005
73 72 0011 6a6176612e6c616e672e496e7465676572 // TC_OBJECT, TC_CLASSDESC, "java.lang.Integer"
12e2a0a4f7818738 02 0001 // serialVersionUID, SC_SERIALIZABLE, 1 field
49 0005 76616c7565 // int value
78 // TC_ENDBLOCKDATA
72 0010 6a6176612e6c616e672e4e756d626572 // superclass TC_CLASSDESC, "java.lang.Number"
86ac951d0b94e08b 02 0000 78 70 // its suid, no fields, no superclass of its own
01020304 // the value
use serde_java_ext::{ArrayList, Integer, LinkedList};
let names = vec!["foo".to_string(), "bar".to_string()];
let list = ArrayList::from(&names[..]); // or ArrayList::from(&names)
let nums = vec![Integer::from(1), Integer::from(2)];
let linked = LinkedList::from(&nums[..]);
let bytes = list.to_bytes()?;Both are tuple structs borrowing a slice (ArrayList<'a, T>(pub &'a [T]), LinkedList<'a, T>(pub &'a [T])) — built
via ::from(&v[..]), or ArrayList::from(&v) directly over a &Vec<T> — and T is anything JavaWriteable: a
String, another ext type, or your own #[derive(JavaSerialize)] struct.
They exist because a plain Vec<T> is not a Java collection — it serializes as a Java array (Foo[]). A real
java.util.ArrayList/LinkedList writes its elements through a custom writeObject, as block data after the
declared fields, which is what these two reproduce. Both also implement serde_java::Layout (Input = Vec<T>),
which is what lets them drop straight onto a derived struct's Vec<T> field via #[java(with = "...")] — see
Using them as fields.
Boxed types (Integer, Boolean, …) are ordinary JavaObjects and drop straight into a derived struct as their own
field type. ArrayList/LinkedList can't — they borrow (ArrayList<'a, T>), so a struct can't own one as a field —
instead route a plain Vec<T> field through them with #[java(with = "...")]:
use serde_java::JavaSerialize;
use serde_java_ext::Integer;
#[derive(JavaSerialize)]
#[java(class = "com.example.Payload", serial_version_uid = 3153513349080412905)]
struct Payload {
id: i64,
count: Integer, // java.lang.Integer, not int
#[java(signature = "Ljava/util/List;", with = "serde_java_ext::ArrayList")]
names: Vec<String>, // Java field declared as List<String>
}The field keeps its plain Rust shape (Vec<String>); with is what routes it through
ArrayList::layout(&self.names).write_to(w) instead of the default Vec<T> → T[] array mapping. This is also why
ArrayList/LinkedList are borrowed wrapper types rather than owning their own Vec<T>: Layout::layout only needs
to hand back a borrowed view for the one write_to call the derive makes. For a with field declared as
Option<Vec<String>>, None writes null directly and only Some(names) goes through ArrayList::layout(names) —
see ext/src/list.rs's ListDemo test for the worked example.
Three things worth knowing here:
withbypasses the derive's Rust→Java mapping table entirely, so it must be paired with an explicit#[java(signature = "...")]— the descriptor no longer comes from the field's Rust type.- The declared descriptor is the interface (
Ljava/util/List;, fromsignature), but the value side still writes the concretejava.util.ArrayListclass descriptor — via<ArrayList<T> as JavaObject>::class()— which is exactly what the JVM does for a field declared asListbut assigned anArrayList. withfields always sort into the object group, after every primitive field, regardless of their Rust type — see Field order is not declaration order. The derive handles that; a hand-writtenclass()must not.
use serde_java_ext::{StackTraceElement, Throwable};
let th = Throwable::with_message("something blew up");
let frame = StackTraceElement::builder("com.example.Foo", "main", "Foo.java", 42).build();StackTraceElement is complete and fixture-verified. Throwable is partial: it writes detailMessage and cause,
but not stackTrace or suppressedExceptions, so a JVM reading it back gets an exception with no stack. Its
round-trip test is #[ignore]d for that reason.
ext/ is where new JDK descriptions belong — it holds pre-built implementations, not protocol primitives. Each file is
the hand-written path from The traits behind the derive: a static Lazy<Class> with
the real class's name, serialVersionUID and sorted field list, plus JavaObject / JavaSerializable. The bar for a new
one is a test asserting hex::encode(..) against bytes captured from a real ObjectOutputStream:
cargo test -p serde-java-ext- No deserialization. Write-only; there is no reader for Java streams.
- No object-identity dedup. Handles are allocated per object but never reused, so one Rust value referenced twice serializes as two distinct Java objects rather than a back-reference. Cyclic graphs are not supported.
- No
char[]orString[]writer.ObjectWritercoversboolean[],byte[],short[],int[],long[],float[],double[]and object arrays;char[]andString[]have none yet, which is why the derive rejectsVec<u16>andVec<String>fields (it rejectsVec<bool>too, thoughwrite_boolean_arraydoes exist). - Custom
writeObjectis hand-written only.ClassFlags::WRITE_METHODworks —serde_java_ext'sArrayListandLinkedListset it, overrideJavaSerializable::write_object, and get proper block-data framing — but#[derive(JavaSerialize)]cannot express it. serde_java_ext::Throwableis partial — it writesdetailMessageandcause, but notstackTraceorsuppressedExceptions(its round-trip test is#[ignore]d for that reason).StackTraceElementis complete.suidis not exported.compute_default_suidand friends live behind a privatemod suid;, so they are only reachable from inside the crate today.- The derive macro covers common cases only.
#[derive(JavaSerialize)]doesn't support generic structs, enums, tuple/unit structs, superclass chains, orClassFlags::WRITE_METHOD; for those, schema/value agreement is on the caller via the hand-written path (see Field order is not declaration order). There is still no reflection — Rust has no runtime access to a real Java class's shape.
cargo build
cargo test --lib # all tests
cargo test --lib test_serialize_nested # one test
RUST_LOG=debug cargo test --lib -- --nocapture
cargo test --test derive # the derive macro's integration tests
bash derive/verify-compile-errors.sh # its compile-time rejections (18 cases)
cargo test -p serde-java-ext # the pre-built JDK type descriptionsThe proc-macro lives in its own workspace member, derive/: attr.rs parses #[java(...)], ty.rs holds the closed
Rust→Java type table, expand.rs sorts the fields and emits both impls. Because the table is closed, whether a field
is primitive is decidable syntactically — which is why the sort can happen at expansion time instead of at runtime.
Tests assert against hex fixtures captured from a real ObjectOutputStream — the fixture is the spec. If encoder
output stops matching, regenerate the fixture from a JVM rather than editing it to match the new bytes.
Apache-2.0. See LICENSE.
