nx_email_addr.nx source
↩ module page · 246 lines · 7796 B
1// email_addr.nx -- RFC 5322 addr-spec validator (practical subset).
2//
3// The full RFC 5322 grammar accepts obscure forms like quoted
4// local parts (\"foo bar\"@example.com), comments ((comment)),
5// and folded whitespace -- practically never seen in real email
6// addresses. We implement the subset that matches what users
7// actually type:
8//
9// local@domain
10// local = 1+ chars from [A-Za-z0-9!#$%&'*+-/=?^_`{|}~.]
11// with '.' not first, not last, not consecutive
12// domain = at least one DNS label separated by '.'
13// label = [A-Za-z0-9] with optional [A-Za-z0-9-] interior,
14// trailing alnum, total length 1..63
15//
16// Returns (local_off, local_len, domain_off, domain_len) on
17// success so caller can upper-case compare, hash, etc. Zero-
18// alloc.
19//
20// Use cases: signup form validation, mailing-list import
21// sanity check, abuse detection, SPF lookup prep.
22//
23// Invariants:
24// EA1 Total length cap 320 bytes (RFC 5321 ยง4.5.3.1.3).
25// EA2 Local part cap 64 bytes.
26// EA3 Domain cap 255 bytes; per-label cap 63.
27// EA4 Returns EA_ERR_* negative on violation; offsets populated
28// even on failure up to the point of failure (useful for
29// error messaging).
30
31// nx_safety_envelope:
32// intended_use: AUTO_APPLIED -- primitive-specific tuning queued
33// sil_target: SIL1
34// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail]
35// verdict: NOT_YET_EVALUATED
36
37import "nx_syscalls.nx"
38
39const EA_MAX_TOTAL: i64 = 320
40const EA_MAX_LOCAL: i64 = 64
41const EA_MAX_DOMAIN: i64 = 255
42const EA_MAX_LABEL: i64 = 63
43
44const EA_ERR_TOTAL_LEN: i64 = -1
45const EA_ERR_NO_AT: i64 = -2
46const EA_ERR_LOCAL_EMPTY: i64 = -3
47const EA_ERR_LOCAL_LEN: i64 = -4
48const EA_ERR_LOCAL_CHAR: i64 = -5
49const EA_ERR_LOCAL_DOT: i64 = -6
50const EA_ERR_DOMAIN_EMPTY: i64 = -7
51const EA_ERR_DOMAIN_LEN: i64 = -8
52const EA_ERR_LABEL_LEN: i64 = -9
53const EA_ERR_LABEL_CHAR: i64 = -10
54const EA_ERR_LABEL_DASH: i64 = -11
55
56struct EmailAddr {
57 local_off: i64, local_len: i64,
58 domain_off: i64, domain_len: i64,
59}
60
61// Is byte allowed in local part (not counting '.' handling)?
62func ea_is_local_char(b: i64) -> i64 {
63 // Letters.
64 if b >= 0x41 {
65 if b <= 0x5A { return 1 }
66 }
67 if b >= 0x61 {
68 if b <= 0x7A { return 1 }
69 }
70 // Digits.
71 if b >= 0x30 {
72 if b <= 0x39 { return 1 }
73 }
74 // Specials: ! # $ % & ' * + - / = ? ^ _ ` { | } ~
75 if b == 0x21 { return 1 } // !
76 if b == 0x23 { return 1 } // #
77 if b == 0x24 { return 1 } // $
78 if b == 0x25 { return 1 } // %
79 if b == 0x26 { return 1 } // &
80 if b == 0x27 { return 1 } // '
81 if b == 0x2A { return 1 } // *
82 if b == 0x2B { return 1 } // +
83 if b == 0x2D { return 1 } // -
84 if b == 0x2F { return 1 } // /
85 if b == 0x3D { return 1 } // =
86 if b == 0x3F { return 1 } // ?
87 if b == 0x5E { return 1 } // ^
88 if b == 0x5F { return 1 } // _
89 if b == 0x60 { return 1 } // `
90 if b == 0x7B { return 1 } // {
91 if b == 0x7C { return 1 } // |
92 if b == 0x7D { return 1 } // }
93 if b == 0x7E { return 1 } // ~
94 return 0
95}
96
97// Is byte alnum? Interior of a domain label.
98func ea_is_alnum(b: i64) -> i64 {
99 if b >= 0x30 {
100 if b <= 0x39 { return 1 }
101 }
102 if b >= 0x41 {
103 if b <= 0x5A { return 1 }
104 }
105 if b >= 0x61 {
106 if b <= 0x7A { return 1 }
107 }
108 return 0
109}
110
111// Forward decl.
112func if_eq_or(a: i64, b: i64, v1: i64, v2: i64) -> i64;
113
114// Validate an email address. Returns 0 on success + fills e;
115// negative on failure.
116func email_addr_validate(buf: *u8, n: i64, e: *EmailAddr) -> i64 {
117 if n > EA_MAX_TOTAL { return EA_ERR_TOTAL_LEN }
118 if n == 0 { return EA_ERR_LOCAL_EMPTY }
119
120 // Find the LAST '@' -- RFC permits '@' in quoted local parts,
121 // and the last unquoted '@' is the separator in addresses
122 // without quoting (our scope).
123 var at_pos: i64 = -1
124 var i: i64 = 0
125 while i < n {
126 if buf[i] == 0x40 { at_pos = i }
127 i = i + 1
128 }
129 if at_pos < 0 { return EA_ERR_NO_AT }
130
131 e.local_off = 0
132 e.local_len = at_pos
133 e.domain_off = at_pos + 1
134 e.domain_len = n - at_pos - 1
135
136 if e.local_len == 0 { return EA_ERR_LOCAL_EMPTY }
137 if e.local_len > EA_MAX_LOCAL { return EA_ERR_LOCAL_LEN }
138 if e.domain_len == 0 { return EA_ERR_DOMAIN_EMPTY }
139 if e.domain_len > EA_MAX_DOMAIN { return EA_ERR_DOMAIN_LEN }
140
141 // Validate local part.
142 if buf[0] == 0x2E { return EA_ERR_LOCAL_DOT }
143 if buf[e.local_len - 1] == 0x2E { return EA_ERR_LOCAL_DOT }
144 i = 0
145 while i < e.local_len {
146 let b: i64 = buf[i]
147 if b == 0x2E {
148 // No consecutive dots.
149 if i + 1 < e.local_len {
150 if buf[i + 1] == 0x2E { return EA_ERR_LOCAL_DOT }
151 }
152 } else {
153 if ea_is_local_char(b) == 0 { return EA_ERR_LOCAL_CHAR }
154 }
155 i = i + 1
156 }
157
158 // Validate domain: sequence of labels separated by '.'.
159 let dom_off: i64 = e.domain_off
160 let dom_end: i64 = e.domain_off + e.domain_len
161 var label_start: i64 = dom_off
162 i = dom_off
163 while i <= dom_end {
164 var is_boundary: i64 = 0
165 if i == dom_end { is_boundary = 1 }
166 if is_boundary == 0 {
167 if buf[i] == 0x2E { is_boundary = 1 }
168 }
169 if is_boundary == 1 {
170 let lbl_len: i64 = i - label_start
171 if lbl_len == 0 { return EA_ERR_LABEL_LEN }
172 if lbl_len > EA_MAX_LABEL { return EA_ERR_LABEL_LEN }
173 // First and last must be alnum (no leading/trailing '-').
174 if ea_is_alnum(buf[label_start]) == 0 {
175 return EA_ERR_LABEL_DASH
176 }
177 if ea_is_alnum(buf[i - 1]) == 0 {
178 return EA_ERR_LABEL_DASH
179 }
180 // Interior chars: alnum or '-'.
181 var k: i64 = label_start + 1
182 while k < i - 1 {
183 let c: i64 = buf[k]
184 if ea_is_alnum(c) == 1 {
185 k = k + 1
186 continue
187 }
188 if c == 0x2D {
189 k = k + 1
190 continue
191 }
192 return EA_ERR_LABEL_CHAR
193 }
194 label_start = i + 1
195 }
196 i = i + 1
197 }
198
199 return 0
200}
201
202// Helper used above -- NishiLang needs explicit forward decl.
203func if_eq_or(a: i64, b: i64, v1: i64, v2: i64) -> i64 {
204 if a == b { return v1 }
205 return v2
206}
207
208// Compile-only smoke.
209func main() -> i64 {
210 let e_raw: *u8 = sys_mmap(64)
211 let e: *EmailAddr = e_raw as *EmailAddr
212
213 // Happy path.
214 if email_addr_validate("user@example.com", 16, e) != 0 { return 1 }
215 if e.local_len != 4 { return 2 }
216 if e.domain_len != 11 { return 3 }
217
218 // Plus-addressing.
219 if email_addr_validate("first.last+tag@sub.example.com", 30, e) != 0 {
220 return 4
221 }
222
223 // Rejected cases.
224 if email_addr_validate("", 0, e) != EA_ERR_LOCAL_EMPTY { return 5 }
225 if email_addr_validate("no-at-sign", 10, e) != EA_ERR_NO_AT { return 6 }
226 if email_addr_validate("@example.com", 12, e) != EA_ERR_LOCAL_EMPTY {
227 return 7
228 }
229 if email_addr_validate("user@", 5, e) != EA_ERR_DOMAIN_EMPTY { return 8 }
230 if email_addr_validate(".leading@example.com", 20, e) != EA_ERR_LOCAL_DOT {
231 return 9
232 }
233 if email_addr_validate("trailing.@example.com", 21, e) != EA_ERR_LOCAL_DOT {
234 return 10
235 }
236 if email_addr_validate("two..dots@example.com", 21, e) != EA_ERR_LOCAL_DOT {
237 return 11
238 }
239 if email_addr_validate("user@-bad.com", 13, e) != EA_ERR_LABEL_DASH {
240 return 12
241 }
242 if email_addr_validate("user@bad-.com", 13, e) != EA_ERR_LABEL_DASH {
243 return 13
244 }
245 return 0
246}