Commits
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
Client: data() starts the DATA phase and returns a DataWriter, an
Io.Writer whose dot-stuffing and CRLF-normalization state machine
persists across writes, so chunks may split lines, CRLF pairs, and
leading dots at any byte boundary with no line-length limits.
sendMessageReader() streams from any Io.Reader; sendMessage() is now a
thin wrapper over data(), sharing one stuffing implementation.
Server: the handler vtable gains messageReader as a streaming
alternative to message (exactly one must be set). The callback gets an
Io.Reader backed by a zero-copy line adapter that removes dot-stuffing;
anything left unread is drained through the terminator so early returns
cannot desynchronize the session. max_message_size is not enforced in
streaming mode.
The CLI send command streams stdin instead of buffering it; verified
with a 5 MB, 100k-dotted-line message round-tripping byte-exact, plus
the full VM interop suite.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
Rename existing demonstrative tests to identifier-named doctests so
autodoc attaches them to their declarations (readLine, Reply, Command,
writeStuffed, sendMail, hello, starttls, authenticate, authPlain,
authLogin, authCramMd5, run), and add new small scripted doctests for
the client functions that had none: init, greet, setTransport,
mailFrom, rcptTo, sendMessage, rset, noop, quit.
Server.run's doctest now constructs the session inline instead of
going through the runScript test helper, so the example shows the
actual API. Edge-case tests keep descriptive string names. Tls.zig
and Server.StartTls have no runnable doctests since a handshake needs
a live peer; their usage examples stay in doc comments.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
Client (RFC 4954/4616/2195):
- Extensions.auth parses the advertised mechanism list (including the
legacy AUTH= form) into plain/login/cram_md5 flags
- authLogin and authCramMd5 join authPlain; CRAM-MD5 is verified against
the RFC 2195 example vector
- authenticate() picks PLAIN, then LOGIN, then CRAM-MD5; a 535 surfaces
as error.AuthenticationFailed with the reply in last_reply
Server:
- an optional authenticate handler callback enables AUTH PLAIN and
LOGIN: initial responses, 334 challenges, "*" cancellation, bad
base64 (501), unknown mechanism (504), re-auth/mid-transaction (503)
- Options.require_auth rejects MAIL with 530 until authenticated;
STARTTLS resets auth state
CLI: send grew --user/--password/--auth-method, serve grew
--auth user:pass (implies require_auth).
VM interop additions: zsmtp client authenticates to Exim via PLAIN and
LOGIN (its plaintext authenticator; CRAM-MD5 is not compiled into
nixpkgs exim) with a wrong-password rejection, and swaks authenticates
to the auth-required zsmtp server via PLAIN and LOGIN with
wrong-password and unauthenticated rejections.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
The VM test now also runs the zsmtp client against Exim (ports
2625/2626) over plaintext, STARTTLS, and implicit TLS, with taint-safe
appendfile delivery checked in /var/spool/exim-mail.
Exim exposed a standard-library TLS bug: std.crypto.tls.Client only
advances its record-decryption state upon receiving the TLS 1.3
middlebox-compatibility ChangeCipherSpec record, which is optional and
disabled by Exim's OpenSSL setup, so the handshake died with
TlsUnexpectedMessage. The client-side Tls wrapper now uses ianic/tls.zig
(already used server-side) instead: init is in-place (the connection
holds interior pointers), and the flush-through workaround is gone since
tls.zig flushes each record to the stream.
A sendmail interop test was built and passing but removed again since
nixpkgs does not package the sendmail MTA.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
nix/package.nix builds zsmtp with zig_0_16 and runs the unit tests; the
tls.zig dependency is provided offline by materializing it into the
project-local zig-pkg/<hash>/ directory with only the files from the
dependency's paths list, so Zig's content hash matches.
nix/interop-test.nix exercises zsmtp against third-party
implementations in one VM:
- zsmtp client -> Postfix: plaintext (25), STARTTLS (25), implicit TLS
(465, submissions wrapper mode), verified via alice's maildir spool
- swaks -> zsmtp server: plaintext and STARTTLS (snakeoil EC cert),
verified via the server's journal
Exposed as packages.zsmtp/default and checks.{zsmtp,interop}. Postfix
on NixOS delivers maildir-style (mail_spool_directory has a trailing
slash), so assertions grep the directory recursively, with 60s
timeouts to fail fast.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
Client (std.crypto.tls):
- Tls.zig wraps std.crypto.tls.Client for implicit TLS and STARTTLS,
verifying against the system trust store by default (caller-managed
bundle and insecure modes available)
- Client.starttls() does the RFC 3207 exchange; setTransport() swaps in
the encrypted reader/writer
- Tls.writer() is a flush-through wrapper: std's TLS writer encrypts on
flush but leaves records in the stream writer's buffer, which deadlocks
request/reply protocols like SMTP
Server (ianic/tls.zig, pinned to zig-0.16.x head):
- Options.starttls advertises and accepts STARTTLS (TLS 1.3 only): 220,
server handshake over the raw stream, transport swap, RFC 3207 state
reset; 503 on a second STARTTLS, close_notify on QUIT
- tls dependency re-exported as zsmtp.tls for CertKeyPair loading
CLI: send grew --tls/--starttls/--insecure, serve grew
--tls-cert/--tls-key. Verified end to end over real sockets: zsmtp
client <-> zsmtp server STARTTLS, openssl s_client -starttls smtp
against the server, and openssl s_server against the client.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
Transport-agnostic SMTP (RFC 5321) over std.Io.Reader/Writer pairs:
- protocol.zig: reply parsing, command parsing, dot-stuffing
- Client.zig: EHLO/HELO, extensions, AUTH PLAIN, mail transactions
- Server.zig: single-connection session with handler vtable, command
sequencing, recipient/message-size limits
- main.zig: demo CLI (send via stdin, debug serve on loopback)
MIT licensed with SPDX headers; REUSE 3.3 compliant.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
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
Client: data() starts the DATA phase and returns a DataWriter, an
Io.Writer whose dot-stuffing and CRLF-normalization state machine
persists across writes, so chunks may split lines, CRLF pairs, and
leading dots at any byte boundary with no line-length limits.
sendMessageReader() streams from any Io.Reader; sendMessage() is now a
thin wrapper over data(), sharing one stuffing implementation.
Server: the handler vtable gains messageReader as a streaming
alternative to message (exactly one must be set). The callback gets an
Io.Reader backed by a zero-copy line adapter that removes dot-stuffing;
anything left unread is drained through the terminator so early returns
cannot desynchronize the session. max_message_size is not enforced in
streaming mode.
The CLI send command streams stdin instead of buffering it; verified
with a 5 MB, 100k-dotted-line message round-tripping byte-exact, plus
the full VM interop suite.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
Rename existing demonstrative tests to identifier-named doctests so
autodoc attaches them to their declarations (readLine, Reply, Command,
writeStuffed, sendMail, hello, starttls, authenticate, authPlain,
authLogin, authCramMd5, run), and add new small scripted doctests for
the client functions that had none: init, greet, setTransport,
mailFrom, rcptTo, sendMessage, rset, noop, quit.
Server.run's doctest now constructs the session inline instead of
going through the runScript test helper, so the example shows the
actual API. Edge-case tests keep descriptive string names. Tls.zig
and Server.StartTls have no runnable doctests since a handshake needs
a live peer; their usage examples stay in doc comments.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
Client (RFC 4954/4616/2195):
- Extensions.auth parses the advertised mechanism list (including the
legacy AUTH= form) into plain/login/cram_md5 flags
- authLogin and authCramMd5 join authPlain; CRAM-MD5 is verified against
the RFC 2195 example vector
- authenticate() picks PLAIN, then LOGIN, then CRAM-MD5; a 535 surfaces
as error.AuthenticationFailed with the reply in last_reply
Server:
- an optional authenticate handler callback enables AUTH PLAIN and
LOGIN: initial responses, 334 challenges, "*" cancellation, bad
base64 (501), unknown mechanism (504), re-auth/mid-transaction (503)
- Options.require_auth rejects MAIL with 530 until authenticated;
STARTTLS resets auth state
CLI: send grew --user/--password/--auth-method, serve grew
--auth user:pass (implies require_auth).
VM interop additions: zsmtp client authenticates to Exim via PLAIN and
LOGIN (its plaintext authenticator; CRAM-MD5 is not compiled into
nixpkgs exim) with a wrong-password rejection, and swaks authenticates
to the auth-required zsmtp server via PLAIN and LOGIN with
wrong-password and unauthenticated rejections.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
The VM test now also runs the zsmtp client against Exim (ports
2625/2626) over plaintext, STARTTLS, and implicit TLS, with taint-safe
appendfile delivery checked in /var/spool/exim-mail.
Exim exposed a standard-library TLS bug: std.crypto.tls.Client only
advances its record-decryption state upon receiving the TLS 1.3
middlebox-compatibility ChangeCipherSpec record, which is optional and
disabled by Exim's OpenSSL setup, so the handshake died with
TlsUnexpectedMessage. The client-side Tls wrapper now uses ianic/tls.zig
(already used server-side) instead: init is in-place (the connection
holds interior pointers), and the flush-through workaround is gone since
tls.zig flushes each record to the stream.
A sendmail interop test was built and passing but removed again since
nixpkgs does not package the sendmail MTA.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
nix/package.nix builds zsmtp with zig_0_16 and runs the unit tests; the
tls.zig dependency is provided offline by materializing it into the
project-local zig-pkg/<hash>/ directory with only the files from the
dependency's paths list, so Zig's content hash matches.
nix/interop-test.nix exercises zsmtp against third-party
implementations in one VM:
- zsmtp client -> Postfix: plaintext (25), STARTTLS (25), implicit TLS
(465, submissions wrapper mode), verified via alice's maildir spool
- swaks -> zsmtp server: plaintext and STARTTLS (snakeoil EC cert),
verified via the server's journal
Exposed as packages.zsmtp/default and checks.{zsmtp,interop}. Postfix
on NixOS delivers maildir-style (mail_spool_directory has a trailing
slash), so assertions grep the directory recursively, with 60s
timeouts to fail fast.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
Client (std.crypto.tls):
- Tls.zig wraps std.crypto.tls.Client for implicit TLS and STARTTLS,
verifying against the system trust store by default (caller-managed
bundle and insecure modes available)
- Client.starttls() does the RFC 3207 exchange; setTransport() swaps in
the encrypted reader/writer
- Tls.writer() is a flush-through wrapper: std's TLS writer encrypts on
flush but leaves records in the stream writer's buffer, which deadlocks
request/reply protocols like SMTP
Server (ianic/tls.zig, pinned to zig-0.16.x head):
- Options.starttls advertises and accepts STARTTLS (TLS 1.3 only): 220,
server handshake over the raw stream, transport swap, RFC 3207 state
reset; 503 on a second STARTTLS, close_notify on QUIT
- tls dependency re-exported as zsmtp.tls for CertKeyPair loading
CLI: send grew --tls/--starttls/--insecure, serve grew
--tls-cert/--tls-key. Verified end to end over real sockets: zsmtp
client <-> zsmtp server STARTTLS, openssl s_client -starttls smtp
against the server, and openssl s_server against the client.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx
Transport-agnostic SMTP (RFC 5321) over std.Io.Reader/Writer pairs:
- protocol.zig: reply parsing, command parsing, dot-stuffing
- Client.zig: EHLO/HELO, extensions, AUTH PLAIN, mail transactions
- Server.zig: single-connection session with handler vtable, command
sequencing, recipient/message-size limits
- main.zig: demo CLI (send via stdin, debug serve on loopback)
MIT licensed with SPDX headers; REUSE 3.3 compliant.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx