Storing Values (Data Serialization)
An AIDL definition is a schema, and the Rust that rsbinder-aidl generates
from it is already a complete serializer. Until now it had nowhere to write
except a transaction, so a project that wanted to save one of its own
types reached for protobuf or serde and ended up maintaining a second
description of the same data.
Two functions remove that second description:
#![allow(unused)] fn main() { let bytes = rsbinder::to_bytes(&settings)?; std::fs::write("settings.bin", &bytes)?; let settings: Settings = rsbinder::from_bytes(&std::fs::read("settings.bin")?)?; }
Neither needs a feature. The encoder runs in the same session-less parcel
mode the RPC transport uses, and the absence of a session is what refuses
binders and file descriptors — so storage costs nothing to a build that
only speaks kernel binder. The #[derive(rsbinder::Parcelable)] below does
need macros, which is not on by default; a type generated from .aidl
does not, since the generator emits the Parcelable impl directly.
Everything on this page is demonstrated end to end, against real .aidl
definitions, by
example-hello/src/bin/serde_demo.rs
— it needs no binder device, no service manager and no socket:
$ cargo run -p example-hello --bin serde_demo
What you can store
Anything that implements Serialize works, but a type you intend to keep
should be a parcelable. Nothing about the declaration is
storage-specific — it is an ordinary .aidl file, compiled by an ordinary
build.rs:
// aidl/settings/Settings.aidl
package settings;
@RustDerive(Clone=true, PartialEq=true)
parcelable Settings {
String name = "unnamed";
int volume = 50;
String[] tags;
@nullable String note;
}
#![allow(unused)] fn main() { // build.rs — the same call any interface gets. rsbinder_aidl::Builder::new() .source(PathBuf::from("aidl/settings/Settings.aidl")) .output(PathBuf::from("settings.rs")) .generate()?; }
#![allow(unused)] fn main() { use settings::Settings::Settings; let value = Settings { name: "studio".into(), volume: 72, ..Default::default() }; std::fs::write("settings.bin", rsbinder::to_bytes(&value)?)?; let restored: Settings = rsbinder::from_bytes(&std::fs::read("settings.bin")?)?; }
The same type can still be sent through a transaction; storing it is just a second destination for the codec the generator already emitted.
If the type has no non-Rust consumer, the interface macros declare the same thing without a file or a build step — the emitted codec is byte-for-byte identical:
#![allow(unused)] fn main() { #[derive(rsbinder::Parcelable, Default, Debug, Clone, PartialEq)] #[parcelable(descriptor = "settings.Settings")] pub struct Settings { pub name: String, pub volume: i32, pub tags: Vec<String>, pub note: Option<String>, } }
Being a parcelable matters for a reason worth being explicit about. A parcelable writes a length header before its fields, and that header is the entire forward-compatibility story: a reader built against an older definition stops at the boundary the writer wrote, and a reader built against a newer one defaults the fields that were never written. So this works:
| Writer | Reader | Result |
|---|---|---|
| v2 (two extra fields) | v1 | v1's fields intact, the extras skipped |
| v1 | v2 (two extra fields) | v1's fields intact, the extras default |
rsbinder::to_bytes(&42i32) is four bytes with no header and no way to
evolve. Wrap what you store in a parcelable.
The one rule for schema changes is the one every positional format has: append only. Reordering or removing a field changes what the bytes at each position mean, and nothing will tell you — the reader will decode whatever is there.
What you cannot store
Binders and file descriptors. Neither means anything outside the process that produced it: a binder handle is an index into that process's table, and an fd is an index into its descriptor table. Storing either would produce bytes that look valid and refer to nothing.
So a value containing one is refused at the point it is written — before any
dup — rather than encoded:
#![allow(unused)] fn main() { // Err(StatusCode::FdsNotAllowed); the file descriptor is never duplicated. let bytes = rsbinder::to_bytes(&value_with_an_fd)?; }
A binder is BadType rather than FdsNotAllowed — the two conditions are
distinct. FdsNotAllowed is what Android's libbinder returns for a
session that permits no fds; the binder case has no AOSP counterpart, since
there is no session-less parcel there to write one into.
The refusal runs in the other direction too. Bytes that merely look like a binder object are never turned into one: the decoder has no object table to resolve them against and returns an error instead of fabricating a reference. That property is what makes it safe to read a file you did not write.
Reading is strict
from_bytes requires the input to be consumed exactly:
| Input | Result |
|---|---|
| Ends inside the value | Err(NotEnoughData) |
| Decodes with bytes left over | Err(BadValue) |
Leftover bytes usually mean the file was written as a different type, and returning the partially-decoded value would hide that behind a plausible-looking wrong answer.
The bytes are the IPC bytes
There is no separate storage format. to_bytes produces exactly what a peer
would receive for the same value over kernel binder or RPC — same codec,
same layout — and since the wire is fixed little-endian, a value that
carries no object is the same bytes on any architecture. A file written on
an aarch64 phone reads on an x86_64 server.
The exception is a null binder. It is written as a 24-byte object header
whose type word stays host-native, and it is recorded in no object table,
so the refusals above do not catch it. It can only get in via a
ParcelableHolder whose bytes were copied from a kernel parcel — but such
a file is not portable between hosts of different endianness, and the value
it came from does not read back even on the host that wrote it: decoding the
payload returns BadType, because the decoder has no session to marshal a
binder through.
What this buys you: a value can move between a file, a socket and a transaction without being re-encoded, and one schema describes all three.
What this is not
It is not a protobuf replacement. The format is positional and untagged,
strings are UTF-16 (roughly double the bytes of UTF-8), and everything is
padded to a 4-byte boundary. There is no self-describing type information,
so nothing can tell you that a file holds a Settings rather than a
Preferences with a compatible prefix — that is your framing to add if you
need it.
It is for dropping a type you have already described in AIDL onto disk, and it is very good at that.