code wiki / (root) / nx_pages_static.nx

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}