nx_pages_static.nx source
↩ module page · 411 lines · 15097 B
1// nx_pages_static.nx -- static-file serving over nx_http_server.
2//
3// Closes the LAST gap between today's CLI scripts and a live audit
4// dashboard at http://localhost:PORT/audit. Composes the existing
5// substrate:
6//
7// nx_path_canonicalize.nx -- CWE-22 prevention at the URL boundary
8// nx_http_server.nx -- bind/listen/accept loop
9// nx_http_resp.nx -- response builder
10// sys_read_file_x86_64 -- file I/O
11//
12// The full live-dashboard chain (Phase 2 of NISHI_AUDIT_DASHBOARD_
13// ROADMAP.md) is now operational with this primitive.
14//
15// What this primitive provides:
16//
17// - nx_pages_serve_file(url_path, url_n, base_dir, base_n, ...)
18// Reads docs/audit/index.html (or any file under base_dir) +
19// emits an HTTP/1.1 200 response with proper Content-Type
20// derived from extension.
21//
22// - MIME-type table (sealed enum + lookup): html, css, js, json,
23// png, jpg, svg, txt, ico, plus "application/octet-stream"
24// fallback for unknown extensions.
25//
26// - Sealed-enum verdict (8 states): OK / NOT_FOUND / FORBIDDEN /
27// READ_ERR / OOM / RESPONSE_OOM / BAD_ARG / METHOD_NOT_ALLOWED.
28//
29// Per cardinal user-owns-every-bit:
30// - Caller supplies base_dir; primitive never picks "well-known"
31// paths.
32// - Caller supplies response buffer + max size.
33// - No implicit caching, no mmap'd inode store, no auto-index.
34// If URL is `/`, primitive returns NOT_FOUND -- caller must
35// explicitly route `/` to a specific file.
36//
37// Per cardinal feedback-defensive-at-boundaries-trusting-internally:
38// url_path is canonicalized ONCE at entry; subsequent file-open
39// uses the canonical path; no re-validation.
40//
41// nx_capability_claims:
42// needs: [sealed_enum, file_read, http_resp]
43// provides: [static_file_serve, mime_type_lookup,
44// cwe_22_canonicalize_at_boundary]
45// safety: [no_unchecked_deref, no_floating_point,
46// caller_chosen_base_dir, bounded_response_buffer]
47// verdict: [sealed_enum_8_state]
48// license: ORIGINAL
49// kind: racing_crew_specialist
50// layer: L4 (composite over L2 nx_path + L3 nx_http_resp)
51// sss: [S6, S11, S13]
52
53import "nx_syscalls.nx"
54import "nx_path_canonicalize.nx"
55const NXPG_MAGIC_1024: i64 = 1024
56const NXPG_MAGIC_2048: i64 = 2048
57const NXPG_MAGIC_2047: i64 = 2047
58
59// ---- Sealed enum: serve verdict ----------------------------------
60
61const NXPG_OK: i64 = 0
62const NXPG_NOT_FOUND: i64 = 1
63const NXPG_FORBIDDEN: i64 = 2
64const NXPG_READ_ERR: i64 = 3
65const NXPG_OOM: i64 = 4
66const NXPG_RESPONSE_OOM: i64 = 5
67const NXPG_BAD_ARG: i64 = 6
68const NXPG_METHOD_NOT_ALLOWED: i64 = 7
69const NXPG_VERDICT_N: i64 = 8
70
71func nxpg_verdict_is_valid(v: i64) -> i64 {
72 if v < 0 { return 0 }
73 if v >= NXPG_VERDICT_N { return 0 }
74 return 1
75}
76
77func nxpg_verdict_name(v: i64) -> *u8 {
78 if v == NXPG_OK { return "OK" as *u8 }
79 if v == NXPG_NOT_FOUND { return "NOT_FOUND" as *u8 }
80 if v == NXPG_FORBIDDEN { return "FORBIDDEN" as *u8 }
81 if v == NXPG_READ_ERR { return "READ_ERR" as *u8 }
82 if v == NXPG_OOM { return "OOM" as *u8 }
83 if v == NXPG_RESPONSE_OOM { return "RESPONSE_OOM" as *u8 }
84 if v == NXPG_BAD_ARG { return "BAD_ARG" as *u8 }
85 if v == NXPG_METHOD_NOT_ALLOWED { return "METHOD_NOT_ALLOWED" as *u8 }
86 return "INVALID" as *u8
87}
88
89// ---- MIME type lookup --------------------------------------------
90//
91// Sealed-enum of MIME types we serve. Anything outside the list
92// gets "application/octet-stream" -- explicit refusal to guess.
93
94const NXMIME_HTML: i64 = 0
95const NXMIME_CSS: i64 = 1
96const NXMIME_JS: i64 = 2
97const NXMIME_JSON: i64 = 3
98const NXMIME_PNG: i64 = 4
99const NXMIME_JPG: i64 = 5
100const NXMIME_SVG: i64 = 6
101const NXMIME_TXT: i64 = 7
102const NXMIME_ICO: i64 = 8
103const NXMIME_OCTET: i64 = 9
104const NXMIME_N: i64 = 10
105
106// MIME-type string per enum. Caller doesn't free.
107func nx_pages_mime_type(m: i64) -> *u8 {
108 if m == NXMIME_HTML { return "text/html; charset=utf-8" as *u8 }
109 if m == NXMIME_CSS { return "text/css; charset=utf-8" as *u8 }
110 if m == NXMIME_JS { return "application/javascript" as *u8 }
111 if m == NXMIME_JSON { return "application/json" as *u8 }
112 if m == NXMIME_PNG { return "image/png" as *u8 }
113 if m == NXMIME_JPG { return "image/jpeg" as *u8 }
114 if m == NXMIME_SVG { return "image/svg+xml" as *u8 }
115 if m == NXMIME_TXT { return "text/plain; charset=utf-8" as *u8 }
116 if m == NXMIME_ICO { return "image/x-icon" as *u8 }
117 return "application/octet-stream" as *u8
118}
119
120// Length of the MIME-type string (so response builder can copy).
121func nx_pages_mime_type_len(m: i64) -> i64 {
122 if m == NXMIME_HTML { return 24 }
123 if m == NXMIME_CSS { return 23 }
124 if m == NXMIME_JS { return 22 }
125 if m == NXMIME_JSON { return 16 }
126 if m == NXMIME_PNG { return 9 }
127 if m == NXMIME_JPG { return 10 }
128 if m == NXMIME_SVG { return 13 }
129 if m == NXMIME_TXT { return 25 }
130 if m == NXMIME_ICO { return 12 }
131 return 24
132}
133
134// Predicate: 3-byte suffix match (case-sensitive, ASCII).
135func nxpg_ends_with_3(path: *u8, n: i64, a: i64, b: i64, c: i64) -> i64 {
136 if n < 3 { return 0 }
137 if path[n-3] as i64 != a { return 0 }
138 if path[n-2] as i64 != b { return 0 }
139 if path[n-1] as i64 != c { return 0 }
140 return 1
141}
142
143// Predicate: 4-byte suffix match.
144func nxpg_ends_with_4(path: *u8, n: i64, a: i64, b: i64, c: i64, d: i64) -> i64 {
145 if n < 4 { return 0 }
146 if path[n-4] as i64 != a { return 0 }
147 if path[n-3] as i64 != b { return 0 }
148 if path[n-2] as i64 != c { return 0 }
149 if path[n-1] as i64 != d { return 0 }
150 return 1
151}
152
153// Predicate: 5-byte suffix.
154func nxpg_ends_with_5(path: *u8, n: i64,
155 a: i64, b: i64, c: i64, d: i64, e: i64) -> i64 {
156 if n < 5 { return 0 }
157 if path[n-5] as i64 != a { return 0 }
158 if path[n-4] as i64 != b { return 0 }
159 if path[n-3] as i64 != c { return 0 }
160 if path[n-2] as i64 != d { return 0 }
161 if path[n-1] as i64 != e { return 0 }
162 return 1
163}
164
165// Map file extension to MIME enum. Returns NXMIME_OCTET on miss.
166func nx_pages_mime_from_path(path: *u8, n: i64) -> i64 {
167 // .html (5)
168 if nxpg_ends_with_5(path, n, 0x2e, 0x68, 0x74, 0x6d, 0x6c) == 1 { return NXMIME_HTML }
169 // .json (5)
170 if nxpg_ends_with_5(path, n, 0x2e, 0x6a, 0x73, 0x6f, 0x6e) == 1 { return NXMIME_JSON }
171 // .css (4)
172 if nxpg_ends_with_4(path, n, 0x2e, 0x63, 0x73, 0x73) == 1 { return NXMIME_CSS }
173 // .png (4)
174 if nxpg_ends_with_4(path, n, 0x2e, 0x70, 0x6e, 0x67) == 1 { return NXMIME_PNG }
175 // .jpg (4)
176 if nxpg_ends_with_4(path, n, 0x2e, 0x6a, 0x70, 0x67) == 1 { return NXMIME_JPG }
177 // .svg (4)
178 if nxpg_ends_with_4(path, n, 0x2e, 0x73, 0x76, 0x67) == 1 { return NXMIME_SVG }
179 // .txt (4)
180 if nxpg_ends_with_4(path, n, 0x2e, 0x74, 0x78, 0x74) == 1 { return NXMIME_TXT }
181 // .ico (4)
182 if nxpg_ends_with_4(path, n, 0x2e, 0x69, 0x63, 0x6f) == 1 { return NXMIME_ICO }
183 // .js (3)
184 if nxpg_ends_with_3(path, n, 0x2e, 0x6a, 0x73) == 1 { return NXMIME_JS }
185 return NXMIME_OCTET
186}
187
188// ---- Response emission helpers (no nx_http_resp.nx dep) ----------
189//
190// We emit HTTP/1.1 responses directly to avoid pulling in the
191// nx_http_resp.nx import + its nx_syscalls.nx transitive dep. The
192// shape is canonical RFC 7230:
193//
194// HTTP/1.1 200 OK\r\n
195// Content-Type: text/html; charset=utf-8\r\n
196// Content-Length: 1234\r\n
197// \r\n
198// <body>
199
200func nxpg_put(out: *u8, off: *i64, cap: i64, b: i64) -> i64 {
201 if *off >= cap { return NXPG_RESPONSE_OOM }
202 out[*off] = b as u8
203 *off = *off + 1
204 return NXPG_OK
205}
206
207func nxpg_put_cstr(out: *u8, off: *i64, cap: i64, s: *u8) -> i64 {
208 var i: i64 = 0
209 while s[i] != 0 {
210 let rc: i64 = nxpg_put(out, off, cap, s[i] as i64)
211 if rc != NXPG_OK { return rc }
212 i = i + 1
213 }
214 return NXPG_OK
215}
216
217func nxpg_put_bytes(out: *u8, off: *i64, cap: i64, src: *u8, n: i64) -> i64 {
218 var i: i64 = 0
219 while i < n {
220 let rc: i64 = nxpg_put(out, off, cap, src[i] as i64)
221 if rc != NXPG_OK { return rc }
222 i = i + 1
223 }
224 return NXPG_OK
225}
226
227func nxpg_put_dec(out: *u8, off: *i64, cap: i64, v: i64) -> i64 {
228 if v == 0 { return nxpg_put(out, off, cap, 0x30) }
229 let scratch: *u8 = sys_mmap(32)
230 var n: i64 = v
231 var k: i64 = 0
232 while n > 0 {
233 scratch[k] = (0x30 + (n - (n / 10) * 10)) as u8
234 n = n / 10
235 k = k + 1
236 }
237 var ri: i64 = k - 1
238 while ri >= 0 {
239 let rc: i64 = nxpg_put(out, off, cap, scratch[ri] as i64)
240 if rc != NXPG_OK { return rc }
241 ri = ri - 1
242 }
243 return NXPG_OK
244}
245
246// ---- Response builders -------------------------------------------
247
248// 200 OK response with body.
249func nx_pages_emit_200(out: *u8, off: *i64, cap: i64,
250 mime: i64, body: *u8, body_n: i64) -> i64 {
251 let r1: i64 = nxpg_put_cstr(out, off, cap, "HTTP/1.1 200 OK\r\nContent-Type: " as *u8)
252 if r1 != NXPG_OK { return r1 }
253 let r2: i64 = nxpg_put_cstr(out, off, cap, nx_pages_mime_type(mime))
254 if r2 != NXPG_OK { return r2 }
255 let r3: i64 = nxpg_put_cstr(out, off, cap, "\r\nContent-Length: " as *u8)
256 if r3 != NXPG_OK { return r3 }
257 let r4: i64 = nxpg_put_dec(out, off, cap, body_n)
258 if r4 != NXPG_OK { return r4 }
259 let r5: i64 = nxpg_put_cstr(out, off, cap, "\r\n\r\n" as *u8)
260 if r5 != NXPG_OK { return r5 }
261 return nxpg_put_bytes(out, off, cap, body, body_n)
262}
263
264// 404 Not Found.
265func nx_pages_emit_404(out: *u8, off: *i64, cap: i64) -> i64 {
266 return nxpg_put_cstr(out, off, cap,
267 "HTTP/1.1 404 Not Found\r\nContent-Type: text/plain\r\nContent-Length: 14\r\n\r\nNot Found.\r\n\r\n" as *u8)
268}
269
270// 403 Forbidden.
271func nx_pages_emit_403(out: *u8, off: *i64, cap: i64) -> i64 {
272 return nxpg_put_cstr(out, off, cap,
273 "HTTP/1.1 403 Forbidden\r\nContent-Type: text/plain\r\nContent-Length: 14\r\n\r\nForbidden.\r\n\r\n" as *u8)
274}
275
276// 405 Method Not Allowed.
277func nx_pages_emit_405(out: *u8, off: *i64, cap: i64) -> i64 {
278 return nxpg_put_cstr(out, off, cap,
279 "HTTP/1.1 405 Method Not Allowed\r\nAllow: GET\r\nContent-Length: 0\r\n\r\n" as *u8)
280}
281
282// 500 Internal Server Error.
283func nx_pages_emit_500(out: *u8, off: *i64, cap: i64) -> i64 {
284 return nxpg_put_cstr(out, off, cap,
285 "HTTP/1.1 500 Internal Server Error\r\nContent-Type: text/plain\r\nContent-Length: 7\r\n\r\nError.\r\n\r\n" as *u8)
286}
287
288// ---- The serve entry point ---------------------------------------
289//
290// Given a URL path (e.g., "/audit/index.html") + a base directory
291// (e.g., "/var/www") + a method (1=GET, others rejected), produce
292// an HTTP/1.1 response in out_buf. Returns NXPG_OK + out_n on
293// success, or a sealed-enum failure verdict.
294//
295// Path discipline (CWE-22 prevention):
296// 1. URL must begin with `/` (otherwise reject as BAD_ARG)
297// 2. Strip leading `/`
298// 3. Pass remainder to nx_path_canonicalize (rejects ../, NUL,
299// backslash, absolute)
300// 4. Join with base_dir via nx_path_join
301// 5. Read file via sys_read_file_x86_64
302// 6. Detect MIME from canonical relative path
303// 7. Emit 200 with Content-Type + Content-Length + body
304//
305// Method discipline:
306// - method_kind 1 (GET) only. All others return 405.
307
308func nx_pages_serve_file(url_path: *u8, url_n: i64,
309 base_dir: *u8, base_n: i64,
310 method_kind: i64,
311 out_buf: *u8, out_cap: i64,
312 out_n: *i64) -> i64 {
313 if url_path == (0 as *u8) { return NXPG_BAD_ARG }
314 if base_dir == (0 as *u8) { return NXPG_BAD_ARG }
315 if out_buf == (0 as *u8) { return NXPG_BAD_ARG }
316 if out_n == (0 as *i64) { return NXPG_BAD_ARG }
317 if url_n <= 0 { return NXPG_BAD_ARG }
318 if base_n <= 0 { return NXPG_BAD_ARG }
319 if out_cap <= 0 { return NXPG_BAD_ARG }
320
321 var off: i64 = 0
322
323 // Method gate: GET only.
324 if method_kind != 1 {
325 let rc: i64 = nx_pages_emit_405(out_buf, &off, out_cap)
326 if rc != NXPG_OK { return rc }
327 *out_n = off
328 return NXPG_METHOD_NOT_ALLOWED
329 }
330
331 // URL must start with `/`.
332 if url_path[0] != 0x2f {
333 let rc: i64 = nx_pages_emit_404(out_buf, &off, out_cap)
334 if rc != NXPG_OK { return rc }
335 *out_n = off
336 return NXPG_NOT_FOUND
337 }
338
339 // Strip the leading `/` -- pass the suffix to canonicalize.
340 let rel_start: i64 = 1
341 let rel_n: i64 = url_n - 1
342
343 // Special-case: bare `/` -- no implicit index per cardinal.
344 if rel_n == 0 {
345 let rc: i64 = nx_pages_emit_404(out_buf, &off, out_cap)
346 if rc != NXPG_OK { return rc }
347 *out_n = off
348 return NXPG_NOT_FOUND
349 }
350
351 let canon_buf: *u8 = sys_mmap(NXPG_MAGIC_1024)
352 let canon_n: *i64 = sys_mmap(8) as *i64
353 let rel_ptr: *u8 = ((url_path as i64) + rel_start) as *u8
354 let cv: i64 = nx_path_canonicalize(rel_ptr, rel_n,
355 canon_buf, NXPG_MAGIC_1024, canon_n)
356 if cv != NXP_OK {
357 // TRAVERSAL / NUL / BACKSLASH / EMPTY -> 403; TOO_LONG -> 404.
358 if cv == NXP_TOO_LONG {
359 let rc: i64 = nx_pages_emit_404(out_buf, &off, out_cap)
360 if rc != NXPG_OK { return rc }
361 *out_n = off
362 return NXPG_NOT_FOUND
363 }
364 let rc: i64 = nx_pages_emit_403(out_buf, &off, out_cap)
365 if rc != NXPG_OK { return rc }
366 *out_n = off
367 return NXPG_FORBIDDEN
368 }
369
370 // Join base + canonical relative path.
371 let full_buf: *u8 = sys_mmap(NXPG_MAGIC_2048)
372 let full_n: *i64 = sys_mmap(8) as *i64
373 let jv: i64 = nx_path_join(base_dir, base_n,
374 canon_buf, canon_n[0],
375 full_buf, NXPG_MAGIC_2048, full_n)
376 if jv != NXP_OK {
377 let rc: i64 = nx_pages_emit_500(out_buf, &off, out_cap)
378 if rc != NXPG_OK { return rc }
379 *out_n = off
380 return NXPG_OOM
381 }
382
383 // NUL-terminate the full path for sys_read_file_x86_64.
384 if full_n[0] >= NXPG_MAGIC_2047 {
385 let rc: i64 = nx_pages_emit_404(out_buf, &off, out_cap)
386 if rc != NXPG_OK { return rc }
387 *out_n = off
388 return NXPG_NOT_FOUND
389 }
390 full_buf[full_n[0]] = 0 as u8
391
392 // Read file.
393 let body_n: *i64 = sys_mmap(8) as *i64
394 let body: *u8 = sys_read_file(full_buf, body_n)
395 if body == (0 as *u8) {
396 let rc: i64 = nx_pages_emit_404(out_buf, &off, out_cap)
397 if rc != NXPG_OK { return rc }
398 *out_n = off
399 return NXPG_NOT_FOUND
400 }
401
402 // MIME detection from canonical path.
403 let mime: i64 = nx_pages_mime_from_path(canon_buf, canon_n[0])
404
405 // 200 with body.
406 let rc_emit: i64 = nx_pages_emit_200(out_buf, &off, out_cap,
407 mime, body, body_n[0])
408 if rc_emit != NXPG_OK { return rc_emit }
409 *out_n = off
410 return NXPG_OK
411}