Monstar DBUS - A small D-Bus client transport for Zig.
  • Zig 97.6%
  • Nix 2.4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie 02079bc6de
record that the depth budgets match the reference implementation
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
2026-08-28 20:24:04 -05:00
.forgejo/workflows open a local file through the desktop portal as an example 2026-08-28 19:12:49 -05:00
examples reveal a file in the file manager as an example 2026-08-28 19:12:59 -05:00
LICENSES license flake.lock under CC0-1.0 2026-08-27 23:43:03 -05:00
src record that the depth budgets match the reference implementation 2026-08-28 20:24:04 -05:00
tools move int32 array content in bulk with int32Slice 2026-08-28 18:31:45 -05:00
.gitignore open a URL through the desktop portal as an example 2026-08-28 19:06:42 -05:00
build.zig measure the encoder alongside the decoder 2026-08-28 15:28:41 -05:00
build.zig.zon serve the API documentation over HTTP 2026-08-28 01:10:54 -05:00
flake.lock update flake 2026-08-27 23:16:22 -05:00
flake.nix run the test suite against a real bus in a NixOS VM 2026-08-28 00:31:02 -05:00
README.md reveal a file in the file manager as an example 2026-08-28 19:12:59 -05:00
REUSE.toml license flake.lock under CC0-1.0 2026-08-27 23:43:03 -05:00
typos.toml add wire format tests from the reference suite 2026-08-28 00:01:34 -05:00

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/bus as 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.