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.

zig-smtp / src / protocol.zig
27 kB 691 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/// A server reply: a 3-digit code and one or more lines of text. 64pub const Reply = struct { 65 code: u16, 66 /// Text of all reply lines joined with '\n', with codes and separators 67 /// stripped. Points into the buffer passed to `read`. 68 text: []const u8, 69 70 pub const ReadError = ReadLineError || error{ 71 InvalidReply, 72 /// The reply text did not fit in the provided buffer. 73 ReplyTooLong, 74 }; 75 76 /// Reads one (possibly multiline) reply. The text is copied into `buffer` 77 /// and the returned reply's `text` field points into it. 78 pub fn read(reader: *Io.Reader, buffer: []u8) ReadError!Reply { 79 var text: Io.Writer = .fixed(buffer); 80 var code: ?u16 = null; 81 var first = true; 82 while (true) { 83 const line = try readLine(reader); 84 if (line.len < 3) return error.InvalidReply; 85 const line_code = std.fmt.parseInt(u16, line[0..3], 10) catch 86 return error.InvalidReply; 87 if (line_code < 100 or line_code > 599) return error.InvalidReply; 88 if (code) |prev| { 89 // All lines of a multiline reply must carry the same code. 90 if (prev != line_code) return error.InvalidReply; 91 } else { 92 code = line_code; 93 } 94 var last = true; 95 var line_text: []const u8 = ""; 96 if (line.len > 3) { 97 switch (line[3]) { 98 ' ' => {}, 99 '-' => last = false, 100 else => return error.InvalidReply, 101 } 102 line_text = line[4..]; 103 } 104 if (!first) text.writeByte('\n') catch return error.ReplyTooLong; 105 text.writeAll(line_text) catch return error.ReplyTooLong; 106 first = false; 107 if (last) break; 108 } 109 return .{ .code = code.?, .text = text.buffered() }; 110 } 111 112 /// Iterates over the individual text lines of the reply. 113 pub fn lines(r: *const Reply) std.mem.SplitIterator(u8, .scalar) { 114 return std.mem.splitScalar(u8, r.text, '\n'); 115 } 116 117 // Reply classes per RFC 5321 §4.2.1 118 // (https://datatracker.ietf.org/doc/html/rfc5321#section-4.2.1). 119 pub fn isPositiveCompletion(r: Reply) bool { 120 return r.code >= 200 and r.code < 300; 121 } 122 pub fn isPositiveIntermediate(r: Reply) bool { 123 return r.code >= 300 and r.code < 400; 124 } 125 pub fn isTransientFailure(r: Reply) bool { 126 return r.code >= 400 and r.code < 500; 127 } 128 pub fn isPermanentFailure(r: Reply) bool { 129 return r.code >= 500 and r.code < 600; 130 } 131 132 test read { 133 var reader: Io.Reader = .fixed("250-first\r\n250 second\r\n"); 134 var buffer: [64]u8 = undefined; 135 const reply = try read(&reader, &buffer); 136 try std.testing.expectEqual(@as(u16, 250), reply.code); 137 try std.testing.expectEqualStrings("first\nsecond", reply.text); 138 } 139 140 test lines { 141 const reply: Reply = .{ .code = 250, .text = "one\ntwo" }; 142 var it = reply.lines(); 143 try std.testing.expectEqualStrings("one", it.next().?); 144 try std.testing.expectEqualStrings("two", it.next().?); 145 try std.testing.expectEqual(@as(?[]const u8, null), it.next()); 146 } 147 148 test isPositiveCompletion { 149 try std.testing.expect((Reply{ .code = 250, .text = "" }).isPositiveCompletion()); 150 try std.testing.expect(!(Reply{ .code = 354, .text = "" }).isPositiveCompletion()); 151 } 152 153 test isPositiveIntermediate { 154 try std.testing.expect((Reply{ .code = 354, .text = "" }).isPositiveIntermediate()); 155 } 156 157 test isTransientFailure { 158 try std.testing.expect((Reply{ .code = 451, .text = "" }).isTransientFailure()); 159 } 160 161 test isPermanentFailure { 162 try std.testing.expect((Reply{ .code = 550, .text = "" }).isPermanentFailure()); 163 } 164}; 165 166/// A parsed client command, as seen by a server. 167pub const Command = union(enum) { 168 helo: []const u8, 169 ehlo: []const u8, 170 /// MAIL FROM. An empty path is the null reverse-path (`MAIL FROM:<>`). 171 mail: PathArgs, 172 /// RCPT TO. 173 rcpt: PathArgs, 174 data, 175 rset, 176 noop, 177 quit, 178 vrfy: []const u8, 179 help, 180 starttls, 181 /// AUTH ([RFC 4954](https://datatracker.ietf.org/doc/html/rfc4954)). 182 auth: AuthArgs, 183 /// BDAT, the CHUNKING extension 184 /// ([RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030)). The 185 /// command line is followed by exactly `size` raw octets. 186 bdat: BdatArgs, 187 /// Unrecognized command verb; the payload is the full line. 188 unknown: []const u8, 189 190 pub const BdatArgs = struct { 191 size: u64, 192 /// True for the final chunk of the message ("BDAT n LAST"). 193 last: bool = false, 194 }; 195 196 pub const AuthArgs = struct { 197 mechanism: []const u8, 198 /// Raw base64 initial response, if the client sent one ("=" denotes 199 /// an empty initial response). 200 initial: []const u8 = "", 201 }; 202 203 pub const PathArgs = struct { 204 /// The mailbox, with angle brackets and any obsolete source route 205 /// stripped. 206 path: []const u8, 207 /// Raw ESMTP parameters that followed the path, e.g. "SIZE=1024". 208 params: []const u8 = "", 209 210 pub fn paramIterator(args: PathArgs) ParamIterator { 211 return .init(args.params); 212 } 213 214 test paramIterator { 215 const args: PathArgs = .{ .path = "a@example.com", .params = "SIZE=7" }; 216 var it = args.paramIterator(); 217 try std.testing.expectEqualStrings("SIZE", it.next().?.keyword); 218 } 219 }; 220 221 pub const ParseError = error{Syntax}; 222 223 /// Parses one command line (without its line ending). Returned slices 224 /// point into `line`. 225 pub fn parse(line: []const u8) ParseError!Command { 226 const trimmed = std.mem.trim(u8, line, " \t"); 227 const verb_end = std.mem.indexOfAny(u8, trimmed, " \t") orelse trimmed.len; 228 const verb = trimmed[0..verb_end]; 229 const rest = std.mem.trimStart(u8, trimmed[verb_end..], " \t"); 230 231 if (ieql(verb, "HELO")) { 232 if (rest.len == 0) return error.Syntax; 233 return .{ .helo = rest }; 234 } 235 if (ieql(verb, "EHLO")) { 236 if (rest.len == 0) return error.Syntax; 237 return .{ .ehlo = rest }; 238 } 239 if (ieql(verb, "MAIL")) return .{ .mail = try parsePathArgs(rest, "FROM:") }; 240 if (ieql(verb, "RCPT")) return .{ .rcpt = try parsePathArgs(rest, "TO:") }; 241 if (ieql(verb, "DATA")) return .data; 242 if (ieql(verb, "RSET")) return .rset; 243 if (ieql(verb, "NOOP")) return .noop; 244 if (ieql(verb, "QUIT")) return .quit; 245 if (ieql(verb, "VRFY")) return .{ .vrfy = rest }; 246 if (ieql(verb, "HELP")) return .help; 247 if (ieql(verb, "STARTTLS")) return .starttls; 248 if (ieql(verb, "BDAT")) { 249 var it = std.mem.tokenizeAny(u8, rest, " \t"); 250 const size_token = it.next() orelse return error.Syntax; 251 const size = std.fmt.parseInt(u64, size_token, 10) catch return error.Syntax; 252 var last = false; 253 if (it.next()) |token| { 254 if (!ieql(token, "LAST")) return error.Syntax; 255 last = true; 256 } 257 if (it.next() != null) return error.Syntax; 258 return .{ .bdat = .{ .size = size, .last = last } }; 259 } 260 if (ieql(verb, "AUTH")) { 261 const mech_end = std.mem.indexOfAny(u8, rest, " \t") orelse rest.len; 262 if (mech_end == 0) return error.Syntax; 263 return .{ .auth = .{ 264 .mechanism = rest[0..mech_end], 265 .initial = std.mem.trimStart(u8, rest[mech_end..], " \t"), 266 } }; 267 } 268 return .{ .unknown = line }; 269 } 270 271 fn parsePathArgs(rest: []const u8, comptime keyword: []const u8) ParseError!PathArgs { 272 if (rest.len < keyword.len or !ieql(rest[0..keyword.len], keyword)) 273 return error.Syntax; 274 const after = std.mem.trimStart(u8, rest[keyword.len..], " \t"); 275 if (after.len == 0 or after[0] != '<') { 276 // Lenient: accept a bare address ending at whitespace. 277 const end = std.mem.indexOfAny(u8, after, " \t") orelse after.len; 278 if (end == 0) return error.Syntax; 279 return .{ 280 .path = after[0..end], 281 .params = std.mem.trimStart(u8, after[end..], " \t"), 282 }; 283 } 284 // The closing bracket must be found outside any quoted local-part: 285 // <"a>b"@example.com> is legal (RFC 5321 quoted-string, with 286 // backslash escapes). 287 const close = close: { 288 var in_quotes = false; 289 var i: usize = 1; 290 while (i < after.len) : (i += 1) { 291 const byte = after[i]; 292 if (in_quotes) { 293 if (byte == '\\') { 294 i += 1; 295 } else if (byte == '"') { 296 in_quotes = false; 297 } 298 } else if (byte == '"') { 299 in_quotes = true; 300 } else if (byte == '>') { 301 break :close i; 302 } 303 } 304 return error.Syntax; 305 }; 306 var path = after[1..close]; 307 // Strip an obsolete source route: <@relay1,@relay2:user@host>. 308 if (path.len > 0 and path[0] == '@') { 309 const colon = std.mem.indexOfScalar(u8, path, ':') orelse return error.Syntax; 310 path = path[colon + 1 ..]; 311 } 312 return .{ 313 .path = path, 314 .params = std.mem.trimStart(u8, after[close + 1 ..], " \t"), 315 }; 316 } 317 318 fn ieql(a: []const u8, b: []const u8) bool { 319 return std.ascii.eqlIgnoreCase(a, b); 320 } 321 322 test parse { 323 const command = try parse("RCPT TO:<bob@example.net>"); 324 try std.testing.expectEqualStrings("bob@example.net", command.rcpt.path); 325 try std.testing.expectError(error.Syntax, parse("MAIL <missing-keyword>")); 326 } 327}; 328 329/// Iterates the ESMTP parameters of a MAIL or RCPT command 330/// ([RFC 5321 §4.1.2](https://datatracker.ietf.org/doc/html/rfc5321#section-4.1.2)), 331/// e.g. "SIZE=1024 BODY=8BITMIME". 332pub const ParamIterator = struct { 333 rest: []const u8, 334 335 pub const Param = struct { 336 keyword: []const u8, 337 /// Empty when the parameter carries no value. 338 value: []const u8 = "", 339 }; 340 341 pub fn init(params: []const u8) ParamIterator { 342 return .{ .rest = params }; 343 } 344 345 pub fn next(it: *ParamIterator) ?Param { 346 it.rest = std.mem.trimStart(u8, it.rest, " \t"); 347 if (it.rest.len == 0) return null; 348 const end = std.mem.indexOfAny(u8, it.rest, " \t") orelse it.rest.len; 349 const token = it.rest[0..end]; 350 it.rest = it.rest[end..]; 351 if (std.mem.indexOfScalar(u8, token, '=')) |eq| { 352 return .{ .keyword = token[0..eq], .value = token[eq + 1 ..] }; 353 } 354 return .{ .keyword = token }; 355 } 356 357 test init { 358 var it: ParamIterator = .init("SIZE=42"); 359 try std.testing.expectEqualStrings("SIZE", it.next().?.keyword); 360 } 361 362 test next { 363 var it: ParamIterator = .init("BODY=8BITMIME CUSTOM"); 364 const body = it.next().?; 365 try std.testing.expectEqualStrings("BODY", body.keyword); 366 try std.testing.expectEqualStrings("8BITMIME", body.value); 367 const custom = it.next().?; 368 try std.testing.expectEqualStrings("CUSTOM", custom.keyword); 369 try std.testing.expectEqualStrings("", custom.value); 370 try std.testing.expectEqual(@as(?Param, null), it.next()); 371 } 372}; 373 374/// Writes `data` as SMTP message content: line endings are normalized to CRLF 375/// and lines beginning with '.' are dot-stuffed 376/// ([RFC 5321 §4.5.2](https://datatracker.ietf.org/doc/html/rfc5321#section-4.5.2)). Does not 377/// write the terminating ".\r\n". 378pub fn writeStuffed(writer: *Io.Writer, data: []const u8) Io.Writer.Error!void { 379 var rest = data; 380 while (rest.len > 0) { 381 var line: []const u8 = undefined; 382 if (std.mem.indexOfScalar(u8, rest, '\n')) |i| { 383 line = rest[0..i]; 384 rest = rest[i + 1 ..]; 385 } else { 386 line = rest; 387 rest = rest[rest.len..]; 388 } 389 if (line.len > 0 and line[line.len - 1] == '\r') line = line[0 .. line.len - 1]; 390 if (line.len > 0 and line[0] == '.') try writer.writeByte('.'); 391 try writer.writeAll(line); 392 try writer.writeAll(crlf); 393 } 394} 395 396test readLine { 397 var reader: Io.Reader = .fixed("first\r\nsecond\nthird\r\n"); 398 try std.testing.expectEqualStrings("first", try readLine(&reader)); 399 try std.testing.expectEqualStrings("second", try readLine(&reader)); 400 try std.testing.expectEqualStrings("third", try readLine(&reader)); 401 try std.testing.expectError(error.EndOfStream, readLine(&reader)); 402} 403 404test Reply { 405 var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n"); 406 var buf: [128]u8 = undefined; 407 const reply = try Reply.read(&reader, &buf); 408 try std.testing.expectEqual(@as(u16, 250), reply.code); 409 try std.testing.expectEqualStrings("2.0.0 Ok", reply.text); 410 try std.testing.expect(reply.isPositiveCompletion()); 411} 412 413test "Reply.read multiline" { 414 var reader: Io.Reader = .fixed("250-mx.example.com\r\n250-PIPELINING\r\n250 SIZE 1000\r\n"); 415 var buf: [128]u8 = undefined; 416 const reply = try Reply.read(&reader, &buf); 417 try std.testing.expectEqual(@as(u16, 250), reply.code); 418 try std.testing.expectEqualStrings("mx.example.com\nPIPELINING\nSIZE 1000", reply.text); 419 var it = reply.lines(); 420 try std.testing.expectEqualStrings("mx.example.com", it.next().?); 421 try std.testing.expectEqualStrings("PIPELINING", it.next().?); 422 try std.testing.expectEqualStrings("SIZE 1000", it.next().?); 423 try std.testing.expectEqual(@as(?[]const u8, null), it.next()); 424} 425 426test "Reply.read rejects malformed replies" { 427 var buf: [128]u8 = undefined; 428 { 429 var reader: Io.Reader = .fixed("2x0 hello\r\n"); 430 try std.testing.expectError(error.InvalidReply, Reply.read(&reader, &buf)); 431 } 432 { 433 var reader: Io.Reader = .fixed("250-one\r\n251 two\r\n"); 434 try std.testing.expectError(error.InvalidReply, Reply.read(&reader, &buf)); 435 } 436 { 437 var reader: Io.Reader = .fixed("42\r\n"); 438 try std.testing.expectError(error.InvalidReply, Reply.read(&reader, &buf)); 439 } 440} 441 442test Command { 443 { 444 const cmd = try Command.parse("EHLO client.example.com"); 445 try std.testing.expectEqualStrings("client.example.com", cmd.ehlo); 446 } 447 { 448 const cmd = try Command.parse("mail from:<alice@example.com> SIZE=1024"); 449 try std.testing.expectEqualStrings("alice@example.com", cmd.mail.path); 450 try std.testing.expectEqualStrings("SIZE=1024", cmd.mail.params); 451 } 452 { 453 // Null reverse-path and a space after the colon. 454 const cmd = try Command.parse("MAIL FROM: <>"); 455 try std.testing.expectEqualStrings("", cmd.mail.path); 456 } 457 { 458 // Obsolete source route is stripped. 459 const cmd = try Command.parse("RCPT TO:<@relay.example:bob@example.net>"); 460 try std.testing.expectEqualStrings("bob@example.net", cmd.rcpt.path); 461 } 462 { 463 // Quoted local-parts (from postfix's address corpora) may contain 464 // spaces and even '>' or escaped quotes. 465 const cmd = try Command.parse("MAIL FROM:<\"foo bar\"@example.com> SIZE=9"); 466 try std.testing.expectEqualStrings("\"foo bar\"@example.com", cmd.mail.path); 467 try std.testing.expectEqualStrings("SIZE=9", cmd.mail.params); 468 } 469 { 470 const cmd = try Command.parse("RCPT TO:<\"a>b\"@example.com>"); 471 try std.testing.expectEqualStrings("\"a>b\"@example.com", cmd.rcpt.path); 472 } 473 { 474 const cmd = try Command.parse("RCPT TO:<\"a\\\">b\"@example.com>"); 475 try std.testing.expectEqualStrings("\"a\\\">b\"@example.com", cmd.rcpt.path); 476 } 477 try std.testing.expectError(error.Syntax, Command.parse("MAIL FROM:<\"unterminated@example.com>")); 478 { 479 const cmd = try Command.parse("QUIT"); 480 try std.testing.expectEqual(Command.quit, cmd); 481 } 482 { 483 const cmd = try Command.parse("AUTH PLAIN AHVzZXIAcGFzcw=="); 484 try std.testing.expectEqualStrings("PLAIN", cmd.auth.mechanism); 485 try std.testing.expectEqualStrings("AHVzZXIAcGFzcw==", cmd.auth.initial); 486 } 487 { 488 const cmd = try Command.parse("auth login"); 489 try std.testing.expectEqualStrings("login", cmd.auth.mechanism); 490 try std.testing.expectEqualStrings("", cmd.auth.initial); 491 } 492 { 493 const cmd = try Command.parse("MADE UP"); 494 try std.testing.expectEqualStrings("MADE UP", cmd.unknown); 495 } 496 { 497 const cmd = try Command.parse("BDAT 1024"); 498 try std.testing.expectEqual(@as(u64, 1024), cmd.bdat.size); 499 try std.testing.expect(!cmd.bdat.last); 500 } 501 { 502 const cmd = try Command.parse("bdat 0 last"); 503 try std.testing.expectEqual(@as(u64, 0), cmd.bdat.size); 504 try std.testing.expect(cmd.bdat.last); 505 } 506 try std.testing.expectError(error.Syntax, Command.parse("BDAT")); 507 try std.testing.expectError(error.Syntax, Command.parse("BDAT nan")); 508 try std.testing.expectError(error.Syntax, Command.parse("BDAT 5 FIRST")); 509 try std.testing.expectError(error.Syntax, Command.parse("BDAT 5 LAST extra")); 510 try std.testing.expectError(error.Syntax, Command.parse("AUTH")); 511 try std.testing.expectError(error.Syntax, Command.parse("MAIL TO:<a@b>")); 512 try std.testing.expectError(error.Syntax, Command.parse("RCPT TO:")); 513 try std.testing.expectError(error.Syntax, Command.parse("HELO")); 514} 515 516test writeStuffed { 517 var buf: [256]u8 = undefined; 518 { 519 var w: Io.Writer = .fixed(&buf); 520 try writeStuffed(&w, "line one\r\n.starts with dot\r\n"); 521 try std.testing.expectEqualStrings("line one\r\n..starts with dot\r\n", w.buffered()); 522 } 523 { 524 // LF-only input is normalized, missing final newline is added. 525 var w: Io.Writer = .fixed(&buf); 526 try writeStuffed(&w, "a\nb"); 527 try std.testing.expectEqualStrings("a\r\nb\r\n", w.buffered()); 528 } 529 { 530 // A lone "." line must not become a terminator. 531 var w: Io.Writer = .fixed(&buf); 532 try writeStuffed(&w, ".\n"); 533 try std.testing.expectEqualStrings("..\r\n", w.buffered()); 534 } 535 { 536 var w: Io.Writer = .fixed(&buf); 537 try writeStuffed(&w, ""); 538 try std.testing.expectEqualStrings("", w.buffered()); 539 } 540} 541 542test "fuzz Command.parse" { 543 try std.testing.fuzz({}, fuzzCommandParse, .{}); 544} 545 546fn fuzzCommandParse(context: void, smith: *std.testing.Smith) !void { 547 _ = context; 548 var line_buf: [512]u8 = undefined; 549 const line = line_buf[0..smith.value(u9)]; 550 smith.bytes(line); 551 552 const command = Command.parse(line) catch return; 553 // Payload slices must always lie within the parsed line. 554 switch (command) { 555 .helo, .ehlo, .vrfy, .unknown => |payload| try std.testing.expect(payload.len <= line.len), 556 .mail, .rcpt => |args| { 557 try std.testing.expect(args.path.len <= line.len); 558 try std.testing.expect(args.params.len <= line.len); 559 }, 560 .auth => |args| { 561 try std.testing.expect(args.mechanism.len <= line.len); 562 try std.testing.expect(args.initial.len <= line.len); 563 }, 564 .data, .rset, .noop, .quit, .help, .starttls, .bdat => {}, 565 } 566} 567 568test "fuzz Reply.read" { 569 try std.testing.fuzz({}, fuzzReplyRead, .{}); 570} 571 572fn fuzzReplyRead(context: void, smith: *std.testing.Smith) !void { 573 _ = context; 574 var input_buf: [1024]u8 = undefined; 575 const input = input_buf[0..smith.value(u10)]; 576 smith.bytes(input); 577 578 var reader: Io.Reader = .fixed(input); 579 var text_buf: [128]u8 = undefined; 580 // Each successful read consumes at least one line, so this terminates. 581 while (true) { 582 const reply = Reply.read(&reader, &text_buf) catch break; 583 try std.testing.expect(reply.code >= 100 and reply.code <= 599); 584 } 585} 586 587test ParamIterator { 588 const command = try Command.parse("MAIL FROM:<a@example.com> SIZE=1024 BODY=8BITMIME FLAG"); 589 var it = command.mail.paramIterator(); 590 591 const size = it.next().?; 592 try std.testing.expectEqualStrings("SIZE", size.keyword); 593 try std.testing.expectEqualStrings("1024", size.value); 594 595 const body = it.next().?; 596 try std.testing.expectEqualStrings("BODY", body.keyword); 597 try std.testing.expectEqualStrings("8BITMIME", body.value); 598 599 const flag = it.next().?; 600 try std.testing.expectEqualStrings("FLAG", flag.keyword); 601 try std.testing.expectEqualStrings("", flag.value); 602 603 try std.testing.expectEqual(@as(?ParamIterator.Param, null), it.next()); 604} 605 606test crlf { 607 try std.testing.expectEqualStrings("\r\n", crlf); 608} 609 610test "RFC 5321 mailbox forms from the is_email corpus round-trip" { 611 // Parses Dominic Sayers' is_email test suite (tests.xml and 612 // tests-original.xml, embedded from the lazy `isemail` dependency by 613 // `zig build test -Disemail-corpus`) and checks that every address 614 // valid at the RFC 5321 layer passes through the lenient path parser 615 // byte-for-byte, params intact. 616 if (comptime @import("build_options").isemail_corpus) { 617 const corpus = @embedFile("isemail_tests_xml") ++ "\n" ++ 618 @embedFile("isemail_tests_original_xml"); 619 var checked: usize = 0; 620 var rest: []const u8 = corpus; 621 while (std.mem.indexOf(u8, rest, "<test ")) |start_index| { 622 const end_index = std.mem.indexOfPos(u8, rest, start_index, "</test>") orelse break; 623 const block = rest[start_index..end_index]; 624 rest = rest[end_index + "</test>".len ..]; 625 626 const category = xmlElementText(block, "category") orelse continue; 627 if (!std.mem.eql(u8, category, "ISEMAIL_VALID_CATEGORY") and 628 !std.mem.eql(u8, category, "ISEMAIL_RFC5321")) continue; 629 const raw = xmlElementText(block, "address") orelse continue; 630 var address_buf: [256]u8 = undefined; 631 const address = try xmlUnescape(&address_buf, raw); 632 633 var line_buf: [300]u8 = undefined; 634 const line = try std.fmt.bufPrint(&line_buf, "MAIL FROM:<{s}> SIZE=1", .{address}); 635 const command = try Command.parse(line); 636 try std.testing.expectEqualStrings(address, command.mail.path); 637 try std.testing.expectEqualStrings("SIZE=1", command.mail.params); 638 checked += 1; 639 } 640 // The two files carry 125 RFC 5321-valid cases between them; fail 641 // loudly if the extraction ever silently rots. 642 try std.testing.expect(checked >= 120); 643 } else return error.SkipZigTest; 644} 645 646fn xmlElementText(block: []const u8, comptime tag: []const u8) ?[]const u8 { 647 const open = "<" ++ tag ++ ">"; 648 const close = "</" ++ tag ++ ">"; 649 const start = (std.mem.indexOf(u8, block, open) orelse return null) + open.len; 650 const end = std.mem.indexOfPos(u8, block, start, close) orelse return null; 651 return block[start..end]; 652} 653 654fn xmlUnescape(buffer: []u8, input: []const u8) ![]const u8 { 655 var out: usize = 0; 656 var i: usize = 0; 657 while (i < input.len) { 658 if (input[i] != '&') { 659 buffer[out] = input[i]; 660 out += 1; 661 i += 1; 662 continue; 663 } 664 const semi = std.mem.indexOfScalarPos(u8, input, i, ';') orelse return error.BadEntity; 665 const entity = input[i + 1 .. semi]; 666 i = semi + 1; 667 if (std.mem.eql(u8, entity, "amp")) { 668 buffer[out] = '&'; 669 out += 1; 670 } else if (std.mem.eql(u8, entity, "lt")) { 671 buffer[out] = '<'; 672 out += 1; 673 } else if (std.mem.eql(u8, entity, "gt")) { 674 buffer[out] = '>'; 675 out += 1; 676 } else if (std.mem.eql(u8, entity, "quot")) { 677 buffer[out] = '"'; 678 out += 1; 679 } else if (std.mem.eql(u8, entity, "apos")) { 680 buffer[out] = '\''; 681 out += 1; 682 } else if (std.mem.startsWith(u8, entity, "#x") or std.mem.startsWith(u8, entity, "#X")) { 683 const codepoint = try std.fmt.parseInt(u21, entity[2..], 16); 684 out += try std.unicode.utf8Encode(codepoint, buffer[out..]); 685 } else if (std.mem.startsWith(u8, entity, "#")) { 686 const codepoint = try std.fmt.parseInt(u21, entity[1..], 10); 687 out += try std.unicode.utf8Encode(codepoint, buffer[out..]); 688 } else return error.BadEntity; 689 } 690 return buffer[0..out]; 691}