XMODEM, YMODEM, ZMODEM and Kermit in Zig 0.16, built to sit between two std.Io streams as a filter
  • Zig 95.1%
  • Python 4.6%
  • Nix 0.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie 4fc5f5745a
Add Kermit sliding windows
Stop-and-wait costs a round trip per packet, which on a link with any
real delay leaves the line idle most of the time. A window lets several
packets go out before the first is answered, and lets the receiver hold
on to ones that arrive early rather than throwing them away and asking
again -- so a single lost packet costs one retransmission rather than a
window's worth.

`window.zig` holds the bookkeeping: where a sequence number falls
relative to the current and previous windows, a ring of packets sent and
not yet acknowledged, and a ring of packets received before their turn.
Acknowledgements can arrive in any order, so one for the middle of the
window settles only itself and the window slides when the gap below it
fills in. The rings mean sliding moves no packet bytes.

The window covers the data phase alone. The name, the attributes and the
end of a file are all points where what to do next depends on the
answer, so there is nothing to gain by running ahead, and C-Kermit
narrows its own window to one for them in the same way.

A slot costs a packet's worth of memory at each end, so the buffer the
caller hands over is what decides how wide the window can be: asking for
more slots than fit narrows it rather than failing, and so does a peer
that will not go as wide. `receiveBufferSize` and `sendBufferSize` work
out what a given window needs. The cap is 31 rather than the 32
C-Kermit allows, which leaves two sequence numbers belonging to neither
window so that a damaged one can be told apart from a real one.

Testing the wide-window case against C-Kermit turned up a bug that had
nothing to do with windows: the attribute packet was going through the
data quoting, and C-Kermit reads that field raw. The tag introducing the
timestamp is the control-quote character, so it went out doubled and the
length beside it then pointed into the middle of a value; C-Kermit
refused the attributes and the sender skipped the file. Our receiver was
decoding incoming lists for the same reason, so timestamps were mangled
in both directions. Whether it actually broke a transfer depended on
which tag the corrupted walk happened to land on, which is why it
survived the existing oracle cases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WuSyAmxqQzfdKvq6yTwhZc
2026-09-02 12:02:51 -05:00
LICENSES Add a ZMODEM implementation that filters two std.Io streams 2026-09-02 07:47:36 -05:00
src Add Kermit sliding windows 2026-09-02 12:02:51 -05:00
tests Add Kermit sliding windows 2026-09-02 12:02:51 -05:00
.gitignore Add a ZMODEM implementation that filters two std.Io streams 2026-09-02 07:47:36 -05:00
build.zig Add XMODEM, YMODEM and Kermit beside ZMODEM 2026-09-02 11:28:55 -05:00
build.zig.zon Add XMODEM, YMODEM and Kermit beside ZMODEM 2026-09-02 11:28:55 -05:00
flake.lock Add a ZMODEM implementation that filters two std.Io streams 2026-09-02 07:47:36 -05:00
flake.nix Add XMODEM, YMODEM and Kermit beside ZMODEM 2026-09-02 11:28:55 -05:00
README.md Add Kermit sliding windows 2026-09-02 12:02:51 -05:00
REUSE.toml Add a ZMODEM implementation that filters two std.Io streams 2026-09-02 07:47:36 -05:00

modem

XMODEM, YMODEM, ZMODEM and Kermit in Zig 0.16, built to sit between two std.Io streams as a filter.

Bytes are passed through unchanged until a ZMODEM frame header or a Kermit S packet appears in the remote stream, at which point the filter stops forwarding and receives the file. Sending works the other way: offer a file and the filter starts a transfer at the next opportunity.

lrzsz and C-Kermit are the references. Every ambiguity in Chuck Forsberg's and Frank da Cruz's specifications was settled by capturing what those programs actually do, and zig build oracle checks both directions of all four protocols against them.

The four protocols

They do the same job and agree on almost nothing about how.

