An SMTP client and server library for Zig implementing RFC 5321.
0

Configure Feed

Select the types of activity you want to include in your feed.

Add fuzz tests

Six std.testing.fuzz targets, all also running once as part of the
normal test suite:

- Command.parse: arbitrary bytes parse or error cleanly, and payload
slices always lie within the input line
- Reply.read: arbitrary reply streams; successful codes stay in range
- client vs arbitrary server replies: full greet/hello/auth/sendMail
sequence must fail cleanly, never crash
- DataWriter differential: streaming stuffing must be byte-identical
to writeStuffed under fuzzer-chosen chunk boundaries
- server session vs arbitrary client input (auth enabled, discarding
writer)
- collecting vs streaming DATA differential: both handler paths must
yield identical unstuffed content

Verified with ~5 minutes of coverage-guided fuzzing (corpus saturated
at 27 entries, no failures). Running the fuzzer on stock Zig 0.16.0
requires a patched std (its fuzz-mode test runner does not compile and
the coverage server panics on a binary with no fuzz tests; both fixed
on master) - documented in the README.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx

+178
+16
README.md
··· 175 175 176 176 ```sh 177 177 zig build test 178 + zig build test --fuzz # run the fuzz tests under the fuzzer (endless) 178 179 ``` 180 + 181 + The fuzz tests cover parser crash-safety (`Command.parse`, `Reply.read`), 182 + whole-session robustness against arbitrary bytes on both the client and 183 + server side, and two differential properties: the streaming `DataWriter` 184 + must produce byte-identical output to the slice-based `writeStuffed` under 185 + fuzzer-chosen chunk boundaries, and the collecting and streaming server 186 + DATA paths must yield identical message content. 187 + 188 + Note: Zig 0.16.0's fuzz *driver* is broken out of the box (its bundled 189 + test runner fails to compile in fuzz mode, and the coverage server panics 190 + on a test binary with no fuzz tests); both are fixed on Zig master. Until 191 + then, fuzzing needs a patched copy of the standard library via 192 + `zig build --zig-lib-dir <patched-lib> test --fuzz`. The fuzz tests 193 + themselves also run once per invocation as part of the normal 194 + `zig build test` suite. 179 195 180 196 Interoperability against third-party implementations is covered by a NixOS 181 197 VM test (`nix/interop-test.nix`): the zsmtp client delivers mail to Postfix
+62
src/Client.zig
··· 783 783 writer.buffered(), 784 784 ); 785 785 } 786 + 787 + test "fuzz client against arbitrary server replies" { 788 + try std.testing.fuzz({}, fuzzClientReplies, .{}); 789 + } 790 + 791 + fn fuzzClientReplies(context: void, smith: *std.testing.Smith) !void { 792 + _ = context; 793 + var input_buf: [1024]u8 = undefined; 794 + const input = input_buf[0..smith.value(u10)]; 795 + smith.bytes(input); 796 + 797 + var reader: Io.Reader = .fixed(input); 798 + var out_buf: [4096]u8 = undefined; 799 + var writer: Io.Writer = .fixed(&out_buf); 800 + var reply_buf: [256]u8 = undefined; 801 + var client: Client = .init(&reader, &writer, &reply_buf); 802 + 803 + // Whatever the "server" says, the client must fail cleanly, never crash. 804 + _ = client.greet() catch return; 805 + const extensions = client.hello("fuzz.example.org") catch return; 806 + client.authenticate(extensions, "user", "password") catch {}; 807 + client.sendMail("a@example.com", &.{"b@example.net"}, ".dot\r\nbody") catch {}; 808 + client.quit() catch {}; 809 + } 810 + 811 + test "fuzz DataWriter equivalence with writeStuffed" { 812 + try std.testing.fuzz({}, fuzzDataWriter, .{}); 813 + } 814 + 815 + fn fuzzDataWriter(context: void, smith: *std.testing.Smith) !void { 816 + _ = context; 817 + var message_buf: [1024]u8 = undefined; 818 + const message = message_buf[0..smith.value(u10)]; 819 + smith.bytes(message); 820 + 821 + // Reference implementation: slice-based stuffing. 822 + var expected_buf: [2100]u8 = undefined; 823 + var expected: Io.Writer = .fixed(&expected_buf); 824 + try protocol.writeStuffed(&expected, message); 825 + 826 + // Streaming implementation, with fuzzer-chosen chunk boundaries. 827 + var responses: Io.Reader = .fixed("354 go\r\n250 ok\r\n"); 828 + var out_buf: [2200]u8 = undefined; 829 + var writer: Io.Writer = .fixed(&out_buf); 830 + var reply_buf: [64]u8 = undefined; 831 + var client: Client = .init(&responses, &writer, &reply_buf); 832 + 833 + var data_writer = try client.data(); 834 + var rest: []const u8 = message; 835 + while (rest.len > 0) { 836 + const n: usize = smith.valueRangeAtMost(u16, 1, @intCast(rest.len)); 837 + try data_writer.interface.writeAll(rest[0..n]); 838 + rest = rest[n..]; 839 + } 840 + try data_writer.end(); 841 + 842 + const written = writer.buffered(); 843 + try std.testing.expect(std.mem.startsWith(u8, written, "DATA\r\n")); 844 + try std.testing.expect(std.mem.endsWith(u8, written, ".\r\n")); 845 + const stuffed = written["DATA\r\n".len .. written.len - ".\r\n".len]; 846 + try std.testing.expectEqualStrings(expected.buffered(), stuffed); 847 + }
+55
src/Server.zig
··· 883 883 // The NOOP after DATA proves the terminator was consumed. 884 884 try std.testing.expect(std.mem.indexOf(u8, output, "250 2.0.0 Ok\r\n221") != null); 885 885 } 886 + 887 + test "fuzz session with arbitrary client input" { 888 + try std.testing.fuzz({}, fuzzSession, .{}); 889 + } 890 + 891 + fn fuzzSession(context: void, smith: *std.testing.Smith) !void { 892 + _ = context; 893 + var input_buf: [2048]u8 = undefined; 894 + const input = input_buf[0..smith.value(u11)]; 895 + smith.bytes(input); 896 + 897 + var h: TestHandler = .{ .password = "secret" }; 898 + defer h.deinit(); 899 + 900 + var reader: Io.Reader = .fixed(input); 901 + var discarding: Io.Writer.Discarding = .init(&.{}); 902 + var session: Server = .init(&reader, &discarding.writer, h.handler(), .{ 903 + .max_message_size = 512, 904 + .max_recipients = 4, 905 + }); 906 + // Whatever the "client" sends, the session must fail cleanly, never crash. 907 + session.run(std.testing.allocator) catch {}; 908 + } 909 + 910 + test "fuzz collecting and streaming DATA agree" { 911 + try std.testing.fuzz({}, fuzzDataEquivalence, .{}); 912 + } 913 + 914 + fn fuzzDataEquivalence(context: void, smith: *std.testing.Smith) !void { 915 + _ = context; 916 + var body_buf: [1024]u8 = undefined; 917 + const body = body_buf[0..smith.value(u10)]; 918 + smith.bytes(body); 919 + 920 + var script_buf: [1200]u8 = undefined; 921 + const script = std.fmt.bufPrint( 922 + &script_buf, 923 + "EHLO fuzz.example.org\r\n" ++ 924 + "MAIL FROM:<a@example.com>\r\n" ++ 925 + "RCPT TO:<b@example.net>\r\n" ++ 926 + "DATA\r\n{s}\r\n.\r\nQUIT\r\n", 927 + .{body}, 928 + ) catch unreachable; 929 + 930 + var collecting: TestHandler = .{}; 931 + defer collecting.deinit(); 932 + var out_buf: [4096]u8 = undefined; 933 + _ = runScript(script, &out_buf, collecting.handler(), .{}) catch {}; 934 + 935 + var streaming: StreamTestHandler = .{}; 936 + defer streaming.collected.deinit(std.testing.allocator); 937 + _ = runScript(script, &out_buf, streaming.handler(), .{}) catch {}; 938 + 939 + try std.testing.expectEqualSlices(u8, collecting.data.items, streaming.collected.items); 940 + }
+45
src/protocol.zig
··· 338 338 try std.testing.expectEqualStrings("", w.buffered()); 339 339 } 340 340 } 341 + 342 + test "fuzz Command.parse" { 343 + try std.testing.fuzz({}, fuzzCommandParse, .{}); 344 + } 345 + 346 + fn fuzzCommandParse(context: void, smith: *std.testing.Smith) !void { 347 + _ = context; 348 + var line_buf: [512]u8 = undefined; 349 + const line = line_buf[0..smith.value(u9)]; 350 + smith.bytes(line); 351 + 352 + const command = Command.parse(line) catch return; 353 + // Payload slices must always lie within the parsed line. 354 + switch (command) { 355 + .helo, .ehlo, .vrfy, .unknown => |payload| try std.testing.expect(payload.len <= line.len), 356 + .mail, .rcpt => |args| { 357 + try std.testing.expect(args.path.len <= line.len); 358 + try std.testing.expect(args.params.len <= line.len); 359 + }, 360 + .auth => |args| { 361 + try std.testing.expect(args.mechanism.len <= line.len); 362 + try std.testing.expect(args.initial.len <= line.len); 363 + }, 364 + .data, .rset, .noop, .quit, .help, .starttls => {}, 365 + } 366 + } 367 + 368 + test "fuzz Reply.read" { 369 + try std.testing.fuzz({}, fuzzReplyRead, .{}); 370 + } 371 + 372 + fn fuzzReplyRead(context: void, smith: *std.testing.Smith) !void { 373 + _ = context; 374 + var input_buf: [1024]u8 = undefined; 375 + const input = input_buf[0..smith.value(u10)]; 376 + smith.bytes(input); 377 + 378 + var reader: Io.Reader = .fixed(input); 379 + var text_buf: [128]u8 = undefined; 380 + // Each successful read consumes at least one line, so this terminates. 381 + while (true) { 382 + const reply = Reply.read(&reader, &text_buf) catch break; 383 + try std.testing.expect(reply.code >= 100 and reply.code <= 599); 384 + } 385 + }