nx_smtp_client.nx source
↩ module page · 284 lines · 11516 B
1// nx_smtp_client.nx -- EMAIL RUNG R1: SMTP client wire protocol (RFC 5321).
2//
3// module: nishi-core.email.smtp_client
4// depends: (none -- pure RFC 5321 line codec + state machine)
5// capability: CORE_EMAIL
6//
7// The conversation grammar a client speaks to a mail server, ABOVE the
8// MX host R0 discovered and the nx_socket TCP rung. Three pure parts,
9// no syscalls (live socket I/O composes in a later nx_smtp_client_io.nx,
10// mirroring the nx_dns / nx_dns_io split) so the whole protocol is
11// deterministically gateable offline against a scripted transcript:
12//
13// 1. REPLY PARSER -- 3-digit reply codes with multiline continuation
14// (RFC 5321 §4.2.1: "250-" continues, "250 " is final). Detects a
15// complete vs still-streaming reply, enforces the code matches
16// across all continuation lines (a mismatch is a malformed/forged
17// reply), rejects non-digit codes and illegal separators.
18//
19// 2. COMMAND BUILDERS -- EHLO / MAIL FROM / RCPT TO / DATA / QUIT etc.
20// with correct CRLF framing and <addr> bracketing.
21//
22// 3. DOT-STUFFING (RFC 5321 §4.5.2) -- the correctness/SECURITY crux:
23// a body line beginning with "." gets an extra "." prepended so a
24// lone "." in the message can never be mistaken for the
25// "<CRLF>.<CRLF>" end-of-DATA terminator (premature-termination /
26// SMTP-injection defense).
27//
28// 4. CLIENT STATE MACHINE -- the deterministic send sequence
29// INIT(220) -> EHLO(2xx) -> MAIL(2xx) -> RCPT(2xx) -> DATA(354) ->
30// BODY(2xx) -> QUIT(221) -> DONE, with any unexpected code routing
31// to ABORT/FAIL (the conversation then closes with QUIT).
32//
33// license_tier: INDEPENDENT_REDERIVE
34// genealogy_id: international-research-sources/ietf/rfc_5321
35// lineage_id: nishi_email_smtp_client_r1
36//
37// nx_safety_envelope:
38// intended_use: "SMTP client reply parse + command build +
39// dot-stuffing + send-sequence state machine.
40// RFC 5321 substrate."
41// sil_target: SIL2 (mis-parse => mail to wrong recipient
42// / premature DATA termination)
43// evidence: [RFC_5321_canonical_basis, multiline_reply_code,
44// cross-line_code_match_gate, dot_stuffing,
45// sealed_verdict_enum, deterministic_state_machine]
46// hazard_register: [bug-tape-SMTP-dot-injection,
47// bug-tape-multiline-reply-confusion]
48// residual_risk: "AUTH + TLS upgrade are R6; this rung is the
49// cleartext command/reply grammar only."
50// verdict: NOT_YET_EVALUATED
51
52// ---- Sealed verdict (negated on the parser's error returns). ----
53const NX_SMTP_BAD_CODE: i64 = 2 // reply code not 3 digits
54const NX_SMTP_CODE_MISMATCH: i64 = 3 // continuation line code != first line
55const NX_SMTP_BAD_LINE: i64 = 4 // illegal separator after code
56const NX_SMTP_BUF_FULL: i64 = 5 // builder ran out of caller buffer
57const NX_SMTP_N: i64 = 6
58
59// ---- Client states. ----
60const SMTP_S_INIT: i64 = 0 // awaiting 220 greeting
61const SMTP_S_EHLO: i64 = 1 // EHLO sent, awaiting 250
62const SMTP_S_MAIL: i64 = 2 // MAIL FROM sent
63const SMTP_S_RCPT: i64 = 3 // RCPT TO sent
64const SMTP_S_DATA: i64 = 4 // DATA sent, awaiting 354
65const SMTP_S_BODY: i64 = 5 // message body sent, awaiting 250
66const SMTP_S_QUIT: i64 = 6 // QUIT sent, awaiting 221
67const SMTP_S_DONE: i64 = 7 // conversation complete
68const SMTP_S_FAIL: i64 = 8 // aborted
69
70// ---- Driver actions. ----
71const SMTP_A_NONE: i64 = 0
72const SMTP_A_SEND_EHLO: i64 = 1
73const SMTP_A_SEND_MAIL: i64 = 2
74const SMTP_A_SEND_RCPT: i64 = 3
75const SMTP_A_SEND_DATA: i64 = 4
76const SMTP_A_SEND_BODY: i64 = 5
77const SMTP_A_SEND_QUIT: i64 = 6
78const SMTP_A_DONE: i64 = 7
79const SMTP_A_ABORT: i64 = 8
80
81const SMTP_MAX_REPLY_LINES: i64 = 64
82
83func nx_smtp_action_name(a: i64) -> *u8 {
84 if a == SMTP_A_NONE { return "NONE" }
85 if a == SMTP_A_SEND_EHLO { return "SEND_EHLO" }
86 if a == SMTP_A_SEND_MAIL { return "SEND_MAIL" }
87 if a == SMTP_A_SEND_RCPT { return "SEND_RCPT" }
88 if a == SMTP_A_SEND_DATA { return "SEND_DATA" }
89 if a == SMTP_A_SEND_BODY { return "SEND_BODY" }
90 if a == SMTP_A_SEND_QUIT { return "SEND_QUIT" }
91 if a == SMTP_A_DONE { return "DONE" }
92 if a == SMTP_A_ABORT { return "ABORT" }
93 return "?"
94}
95
96func smtp_is_digit(c: i64) -> i64 {
97 if c >= 48 && c <= 57 { return 1 }
98 return 0
99}
100
101func smtp_is_2xx(code: i64) -> i64 {
102 if code >= 200 && code < 300 { return 1 }
103 return 0
104}
105
106// ---- Parse an SMTP reply (possibly multiline). ----
107//
108// Returns the 3-digit reply code (>= 0) on a well-formed reply, or a
109// NEGATIVE verdict on malformed input. *out_complete = 1 when the final
110// line ("<code><SP>...") was seen within [0,n); 0 when the buffer ends
111// mid-reply (caller must read more). *out_consumed = bytes the complete
112// reply occupied (0 if incomplete). Every continuation line's code MUST
113// equal the first line's -- a mismatch is rejected (forged/garbled).
114func nx_smtp_parse_reply(buf: *u8, n: i64, out_complete: *i64, out_consumed: *i64) -> i64 {
115 *out_complete = 0
116 *out_consumed = 0
117 var i: i64 = 0
118 var first_code: i64 = 0 - 1
119 var lines: i64 = 0
120 while lines < SMTP_MAX_REPLY_LINES {
121 if i + 3 > n { return first_code_or_badline(first_code) }
122 let c0: i64 = buf[i] & 0xff
123 let c1: i64 = buf[i + 1] & 0xff
124 let c2: i64 = buf[i + 2] & 0xff
125 if smtp_is_digit(c0) == 0 { return 0 - NX_SMTP_BAD_CODE }
126 if smtp_is_digit(c1) == 0 { return 0 - NX_SMTP_BAD_CODE }
127 if smtp_is_digit(c2) == 0 { return 0 - NX_SMTP_BAD_CODE }
128 let code: i64 = (c0 - 48) * 100 + (c1 - 48) * 10 + (c2 - 48)
129 if first_code < 0 { first_code = code }
130 if code != first_code { return 0 - NX_SMTP_CODE_MISMATCH }
131 // separator after the code
132 if i + 3 >= n { return first_code } // code present but unterminated -> incomplete
133 let sep: i64 = buf[i + 3] & 0xff
134 var is_final: i64 = 0
135 if sep == 45 {
136 is_final = 0 // '-' continuation
137 } else {
138 if sep == 32 || sep == 13 || sep == 10 {
139 is_final = 1 // SP / CR / LF -> final line
140 } else {
141 return 0 - NX_SMTP_BAD_LINE
142 }
143 }
144 // find end of this line (the LF)
145 var le: i64 = 0 - 1
146 var j: i64 = i + 3
147 while j < n {
148 if (buf[j] & 0xff) == 10 { le = j; j = n } else { j = j + 1 }
149 }
150 if le < 0 { return first_code } // line unterminated -> incomplete
151 let next_i: i64 = le + 1
152 if is_final == 1 {
153 *out_complete = 1
154 *out_consumed = next_i
155 return first_code
156 }
157 i = next_i
158 lines = lines + 1
159 }
160 return 0 - NX_SMTP_BAD_LINE // too many continuation lines
161}
162
163// Helper for the incomplete-at-code path: a code we already read but a
164// truncated buffer is NOT malformed -- return the code (out_complete
165// stays 0). Only a buffer with no parseable code yet is BAD_LINE.
166func first_code_or_badline(first_code: i64) -> i64 {
167 if first_code >= 0 { return first_code }
168 return 0 - NX_SMTP_BAD_LINE
169}
170
171// ---- Command builders (return bytes written or -BUF_FULL). ----
172
173func sm_cat(out: *u8, oi: i64, s: *u8) -> i64 {
174 var k: i64 = 0
175 while s[k] != (0 as u8) { out[oi] = s[k]; oi = oi + 1; k = k + 1 }
176 return oi
177}
178func sm_catn(out: *u8, oi: i64, s: *u8, len: i64) -> i64 {
179 var k: i64 = 0
180 while k < len { out[oi] = s[k]; oi = oi + 1; k = k + 1 }
181 return oi
182}
183
184func nx_smtp_build_ehlo(out: *u8, cap: i64, domain: *u8, dlen: i64) -> i64 {
185 if cap < dlen + 7 { return 0 - NX_SMTP_BUF_FULL }
186 var oi: i64 = 0
187 oi = sm_cat(out, oi, "EHLO " as *u8)
188 oi = sm_catn(out, oi, domain, dlen)
189 oi = sm_cat(out, oi, "\r\n" as *u8)
190 return oi
191}
192
193func nx_smtp_build_mail_from(out: *u8, cap: i64, addr: *u8, alen: i64) -> i64 {
194 if cap < alen + 14 { return 0 - NX_SMTP_BUF_FULL }
195 var oi: i64 = 0
196 oi = sm_cat(out, oi, "MAIL FROM:<" as *u8)
197 oi = sm_catn(out, oi, addr, alen)
198 oi = sm_cat(out, oi, ">\r\n" as *u8)
199 return oi
200}
201
202func nx_smtp_build_rcpt_to(out: *u8, cap: i64, addr: *u8, alen: i64) -> i64 {
203 if cap < alen + 12 { return 0 - NX_SMTP_BUF_FULL }
204 var oi: i64 = 0
205 oi = sm_cat(out, oi, "RCPT TO:<" as *u8)
206 oi = sm_catn(out, oi, addr, alen)
207 oi = sm_cat(out, oi, ">\r\n" as *u8)
208 return oi
209}
210
211func nx_smtp_build_simple(out: *u8, cap: i64, verb: *u8) -> i64 {
212 var oi: i64 = 0
213 oi = sm_cat(out, oi, verb)
214 if oi + 2 > cap { return 0 - NX_SMTP_BUF_FULL }
215 oi = sm_cat(out, oi, "\r\n" as *u8)
216 return oi
217}
218
219// ---- Dot-stuff a message body for the DATA phase. ----
220//
221// Prepends an extra "." to every line that begins with "." and appends
222// the "<CRLF>.<CRLF>" terminator. Input lines are CRLF-delimited (RFC
223// 5322). Returns bytes written or -BUF_FULL.
224func nx_smtp_dot_stuff(msg: *u8, mlen: i64, out: *u8, cap: i64) -> i64 {
225 var oi: i64 = 0
226 var at_line_start: i64 = 1
227 var i: i64 = 0
228 while i < mlen {
229 let c: i64 = msg[i] & 0xff
230 if at_line_start == 1 && c == 46 {
231 if oi + 1 > cap { return 0 - NX_SMTP_BUF_FULL }
232 out[oi] = 46 as u8 // stuff the leading dot
233 oi = oi + 1
234 }
235 if oi + 1 > cap { return 0 - NX_SMTP_BUF_FULL }
236 out[oi] = c as u8
237 oi = oi + 1
238 if c == 10 { at_line_start = 1 } else { at_line_start = 0 }
239 i = i + 1
240 }
241 if oi + 3 > cap { return 0 - NX_SMTP_BUF_FULL }
242 out[oi] = 46 as u8; oi = oi + 1 // .
243 out[oi] = 13 as u8; oi = oi + 1 // CR
244 out[oi] = 10 as u8; oi = oi + 1 // LF
245 return oi
246}
247
248// ---- The client send-sequence state machine. ----
249//
250// Given the current state (the command we last sent / are awaiting a
251// reply for) and the reply code received, set *out_action to the next
252// thing to send and return the next state. Any unexpected code -> ABORT
253// / SMTP_S_FAIL (caller closes with QUIT).
254func nx_smtp_advance(state: i64, code: i64, out_action: *i64) -> i64 {
255 if state == SMTP_S_INIT {
256 if code == 220 { *out_action = SMTP_A_SEND_EHLO; return SMTP_S_EHLO }
257 *out_action = SMTP_A_ABORT; return SMTP_S_FAIL
258 }
259 if state == SMTP_S_EHLO {
260 if smtp_is_2xx(code) == 1 { *out_action = SMTP_A_SEND_MAIL; return SMTP_S_MAIL }
261 *out_action = SMTP_A_ABORT; return SMTP_S_FAIL
262 }
263 if state == SMTP_S_MAIL {
264 if smtp_is_2xx(code) == 1 { *out_action = SMTP_A_SEND_RCPT; return SMTP_S_RCPT }
265 *out_action = SMTP_A_ABORT; return SMTP_S_FAIL
266 }
267 if state == SMTP_S_RCPT {
268 if smtp_is_2xx(code) == 1 { *out_action = SMTP_A_SEND_DATA; return SMTP_S_DATA }
269 *out_action = SMTP_A_ABORT; return SMTP_S_FAIL
270 }
271 if state == SMTP_S_DATA {
272 if code == 354 { *out_action = SMTP_A_SEND_BODY; return SMTP_S_BODY }
273 *out_action = SMTP_A_ABORT; return SMTP_S_FAIL
274 }
275 if state == SMTP_S_BODY {
276 if smtp_is_2xx(code) == 1 { *out_action = SMTP_A_SEND_QUIT; return SMTP_S_QUIT }
277 *out_action = SMTP_A_ABORT; return SMTP_S_FAIL
278 }
279 if state == SMTP_S_QUIT {
280 *out_action = SMTP_A_DONE; return SMTP_S_DONE
281 }
282 *out_action = SMTP_A_NONE
283 return state
284}