SMTP client for Zig
0

Configure Feed

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

Fix Auth Login

Refactor so that client can be used more easily by apps.

Karl Seguin (Apr 28, 2024, 7:10 PM +0800) 8fcfad9c e79e4118

+501 -130
+2 -1
Makefile
··· 1 + F= 1 2 .PHONY: t 2 3 t: 3 - zig build test --summary all 4 + TEST_FILTER="${F}" zig build test --summary all -freference-trace
+4 -3
build.zig
··· 5 5 const optimize = b.standardOptimizeOption(.{}); 6 6 7 7 const smtp_client = b.addModule("smtp_client", .{ 8 - .root_source_file = .{ .path = "src/smtp.zig" }, 8 + .root_source_file = b.path("src/smtp.zig"), 9 9 }); 10 10 11 11 { 12 12 // example 13 13 const exe = b.addExecutable(.{ 14 14 .name = "smtp_client_demo", 15 - .root_source_file = .{ .path = "example/main.zig" }, 15 + .root_source_file = b.path("example/main.zig"), 16 16 .target = target, 17 17 .optimize = optimize, 18 18 }); ··· 32 32 { 33 33 // tests 34 34 const lib_test = b.addTest(.{ 35 - .root_source_file = .{ .path = "src/smtp.zig" }, 35 + .root_source_file = b.path("src/smtp.zig"), 36 36 .target = target, 37 37 .optimize = optimize, 38 + .test_runner = b.path("test_runner.zig"), 38 39 }); 39 40 const run_test = b.addRunArtifact(lib_test); 40 41 run_test.has_side_effects = true;
+35 -6
readme.md
··· 27 27 .optimize = optimize, 28 28 }); 29 29 exe.addModule("smtp_client", smtp_client.module("smtp_client")); 30 - 31 30 ``` 32 31 33 32 # Basic Usage ··· 45 44 .encryption = .none, 46 45 .host = "localhost", 47 46 .allocator = allocator, 48 - // .username="username", 49 - // .password="password", 47 + // .username = "username", 48 + // .password = "password", 50 49 }; 51 50 52 51 try smtp.send(.{ ··· 63 62 * The message must be terminated with a `\r\n.\r\n` (yes, the dot in there is intentional) 64 63 65 64 I plan on adding some type of `builder` to help with generating a valid `data` payload. 66 - 67 65 68 66 ## Encryption 69 67 Prefer using `.encryption = .tls` where possible. Most modern email vendors provider SMTP over TLS and support TLS 1.3. ··· 76 74 77 75 Regardless of the encryption setting, the library will favor authenticating via `CRAM-MD5` if the server supports it. 78 76 77 + # Client 78 + The `send` and `sendAll` functions are wrappers around an `smtp.Client`. Where `send` and `sendAll` open a connection, send one or more messages and then close the connection, an `smtp.Client` keeps the connection open until `deinit` is called. The client is **not** thread safe. 79 + 80 + ```zig 81 + var client = try smtp.connect({ 82 + .port = 25, 83 + .encryption = .none, 84 + .host = "localhost", 85 + .allocator = allocator, 86 + // .username = "username", 87 + // .password = "password", 88 + }); 89 + defer client.deinit(); 90 + 91 + try client.hello(); 92 + try client.auth(); 93 + 94 + 95 + // Multiple messages can be sent here 96 + try client.from("email1@example.com"); 97 + try client.to(&.{"recipient@example.com"}); 98 + try client.data("From: Admin <email1@example.com>\r\nTo: User <recipient@example.com>\r\nSuject: Test\r\n\r\nThis is a test email from Zig\r\n.\r\n"); 99 + 100 + // One this is called, no more messages can be sent 101 + try client.quit(); 102 + ``` 103 + 104 + `hello` and `auth` can be called upfront, while `from`, `to` and `data` can be called repeatedly (protected by a mutex if thread-safety is needed). 105 + 79 106 ## Performance 80 107 ### Tip 1 - sendAll 81 108 The `sendAll` function takes an array of `smtp.Message`. It is much more efficient than calling `send` in a loop. ··· 129 156 ### Tip 3 - Skip DNS Resolution 130 157 Every call to `send` and `sendAll` requires a DNS lookup on `config.host`. The `sendTo` and `sendAllTo` functions, which take an `std.net.Address`, can be used instead. When using these functions, `config.host` must still be set to the valid host when `.tls` or `.start_tls` is used. 131 158 159 + Similarly, instead of `connect` to create a `Client`, `connectTo` can be used which takes an `std.net.Address`. 160 + 132 161 ### Allocator 133 162 `config.allocator` is required in two cases: 134 - 1. `send` or `sendAll` are used, OR 163 + 1. `send`, `sendAll` or `connect` are used, OR 135 164 2. `config.ca_bundle` is not specified and `.tls` or `.start_tls` are used 136 165 137 166 Put differently, `config.allocator` can be null when both these cases are true: 138 - 1. `sendTo` or `sendAllTo` are used, AND 167 + 1. `sendTo`, `sendAllTo` or `connectTo` are used, AND 139 168 2. `config.ca_bundle` is provided or `.encryption` is set to `.none` or `.insecure`. 140 169 141 170 Put differently again, `config.allocator` is only used by the library to (a) call `std.net.tcpConnectToHost` which does a DNS lookup and (b) manage the `std.crypto.Certificate.Bundle`.
+109 -52
src/client.zig
··· 14 14 return struct { 15 15 stream: S, 16 16 17 - reader: Reader(S), 17 + reader: Reader(*S), 18 18 19 19 // maximum reply length is 512 20 20 buf: [512]u8 = undefined, ··· 28 28 29 29 const Self = @This(); 30 30 31 - pub fn init(stream: S, config: Config) !Self { 32 - var client = Self{ 31 + pub fn init(stream: S, config: Config) Self { 32 + return .{ 33 33 .stream = stream, 34 34 .config = config, 35 - .reader = Reader(S).init(stream, config.timeout), 35 + .reader = undefined, 36 36 }; 37 + } 37 38 38 - const code = (try client.reader.read()).code; 39 - if (code != 220) { 40 - return errorFromCode(code); 41 - } 42 - return client; 39 + pub fn deinit(self: *Self) void { 40 + self.stream.deinit(); 43 41 } 44 42 45 43 pub fn hello(self: *Self) !void { 46 - const buf = &self.buf; 44 + const config = &self.config; 45 + if (config.encryption == .tls) { 46 + try self.stream.toTLS(config); 47 + } 48 + 49 + self.reader = Reader(*S).init(&self.stream, config.timeout); 47 50 var reader = &self.reader; 48 51 52 + { 53 + // server should send the first message 54 + const code = (try reader.read()).code; 55 + if (code != 220) { 56 + return errorFromCode(code); 57 + } 58 + } 59 + 60 + const buf = &self.buf; 49 61 const msg = try std.fmt.bufPrint(buf, "EHLO {s}\r\n", .{self.config.local_name}); 50 62 try self.stream.writeAll(msg); 51 63 ··· 85 97 } 86 98 87 99 pub fn auth(self: *Self) !void { 100 + if (self.config.encryption == .start_tls) { 101 + try self.startTLS(); 102 + } 103 + 88 104 if (self.config.username == null) { 89 105 return; 90 106 } ··· 133 149 pub fn to(self: *Self, recepients: []const []const u8) !void { 134 150 var buf = &self.buf; 135 151 var reader = &self.reader; 136 - const stream = self.stream; 152 + const stream = &self.stream; 137 153 138 154 @memcpy(buf[0..9], "RCPT TO:<"); 139 155 for (recepients) |recepient| { ··· 153 169 154 170 pub fn data(self: *Self, d: []const u8) !void { 155 171 var reader = &self.reader; 156 - const stream = self.stream; 172 + const stream = &self.stream; 157 173 158 174 { 159 175 try stream.writeAll("DATA\r\n"); ··· 224 240 buf[end-2] = '\r'; 225 241 buf[end-1] = '\n'; 226 242 227 - try self.stream.writeAll(buf); 243 + try self.stream.writeAll(buf[0..end]); 244 + } 245 + 246 + { 228 247 const reply = try reader.read(); 229 248 const code = reply.code; 230 249 if (code != 334) { 231 250 return errorFromCode(code); 232 251 } 233 252 234 - // base64 encoded "Password" 235 - if (std.mem.eql(u8, reply.data, "TXktVXNlcm5hbWU=") == false) { 253 + // base64 encoded "Password:" 254 + if (std.mem.eql(u8, reply.data, "UGFzc3dvcmQ6") == false) { 236 255 return error.UnexpectedServerResponse; 237 256 } 238 - } 239 257 240 - { 241 - const password = encoder.encode(buf, config.password.?); 242 - try self.stream.writeAll(password); 258 + const password = config.password orelse return error.PasswordRequired; 259 + const encoded = encoder.encode(buf[0..], password); 260 + const end = encoded.len + 2; 261 + buf[end-2] = '\r'; 262 + buf[end-1] = '\n'; 263 + try self.stream.writeAll(buf[0..end]); 264 + } 243 265 244 - const code = (try reader.read()).code; 245 - if (code != 334) { 246 - return errorFromCode(code); 247 - } 266 + const code = (try reader.read()).code; 267 + if (code != 235) { 268 + return errorFromCode(code); 248 269 } 249 270 } 250 271 ··· 324 345 } 325 346 326 347 const t = @import("t.zig"); 327 - test "client: init" { 328 - const stream = t.Stream.init(); 329 - defer stream.deinit(); 330 - 348 + test "client: helo" { 331 349 const config = testConfig(.{}); 332 350 333 351 { 334 352 // wrong server init reply 353 + var stream = t.Stream.init(); 335 354 stream.add("421 go away\r\n"); 336 - try t.expectError(error.ServiceNotAvailable, TestClient.init(stream, config)); 337 - } 355 + var client = TestClient.init(stream, config); 356 + defer client.deinit(); 338 357 339 - { 340 - // ok 341 - stream.add("220 Hi\r\n"); 342 - _ = try TestClient.init(stream, config); 358 + try t.expectError(error.ServiceNotAvailable, client.hello()); 343 359 } 344 - } 345 360 346 - test "client: hello" { 347 - const stream = t.Stream.init(); 348 - defer stream.deinit(); 349 - 350 - const config = testConfig(.{}); 351 361 352 362 { 353 363 // wrong server reply 364 + var stream = t.Stream.init(); 354 365 stream.add("220\r\n502 nope\r\n"); 355 - var client = try TestClient.init(stream, config); 366 + var client = TestClient.init(stream, config); 367 + defer client.deinit(); 368 + 356 369 try t.expectError(error.CommandNotImplemented, client.hello()); 357 370 } 358 371 359 372 { 360 373 // no exstensions 374 + var stream = t.Stream.init(); 361 375 stream.add("220\r\n250\r\n"); 362 - var client = try TestClient.init(stream, config); 376 + var client = TestClient.init(stream, config); 377 + defer client.deinit(); 378 + 363 379 try client.hello(); 364 380 try t.expectEqual(false, client.ext_smtp_utf8); 365 381 try t.expectEqual(false, client.ext_8_bit_mine); ··· 368 384 369 385 { 370 386 // SMTPUTF8 387 + var stream = t.Stream.init(); 371 388 stream.add("220\r\n250-\r\n250 SMTPUTF8\r\n"); 372 - var client = try TestClient.init(stream, config); 389 + var client = TestClient.init(stream, config); 390 + defer client.deinit(); 391 + 373 392 try client.hello(); 374 393 try t.expectEqual(true, client.ext_smtp_utf8); 375 394 try t.expectEqual(false, client.ext_8_bit_mine); ··· 378 397 379 398 { 380 399 // 8BITMIME 400 + var stream = t.Stream.init(); 381 401 stream.add("220\r\n250-\r\n250 8BITMIME\r\n"); 382 - var client = try TestClient.init(stream, config); 402 + var client = TestClient.init(stream, config); 403 + defer client.deinit(); 404 + 383 405 try client.hello(); 384 406 try t.expectEqual(false, client.ext_smtp_utf8); 385 407 try t.expectEqual(true, client.ext_8_bit_mine); ··· 388 410 389 411 { 390 412 // CRAM-MD5 413 + var stream = t.Stream.init(); 391 414 stream.add("220\r\n250-\r\n250 AUTH LOGIN PLAIN CRAM-MD5\r\n"); 392 - var client = try TestClient.init(stream, config); 415 + var client = TestClient.init(stream, config); 416 + defer client.deinit(); 417 + 393 418 try client.hello(); 394 419 try t.expectEqual(false, client.ext_smtp_utf8); 395 420 try t.expectEqual(false, client.ext_8_bit_mine); ··· 398 423 399 424 { 400 425 // Login 426 + var stream = t.Stream.init(); 401 427 stream.add("220\r\n250-\r\n250 AUTH LOGIN PLAIN\r\n"); 402 - var client = try TestClient.init(stream, config); 428 + var client = TestClient.init(stream, config); 429 + defer client.deinit(); 430 + 403 431 try client.hello(); 404 432 try t.expectEqual(.LOGIN, client.ext_auth); 405 433 } 406 434 407 435 { 408 436 // Plain 437 + var stream = t.Stream.init(); 409 438 stream.add("220\r\n250 Auth PlAIN\r\n"); 410 - var client = try TestClient.init(stream, config); 439 + var client = TestClient.init(stream, config); 440 + defer client.deinit(); 441 + 411 442 try client.hello(); 412 443 try t.expectEqual(.PLAIN, client.ext_auth); 413 444 } 414 445 } 415 446 416 447 test "client: auth" { 417 - const stream = t.Stream.init(); 418 - defer stream.deinit(); 419 - 420 448 { 421 449 // cannot use PLAIN auth with unencrypted connection 450 + var stream = t.Stream.init(); 422 451 stream.add("220\r\n250 AUTH PLAIN\r\n"); 423 - var client = try TestClient.init(stream, testConfig(.{.encryption = .none, .username = "leto"})); 452 + var client = TestClient.init(stream, testConfig(.{.encryption = .none, .username = "leto"})); 453 + defer client.deinit(); 454 + 424 455 try client.hello(); 425 456 try t.expectError(error.InsecureAuth, client.auth()); 426 457 } 427 458 428 459 { 429 460 // cannot use LOGIN auth with unencrypted connection 461 + var stream = t.Stream.init(); 430 462 stream.add("220\r\n250 AUTH LOGIN\r\n"); 431 - var client = try TestClient.init(stream, testConfig(.{.encryption = .none, .username = "leto"})); 463 + var client = TestClient.init(stream, testConfig(.{.encryption = .none, .username = "leto"})); 464 + defer client.deinit(); 465 + 432 466 try client.hello(); 433 467 try t.expectError(error.InsecureAuth, client.auth()); 434 468 } 469 + 470 + { 471 + // cannot use LOGIN auth with unencrypted connection 472 + var stream = t.Stream.init(); 473 + stream.add("220\r\n250 AUTH LOGIN\r\n"); 474 + stream.add("334 UGFzc3dvcmQ6\r\n"); 475 + stream.add("235 Auth Successful\r\n"); 476 + var client = TestClient.init(stream, testConfig(.{.username = "leto", .password = "ghanima", .encryption = .tls})); 477 + defer client.deinit(); 478 + 479 + try client.hello(); 480 + try client.auth(); 481 + try client.quit(); 482 + 483 + const received = client.stream.received(); 484 + try t.expectEqual(4, received.len); 485 + try t.expectString("EHLO localhost\r\n", received[0]); 486 + try t.expectString("AUTH LOGIN bGV0bw==\r\n", received[1]); 487 + try t.expectString("Z2hhbmltYQ==\r\n", received[2]); 488 + try t.expectString("QUIT\r\n", received[3]); 489 + } 435 490 } 436 491 437 - const TestClient = Client(*t.Stream); 492 + 493 + 494 + const TestClient = Client(t.Stream); 438 495 fn testConfig(config: anytype) Config { 439 496 const T = @TypeOf(config); 440 497 return .{
+4 -4
src/reader.zig
··· 197 197 test "reader: read fuzz" { 198 198 // stream randomly fragments the data into N reads 199 199 for (0..500) |_| { 200 - const stream = t.Stream.init(); 200 + var stream = t.Stream.init(); 201 201 defer stream.deinit(); 202 202 203 203 stream.add("100\r\n"); ··· 210 210 stream.add("301-even more data\r\n"); 211 211 stream.add("301\r\n"); 212 212 213 - var reader = Reader(*t.Stream).init(stream, 10); 213 + var reader = Reader(*t.Stream).init(&stream, 10); 214 214 try expectReply(try reader.read(), 100, false, ""); 215 215 try expectReply(try reader.read(), 101, false, "a"); 216 216 try expectReply(try reader.read(), 200, false, "this is a bit longer"); ··· 224 224 } 225 225 226 226 test "reader: closed" { 227 - const stream = t.Stream.init(); 227 + var stream = t.Stream.init(); 228 228 defer stream.deinit(); 229 229 stream.add("100\r\n200-hello\r"); 230 230 231 - var reader = Reader(*t.Stream).init(stream, 10); 231 + var reader = Reader(*t.Stream).init(&stream, 10); 232 232 try expectReply(try reader.read(), 100, false, ""); 233 233 try t.expectError(error.Closed, reader.read()); 234 234 }
+60 -53
src/smtp.zig
··· 4 4 const Bundle = std.crypto.Certificate.Bundle; 5 5 6 6 pub const client = @import("client.zig"); 7 - pub const Client = client.Client(*Stream); 7 + pub const Client = client.Client(Stream); 8 8 pub const Stream = @import("stream.zig").Stream; 9 9 10 10 pub const Encryption = enum { ··· 17 17 pub const Config = struct { 18 18 port: u16 = 25, 19 19 host: []const u8, 20 + ca_bundle: ?Bundle = null, 20 21 timeout: i32 = 10_000, 21 22 encryption: Encryption = .tls, 22 23 username: ?[]const u8 = null, 23 24 password: ?[]const u8 = null, 24 25 local_name: []const u8 = "localhost", 25 - ca_bundle: ?Bundle = null, 26 26 allocator: ?Allocator = null, 27 27 }; 28 28 ··· 31 31 from: []const u8, 32 32 data: []const u8, 33 33 }; 34 + 35 + pub fn connect(config: Config) !Client { 36 + const allocator = config.allocator orelse return error.AllocatorRequired; 37 + const stream = Stream.init(try std.net.tcpConnectToHost(allocator, config.host, config.port)); 38 + return Client.init(stream, config); 39 + } 40 + 41 + pub fn connectTo(address: std.net.Address, config: Config) !Client { 42 + const stream = Stream.init(try std.net.tcpConnectToAddress(address)); 43 + return Client.init(stream, config); 44 + } 34 45 35 46 pub fn send(message: Message, config: Config) !void { 36 - var count: usize = 0; 37 - return sendAll(&[_]Message{message}, config, &count); 47 + var sent: usize = 0; 48 + return sendAll(&[_]Message{message}, config, &sent); 38 49 } 39 50 40 51 pub fn sendAll(messages: []const Message, config: Config, sent: *usize) !void { 41 - const allocator = config.allocator orelse return error.AllocatorRequired; 42 - const net_stream = try std.net.tcpConnectToHost(allocator, config.host, config.port); 43 - var stream = Stream.init(net_stream); 44 - defer stream.deinit(); 45 - return sendAllT(*Stream, &stream, messages, config, sent); 52 + var c = try connect(config); 53 + defer c.deinit(); 54 + try oneShot(&c, messages, sent); 46 55 } 47 56 48 57 pub fn sendTo(address: std.net.Address, message: Message, config: Config) !void { 49 - var count: usize = 0; 50 - return sendAllTo(address, &[_]Message{message}, config, &count); 58 + var sent: usize = 0; 59 + return sendAllTo(address, &[_]Message{message}, config, &sent); 51 60 } 52 61 53 62 pub fn sendAllTo(address: std.net.Address, messages: []const Message, config: Config, sent: *usize) !void { 54 - const net_stream = try std.net.tcpConnectToAddress(address); 55 - var stream = Stream.init(net_stream); 56 - defer stream.deinit(); 57 - return sendAllT(*Stream, &stream, messages, config, sent); 63 + var c = try connectTo(address, config); 64 + defer c.deinit(); 65 + try oneShot(&c, messages, sent); 58 66 } 59 67 60 - // done this way do we can call sendT in test and inject a mock stream object 61 - fn sendAllT(comptime S: type, stream: S, messages: []const Message, config: Config, sent: *usize) !void { 62 - const encryption = config.encryption; 63 - if (encryption == .tls) { 64 - try stream.toTLS(&config); 65 - } 66 - 67 - var c = try client.Client(S).init(stream, config); 68 + // client is anytype for testing. It's a client.Client(net.Stream) during normal 69 + // execution, but a client.Client(*t.MockServer) during tests. 70 + fn oneShot(c: anytype, messages: []const Message, sent: *usize) !void { 68 71 defer c.quit() catch {}; 69 72 70 73 try c.hello(); 71 - if (encryption == .start_tls) { 72 - try c.startTLS(); 73 - } 74 74 try c.auth(); 75 75 76 76 for (messages) |message| { ··· 88 88 } 89 89 90 90 test "send: unencrypted single to" { 91 - var ms = t.MockServer.init(&.{ 91 + const ms = t.MockServer.init(&.{ 92 92 .{.req = "EHLO localhost\r\n", .res = "250\r\n"}, 93 93 .{.req = "MAIL FROM:<from-user@localhost.local>\r\n", .res = "250\r\n"}, 94 94 .{.req = "RCPT TO:<to-user@localhost.local>\r\n", .res = "250\r\n"}, ··· 97 97 .{.req = "QUIT\r\n", .res = null}, 98 98 }); 99 99 100 - var count: usize = 0; 101 - try sendAllT(*t.MockServer, &ms, &.{.{ 100 + var sent: usize = 0; 101 + var c = client.Client(t.MockServer).init(ms, .{ 102 + .port = 0, 103 + .host = "localhost", 104 + }); 105 + 106 + try oneShot(&c, &.{.{ 102 107 .from = "from-user@localhost.local", 103 108 .to = &.{"to-user@localhost.local"}, 104 109 .data = "This is the data\r\n.\r\n", 105 - }}, .{ 106 - .port = 0, 107 - .host = "localhost", 108 - }, &count); 109 - try t.expectEqual(1, count); 110 + }}, &sent); 111 + try t.expectEqual(1, sent); 110 112 } 111 113 112 114 test "send: scram-md5 + multiple to" { 113 - var ms = t.MockServer.init(&.{ 115 + const ms = t.MockServer.init(&.{ 114 116 .{.req = "EHLO localhost\r\n", .res = "250-Ok\r\n250 AUTH Plain cram-MD5\r\n"}, 115 117 .{.req = "AUTH CRAM-MD5\r\n", .res = "235 my secret\r\n"}, 116 118 .{.req = "leto 9103a6f589ce8c5a3b775dd878b5ac3a\r\n", .res = "235 my secret\r\n"}, ··· 122 124 .{.req = "QUIT\r\n", .res = null}, 123 125 }); 124 126 125 - var count: usize = 0; 126 - try sendAllT(*t.MockServer, &ms, &.{.{ 127 - .from = "from-user@localhost.local", 128 - .to = &.{"to-user1@localhost.local", "to-user2@localhost.local"}, 129 - .data = "hi\r\n", 130 - }}, .{ 127 + var sent: usize = 0; 128 + var c = client.Client(t.MockServer).init(ms, .{ 131 129 .port = 0, 132 130 .host = "localhost", 133 131 .username = "leto", 134 132 .password = "ghanima", 135 - }, &count); 136 - try t.expectEqual(1, count); 133 + }); 134 + 135 + try oneShot(&c, &.{.{ 136 + .from = "from-user@localhost.local", 137 + .to = &.{"to-user1@localhost.local", "to-user2@localhost.local"}, 138 + .data = "hi\r\n", 139 + }}, &sent); 140 + try t.expectEqual(1, sent); 137 141 } 138 142 139 143 test "sendAll: success" { 140 - var ms = t.MockServer.init(&.{ 144 + const ms = t.MockServer.init(&.{ 141 145 .{.req = "EHLO localhost\r\n", .res = "250 Ok\r\n"}, 142 146 .{.req = "MAIL FROM:<from-user1@localhost.local>\r\n", .res = "250\r\n"}, 143 147 .{.req = "RCPT TO:<to-user1@localhost.local>\r\n", .res = "250\r\n"}, ··· 151 155 }); 152 156 153 157 var sent: usize = 0; 154 - try sendAllT(*t.MockServer, &ms, &.{ 158 + var c = client.Client(t.MockServer).init(ms, .{ 159 + .port = 0, 160 + .host = "localhost", 161 + }); 162 + 163 + try oneShot(&c, &.{ 155 164 .{ 156 165 .from = "from-user1@localhost.local", 157 166 .to = &.{"to-user1@localhost.local"}, ··· 162 171 .to = &.{"to-user2@localhost.local"}, 163 172 .data = "hi2\r\n", 164 173 }, 165 - }, .{ 166 - .port = 0, 167 - .host = "localhost", 168 174 }, &sent); 169 175 170 176 try t.expectEqual(2, sent); 171 177 } 172 178 173 179 test "sendAll: partial" { 174 - var ms = t.MockServer.init(&.{ 180 + const ms = t.MockServer.init(&.{ 175 181 .{.req = "EHLO localhost\r\n", .res = "250 Ok\r\n"}, 176 182 .{.req = "MAIL FROM:<from-user1@localhost.local>\r\n", .res = "250\r\n"}, 177 183 .{.req = "RCPT TO:<to-user1@localhost.local>\r\n", .res = "250\r\n"}, ··· 182 188 }); 183 189 184 190 var sent: usize = 0; 185 - const err = sendAllT(*t.MockServer, &ms, &.{ 191 + var c = client.Client(t.MockServer).init(ms, .{ 192 + .port = 0, 193 + .host = "localhost", 194 + }); 195 + const err = oneShot(&c, &.{ 186 196 .{ 187 197 .from = "from-user1@localhost.local", 188 198 .to = &.{"to-user1@localhost.local"}, ··· 193 203 .to = &.{"to-user2@localhost.local"}, 194 204 .data = "hi2\r\n", 195 205 }, 196 - }, .{ 197 - .port = 0, 198 - .host = "localhost", 199 206 }, &sent); 200 207 201 208 try t.expectEqual(error.MailboxNotAvailable, err);
+15 -11
src/t.zig
··· 23 23 closed: bool, 24 24 handle: c_int = 0, 25 25 _read_index: usize, 26 + _arena: std.heap.ArenaAllocator, 26 27 _random: std.rand.DefaultPrng, 27 28 _to_read: std.ArrayList(u8), 28 - _received: std.ArrayList(u8), 29 + _received: std.ArrayList([]u8), 29 30 30 - pub fn init() *Stream { 31 + pub fn init() Stream { 31 32 var seed: u64 = undefined; 32 33 std.posix.getrandom(std.mem.asBytes(&seed)) catch unreachable; 33 34 34 - const s = allocator.create(Stream) catch unreachable; 35 - s.* = .{ 35 + return .{ 36 36 .closed = false, 37 37 ._read_index = 0, 38 + ._arena = std.heap.ArenaAllocator.init(allocator), 38 39 ._random = std.rand.DefaultPrng.init(seed), 39 40 ._to_read = std.ArrayList(u8).init(allocator), 40 - ._received = std.ArrayList(u8).init(allocator), 41 + ._received = std.ArrayList([]u8).init(allocator), 41 42 }; 42 - return s; 43 43 } 44 44 45 45 pub fn deinit(self: *Stream) void { 46 46 self._to_read.deinit(); 47 47 self._received.deinit(); 48 - allocator.destroy(self); 48 + self._arena.deinit(); 49 49 } 50 50 51 51 pub fn reset(self: *Stream) void { ··· 54 54 self._received.clearRetainingCapacity(); 55 55 } 56 56 57 - pub fn received(self: *Stream) []const u8 { 57 + pub fn received(self: *Stream) [][]const u8 { 58 58 return self._received.items; 59 59 } 60 60 61 - pub fn poll(_: Stream, _: i32) !usize { 61 + pub fn poll(_: *Stream, _: i32) !usize { 62 62 return 1; 63 + } 64 + 65 + pub fn toTLS(_: *Stream, _: *const lib.Config) !void { 66 + // noop 63 67 } 64 68 65 69 pub fn add(self: *Stream, value: []const u8) void { ··· 102 106 103 107 // store messages that are written to the stream 104 108 pub fn writeAll(self: *Stream, data: []const u8) !void { 105 - self._received.appendSlice(data) catch unreachable; 109 + const d = self._arena.allocator().dupe(u8, data) catch unreachable; 110 + self._received.append(d) catch unreachable; 106 111 } 107 112 108 113 pub fn close(self: *Stream) void { 109 114 self.closed = true; 110 115 } 111 116 }; 112 - 113 117 114 118 pub const MockServer = struct { 115 119 index: ?usize,
+272
test_runner.zig
··· 1 + // in your build.zig, you can specify a custom test runner: 2 + // const tests = b.addTest(.{ 3 + // .target = target, 4 + // .optimize = optimize, 5 + // .test_runner = "test_runner.zig", // add this line 6 + // .root_source_file = .{ .path = "src/main.zig" }, 7 + // }); 8 + 9 + const std = @import("std"); 10 + const builtin = @import("builtin"); 11 + 12 + const Allocator = std.mem.Allocator; 13 + 14 + const BORDER = "=" ** 80; 15 + 16 + // use in custom panic handler 17 + var current_test: ?[]const u8 = null; 18 + 19 + pub fn main() !void { 20 + var mem: [4096]u8 = undefined; 21 + var fba = std.heap.FixedBufferAllocator.init(&mem); 22 + 23 + const allocator = fba.allocator(); 24 + 25 + const env = Env.init(allocator); 26 + defer env.deinit(allocator); 27 + 28 + var slowest = SlowTracker.init(allocator, 5); 29 + defer slowest.deinit(); 30 + 31 + var pass: usize = 0; 32 + var fail: usize = 0; 33 + var skip: usize = 0; 34 + var leak: usize = 0; 35 + 36 + const printer = Printer.init(); 37 + printer.fmt("\r\x1b[0K", .{}); // beginning of line and clear to end of line 38 + 39 + for (builtin.test_functions) |t| { 40 + std.testing.allocator_instance = .{}; 41 + var status = Status.pass; 42 + slowest.startTiming(); 43 + 44 + const is_unnamed_test = std.mem.endsWith(u8, t.name, ".test_0"); 45 + if (env.filter) |f| { 46 + if (!is_unnamed_test and std.mem.indexOf(u8, t.name, f) == null) { 47 + continue; 48 + } 49 + } 50 + 51 + const friendly_name = blk: { 52 + const name = t.name; 53 + var it = std.mem.splitScalar(u8, name, '.'); 54 + while (it.next()) |value| { 55 + if (std.mem.eql(u8, value, "test")) { 56 + const rest = it.rest(); 57 + break :blk if (rest.len > 0) rest else name; 58 + } 59 + } 60 + break :blk name; 61 + }; 62 + 63 + current_test = friendly_name; 64 + const result = t.func(); 65 + current_test = null; 66 + 67 + if (is_unnamed_test) { 68 + continue; 69 + } 70 + 71 + const ns_taken = slowest.endTiming(friendly_name); 72 + 73 + if (std.testing.allocator_instance.deinit() == .leak) { 74 + leak += 1; 75 + printer.status(.fail, "\n{s}\n\"{s}\" - Memory Leak\n{s}\n", .{BORDER, friendly_name, BORDER}); 76 + } 77 + 78 + if (result) |_| { 79 + pass += 1; 80 + } else |err| switch (err) { 81 + error.SkipZigTest => { 82 + skip += 1; 83 + status = .skip; 84 + }, 85 + else => { 86 + status = .fail; 87 + fail += 1; 88 + printer.status(.fail, "\n{s}\n\"{s}\" - {s}\n{s}\n", .{BORDER, friendly_name, @errorName(err), BORDER}); 89 + if (@errorReturnTrace()) |trace| { 90 + std.debug.dumpStackTrace(trace.*); 91 + } 92 + if (env.fail_first) { 93 + break; 94 + } 95 + } 96 + } 97 + 98 + if (env.verbose) { 99 + const ms = @as(f64, @floatFromInt(ns_taken)) / 100_000.0; 100 + printer.status(status, "{s} ({d:.2}ms)\n", .{friendly_name, ms}); 101 + } else { 102 + printer.status(status, ".", .{}); 103 + } 104 + } 105 + 106 + const total_tests = pass + fail; 107 + const status = if (fail == 0) Status.pass else Status.fail; 108 + printer.status(status, "\n{d} of {d} test{s} passed\n", .{pass, total_tests, if (total_tests != 1) "s" else ""}); 109 + if (skip > 0) { 110 + printer.status(.skip, "{d} test{s} skipped\n", .{skip, if (skip != 1) "s" else ""}); 111 + } 112 + if (leak > 0) { 113 + printer.status(.fail, "{d} test{s} leaked\n", .{leak, if (leak != 1) "s" else ""}); 114 + } 115 + printer.fmt("\n", .{}); 116 + try slowest.display(printer); 117 + printer.fmt("\n", .{}); 118 + std.posix.exit(if (fail == 0) 0 else 1); 119 + } 120 + 121 + const Printer = struct { 122 + out: std.fs.File.Writer, 123 + 124 + fn init() Printer { 125 + return .{ 126 + .out = std.io.getStdErr().writer(), 127 + }; 128 + } 129 + 130 + fn fmt(self: Printer, comptime format: []const u8, args: anytype) void { 131 + std.fmt.format(self.out, format, args) catch unreachable; 132 + } 133 + 134 + fn status(self: Printer, s: Status, comptime format: []const u8, args: anytype) void { 135 + const color = switch (s) { 136 + .pass => "\x1b[32m", 137 + .fail => "\x1b[31m", 138 + .skip => "\x1b[33m", 139 + else => "", 140 + }; 141 + const out = self.out; 142 + out.writeAll(color) catch @panic("writeAll failed?!"); 143 + std.fmt.format(out, format, args) catch @panic("std.fmt.format failed?!"); 144 + self.fmt("\x1b[0m", .{}); 145 + } 146 + }; 147 + 148 + const Status = enum { 149 + pass, 150 + fail, 151 + skip, 152 + text, 153 + }; 154 + 155 + const SlowTracker = struct { 156 + const SlowestQueue = std.PriorityDequeue(TestInfo, void, compareTiming); 157 + max: usize, 158 + slowest: SlowestQueue, 159 + timer: std.time.Timer, 160 + 161 + fn init(allocator: Allocator, count: u32) SlowTracker { 162 + const timer = std.time.Timer.start() catch @panic("failed to start timer"); 163 + var slowest = SlowestQueue.init(allocator, {}); 164 + slowest.ensureTotalCapacity(count) catch @panic("OOM"); 165 + return .{ 166 + .max = count, 167 + .timer = timer, 168 + .slowest = slowest, 169 + }; 170 + } 171 + 172 + const TestInfo = struct { 173 + ns: u64, 174 + name: []const u8, 175 + }; 176 + 177 + fn deinit(self: SlowTracker) void { 178 + self.slowest.deinit(); 179 + } 180 + 181 + fn startTiming(self: *SlowTracker) void { 182 + self.timer.reset(); 183 + } 184 + 185 + fn endTiming(self: *SlowTracker, test_name: []const u8) u64 { 186 + var timer = self.timer; 187 + const ns = timer.lap(); 188 + 189 + var slowest = &self.slowest; 190 + 191 + if (slowest.count() < self.max) { 192 + // Capacity is fixed to the # of slow tests we want to track 193 + // If we've tracked fewer tests than this capacity, than always add 194 + slowest.add(TestInfo{ .ns = ns, .name = test_name }) catch @panic("failed to track test timing"); 195 + return ns; 196 + } 197 + 198 + { 199 + // Optimization to avoid shifting the dequeue for the common case 200 + // where the test isn't one of our slowest. 201 + const fastest_of_the_slow = slowest.peekMin() orelse unreachable; 202 + if (fastest_of_the_slow.ns > ns) { 203 + // the test was faster than our fastest slow test, don't add 204 + return ns; 205 + } 206 + } 207 + 208 + // the previous fastest of our slow tests, has been pushed off. 209 + _ = slowest.removeMin(); 210 + slowest.add(TestInfo{ .ns = ns, .name = test_name }) catch @panic("failed to track test timing"); 211 + return ns; 212 + } 213 + 214 + fn display(self: *SlowTracker, printer: Printer) !void { 215 + var slowest = self.slowest; 216 + const count = slowest.count(); 217 + printer.fmt("Slowest {d} test{s}: \n", .{count, if (count != 1) "s" else ""}); 218 + while (slowest.removeMinOrNull()) |info| { 219 + const ms = @as(f64, @floatFromInt(info.ns)) / 100_000.0; 220 + printer.fmt(" {d:.2}ms\t{s}\n", .{ms, info.name}); 221 + } 222 + } 223 + 224 + fn compareTiming(context: void, a: TestInfo, b: TestInfo) std.math.Order { 225 + _ = context; 226 + return std.math.order(a.ns, b.ns); 227 + } 228 + }; 229 + 230 + const Env = struct { 231 + verbose: bool, 232 + fail_first: bool, 233 + filter: ?[]const u8, 234 + 235 + fn init(allocator: Allocator) Env { 236 + return .{ 237 + .verbose = readEnvBool(allocator, "TEST_VERBOSE", true), 238 + .fail_first = readEnvBool(allocator, "TEST_FAIL_FIRST", false), 239 + .filter = readEnv(allocator, "TEST_FILTER"), 240 + }; 241 + } 242 + 243 + fn deinit(self: Env, allocator: Allocator) void { 244 + if (self.filter) |f| { 245 + allocator.free(f); 246 + } 247 + } 248 + 249 + fn readEnv(allocator: Allocator, key: []const u8) ?[]const u8 { 250 + const v = std.process.getEnvVarOwned(allocator, key) catch |err| { 251 + if (err == error.EnvironmentVariableNotFound) { 252 + return null; 253 + } 254 + std.log.warn("failed to get env var {s} due to err {}", .{key, err}); 255 + return null; 256 + }; 257 + return v; 258 + } 259 + 260 + fn readEnvBool(allocator: Allocator, key: []const u8, deflt: bool) bool { 261 + const value = readEnv(allocator, key) orelse return deflt; 262 + defer allocator.free(value); 263 + return std.ascii.eqlIgnoreCase(value, "true"); 264 + } 265 + }; 266 + 267 + pub fn panic(msg: []const u8, error_return_trace: ?*std.builtin.StackTrace, ret_addr: ?usize) noreturn { 268 + if (current_test) |ct| { 269 + std.debug.print("\x1b[31m{s}\npanic running \"{s}\"\n{s}\x1b[0m\n", .{BORDER, ct, BORDER}); 270 + } 271 + std.builtin.default_panic(msg, error_return_trace, ret_addr); 272 + }