XMODEM YMODEM ZMODEM Kermit
Files per session one many many many
Carries a file name no yes yes yes
Carries a length no yes yes yes
Receiver can decline no no yes yes
Resume part way no no yes no
Sender announces no no yes yes
Block check sum, CRC16 CRC16 CRC16, CRC32 3 kinds
Sends 8-bit data raw yes yes escaped quoted
Packets in flight 1, or all 1, or all all 1 to 31

Each has its own Receiver and Sender with the options its wire format actually needs, under modem.xmodem, modem.ymodem, modem.zmodem and modem.kermit. AnyReceiver and AnySender sit over the top for callers that pick a protocol at run time.

What "the sender announces" costs is worth spelling out, because it decides what a filter can do. An XMODEM or YMODEM sender says nothing at all until a receiver prompts it, so an incoming transfer cannot be recognised: somebody has to decide to go and fetch. ZMODEM and Kermit senders speak first, so those transfers start by themselves.

Using the filter

const modem = @import("modem");

var storage: [1 << 14]u8 = undefined;
var pipe_buffer: [8192]u8 = undefined;
var pipe: modem.Pipe = .init(io, &storage, &pipe_buffer);

var receive_buffer: [modem.receive_buffer_size]u8 = undefined;
var send_buffer: [modem.send_buffer_size]u8 = undefined;

var filter: modem.Filter = .init(
    io,
    .{ .in = &remote_in, .out = &remote_out },
    .{ .in = &local_in, .out = &local_out },
    my_sink,
    .{},
    &pipe,
    &receive_buffer,
    &send_buffer,
);

try filter.run(); // forwards both directions until `filter.stop()`

Received files go wherever the caller's Sink puts them. The library never touches the filesystem:

fn open(context: *anyopaque, info: modem.file.Info, offset: *u64)
    modem.Sink.OpenError!?*std.Io.Writer
{
    const self: *MySink = @ptrCast(@alignCast(context));
    // `info.name` comes from the peer. `baseName` strips any directory
    // prefix, so a sender cannot ask for `../../etc/passwd`. It is empty
    // for XMODEM, which carries no name at all.
    const name = info.baseName();
    if (name.len == 0) return null; // returning null declines the file

    self.file = try self.dir.createFile(self.io, name, .{});
    self.writer = self.file.writer(self.io, &self.buffer);
    return &self.writer.interface;
}

To resume an interrupted transfer, set offset.* to the number of bytes already held; the sender is asked to restart there. Only ZMODEM can do this.

Sending is queued from any task and awaited separately, so a user interface can offer a file while the filter is busy forwarding:

var upload: modem.Filter.Offer = .{
    .info = info,
    .source = source,
    .protocol = .ymodem,
};
try filter.offer(&upload);
switch (try filter.awaitOffer(&upload)) {
    .sent => {},
    .skipped => {},                 // the peer declined it
    .failed => return upload.err.?,
    .pending, .sending => unreachable,
}

Consecutive offers naming the same protocol are sent as one session.

An XMODEM or YMODEM download has to be asked for, since neither sender will speak first:

var download: modem.Filter.Request = .{
    .protocol = .xmodem,
    // XMODEM carries no name, so the receiving end picks one.
    .info = .{ .name = "download.bin" },
};
try filter.request(&download);
_ = try filter.awaitRequest(&download);

Either half of any protocol can also be driven on its own, without the filter, against any pair of std.Io streams: see each protocol's Receiver and Sender.

Command line

The modem binary exists mostly so the library can be tested against lrzsz and C-Kermit, but it is usable on its own:

modem recv [-p PROTO] [-d DIR] [-n NAME]   receive over stdin/stdout
modem send [-p PROTO] FILE...              send over stdin/stdout
modem filter [options]                     pass stdin/stdout through

-p takes zmodem (the default), xmodem, ymodem or kermit. -w sets how many Kermit packets may be in flight at once. -n names a file received over XMODEM, which carries no name of its own; the filter takes --send FILE to offer a file and --receive to fetch one.

