An SMTP and LMTP client and server library for Zig, with TLS, SASL, PIPELINING, CHUNKING, DSN and the PROXY protocol.
0

Configure Feed

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

zig-smtp / src / protocol.zig
51 kB 1249 lines
1// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2// SPDX-License-Identifier: MIT 3 4//! Shared SMTP protocol primitives ([RFC 5321](https://datatracker.ietf.org/doc/html/rfc5321)): 5//! line reading, reply parsing, 6//! command parsing, and message data dot-stuffing. Used by both the client 7//! and server layers, and usable directly for custom protocol handling. 8 9const std = @import("std"); 10const Io = std.Io; 11 12pub const crlf = "\r\n"; 13 14/// Bytes that may never appear in a command argument. 15/// 16/// CR and LF end the command line, so a value carrying either one lets 17/// whatever follows it be read by the server as further SMTP commands — an 18/// address of `a@b>\r\nRCPT TO:<victim@c` turns one recipient into two. 19/// NUL is here because it separates the three fields of an SASL PLAIN 20/// response, where a value carrying one silently shifts the boundary 21/// between authorization identity, username and password. 22pub const forbidden_in_argument = "\r\n\x00"; 23 24/// Whether `text` is safe to write into a command line as an argument. 25/// 26/// This is a framing check, not address validation: it says that `text` 27/// cannot end the line early, not that it is a well-formed mailbox. The 28/// full RFC 5321 path grammar is deliberately not enforced, because plenty 29/// of addresses in real use do not satisfy it and a client that refused to 30/// carry them would be the wrong tool. Callers building commands from 31/// untrusted input should check here and reject what fails. 32pub fn isSafeArgument(text: []const u8) bool { 33 return std.mem.findAny(u8, text, forbidden_in_argument) == null; 34} 35 36test isSafeArgument { 37 try std.testing.expect(isSafeArgument("alice@example.com")); 38 try std.testing.expect(isSafeArgument("\"odd name\"@example.com")); 39 try std.testing.expect(!isSafeArgument("a@b>\r\nRCPT TO:<victim@c")); 40 try std.testing.expect(!isSafeArgument("a@b\nMAIL FROM:<c@d>")); 41 try std.testing.expect(!isSafeArgument("alice\x00root")); 42} 43 44pub const ReadLineError = error{ 45 ReadFailed, 46 EndOfStream, 47 /// The line did not fit in the reader's buffer. 48 LineTooLong, 49}; 50 51/// Reads one CRLF- (or bare LF-) terminated line, returning it without the 52/// line ending. The returned slice points into the reader's buffer and is 53/// invalidated by the next read. 54pub fn readLine(reader: *Io.Reader) ReadLineError![]u8 { 55 const line = reader.takeSentinel('\n') catch |err| switch (err) { 56 error.StreamTooLong => return error.LineTooLong, 57 error.ReadFailed, error.EndOfStream => |e| return e, 58 }; 59 if (line.len > 0 and line[line.len - 1] == '\r') return line[0 .. line.len - 1]; 60 return line; 61} 62 63/// An enhanced status code 64/// ([RFC 3463](https://datatracker.ietf.org/doc/html/rfc3463)), the 65/// `class.subject.detail` triple that 66/// [RFC 2034](https://datatracker.ietf.org/doc/html/rfc2034) puts at the 67/// front of a reply's text. 68/// 69/// It says more than the three-digit code can. `550` is "no", where 70/// `5.1.1` is "no, that mailbox does not exist" and `5.7.1` is "no, and not 71/// because of anything about the address" — a difference worth acting on. 72pub const Enhanced = struct { 73 /// 2, 4 or 5: success, persistent transient failure, permanent failure. 74 /// RFC 3463 defines no others. 75 class: u8, 76 subject: u16, 77 detail: u16, 78 79 /// What the subject sub-code is about. Non-exhaustive because the 80 /// production allows three digits and only eight are defined. 81 pub const Subject = enum(u16) { 82 other = 0, 83 addressing = 1, 84 mailbox = 2, 85 mail_system = 3, 86 network = 4, 87 delivery_protocol = 5, 88 content = 6, 89 security = 7, 90 _, 91 }; 92 93 pub fn subjectClass(e: Enhanced) Subject { 94 return @enumFromInt(e.subject); 95 } 96 97 /// Whether the class agrees with the reply code it came with. 98 /// 99 /// They should: a 250 carrying `5.1.1` is a server contradicting itself, 100 /// and worth not believing. RFC 3463 does not say what a receiver should 101 /// do about it, so this only reports it. 102 pub fn agrees(e: Enhanced, code: u16) bool { 103 return e.class == code / 100; 104 } 105 106 /// Reads a code from the front of `text`, or null if there is not one. 107 /// 108 /// Strict, because the alternative is misreading an ordinary message 109 /// that happens to begin with digits: the class must be one RFC 3463 110 /// defines, each field is one to three digits with no leading zeros, and 111 /// the code must be followed by a space or be the whole text. 112 pub fn parse(text: []const u8) ?Enhanced { 113 var rest = text; 114 const class = switch (if (rest.len == 0) return null else rest[0]) { 115 '2', '4', '5' => |digit| digit - '0', 116 else => return null, 117 }; 118 rest = rest[1..]; 119 if (rest.len == 0 or rest[0] != '.') return null; 120 rest = rest[1..]; 121 122 const subject = takeNumber(&rest) orelse return null; 123 if (rest.len == 0 or rest[0] != '.') return null; 124 rest = rest[1..]; 125 const detail = takeNumber(&rest) orelse return null; 126 127 // RFC 2034: "always followed by one or more spaces". A code with no 128 // text after it at all is not what the RFC describes, but servers 129 // send it and there is nothing ambiguous about it. 130 if (rest.len != 0 and rest[0] != ' ') return null; 131 return .{ .class = class, .subject = subject, .detail = detail }; 132 } 133 134 /// `text` with a leading enhanced code and the spaces after it removed, 135 /// or `text` unchanged if there was none. 136 /// 137 /// A multiline reply repeats the code on every line, so this strips it 138 /// from the first one only; walk `Reply.lines` and strip each. 139 pub fn strip(text: []const u8) []const u8 { 140 if (parse(text) == null) return text; 141 const space = std.mem.findScalar(u8, text, ' ') orelse return text[text.len..]; 142 return std.mem.trimStart(u8, text[space..], " "); 143 } 144 145 /// Writes the code as `class.subject.detail`. 146 pub fn format(e: Enhanced, writer: *Io.Writer) Io.Writer.Error!void { 147 try writer.print("{d}.{d}.{d}", .{ e.class, e.subject, e.detail }); 148 } 149 150 /// One to three digits with no leading zeros, consumed from the front. 151 fn takeNumber(rest: *[]const u8) ?u16 { 152 var digits: usize = 0; 153 while (digits < rest.len and std.ascii.isDigit(rest.*[digits])) digits += 1; 154 if (digits == 0 or digits > 3) return null; 155 // "expressed without leading zero digits", so "0" is a number and 156 // "01" is not one. 157 if (digits > 1 and rest.*[0] == '0') return null; 158 const value = std.fmt.parseInt(u16, rest.*[0..digits], 10) catch return null; 159 rest.* = rest.*[digits..]; 160 return value; 161 } 162 163 test parse { 164 const ok = parse("2.1.5 Ok").?; 165 try std.testing.expectEqual(@as(u8, 2), ok.class); 166 try std.testing.expectEqual(Subject.addressing, ok.subjectClass()); 167 try std.testing.expectEqual(@as(u16, 5), ok.detail); 168 try std.testing.expect(ok.agrees(250)); 169 try std.testing.expect(!ok.agrees(550)); 170 try std.testing.expectEqualStrings("Ok", strip("2.1.5 Ok")); 171 172 // A code with no text after it. 173 try std.testing.expectEqual(@as(u16, 0), parse("5.0.0").?.detail); 174 // Three digits each is the most the grammar allows. 175 try std.testing.expectEqual(@as(u16, 999), parse("5.999.999 x").?.subject); 176 177 // And the things that are not codes. 178 try std.testing.expectEqual(@as(?Enhanced, null), parse("")); // nothing 179 try std.testing.expectEqual(@as(?Enhanced, null), parse("3.1.1 x")); // no such class 180 try std.testing.expectEqual(@as(?Enhanced, null), parse("2.1 x")); // two fields 181 try std.testing.expectEqual(@as(?Enhanced, null), parse("2.1.5x")); // no space 182 try std.testing.expectEqual(@as(?Enhanced, null), parse("2.01.5 x")); // leading zero 183 try std.testing.expectEqual(@as(?Enhanced, null), parse("2.1234.5 x")); // too long 184 // An ordinary message that begins with something numeric-looking. 185 try std.testing.expectEqualStrings("2.1 GB is too large", strip("2.1 GB is too large")); 186 } 187}; 188 189/// A server reply: a 3-digit code and one or more lines of text. 190pub const Reply = struct { 191 code: u16, 192 /// Text of all reply lines joined with '\n', with codes and separators 193 /// stripped. Points into the buffer passed to `read`. 194 text: []const u8, 195 196 pub const ReadError = ReadLineError || error{ 197 InvalidReply, 198 /// The reply text did not fit in the provided buffer. 199 ReplyTooLong, 200 }; 201 202 /// Reads one (possibly multiline) reply. The text is copied into `buffer` 203 /// and the returned reply's `text` field points into it. 204 pub fn read(reader: *Io.Reader, buffer: []u8) ReadError!Reply { 205 var text: Io.Writer = .fixed(buffer); 206 var code: ?u16 = null; 207 var first = true; 208 while (true) { 209 const line = try readLine(reader); 210 if (line.len < 3) return error.InvalidReply; 211 const line_code = std.fmt.parseInt(u16, line[0..3], 10) catch 212 return error.InvalidReply; 213 if (line_code < 100 or line_code > 599) return error.InvalidReply; 214 if (code) |prev| { 215 // All lines of a multiline reply must carry the same code. 216 if (prev != line_code) return error.InvalidReply; 217 } else { 218 code = line_code; 219 } 220 var last = true; 221 var line_text: []const u8 = ""; 222 if (line.len > 3) { 223 switch (line[3]) { 224 ' ' => {}, 225 '-' => last = false, 226 else => return error.InvalidReply, 227 } 228 line_text = line[4..]; 229 } 230 if (!first) text.writeByte('\n') catch return error.ReplyTooLong; 231 text.writeAll(line_text) catch return error.ReplyTooLong; 232 first = false; 233 if (last) break; 234 } 235 return .{ .code = code.?, .text = text.buffered() }; 236 } 237 238 /// Iterates over the individual text lines of the reply. 239 pub fn lines(r: *const Reply) std.mem.SplitIterator(u8, .scalar) { 240 return std.mem.splitScalar(u8, r.text, '\n'); 241 } 242 243 // Reply classes per RFC 5321 §4.2.1 244 // (https://datatracker.ietf.org/doc/html/rfc5321#section-4.2.1). 245 pub fn isPositiveCompletion(r: Reply) bool { 246 return r.code >= 200 and r.code < 300; 247 } 248 pub fn isPositiveIntermediate(r: Reply) bool { 249 return r.code >= 300 and r.code < 400; 250 } 251 pub fn isTransientFailure(r: Reply) bool { 252 return r.code >= 400 and r.code < 500; 253 } 254 /// The enhanced status code at the front of the text, if there is one 255 /// ([RFC 2034](https://datatracker.ietf.org/doc/html/rfc2034)). 256 /// 257 /// A server implementing that extension attaches one to every 2xx, 4xx 258 /// and 5xx reply whether or not the client said EHLO — but not to the 259 /// greeting, not to a HELO or EHLO response, and not to a 3xx, so those 260 /// answer null and correctly so. 261 /// 262 /// Worth checking `agrees` against `code` before acting on it: the two 263 /// disagreeing means the server contradicted itself. 264 pub fn enhanced(r: Reply) ?Enhanced { 265 return Enhanced.parse(r.text); 266 } 267 268 /// The text with the enhanced status code removed, which is the part 269 /// meant for a person. A multiline reply repeats the code on every line, 270 /// so this strips it from the first only; walk `lines` and use 271 /// `Enhanced.strip` on each for the rest. 272 pub fn message(r: Reply) []const u8 { 273 return Enhanced.strip(r.text); 274 } 275 276 test enhanced { 277 const rejected: Reply = .{ .code = 550, .text = "5.1.1 No such user" }; 278 const status = rejected.enhanced().?; 279 try std.testing.expect(status.agrees(rejected.code)); 280 try std.testing.expectEqual(Enhanced.Subject.addressing, status.subjectClass()); 281 try std.testing.expectEqualStrings("No such user", rejected.message()); 282 283 // A server that does not implement the extension, and one that is 284 // excluded from it: neither has a code, and neither is an error. 285 const plain: Reply = .{ .code = 550, .text = "No such user" }; 286 try std.testing.expectEqual(@as(?Enhanced, null), plain.enhanced()); 287 try std.testing.expectEqualStrings("No such user", plain.message()); 288 } 289 290 pub fn isPermanentFailure(r: Reply) bool { 291 return r.code >= 500 and r.code < 600; 292 } 293 294 test read { 295 var reader: Io.Reader = .fixed("250-first\r\n250 second\r\n"); 296 var buffer: [64]u8 = undefined; 297 const reply = try read(&reader, &buffer); 298 try std.testing.expectEqual(@as(u16, 250), reply.code); 299 try std.testing.expectEqualStrings("first\nsecond", reply.text); 300 } 301 302 test lines { 303 const reply: Reply = .{ .code = 250, .text = "one\ntwo" }; 304 var it = reply.lines(); 305 try std.testing.expectEqualStrings("one", it.next().?); 306 try std.testing.expectEqualStrings("two", it.next().?); 307 try std.testing.expectEqual(@as(?[]const u8, null), it.next()); 308 } 309 310 test isPositiveCompletion { 311 try std.testing.expect((Reply{ .code = 250, .text = "" }).isPositiveCompletion()); 312 try std.testing.expect(!(Reply{ .code = 354, .text = "" }).isPositiveCompletion()); 313 } 314 315 test isPositiveIntermediate { 316 try std.testing.expect((Reply{ .code = 354, .text = "" }).isPositiveIntermediate()); 317 } 318 319 test isTransientFailure { 320 try std.testing.expect((Reply{ .code = 451, .text = "" }).isTransientFailure()); 321 } 322 323 test isPermanentFailure { 324 try std.testing.expect((Reply{ .code = 550, .text = "" }).isPermanentFailure()); 325 } 326}; 327 328/// A parsed client command, as seen by a server. 329pub const Command = union(enum) { 330 helo: []const u8, 331 ehlo: []const u8, 332 /// LHLO, the LMTP greeting 333 /// ([RFC 2033](https://datatracker.ietf.org/doc/html/rfc2033)), which 334 /// has the same semantics as EHLO. An LMTP server takes this one and 335 /// refuses HELO and EHLO; an SMTP server does the reverse. 336 lhlo: []const u8, 337 /// MAIL FROM. An empty path is the null reverse-path (`MAIL FROM:<>`). 338 mail: PathArgs, 339 /// RCPT TO. 340 rcpt: PathArgs, 341 data, 342 rset, 343 noop, 344 quit, 345 /// VRFY: is this a deliverable address? 346 /// ([RFC 5321 §4.1.1.6](https://datatracker.ietf.org/doc/html/rfc5321#section-4.1.1.6)) 347 vrfy: []const u8, 348 /// EXPN: what addresses does this mailing list expand to? 349 /// ([RFC 5321 §4.1.1.7](https://datatracker.ietf.org/doc/html/rfc5321#section-4.1.1.7)) 350 /// 351 /// Recognized so that a server can decline it with 502 — "I know this 352 /// command and do not implement it" — rather than 500, which claims not 353 /// to have heard of it. RFC 5321 §4.2.4 allows either, and the first is 354 /// the true one for a server that parsed the verb. 355 expn: []const u8, 356 help, 357 starttls, 358 /// AUTH ([RFC 4954](https://datatracker.ietf.org/doc/html/rfc4954)). 359 auth: AuthArgs, 360 /// BDAT, the CHUNKING extension 361 /// ([RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030)). The 362 /// command line is followed by exactly `size` raw octets. 363 bdat: BdatArgs, 364 /// Unrecognized command verb; the payload is the full line. 365 unknown: []const u8, 366 367 pub const BdatArgs = struct { 368 size: u64, 369 /// True for the final chunk of the message ("BDAT n LAST"). 370 last: bool = false, 371 }; 372 373 pub const AuthArgs = struct { 374 mechanism: []const u8, 375 /// Raw base64 initial response, if the client sent one ("=" denotes 376 /// an empty initial response). 377 initial: []const u8 = "", 378 }; 379 380 pub const PathArgs = struct { 381 /// The mailbox, with angle brackets and any obsolete source route 382 /// stripped. 383 path: []const u8, 384 /// Raw ESMTP parameters that followed the path, e.g. "SIZE=1024". 385 params: []const u8 = "", 386 387 pub fn paramIterator(args: PathArgs) ParamIterator { 388 return .init(args.params); 389 } 390 391 test paramIterator { 392 const args: PathArgs = .{ .path = "a@example.com", .params = "SIZE=7" }; 393 var it = args.paramIterator(); 394 try std.testing.expectEqualStrings("SIZE", it.next().?.keyword); 395 } 396 }; 397 398 pub const ParseError = error{Syntax}; 399 400 /// Parses one command line (without its line ending). Returned slices 401 /// point into `line`. 402 pub fn parse(line: []const u8) ParseError!Command { 403 const trimmed = std.mem.trim(u8, line, " \t"); 404 const verb_end = std.mem.indexOfAny(u8, trimmed, " \t") orelse trimmed.len; 405 const verb = trimmed[0..verb_end]; 406 const rest = std.mem.trimStart(u8, trimmed[verb_end..], " \t"); 407 408 if (ieql(verb, "HELO")) { 409 if (rest.len == 0) return error.Syntax; 410 return .{ .helo = rest }; 411 } 412 if (ieql(verb, "EHLO")) { 413 if (rest.len == 0) return error.Syntax; 414 return .{ .ehlo = rest }; 415 } 416 if (ieql(verb, "LHLO")) { 417 if (rest.len == 0) return error.Syntax; 418 return .{ .lhlo = rest }; 419 } 420 if (ieql(verb, "MAIL")) return .{ .mail = try parsePathArgs(rest, "FROM:") }; 421 if (ieql(verb, "RCPT")) return .{ .rcpt = try parsePathArgs(rest, "TO:") }; 422 if (ieql(verb, "DATA")) return .data; 423 if (ieql(verb, "RSET")) return .rset; 424 if (ieql(verb, "NOOP")) return .noop; 425 if (ieql(verb, "QUIT")) return .quit; 426 // Both take a mandatory argument: `vrfy = "VRFY" SP String CRLF` 427 // and the same shape for EXPN. Without one there is nothing to act 428 // on, so it is a syntax error rather than a command. 429 if (ieql(verb, "VRFY")) { 430 if (rest.len == 0) return error.Syntax; 431 return .{ .vrfy = rest }; 432 } 433 if (ieql(verb, "EXPN")) { 434 if (rest.len == 0) return error.Syntax; 435 return .{ .expn = rest }; 436 } 437 if (ieql(verb, "HELP")) return .help; 438 if (ieql(verb, "STARTTLS")) return .starttls; 439 if (ieql(verb, "BDAT")) { 440 var it = std.mem.tokenizeAny(u8, rest, " \t"); 441 const size_token = it.next() orelse return error.Syntax; 442 const size = std.fmt.parseInt(u64, size_token, 10) catch return error.Syntax; 443 var last = false; 444 if (it.next()) |token| { 445 if (!ieql(token, "LAST")) return error.Syntax; 446 last = true; 447 } 448 if (it.next() != null) return error.Syntax; 449 return .{ .bdat = .{ .size = size, .last = last } }; 450 } 451 if (ieql(verb, "AUTH")) { 452 const mech_end = std.mem.indexOfAny(u8, rest, " \t") orelse rest.len; 453 if (mech_end == 0) return error.Syntax; 454 return .{ .auth = .{ 455 .mechanism = rest[0..mech_end], 456 .initial = std.mem.trimStart(u8, rest[mech_end..], " \t"), 457 } }; 458 } 459 return .{ .unknown = line }; 460 } 461 462 fn parsePathArgs(rest: []const u8, comptime keyword: []const u8) ParseError!PathArgs { 463 if (rest.len < keyword.len or !ieql(rest[0..keyword.len], keyword)) 464 return error.Syntax; 465 const after = std.mem.trimStart(u8, rest[keyword.len..], " \t"); 466 if (after.len == 0 or after[0] != '<') { 467 // Lenient: accept a bare address ending at whitespace. 468 const end = std.mem.indexOfAny(u8, after, " \t") orelse after.len; 469 if (end == 0) return error.Syntax; 470 return .{ 471 .path = after[0..end], 472 .params = std.mem.trimStart(u8, after[end..], " \t"), 473 }; 474 } 475 // The closing bracket must be found outside any quoted local-part: 476 // <"a>b"@example.com> is legal (RFC 5321 quoted-string, with 477 // backslash escapes). 478 const close = close: { 479 var in_quotes = false; 480 var i: usize = 1; 481 while (i < after.len) : (i += 1) { 482 const byte = after[i]; 483 if (in_quotes) { 484 if (byte == '\\') { 485 i += 1; 486 } else if (byte == '"') { 487 in_quotes = false; 488 } 489 } else if (byte == '"') { 490 in_quotes = true; 491 } else if (byte == '>') { 492 break :close i; 493 } 494 } 495 return error.Syntax; 496 }; 497 var path = after[1..close]; 498 // Strip an obsolete source route: <@relay1,@relay2:user@host>. 499 if (path.len > 0 and path[0] == '@') { 500 const colon = std.mem.indexOfScalar(u8, path, ':') orelse return error.Syntax; 501 path = path[colon + 1 ..]; 502 } 503 return .{ 504 .path = path, 505 .params = std.mem.trimStart(u8, after[close + 1 ..], " \t"), 506 }; 507 } 508 509 fn ieql(a: []const u8, b: []const u8) bool { 510 return std.ascii.eqlIgnoreCase(a, b); 511 } 512 513 test parse { 514 const command = try parse("RCPT TO:<bob@example.net>"); 515 try std.testing.expectEqualStrings("bob@example.net", command.rcpt.path); 516 try std.testing.expectError(error.Syntax, parse("MAIL <missing-keyword>")); 517 } 518}; 519 520/// The `RET` parameter of an extended MAIL command 521/// ([RFC 3461 §4.3](https://datatracker.ietf.org/doc/html/rfc3461#section-4.3)): 522/// how much of the message a failed DSN should carry back. Absent, the 523/// choice is the reporting MTA's. 524/// The `BODY` parameter of an extended MAIL command: what kind of content 525/// the message carries, and so what the receiver has to be able to take. 526pub const Body = enum { 527 /// [RFC 6152](https://datatracker.ietf.org/doc/html/rfc6152). Lines of 528 /// at most 998 characters from the ASCII repertoire. 529 seven_bit, 530 /// [RFC 6152](https://datatracker.ietf.org/doc/html/rfc6152). The same 531 /// line structure, with the high bit allowed. 532 eight_bit_mime, 533 /// [RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030). Arbitrary 534 /// octets with no line structure at all, which is why it can only be 535 /// carried by BDAT: DATA has no way to frame content that may hold the 536 /// terminator itself. 537 binary_mime, 538 539 pub const ParseError = error{Syntax}; 540 541 pub fn parse(value: []const u8) ParseError!Body { 542 if (std.ascii.eqlIgnoreCase(value, "7BIT")) return .seven_bit; 543 if (std.ascii.eqlIgnoreCase(value, "8BITMIME")) return .eight_bit_mime; 544 if (std.ascii.eqlIgnoreCase(value, "BINARYMIME")) return .binary_mime; 545 return error.Syntax; 546 } 547 548 /// Writes the value as it appears on the wire. 549 pub fn format(b: Body, writer: *Io.Writer) Io.Writer.Error!void { 550 try writer.writeAll(switch (b) { 551 .seven_bit => "7BIT", 552 .eight_bit_mime => "8BITMIME", 553 .binary_mime => "BINARYMIME", 554 }); 555 } 556 557 test parse { 558 try std.testing.expectEqual(Body.binary_mime, try parse("binarymime")); 559 try std.testing.expectEqual(Body.seven_bit, try parse("7BIT")); 560 try std.testing.expectError(error.Syntax, parse("BINARY")); 561 } 562}; 563 564/// The `AUTH` parameter of an extended MAIL command 565/// ([RFC 4954 §5](https://datatracker.ietf.org/doc/html/rfc4954#section-5)): 566/// who originally submitted this message, carried forward by a relay that 567/// authenticated them. 568/// 569/// It is an assertion, not a proof — the peer is claiming this on its own 570/// authority — which is why RFC 4954 requires a server to disregard it and 571/// behave as though `<>` had been sent whenever the client is unauthenticated 572/// or insufficiently trusted. 573pub const Submitter = union(enum) { 574 /// Sent as `<>`: the two characters that mean "I do not know", which a 575 /// client should send rather than omitting the parameter when it is 576 /// relaying something it cannot vouch for. 577 unknown, 578 /// The mailbox asserted, xtext-decoded. A bare address with no angle 579 /// brackets, which is what RFC 5321's `Mailbox` production is. 580 mailbox: []const u8, 581 582 /// RFC 4954 §5 extends the MAIL command line by 500 characters to make 583 /// room for this, which is the only ceiling it gives. 584 pub const max_len = 500; 585 586 pub const ParseError = error{Syntax}; 587 588 /// Parses the parameter value, decoding the mailbox into `buffer`. 589 pub fn parse(buffer: []u8, value: []const u8) ParseError!Submitter { 590 if (value.len == 0 or value.len > max_len) return error.Syntax; 591 const decoded = xtextDecode(buffer, value) catch return error.Syntax; 592 if (std.mem.eql(u8, decoded, "<>")) return .unknown; 593 // Anything else must be a mailbox, and an empty one is not. 594 if (decoded.len == 0) return error.Syntax; 595 return .{ .mailbox = decoded }; 596 } 597 598 /// Writes the parameter value as it appears on the wire, xtext-encoding 599 /// the mailbox. 600 pub fn format(s: Submitter, writer: *Io.Writer) Io.Writer.Error!void { 601 switch (s) { 602 .unknown => try writer.writeAll("<>"), 603 .mailbox => |mailbox| try writeXtext(writer, mailbox), 604 } 605 } 606 607 test parse { 608 var buffer: [64]u8 = undefined; 609 try std.testing.expectEqual(Submitter.unknown, try parse(&buffer, "<>")); 610 const who = try parse(&buffer, "e+3Dmc2@example.com"); 611 try std.testing.expectEqualStrings("e=mc2@example.com", who.mailbox); 612 try std.testing.expectError(error.Syntax, parse(&buffer, "")); 613 try std.testing.expectError(error.Syntax, parse(&buffer, "not xtext!")); 614 } 615}; 616 617/// RFC 3461 §4.4 caps the `ENVID` parameter value at 100 characters, which 618/// is a limit on the xtext-encoded form and not on what went into it. 619pub const max_envid_len = 100; 620 621pub const Ret = enum { 622 /// Return the entire message. 623 full, 624 /// Return the headers only. 625 hdrs, 626 627 pub const ParseError = error{Syntax}; 628 629 pub fn parse(value: []const u8) ParseError!Ret { 630 if (std.ascii.eqlIgnoreCase(value, "FULL")) return .full; 631 if (std.ascii.eqlIgnoreCase(value, "HDRS")) return .hdrs; 632 return error.Syntax; 633 } 634 635 /// Writes the value as it appears on the wire. 636 pub fn format(r: Ret, writer: *Io.Writer) Io.Writer.Error!void { 637 try writer.writeAll(switch (r) { 638 .full => "FULL", 639 .hdrs => "HDRS", 640 }); 641 } 642 643 test parse { 644 try std.testing.expectEqual(Ret.hdrs, try parse("hdrs")); 645 try std.testing.expectError(error.Syntax, parse("PARTIAL")); 646 } 647}; 648 649/// The `NOTIFY` parameter of an extended RCPT command 650/// ([RFC 3461 §4.1](https://datatracker.ietf.org/doc/html/rfc3461#section-4.1)): 651/// the conditions under which the sender wants to hear about this 652/// recipient. Absent, RFC 3461 lets a server read it as either 653/// `FAILURE` or `FAILURE,DELAY` — which is why "not specified" is an 654/// absent `?Notify` here and not a value of it. 655pub const Notify = union(enum) { 656 /// `NOTIFY=NEVER`: no DSN for this recipient under any circumstance. 657 /// RFC 3461 requires the keyword to appear on its own, and parsing 658 /// rejects it in a list. 659 never, 660 /// One or more of `SUCCESS`, `FAILURE` and `DELAY`. 661 on: Conditions, 662 663 pub const Conditions = struct { 664 success: bool = false, 665 failure: bool = false, 666 delay: bool = false, 667 }; 668 669 pub const ParseError = error{Syntax}; 670 671 pub fn parse(value: []const u8) ParseError!Notify { 672 if (std.ascii.eqlIgnoreCase(value, "NEVER")) return .never; 673 var conditions: Conditions = .{}; 674 var it = std.mem.splitScalar(u8, value, ','); 675 var any = false; 676 while (it.next()) |keyword| { 677 if (std.ascii.eqlIgnoreCase(keyword, "SUCCESS")) { 678 conditions.success = true; 679 } else if (std.ascii.eqlIgnoreCase(keyword, "FAILURE")) { 680 conditions.failure = true; 681 } else if (std.ascii.eqlIgnoreCase(keyword, "DELAY")) { 682 conditions.delay = true; 683 } else return error.Syntax; // Including NEVER: it may not be listed. 684 any = true; 685 } 686 if (!any) return error.Syntax; 687 return .{ .on = conditions }; 688 } 689 690 /// Writes the value as it appears on the wire. 691 pub fn format(n: Notify, writer: *Io.Writer) Io.Writer.Error!void { 692 switch (n) { 693 .never => try writer.writeAll("NEVER"), 694 .on => |conditions| { 695 var written = false; 696 inline for (.{ 697 .{ conditions.success, "SUCCESS" }, 698 .{ conditions.failure, "FAILURE" }, 699 .{ conditions.delay, "DELAY" }, 700 }) |pair| { 701 if (pair[0]) { 702 if (written) try writer.writeByte(','); 703 try writer.writeAll(pair[1]); 704 written = true; 705 } 706 } 707 // An empty condition set has no legal spelling; NEVER is 708 // what "tell me nothing" is written as. 709 if (!written) try writer.writeAll("NEVER"); 710 }, 711 } 712 } 713 714 test parse { 715 try std.testing.expectEqual(Notify.never, try parse("NEVER")); 716 const both = try parse("SUCCESS,delay"); 717 try std.testing.expect(both.on.success and both.on.delay and !both.on.failure); 718 try std.testing.expectError(error.Syntax, parse("NEVER,SUCCESS")); 719 try std.testing.expectError(error.Syntax, parse("")); 720 try std.testing.expectError(error.Syntax, parse("SUCCESS,MAYBE")); 721 } 722}; 723 724/// The `ORCPT` parameter of an extended RCPT command 725/// ([RFC 3461 §4.2](https://datatracker.ietf.org/doc/html/rfc3461#section-4.2)): 726/// the address the message was originally addressed to, carried unchanged 727/// through aliasing and forwarding so that a DSN can name what the sender 728/// actually wrote. 729pub const Orcpt = struct { 730 /// The address type, an atom — `rfc822` in all but the unusual cases. 731 addr_type: []const u8, 732 /// The original recipient, xtext-decoded. 733 address: []const u8, 734 735 /// RFC 3461 §4.2 caps the whole parameter value at 500 characters. 736 pub const max_len = 500; 737 738 pub const ParseError = error{Syntax}; 739 740 /// Parses `addr-type ";" xtext`, decoding the address into `buffer`. 741 /// The returned `addr_type` points into `value` and `address` points 742 /// into `buffer`, so the two have different lifetimes; a caller keeping 743 /// the result past either one copies both. 744 pub fn parse(buffer: []u8, value: []const u8) ParseError!Orcpt { 745 const semicolon = std.mem.findScalar(u8, value, ';') orelse return error.Syntax; 746 const addr_type = value[0..semicolon]; 747 if (addr_type.len == 0) return error.Syntax; 748 for (addr_type) |byte| if (!isAtomByte(byte)) return error.Syntax; 749 return .{ 750 .addr_type = addr_type, 751 .address = xtextDecode(buffer, value[semicolon + 1 ..]) catch return error.Syntax, 752 }; 753 } 754 755 /// Writes the parameter value as it appears on the wire, xtext-encoding 756 /// the address. 757 pub fn format(o: Orcpt, writer: *Io.Writer) Io.Writer.Error!void { 758 try writer.writeAll(o.addr_type); 759 try writer.writeByte(';'); 760 try writeXtext(writer, o.address); 761 } 762 763 /// RFC 5321 `atom` less the specials, which is what an addr-type may be. 764 fn isAtomByte(byte: u8) bool { 765 return switch (byte) { 766 'A'...'Z', 'a'...'z', '0'...'9' => true, 767 '!', '#', '$', '%', '&', '\'', '*', '+', '-', '/', '=', '?' => true, 768 '^', '_', '`', '{', '|', '}', '~' => true, 769 else => false, 770 }; 771 } 772 773 test parse { 774 var buffer: [64]u8 = undefined; 775 const orcpt = try parse(&buffer, "rfc822;bob+2Bx@example.net"); 776 try std.testing.expectEqualStrings("rfc822", orcpt.addr_type); 777 try std.testing.expectEqualStrings("bob+x@example.net", orcpt.address); 778 try std.testing.expectError(error.Syntax, parse(&buffer, "bob@example.net")); 779 try std.testing.expectError(error.Syntax, parse(&buffer, ";bob@example.net")); 780 } 781}; 782 783/// Whether `byte` may appear in an xtext unencoded 784/// ([RFC 3461 §4](https://datatracker.ietf.org/doc/html/rfc3461#section-4)): 785/// printable US-ASCII other than `+`, which introduces an escape, and `=`, 786/// which separates an ESMTP keyword from its value. 787pub fn isXchar(byte: u8) bool { 788 return byte >= '!' and byte <= '~' and byte != '+' and byte != '='; 789} 790 791/// Writes `text` xtext-encoded: anything that is not an `xchar` becomes 792/// `+` and two upper-case hex digits. Every byte therefore survives, 793/// including the ones that would otherwise end the command line, so an 794/// xtext-encoded parameter is safe to write from untrusted input. 795/// 796/// RFC 3461 asks that the value before encoding be printable US-ASCII. 797/// That is the caller's to observe; encoding anything else here produces 798/// valid xtext regardless rather than a corrupt command. 799pub fn writeXtext(writer: *Io.Writer, text: []const u8) Io.Writer.Error!void { 800 for (text) |byte| { 801 if (isXchar(byte)) { 802 try writer.writeByte(byte); 803 } else { 804 try writer.print("+{X:0>2}", .{byte}); 805 } 806 } 807} 808 809/// The length `writeXtext` will produce for `text`, for checking a value 810/// against the length limits RFC 3461 puts on the encoded form. 811pub fn xtextEncodedLen(text: []const u8) usize { 812 var len: usize = 0; 813 for (text) |byte| len += if (isXchar(byte)) 1 else 3; 814 return len; 815} 816 817pub const XtextError = error{ 818 /// Not valid xtext: a `+` not followed by two hex digits, or a raw byte 819 /// that the encoder was required to escape. 820 BadXtext, 821 NoSpaceLeft, 822}; 823 824/// Decodes xtext into `buffer`, returning the decoded bytes. Decoding is 825/// strict: a byte an encoder was obliged to escape is rejected rather than 826/// passed through, since accepting it would let two different encodings 827/// mean the same thing. 828pub fn xtextDecode(buffer: []u8, text: []const u8) XtextError![]u8 { 829 var out: usize = 0; 830 var i: usize = 0; 831 while (i < text.len) { 832 const byte = text[i]; 833 if (byte == '+') { 834 if (i + 2 >= text.len) return error.BadXtext; 835 const hex = text[i + 1 ..][0..2]; 836 // Checked before parsing because `parseInt` also accepts a sign 837 // and underscore separators, which hex digits are not. Lower 838 // case is accepted on the way in even though RFC 3461 requires 839 // upper case on the way out. 840 for (hex) |digit| if (!std.ascii.isHex(digit)) return error.BadXtext; 841 const value = std.fmt.parseInt(u8, hex, 16) catch return error.BadXtext; 842 if (out >= buffer.len) return error.NoSpaceLeft; 843 buffer[out] = value; 844 out += 1; 845 i += 3; 846 } else { 847 if (!isXchar(byte)) return error.BadXtext; 848 if (out >= buffer.len) return error.NoSpaceLeft; 849 buffer[out] = byte; 850 out += 1; 851 i += 1; 852 } 853 } 854 return buffer[0..out]; 855} 856 857test xtextDecode { 858 var buffer: [64]u8 = undefined; 859 try std.testing.expectEqualStrings( 860 "a+b=c", 861 try xtextDecode(&buffer, "a+2Bb+3Dc"), 862 ); 863 try std.testing.expectError(error.BadXtext, xtextDecode(&buffer, "a+2")); 864 try std.testing.expectError(error.BadXtext, xtextDecode(&buffer, "a+ZZb")); 865 // A raw '=' or ' ' is what the encoder had to escape. 866 try std.testing.expectError(error.BadXtext, xtextDecode(&buffer, "a=b")); 867 try std.testing.expectError(error.BadXtext, xtextDecode(&buffer, "a b")); 868} 869 870test writeXtext { 871 var out_buf: [64]u8 = undefined; 872 var writer: Io.Writer = .fixed(&out_buf); 873 try writeXtext(&writer, "id+1=2 \r\n"); 874 try std.testing.expectEqualStrings("id+2B1+3D2+20+0D+0A", writer.buffered()); 875 try std.testing.expectEqual(writer.buffered().len, xtextEncodedLen("id+1=2 \r\n")); 876 877 // Every byte survives the round trip. 878 var raw: [256]u8 = undefined; 879 for (&raw, 0..) |*byte, i| byte.* = @intCast(i); 880 var round_buf: [1024]u8 = undefined; 881 var round: Io.Writer = .fixed(&round_buf); 882 try writeXtext(&round, &raw); 883 var decoded_buf: [256]u8 = undefined; 884 try std.testing.expectEqualSlices(u8, &raw, try xtextDecode(&decoded_buf, round.buffered())); 885} 886 887/// Iterates the ESMTP parameters of a MAIL or RCPT command 888/// ([RFC 5321 §4.1.2](https://datatracker.ietf.org/doc/html/rfc5321#section-4.1.2)), 889/// e.g. "SIZE=1024 BODY=8BITMIME". 890pub const ParamIterator = struct { 891 rest: []const u8, 892 893 pub const Param = struct { 894 keyword: []const u8, 895 /// Empty when the parameter carries no value. 896 value: []const u8 = "", 897 }; 898 899 pub fn init(params: []const u8) ParamIterator { 900 return .{ .rest = params }; 901 } 902 903 pub fn next(it: *ParamIterator) ?Param { 904 it.rest = std.mem.trimStart(u8, it.rest, " \t"); 905 if (it.rest.len == 0) return null; 906 const end = std.mem.indexOfAny(u8, it.rest, " \t") orelse it.rest.len; 907 const token = it.rest[0..end]; 908 it.rest = it.rest[end..]; 909 if (std.mem.indexOfScalar(u8, token, '=')) |eq| { 910 return .{ .keyword = token[0..eq], .value = token[eq + 1 ..] }; 911 } 912 return .{ .keyword = token }; 913 } 914 915 test init { 916 var it: ParamIterator = .init("SIZE=42"); 917 try std.testing.expectEqualStrings("SIZE", it.next().?.keyword); 918 } 919 920 test next { 921 var it: ParamIterator = .init("BODY=8BITMIME CUSTOM"); 922 const body = it.next().?; 923 try std.testing.expectEqualStrings("BODY", body.keyword); 924 try std.testing.expectEqualStrings("8BITMIME", body.value); 925 const custom = it.next().?; 926 try std.testing.expectEqualStrings("CUSTOM", custom.keyword); 927 try std.testing.expectEqualStrings("", custom.value); 928 try std.testing.expectEqual(@as(?Param, null), it.next()); 929 } 930}; 931 932/// Writes `data` as SMTP message content: line endings are normalized to CRLF 933/// and lines beginning with '.' are dot-stuffed 934/// ([RFC 5321 §4.5.2](https://datatracker.ietf.org/doc/html/rfc5321#section-4.5.2)). Does not 935/// write the terminating ".\r\n". 936pub fn writeStuffed(writer: *Io.Writer, data: []const u8) Io.Writer.Error!void { 937 var rest = data; 938 while (rest.len > 0) { 939 var line: []const u8 = undefined; 940 if (std.mem.indexOfScalar(u8, rest, '\n')) |i| { 941 line = rest[0..i]; 942 rest = rest[i + 1 ..]; 943 } else { 944 line = rest; 945 rest = rest[rest.len..]; 946 } 947 if (line.len > 0 and line[line.len - 1] == '\r') line = line[0 .. line.len - 1]; 948 if (line.len > 0 and line[0] == '.') try writer.writeByte('.'); 949 try writer.writeAll(line); 950 try writer.writeAll(crlf); 951 } 952} 953 954test readLine { 955 var reader: Io.Reader = .fixed("first\r\nsecond\nthird\r\n"); 956 try std.testing.expectEqualStrings("first", try readLine(&reader)); 957 try std.testing.expectEqualStrings("second", try readLine(&reader)); 958 try std.testing.expectEqualStrings("third", try readLine(&reader)); 959 try std.testing.expectError(error.EndOfStream, readLine(&reader)); 960} 961 962test Reply { 963 var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n"); 964 var buf: [128]u8 = undefined; 965 const reply = try Reply.read(&reader, &buf); 966 try std.testing.expectEqual(@as(u16, 250), reply.code); 967 try std.testing.expectEqualStrings("2.0.0 Ok", reply.text); 968 try std.testing.expect(reply.isPositiveCompletion()); 969} 970 971test "Reply.read multiline" { 972 var reader: Io.Reader = .fixed("250-mx.example.com\r\n250-PIPELINING\r\n250 SIZE 1000\r\n"); 973 var buf: [128]u8 = undefined; 974 const reply = try Reply.read(&reader, &buf); 975 try std.testing.expectEqual(@as(u16, 250), reply.code); 976 try std.testing.expectEqualStrings("mx.example.com\nPIPELINING\nSIZE 1000", reply.text); 977 var it = reply.lines(); 978 try std.testing.expectEqualStrings("mx.example.com", it.next().?); 979 try std.testing.expectEqualStrings("PIPELINING", it.next().?); 980 try std.testing.expectEqualStrings("SIZE 1000", it.next().?); 981 try std.testing.expectEqual(@as(?[]const u8, null), it.next()); 982} 983 984test "Reply.read rejects malformed replies" { 985 var buf: [128]u8 = undefined; 986 { 987 var reader: Io.Reader = .fixed("2x0 hello\r\n"); 988 try std.testing.expectError(error.InvalidReply, Reply.read(&reader, &buf)); 989 } 990 { 991 var reader: Io.Reader = .fixed("250-one\r\n251 two\r\n"); 992 try std.testing.expectError(error.InvalidReply, Reply.read(&reader, &buf)); 993 } 994 { 995 var reader: Io.Reader = .fixed("42\r\n"); 996 try std.testing.expectError(error.InvalidReply, Reply.read(&reader, &buf)); 997 } 998} 999 1000test Command { 1001 { 1002 const cmd = try Command.parse("EHLO client.example.com"); 1003 try std.testing.expectEqualStrings("client.example.com", cmd.ehlo); 1004 } 1005 { 1006 const cmd = try Command.parse("mail from:<alice@example.com> SIZE=1024"); 1007 try std.testing.expectEqualStrings("alice@example.com", cmd.mail.path); 1008 try std.testing.expectEqualStrings("SIZE=1024", cmd.mail.params); 1009 } 1010 { 1011 // Null reverse-path and a space after the colon. 1012 const cmd = try Command.parse("MAIL FROM: <>"); 1013 try std.testing.expectEqualStrings("", cmd.mail.path); 1014 } 1015 { 1016 // Obsolete source route is stripped. 1017 const cmd = try Command.parse("RCPT TO:<@relay.example:bob@example.net>"); 1018 try std.testing.expectEqualStrings("bob@example.net", cmd.rcpt.path); 1019 } 1020 { 1021 // Quoted local-parts (from postfix's address corpora) may contain 1022 // spaces and even '>' or escaped quotes. 1023 const cmd = try Command.parse("MAIL FROM:<\"foo bar\"@example.com> SIZE=9"); 1024 try std.testing.expectEqualStrings("\"foo bar\"@example.com", cmd.mail.path); 1025 try std.testing.expectEqualStrings("SIZE=9", cmd.mail.params); 1026 } 1027 { 1028 const cmd = try Command.parse("RCPT TO:<\"a>b\"@example.com>"); 1029 try std.testing.expectEqualStrings("\"a>b\"@example.com", cmd.rcpt.path); 1030 } 1031 { 1032 const cmd = try Command.parse("RCPT TO:<\"a\\\">b\"@example.com>"); 1033 try std.testing.expectEqualStrings("\"a\\\">b\"@example.com", cmd.rcpt.path); 1034 } 1035 try std.testing.expectError(error.Syntax, Command.parse("MAIL FROM:<\"unterminated@example.com>")); 1036 { 1037 const cmd = try Command.parse("QUIT"); 1038 try std.testing.expectEqual(Command.quit, cmd); 1039 } 1040 { 1041 const cmd = try Command.parse("AUTH PLAIN AHVzZXIAcGFzcw=="); 1042 try std.testing.expectEqualStrings("PLAIN", cmd.auth.mechanism); 1043 try std.testing.expectEqualStrings("AHVzZXIAcGFzcw==", cmd.auth.initial); 1044 } 1045 { 1046 const cmd = try Command.parse("auth login"); 1047 try std.testing.expectEqualStrings("login", cmd.auth.mechanism); 1048 try std.testing.expectEqualStrings("", cmd.auth.initial); 1049 } 1050 { 1051 const cmd = try Command.parse("MADE UP"); 1052 try std.testing.expectEqualStrings("MADE UP", cmd.unknown); 1053 } 1054 { 1055 const cmd = try Command.parse("BDAT 1024"); 1056 try std.testing.expectEqual(@as(u64, 1024), cmd.bdat.size); 1057 try std.testing.expect(!cmd.bdat.last); 1058 } 1059 { 1060 const cmd = try Command.parse("bdat 0 last"); 1061 try std.testing.expectEqual(@as(u64, 0), cmd.bdat.size); 1062 try std.testing.expect(cmd.bdat.last); 1063 } 1064 try std.testing.expectError(error.Syntax, Command.parse("BDAT")); 1065 try std.testing.expectError(error.Syntax, Command.parse("BDAT nan")); 1066 try std.testing.expectError(error.Syntax, Command.parse("BDAT 5 FIRST")); 1067 try std.testing.expectError(error.Syntax, Command.parse("BDAT 5 LAST extra")); 1068 try std.testing.expectError(error.Syntax, Command.parse("AUTH")); 1069 try std.testing.expectError(error.Syntax, Command.parse("MAIL TO:<a@b>")); 1070 try std.testing.expectError(error.Syntax, Command.parse("RCPT TO:")); 1071 try std.testing.expectError(error.Syntax, Command.parse("HELO")); 1072} 1073 1074test writeStuffed { 1075 var buf: [256]u8 = undefined; 1076 { 1077 var w: Io.Writer = .fixed(&buf); 1078 try writeStuffed(&w, "line one\r\n.starts with dot\r\n"); 1079 try std.testing.expectEqualStrings("line one\r\n..starts with dot\r\n", w.buffered()); 1080 } 1081 { 1082 // LF-only input is normalized, missing final newline is added. 1083 var w: Io.Writer = .fixed(&buf); 1084 try writeStuffed(&w, "a\nb"); 1085 try std.testing.expectEqualStrings("a\r\nb\r\n", w.buffered()); 1086 } 1087 { 1088 // A lone "." line must not become a terminator. 1089 var w: Io.Writer = .fixed(&buf); 1090 try writeStuffed(&w, ".\n"); 1091 try std.testing.expectEqualStrings("..\r\n", w.buffered()); 1092 } 1093 { 1094 var w: Io.Writer = .fixed(&buf); 1095 try writeStuffed(&w, ""); 1096 try std.testing.expectEqualStrings("", w.buffered()); 1097 } 1098} 1099 1100test "fuzz Command.parse" { 1101 try std.testing.fuzz({}, fuzzCommandParse, .{}); 1102} 1103 1104fn fuzzCommandParse(context: void, smith: *std.testing.Smith) !void { 1105 _ = context; 1106 var line_buf: [512]u8 = undefined; 1107 const line = line_buf[0..smith.value(u9)]; 1108 smith.bytes(line); 1109 1110 const command = Command.parse(line) catch return; 1111 // Payload slices must always lie within the parsed line. 1112 switch (command) { 1113 .helo, .ehlo, .lhlo, .vrfy, .expn, .unknown => |payload| try std.testing.expect(payload.len <= line.len), 1114 .mail, .rcpt => |args| { 1115 try std.testing.expect(args.path.len <= line.len); 1116 try std.testing.expect(args.params.len <= line.len); 1117 }, 1118 .auth => |args| { 1119 try std.testing.expect(args.mechanism.len <= line.len); 1120 try std.testing.expect(args.initial.len <= line.len); 1121 }, 1122 .data, .rset, .noop, .quit, .help, .starttls, .bdat => {}, 1123 } 1124} 1125 1126test "fuzz Reply.read" { 1127 try std.testing.fuzz({}, fuzzReplyRead, .{}); 1128} 1129 1130fn fuzzReplyRead(context: void, smith: *std.testing.Smith) !void { 1131 _ = context; 1132 var input_buf: [1024]u8 = undefined; 1133 const input = input_buf[0..smith.value(u10)]; 1134 smith.bytes(input); 1135 1136 var reader: Io.Reader = .fixed(input); 1137 var text_buf: [128]u8 = undefined; 1138 // Each successful read consumes at least one line, so this terminates. 1139 while (true) { 1140 const reply = Reply.read(&reader, &text_buf) catch break; 1141 try std.testing.expect(reply.code >= 100 and reply.code <= 599); 1142 } 1143} 1144 1145test ParamIterator { 1146 const command = try Command.parse("MAIL FROM:<a@example.com> SIZE=1024 BODY=8BITMIME FLAG"); 1147 var it = command.mail.paramIterator(); 1148 1149 const size = it.next().?; 1150 try std.testing.expectEqualStrings("SIZE", size.keyword); 1151 try std.testing.expectEqualStrings("1024", size.value); 1152 1153 const body = it.next().?; 1154 try std.testing.expectEqualStrings("BODY", body.keyword); 1155 try std.testing.expectEqualStrings("8BITMIME", body.value); 1156 1157 const flag = it.next().?; 1158 try std.testing.expectEqualStrings("FLAG", flag.keyword); 1159 try std.testing.expectEqualStrings("", flag.value); 1160 1161 try std.testing.expectEqual(@as(?ParamIterator.Param, null), it.next()); 1162} 1163 1164test crlf { 1165 try std.testing.expectEqualStrings("\r\n", crlf); 1166} 1167 1168test "RFC 5321 mailbox forms from the is_email corpus round-trip" { 1169 // Parses Dominic Sayers' is_email test suite (tests.xml and 1170 // tests-original.xml, embedded from the lazy `isemail` dependency by 1171 // `zig build test -Disemail-corpus`) and checks that every address 1172 // valid at the RFC 5321 layer passes through the lenient path parser 1173 // byte-for-byte, params intact. 1174 if (comptime @import("build_options").isemail_corpus) { 1175 const corpus = @embedFile("isemail_tests_xml") ++ "\n" ++ 1176 @embedFile("isemail_tests_original_xml"); 1177 var checked: usize = 0; 1178 var rest: []const u8 = corpus; 1179 while (std.mem.indexOf(u8, rest, "<test ")) |start_index| { 1180 const end_index = std.mem.indexOfPos(u8, rest, start_index, "</test>") orelse break; 1181 const block = rest[start_index..end_index]; 1182 rest = rest[end_index + "</test>".len ..]; 1183 1184 const category = xmlElementText(block, "category") orelse continue; 1185 if (!std.mem.eql(u8, category, "ISEMAIL_VALID_CATEGORY") and 1186 !std.mem.eql(u8, category, "ISEMAIL_RFC5321")) continue; 1187 const raw = xmlElementText(block, "address") orelse continue; 1188 var address_buf: [256]u8 = undefined; 1189 const address = try xmlUnescape(&address_buf, raw); 1190 1191 var line_buf: [300]u8 = undefined; 1192 const line = try std.fmt.bufPrint(&line_buf, "MAIL FROM:<{s}> SIZE=1", .{address}); 1193 const command = try Command.parse(line); 1194 try std.testing.expectEqualStrings(address, command.mail.path); 1195 try std.testing.expectEqualStrings("SIZE=1", command.mail.params); 1196 checked += 1; 1197 } 1198 // The two files carry 125 RFC 5321-valid cases between them; fail 1199 // loudly if the extraction ever silently rots. 1200 try std.testing.expect(checked >= 120); 1201 } else return error.SkipZigTest; 1202} 1203 1204fn xmlElementText(block: []const u8, comptime tag: []const u8) ?[]const u8 { 1205 const open = "<" ++ tag ++ ">"; 1206 const close = "</" ++ tag ++ ">"; 1207 const start = (std.mem.indexOf(u8, block, open) orelse return null) + open.len; 1208 const end = std.mem.indexOfPos(u8, block, start, close) orelse return null; 1209 return block[start..end]; 1210} 1211 1212fn xmlUnescape(buffer: []u8, input: []const u8) ![]const u8 { 1213 var out: usize = 0; 1214 var i: usize = 0; 1215 while (i < input.len) { 1216 if (input[i] != '&') { 1217 buffer[out] = input[i]; 1218 out += 1; 1219 i += 1; 1220 continue; 1221 } 1222 const semi = std.mem.indexOfScalarPos(u8, input, i, ';') orelse return error.BadEntity; 1223 const entity = input[i + 1 .. semi]; 1224 i = semi + 1; 1225 if (std.mem.eql(u8, entity, "amp")) { 1226 buffer[out] = '&'; 1227 out += 1; 1228 } else if (std.mem.eql(u8, entity, "lt")) { 1229 buffer[out] = '<'; 1230 out += 1; 1231 } else if (std.mem.eql(u8, entity, "gt")) { 1232 buffer[out] = '>'; 1233 out += 1; 1234 } else if (std.mem.eql(u8, entity, "quot")) { 1235 buffer[out] = '"'; 1236 out += 1; 1237 } else if (std.mem.eql(u8, entity, "apos")) { 1238 buffer[out] = '\''; 1239 out += 1; 1240 } else if (std.mem.startsWith(u8, entity, "#x") or std.mem.startsWith(u8, entity, "#X")) { 1241 const codepoint = try std.fmt.parseInt(u21, entity[2..], 16); 1242 out += try std.unicode.utf8Encode(codepoint, buffer[out..]); 1243 } else if (std.mem.startsWith(u8, entity, "#")) { 1244 const codepoint = try std.fmt.parseInt(u21, entity[1..], 10); 1245 out += try std.unicode.utf8Encode(codepoint, buffer[out..]); 1246 } else return error.BadEntity; 1247 } 1248 return buffer[0..out]; 1249}