- Zig 97.6%
- Nix 2.4%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
The audit had flagged the separate dict-entry budget as a possibly lenient reading of "32 array type codes and 32 open parentheses". Reading the reference implementation settles it: its signature validator keeps the same three separate counters, dict entries with their own, and its body walk enforces the same 64 total counting every array, struct, dict entry, and variant. Both doc comments say so now, so the question stays settled. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_019d2agcv3tGCYJfBVZoKYzK |
||
| .forgejo/workflows | ||
| examples | ||
| LICENSES | ||
| src | ||
| tools | ||
| .gitignore | ||
| build.zig | ||
| build.zig.zon | ||
| flake.lock | ||
| flake.nix | ||
| README.md | ||
| REUSE.toml | ||
| typos.toml | ||
mdbus
Monstar DBUS - A small D-Bus client transport for Zig.
mdbus speaks enough of the D-Bus protocol to talk to services on the session
bus: it authenticates over a Unix-domain socket, encodes and decodes the wire
format, sends method calls and signals, correlates replies with their calls, and
passes Unix file descriptors. It is deliberately not a general D-Bus binding —
there is no object model, no introspection, and no code generation.
Service-specific message construction belongs in the module that owns it.
This library was originally part of Monstar.
Requirements
- Zig 0.16 or newer
- Linux
- A session bus reachable over a Unix socket (
DBUS_SESSION_BUS_ADDRESS, or$XDG_RUNTIME_DIR/busas a fallback)
Installation
Add the dependency to your build.zig.zon:
zig fetch --save <repository-url>
Then wire the module into your build.zig:
const mdbus = b.dependency("mdbus", .{
.target = target,
.optimize = optimize,
});
exe.root_module.addImport("mdbus", mdbus.module("mdbus"));
Message size limit
mdbus refuses to encode or accept a message larger than 16 MiB by default.
The limit is what bounds how much a hostile peer can make the library
allocate, so it is deliberately well under the 128 MiB the protocol permits.
A build that expects larger messages — or that wants a tighter bound than the
default — sets max_message_size:
const mdbus = b.dependency("mdbus", .{
.target = target,
.optimize = optimize,
.max_message_size = @as(usize, 64 * 1024 * 1024),
});
A value above the protocol maximum, or below a message's 16-byte fixed header,
fails the build rather than being clamped silently. The value in effect is
readable as mdbus.max_message_size, and the protocol's own ceiling as
mdbus.spec_max_message_size. Raise it only as far as the messages actually
expected: a connection queues up to 256 messages, so the worst-case memory a
peer can pin scales with this setting.
Usage
const std = @import("std");
const mdbus = @import("mdbus");
pub fn listNames(
io: std.Io,
allocator: std.mem.Allocator,
environ: std.process.Environ,
) !void {
var connection = try mdbus.Connection.connectSession(io, allocator, environ);
defer connection.deinit();
var reply = try connection.call(.{
.destination = "org.freedesktop.DBus",
.path = "/org/freedesktop/DBus",
.interface = "org.freedesktop.DBus",
.member = "ListNames",
}, "", &.{}, &.{}, 5000);
defer reply.deinit();
if (reply.messageType() == .error_reply) return error.RemoteError;
var decoder = reply.bodyDecoder();
const end = try decoder.beginArray('s');
while (!try decoder.arrayFinished(end)) {
const name = try decoder.string();
std.debug.print("{s}\n", .{name});
}
try decoder.endArray(end);
}
Arguments are built with an Encoder and passed alongside their D-Bus
signature. To send a file descriptor, encode its index into the fds slice:
var body: mdbus.Connection.Encoder = .init(allocator);
defer body.deinit();
try body.unixFd(0);
var reply = try connection.call(.{
.destination = "com.example.Service",
.path = "/com/example/Service",
.interface = "com.example.Service",
.member = "TakeFd",
}, "h", body.bytes(), &.{fd}, 1000);
defer reply.deinit();
Complete programs live under examples, each its own package
consuming mdbus as a path dependency the way an out-of-tree caller would:
open-uri opens a URL through the desktop portal and
waits for the portal's Response signal,
open-file does the same for a local file, which the
portal takes as a passed file descriptor rather than a path, and
open-directory reveals a file in the file
manager the same way. From an example's directory:
zig build run -- https://example.org
Encoding a body
A body is built value by value with an Encoder, then handed to call,
sendSignal, or sendReply together with the signature describing it. The
encoder does not track the signature — you state it once, at the send, and
write values that match it. What the encoder does track is alignment: every
value is padded to the boundary its type requires, computed against the
message as a whole, which is the part of the wire format worth outsourcing.
Scalars and strings are single calls, written in signature order. This body
matches the signature sub:
var body: mdbus.Connection.Encoder = .init(allocator);
defer body.deinit();
try body.string("backlight");
try body.uint32(40);
try body.boolean(true);
An array is bracketed by beginArray and endArray. The wire format prefixes
an array with its byte length, which is not known until the elements have been
written, so beginArray leaves a hole and returns a bookmark, and endArray
patches the length in. beginArray takes the character the element type's
signature begins with — 'i' for ai, '(' for a(us), '{' for a{sv} —
and derives the element alignment from it. A struct's fields are preceded by
structAlignment, which pads to the eight-byte boundary every struct starts
on. Together, for a(us):
const entries = try body.beginArray('(');
for (devices) |device| {
try body.structAlignment();
try body.uint32(device.id);
try body.string(device.name);
}
try body.endArray(entries);
A dictionary is an array of dict entries, and an entry is a key, a value, and
the same eight-byte alignment a struct gets — dictEntryAlignment, named for
what it precedes. The common a{sv} pairs string keys with variants, where a
variant is a signature naming exactly one type followed by a value of that
type:
const properties = try body.beginArray('{');
try body.dictEntryAlignment();
try body.string("Volume");
try body.variantSignature("d");
try body.double(0.5);
try body.dictEntryAlignment();
try body.string("Muted");
try body.variantSignature("b");
try body.boolean(false);
try body.endArray(properties);
The content of a byte array moves in bulk rather than a call per byte, so
ay carrying real data is three calls regardless of its size:
const blob = try body.beginArray('y');
try body.byteSlice(image_data);
try body.endArray(blob);
Sending is where the signature is stated and checked — the encoder trusts you until then, and the values written must match it:
var reply = try connection.call(.{
.destination = "com.example.Mixer",
.path = "/com/example/Mixer",
.interface = "com.example.Mixer",
.member = "Configure",
}, "suba(us)a{sv}ay", body.bytes(), &.{}, 5000);
defer reply.deinit();
The connection sends a body as a gathered write: the header and your body
bytes go to the socket as two vectors of one sendmsg, so a large body — a
megabyte of ay, say — is handed to the kernel directly rather than copied
into a full-message buffer first.
Reusing encoding memory
The allocator handed to Encoder.init is where the body's bytes accumulate,
and the encoder does not care where that memory comes from. A caller
assembling many messages can hand it a std.heap.FixedBufferAllocator over
one long-lived buffer, or an ArenaAllocator it resets between messages,
which turns per-message heap traffic into reuse of the same warm memory —
worth doing when profiling shows allocation churn, and not before.
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
for (readings) |reading| {
// Reclaims every body at once and keeps the buffer for the next one,
// which is also why no encoder deinit is needed here.
_ = arena.reset(.retain_capacity);
var body: mdbus.Connection.Encoder = .init(arena.allocator());
try body.string(reading.name);
try body.double(reading.value);
try connection.sendSignal(.{
.path = "/com/example/Sensor",
.interface = "com.example.Sensor",
.member = "Reading",
}, "sd", body.bytes());
}
The allocator given to connectSession is a different matter: the
connection allocates every received message from it, and each lives until
its deinit, so that one must be a real allocator that can free
independently — never an arena being reset while messages are alive.
Decoding a body
Reading mirrors writing: Message.bodyDecoder returns a Decoder positioned
at the start of the body, and values are read in the order the signature
(Message.bodySignature) lists them. A message that came out of
parseMessage — which is every message a Connection hands you — has
already had its body validated against its signature, so decoding follows
the signature without running off the end. Strings, paths, and byte slices
alias the message's buffer rather than being copied, and are valid until the
message's deinit.
An array read is bracketed the way an array write is: beginArray takes the
same element character, returns the position where the array ends, and
arrayFinished against that position drives the element loop. For the
a(us) written above:
var decoder = reply.bodyDecoder();
const entries = try decoder.beginArray('(');
while (!try decoder.arrayFinished(entries)) {
try decoder.structAlignment();
const id = try decoder.uint32();
const name = try decoder.string();
std.debug.print("{d}: {s}\n", .{ id, name });
}
try decoder.endArray(entries);
A dictionary reads as an array of eight-aligned entries — structAlignment
covers a dict entry too, since both pad to the same boundary. A variant's
value is read according to the signature that opens it, and
skipSignatureValue steps over a value of any type, which is what to do
with a key you do not recognize:
const properties = try decoder.beginArray('{');
while (!try decoder.arrayFinished(properties)) {
try decoder.structAlignment();
const key = try decoder.string();
const signature = try decoder.variantSignature();
if (std.mem.eql(u8, key, "Volume") and std.mem.eql(u8, signature, "d")) {
volume = try decoder.double();
} else {
try decoder.skipSignatureValue(signature);
}
}
try decoder.endArray(properties);
A byte array comes back in bulk as a slice of the message, with the array's end position saying how much to take:
const blob = try decoder.beginArray('y');
const image_data = try decoder.byteSlice(blob);
try decoder.endArray(blob);
A received file descriptor travels as an h index into the message's
descriptor array, which is resolved — after checking it, since indices are
values a peer chose — against Message.fds:
const index = try decoder.unixFd();
if (index >= reply.fds.len) return error.InvalidMessage;
const fd = reply.fds[index];
Finishing with decoder.end() asserts the body held nothing beyond what was
read, which catches a signature misunderstanding on the last value as
surely as on the first.
API
The full API reference, rendered from the module's doc comments, is published at https://jcollie.codeberg.page/mdbus/.
Connection is the entry point:
| Function | Purpose |
|---|---|
connectSession |
Connect, authenticate, and say Hello to the bus |
call |
Send a method call and wait for its reply |
sendMethod |
Send a method call, returning its serial |
sendSignal |
Emit a signal |
sendReply |
Answer a received method call with its return value |
sendError |
Answer a received method call with an error |
addMatch |
Install a match rule and confirm the bus accepted it |
waitForReply |
Wait for one serial, queueing unrelated messages |
nextMessage |
Take the next queued or received message, non-blocking |
getFd / hasQueuedMessages |
Integrate the connection into an event loop |
A received method call carries the unique name of whoever sent it, which is
what sendReply and sendError address their answer to, so answering one
takes nothing beyond the message itself. Message.noReplyExpected reports
whether the caller is waiting for that answer at all.
Messages own their memory and their received descriptors; call deinit on every
message you are handed. Message.bodyDecoder returns a bounds-checked Decoder
over the body, and Encoder builds bodies for outgoing messages. Both handle
every type in the specification: bytes, booleans, signed and unsigned 16-, 32-,
and 64-bit integers, doubles, Unix file descriptors, strings, object paths, and
signatures, plus arrays, structs, dict entries, and variants. The content of a
byte array moves in bulk through Encoder.byteSlice and Decoder.byteSlice,
and an i array's through int32Slice, rather than a call per element. Neither tracks a
signature of its own — the caller states the signature and writes values that
match it — but Decoder.skipSignatureValue walks any value from its signature,
and parseMessage uses it to validate an incoming body before returning it.
Because dispatch is non-blocking and driven by getFd, an application can poll
the connection alongside its other file descriptors and drain messages with
nextMessage as they arrive.
Development
A Nix flake provides Zig 0.16 and the REUSE tooling:
nix develop
zig build test
The unit tests run without a bus. The live-bus tests in
src/live_bus_test.zig skip themselves unless a session bus is available and
MONSTAR_DBUS_INTEGRATION=1 is set:
MONSTAR_DBUS_INTEGRATION=1 zig build test
To run the whole suite against a bus that is known to be dbus-broker, without needing one on the development machine, the flake boots a NixOS virtual machine whose session bus is dbus-broker and runs the tests inside it:
nix build .#checks.x86_64-linux.dbus-broker
The check fails unless every test ran, so a live-bus test that skipped itself
is treated as a failure rather than a pass. packages.<system>.test-runner
builds the same test binary on its own, which is what the virtual machine
installs and executes.
zig build docs renders the module's doc comments to zig-out/docs, and
zig build docs-serve builds the same pages and serves them at
http://127.0.0.1:8000/, with -Ddocs-port=N to choose another port. A server
is needed rather than opening the files directly, because the viewer fetches
its data at runtime and a browser refuses that from a file:// page; zig std
works this way for the same reason. CI publishes the same pages to
https://jcollie.codeberg.page/mdbus/ on every push to main that passes the
tests.
Licensing headers follow the REUSE specification;
reuse lint checks them.
License
MIT. See LICENSES/MIT.txt.
The D-Bus client originated in Tim Culverhouse's Monstar project and is reused here under the same license; Tim is credited as the original author in every file header.