code wiki / (root) / nx_email_addr.nx

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}