Keeping the line busy

Only ZMODEM streams unconditionally: its sender talks until the receiver interrupts, so a round trip costs nothing. The other three wait to be acknowledged, and on a link with any real delay that leaves the line idle most of the time.

XMODEM and YMODEM have one answer between them, YMODEM-G, which drops acknowledgements entirely and gives up on the first error. Kermit has a better one: a window of up to 31 packets, with the receiver holding back anything that arrives early and the sender resending only what actually went missing. A single lost packet then costs one retransmission rather than a window's worth.

var receive_buffer: [modem.receiveBufferSize(16)]u8 = undefined;
var send_buffer: [modem.sendBufferSize(16)]u8 = undefined;

var receiver: modem.kermit.Receiver = .init(
    io, &pipe, &out, my_sink, .{ .window = 16 }, &receive_buffer,
);

The window costs a packet's worth of memory per slot at each end, so it is the buffer the caller hands over that decides how wide it can be: asking for more slots than the buffer holds narrows the window rather than failing, and so does a peer that will not go as wide. modem.receiveBufferSize and modem.sendBufferSize work out what a given window needs.

Why a Pipe sits in the middle

All four protocols are built on timeouts: a ZMODEM receiver that hears nothing for ten seconds prods the sender with a ZRPOS rather than waiting forever, an XMODEM receiver re-prompts every three, a Kermit receiver re-sends its NAK. std.Io.Reader has no notion of a deadline, and cancelling a read already in flight risks losing bytes the transport has handed over.

So a feeder task owns the blocking read and pushes whatever it gets into a ring buffer. The protocol engine consumes from the ring with a deadline it can change between frames, and giving up on a read costs nothing because the bytes stay buffered for whoever asks next. Pipe is that ring; it is the reason Filter.run needs real concurrency rather than Io.async.

What is implemented

XMODEM. Both block checks, 128 and 1024 byte blocks, the C/NAK handshake with a fallback from CRC to checksum, duplicate block detection and retransmission, and the two-CAN abort. Optionally strips the trailing SUB padding a sender adds to the final block, which recovers the original length of a text file and corrupts a binary one that genuinely ends in SUB.

YMODEM. Everything XMODEM has, plus block zero carrying the name, length, timestamp and mode; batches; exact truncation to the stated length; and YMODEM-G, which streams without acknowledgements and abandons the transfer on the first error, because that is all it can do.

ZMODEM. Binary and hex headers, 16- and 32-bit frame checks, ZDLE escaping including the control-character and carriage-return-after-@ rules, streaming with ZCRCG, windowed flow control with ZCRCW/ZACK for receivers that advertise a bounded buffer, ZRPOS error recovery, batch transfers, ZSKIP, resume at an offset, and the five-CAN abort.

Kermit. The S/F/A/D/Z/B/E/N/Y packets, all three block checks, control quoting, the 8th-bit prefix, run-length encoding with the repeat prefix, the full parameter exchange, extended packet lengths up to four kilobytes, attribute packets carrying the length and timestamp, and sliding windows of up to 31 packets.

What is not

ESC8, the ZCOMPRESS transport options, and ZCOMMAND — the last deliberately, since honouring it would hand the peer arbitrary code execution. It is always answered with ZNAK.

Kermit's locking shifts, RESEND and server mode.

Kermit's timestamps are written and read as UTC, while C-Kermit writes local time, so a timestamp exchanged with it is offset by the local zone. The value is handed to the caller's sink and nothing here acts on it; the alternative was carrying a timezone database for a field that is only ever displayed.

ZMODEM and YMODEM carry file positions in 32 bits, so files of 4 GiB and beyond cannot be described. That is a limit of the protocols.

Building

zig build              # the CLI
zig build test         # unit tests and in-process sender/receiver sessions
zig build oracle       # interoperability (cases skip if their tool is absent)

A flake.nix provides Zig 0.16, lrzsz and C-Kermit.