nx_http.nx source
↩ module page · 300 lines · 11022 B
1// http.nx -- HTTP/1.1 request parser (RFC 7230/9112 subset).
2//
3// Parses an HTTP request from a byte buffer (typically accumulated
4// from socket reads). Reader-only subset:
5// - Request line: METHOD SP path SP HTTP/1.1 CRLF
6// - Headers: Name ": " Value CRLF (until empty line)
7// - Body: remaining bytes per Content-Length
8//
9// Response generation is the caller's job; we provide a typed
10// view of the request + a Content-Length header lookup helper.
11// Real-world HTTP servers add: chunked transfer, keep-alive,
12// HTTP/2 framing, pipelining. Those are future work.
13//
14// Invariants:
15// H1 All reads bounds-check against caller-supplied buffer end.
16// H2 Header parsing stops at CRLF CRLF (empty line) per
17// RFC 7230 ยง3.
18// H3 Returns offsets into the caller's buffer; nothing copied.
19// Parser struct owns only position metadata.
20// H4 Malformed input returns negative HTTP_ERR_*; no silent
21// acceptance of weird forms (e.g. bare LF line ends).
22
23// nx_safety_envelope:
24// intended_use: AUTO_APPLIED -- primitive-specific tuning queued
25// sil_target: SIL1
26// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail]
27// verdict: NOT_YET_EVALUATED
28
29import "nx_syscalls.nx"
30
31// Error codes.
32const HTTP_ERR_TRUNCATED: i64 = -1
33const HTTP_ERR_BAD_METHOD: i64 = -2
34const HTTP_ERR_BAD_VERSION: i64 = -3
35const HTTP_ERR_BAD_HEADER: i64 = -4
36const HTTP_ERR_TOO_MANY_HDR: i64 = -5
37
38const HTTP_MAX_HEADERS: i64 = 64
39
40// Parsed request descriptor. Offsets + lengths point into the
41// caller's buffer. Headers stored as parallel offset/length
42// arrays (memory-friendly, no struct-of-structs).
43struct HttpRequest {
44 method_off: i64, method_len: i64,
45 path_off: i64, path_len: i64,
46 version_off: i64, version_len: i64,
47 n_headers: i64,
48 body_off: i64, body_len: i64, // body_len = 0 if absent
49 // parallel header arrays; allocated externally by the caller
50 // via http_request_init below.
51 hdr_name_off: *i64,
52 hdr_name_len: *i64,
53 hdr_val_off: *i64,
54 hdr_val_len: *i64,
55}
56
57// Allocate header slot arrays + wire into the struct.
58func http_request_init(req: *HttpRequest) -> i64 {
59 let raw1: *u8 = sys_mmap(HTTP_MAX_HEADERS * 8)
60 let raw2: *u8 = sys_mmap(HTTP_MAX_HEADERS * 8)
61 let raw3: *u8 = sys_mmap(HTTP_MAX_HEADERS * 8)
62 let raw4: *u8 = sys_mmap(HTTP_MAX_HEADERS * 8)
63 req.hdr_name_off = raw1 as *i64
64 req.hdr_name_len = raw2 as *i64
65 req.hdr_val_off = raw3 as *i64
66 req.hdr_val_len = raw4 as *i64
67 req.n_headers = 0
68 return 0
69}
70
71// Scan until any of: SP (0x20), TAB (0x09), CR (0x0D), LF (0x0A).
72// Returns position of the whitespace byte, or `end` if not found.
73func http_scan_token_end(buf: *u8, pos: i64, end: i64) -> i64 {
74 var p: i64 = pos
75 while p < end {
76 let b: i64 = buf[p]
77 if b == 0x20 { return p }
78 if b == 0x09 { return p }
79 if b == 0x0D { return p }
80 if b == 0x0A { return p }
81 p = p + 1
82 }
83 return end
84}
85
86// Skip consecutive SP / TAB bytes. Returns new position.
87func http_skip_hspace(buf: *u8, pos: i64, end: i64) -> i64 {
88 var p: i64 = pos
89 var done: i64 = 0
90 // Loop on `done` flag, not `p`, so when we hit a non-whitespace byte we
91 // stop without overwriting p. Earlier sentinel-exit pattern set
92 // p = end + 1 and tried to restore via p - 1, which gave p = end --
93 // wrong position, broke every caller of http_parse_request_line.
94 while done == 0 {
95 if p >= end { done = 1 }
96 if done == 0 {
97 let b: i64 = buf[p] as i64
98 if b == 0x20 { p = p + 1 }
99 if b == 0x09 { p = p + 1 }
100 if b != 0x20 { if b != 0x09 { done = 1 } }
101 }
102 }
103 return p
104}
105
106// Find next CRLF starting at `pos`. Returns position of CR (first
107// byte of the CRLF pair), or -1 if not found / malformed (bare CR
108// or bare LF).
109func http_find_crlf(buf: *u8, pos: i64, end: i64) -> i64 {
110 var p: i64 = pos
111 while p + 1 < end {
112 if buf[p] == 0x0D {
113 if buf[p + 1] == 0x0A { return p }
114 return HTTP_ERR_BAD_HEADER
115 }
116 if buf[p] == 0x0A { return HTTP_ERR_BAD_HEADER } // bare LF
117 p = p + 1
118 }
119 return HTTP_ERR_TRUNCATED
120}
121
122// Parse the request line: METHOD SP path SP version CRLF.
123// Returns position after the CRLF on success.
124func http_parse_request_line(buf: *u8, end: i64, req: *HttpRequest) -> i64 {
125 var pos: i64 = 0
126
127 // METHOD.
128 req.method_off = pos
129 let after_method: i64 = http_scan_token_end(buf, pos, end)
130 req.method_len = after_method - pos
131 if req.method_len == 0 { return HTTP_ERR_BAD_METHOD }
132 pos = http_skip_hspace(buf, after_method, end)
133
134 // Path.
135 req.path_off = pos
136 let after_path: i64 = http_scan_token_end(buf, pos, end)
137 req.path_len = after_path - pos
138 if req.path_len == 0 { return HTTP_ERR_BAD_METHOD }
139 pos = http_skip_hspace(buf, after_path, end)
140
141 // Version "HTTP/1.1".
142 req.version_off = pos
143 let crlf_pos: i64 = http_find_crlf(buf, pos, end)
144 if crlf_pos < 0 { return crlf_pos }
145 req.version_len = crlf_pos - pos
146 if req.version_len < 8 { return HTTP_ERR_BAD_VERSION }
147 // Minimum check: prefix must be "HTTP/".
148 if buf[pos] != 0x48 { return HTTP_ERR_BAD_VERSION } // 'H'
149 if buf[pos + 1] != 0x54 { return HTTP_ERR_BAD_VERSION }
150 if buf[pos + 2] != 0x54 { return HTTP_ERR_BAD_VERSION }
151 if buf[pos + 3] != 0x50 { return HTTP_ERR_BAD_VERSION } // 'P'
152 if buf[pos + 4] != 0x2F { return HTTP_ERR_BAD_VERSION } // '/'
153
154 return crlf_pos + 2 // past CRLF
155}
156
157// Parse headers starting at pos. Populates req.hdr_* arrays and
158// n_headers. Returns position after the empty-line CRLF.
159func http_parse_headers(buf: *u8, start: i64, end: i64,
160 req: *HttpRequest) -> i64 {
161 var pos: i64 = start
162 while pos < end {
163 // Empty line (CRLF) marks end of headers.
164 if pos + 1 < end {
165 if buf[pos] == 0x0D {
166 if buf[pos + 1] == 0x0A { return pos + 2 }
167 }
168 }
169 if req.n_headers >= HTTP_MAX_HEADERS {
170 return HTTP_ERR_TOO_MANY_HDR
171 }
172 // Header name: token up to ':'. Capture the colon position
173 // in `colon_pos`; without the capture the sentinel-exit pattern
174 // (p = end + 1) would clobber the position we need below.
175 let name_start: i64 = pos
176 var p: i64 = pos
177 var colon_pos: i64 = -1
178 while p < end {
179 if colon_pos < 0 {
180 let b: i64 = buf[p] as i64
181 if b == 0x3A { colon_pos = p; p = end } // exit
182 if b == 0x0D { return HTTP_ERR_BAD_HEADER }
183 if colon_pos < 0 { p = p + 1 }
184 }
185 }
186 if colon_pos < 0 { return HTTP_ERR_TRUNCATED }
187 let name_off: i64 = name_start
188 let name_len: i64 = colon_pos - name_start
189 if name_len == 0 { return HTTP_ERR_BAD_HEADER }
190
191 // Skip ':' + optional whitespace.
192 pos = colon_pos + 1
193 pos = http_skip_hspace(buf, pos, end)
194
195 // Header value: up to CRLF.
196 let val_start: i64 = pos
197 let crlf: i64 = http_find_crlf(buf, pos, end)
198 if crlf < 0 { return crlf }
199 // Trim trailing hspace from value. Loop on `done` so non-whitespace
200 // exit doesn't clobber val_end (the original sentinel-exit set
201 // val_end = val_start - 1 then forced val_end = val_start, which made
202 // every value parse as length 0).
203 var val_end: i64 = crlf
204 var trim_done: i64 = 0
205 while trim_done == 0 {
206 if val_end <= val_start { trim_done = 1 }
207 if trim_done == 0 {
208 let b: i64 = buf[val_end - 1] as i64
209 if b == 0x20 { val_end = val_end - 1 }
210 if b == 0x09 { val_end = val_end - 1 }
211 if b != 0x20 { if b != 0x09 { trim_done = 1 } }
212 }
213 }
214
215 let idx: i64 = req.n_headers
216 req.hdr_name_off[idx] = name_off
217 req.hdr_name_len[idx] = name_len
218 req.hdr_val_off[idx] = val_start
219 req.hdr_val_len[idx] = val_end - val_start
220 req.n_headers = idx + 1
221
222 pos = crlf + 2
223 }
224 return HTTP_ERR_TRUNCATED
225}
226
227// Full parse. Returns 0 on success; negative HTTP_ERR_* on failure.
228// req.body_off is set to the byte after the empty-line CRLF; body_len
229// is NOT populated here (requires Content-Length / chunked decode).
230func http_parse(buf: *u8, n: i64, req: *HttpRequest) -> i64 {
231 http_request_init(req)
232 let after_line: i64 = http_parse_request_line(buf, n, req)
233 if after_line < 0 { return after_line }
234 let after_hdrs: i64 = http_parse_headers(buf, after_line, n, req)
235 if after_hdrs < 0 { return after_hdrs }
236 req.body_off = after_hdrs
237 req.body_len = 0 // caller sets via Content-Length lookup
238 return 0
239}
240
241// Case-insensitive ASCII compare of a byte range against a null-
242// terminated cstring. Used for header-name matching where the
243// client's casing is arbitrary ("Content-Length" vs "content-length").
244func http_header_name_eq(buf: *u8, off: i64, len: i64,
245 cstr: *u8) -> i64 {
246 var i: i64 = 0
247 while i < len {
248 if cstr[i] == 0 { return 0 }
249 let a: i64 = buf[off + i]
250 let b: i64 = cstr[i]
251 var an: i64 = a
252 var bn: i64 = b
253 // ASCII lowercase.
254 if an >= 0x41 { if an <= 0x5A { an = an + 32 } }
255 if bn >= 0x41 { if bn <= 0x5A { bn = bn + 32 } }
256 if an != bn { return 0 }
257 i = i + 1
258 }
259 if cstr[i] != 0 { return 0 }
260 return 1
261}
262
263// Find a header by name (case-insensitive). Writes value offset
264// and length to out slots. Returns 1 if found, 0 otherwise.
265func http_get_header(buf: *u8, req: *HttpRequest, name: *u8,
266 val_off_out: *i64, val_len_out: *i64) -> i64 {
267 var i: i64 = 0
268 while i < req.n_headers {
269 if http_header_name_eq(buf, req.hdr_name_off[i],
270 req.hdr_name_len[i], name) == 1 {
271 *val_off_out = req.hdr_val_off[i]
272 *val_len_out = req.hdr_val_len[i]
273 return 1
274 }
275 i = i + 1
276 }
277 return 0
278}
279
280// Compile-only smoke.
281func main() -> i64 {
282 let raw: *u8 = "GET /index.html HTTP/1.1\r\nHost: example.com\r\n\r\n"
283 var n: i64 = 0
284 while raw[n] != 0 { n = n + 1 }
285
286 let req_raw: *u8 = sys_mmap(512)
287 let req: *HttpRequest = req_raw as *HttpRequest
288 let rc: i64 = http_parse(raw, n, req)
289 if rc != 0 { return 1 }
290 if req.method_len != 3 { return 2 } // "GET"
291 if req.path_len != 11 { return 3 } // "/index.html"
292 if req.n_headers != 1 { return 4 }
293
294 // Look up Host header.
295 let vo: *i64 = sys_mmap(16) as *i64
296 let vl: *i64 = sys_mmap(16) as *i64
297 if http_get_header(raw, req, "host", vo, vl) != 1 { return 5 }
298 if *vl != 11 { return 6 } // "example.com"
299 return 0
300}