Skip to content

Latest commit

 

History

53 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

logo.png

serde-java

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.

Install

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.

Quick start

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 traits behind the derive

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 a static Lazy<Class>; Class is Arc-backed, so handing out clones is cheap.
  • JavaSerializable::write_object(&self, w) — the instance: write each field's value through w, in the same order as the schema. Values only — the TC_OBJECT tag, 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.

Derive attributes and supported types

Field attributes:

  • #[java(rename = "...")] — changes the Java-side field name (and therefore its sort position)
  • #[java(skip)] — Java transient; 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 as SomeLayout::layout(&self.field).write_to(w), where SomeLayout: serde_java::Layout. This bypasses the Rust→Java mapping table entirely, so it must be paired with an explicit signature, and it always sorts into the object group regardless of the field's Rust type. For an Option<T> field, pair with with a Layout<Input = T>None writes null directly and only Some(v) goes through Layout::layout. This is how serde-java-ext's ArrayList/LinkedList/Boolean/boxed numbers attach to a plain Vec<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.

Field order is not declaration order

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, derived

Computing serialVersionUID

Every 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 | ABSTRACT count for the class; fields keep PUBLIC | PRIVATE | PROTECTED | STATIC | FINAL | VOLATILE | TRANSIENT; methods and constructors keep PUBLIC | PRIVATE | PROTECTED | STATIC | FINAL | SYNCHRONIZED | NATIVE | ABSTRACT | STRICT.
  • Interface ABSTRACT fixup. For an interface, ABSTRACT is forced on when it declares methods and cleared when it doesn't.
  • Filtering. private static and private transient fields are dropped, as are all private constructors 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.

Type mapping

Rust → Java

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.

Rust types with no Java mapping

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.

Pre-built JDK types (serde-java-ext)

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).

Boxed primitives

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

Collections

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.

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:

  • with bypasses 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;, from signature), but the value side still writes the concrete java.util.ArrayList class descriptor — via <ArrayList<T> as JavaObject>::class() — which is exactly what the JVM does for a field declared as List but assigned an ArrayList.
  • with fields 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-written class() must not.

Throwable

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.

Adding a type

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

Limitations

  • 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[] or String[] writer. ObjectWriter covers boolean[], byte[], short[], int[], long[], float[], double[] and object arrays; char[] and String[] have none yet, which is why the derive rejects Vec<u16> and Vec<String> fields (it rejects Vec<bool> too, though write_boolean_array does exist).
  • Custom writeObject is hand-written only. ClassFlags::WRITE_METHOD works — serde_java_ext's ArrayList and LinkedList set it, override JavaSerializable::write_object, and get proper block-data framing — but #[derive(JavaSerialize)] cannot express it.
  • serde_java_ext::Throwable is partial — it writes detailMessage and cause, but not stackTrace or suppressedExceptions (its round-trip test is #[ignore]d for that reason). StackTraceElement is complete.
  • suid is not exported. compute_default_suid and friends live behind a private mod 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, or ClassFlags::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.

Development

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 descriptions

The 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.

License

Apache-2.0. See LICENSE.

About

A java serialization library in Rust.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages