nx_wiki_archive_router.nx source
↩ module page · 450 lines · 18467 B
1// nx_wiki_archive_router.nx -- /wiki/archive/* + /wiki/wayback/<hash>
2// HTTP handler. V1 closes the last wiki 501 per the archive cardinal
3// ("no link rot as hygiene of the nishi ecosystem").
4//
5// COMPOSES:
6// nx_syscalls (sys_mmap; sys_now_ms)
7// nx_search_inverted (nx_inv_hash_bytes_lower for URL -> 64-bit hash)
8//
9// COMPOSED BY:
10// wiki/nx_wiki_routes (dispatches /wiki/archive* + /wiki/wayback/*)
11//
12// V1 SCOPE per NISHI_ARCHIVE_INTEGRATION_CHARTER §5:
13// - NxArchiveStore (in-memory; parallel pools per URL hash)
14// - /wiki/archive -- index page listing all stored snapshots
15// - /wiki/wayback/<hash> -- serves stored snapshot bytes with
16// X-Archive-Original-URL + X-Archive-Snapshot-Date headers
17// - /wiki/commit/<repo>/<hash> -- 404 V1 (V2 ships code-archive walker)
18// - Hardcoded V1 seed list (3 sample snapshots) demonstrates the
19// cite-time-snapshot pattern with real content; V2 adds live fetch
20// - Sealed verdict surface 800-819 (reserved per charter §9)
21//
22// V2 SCOPE (TODO):
23// - nx_wiki_wayback.nx live fetch via nx_http_client + robots.txt
24// - nx_wiki_code_archive.nx gitea API walker
25// - nx_wiki_cite_rewriter.nx wiki build pass that rewrites external
26// URLs to /wiki/wayback/<hash>
27// - nx_wiki_audit_emit.nx structured audit log
28// - nx_kv_store-backed persistence (currently in-memory only)
29//
30// Status: V1. 2026-05-27.
31
32import "nx_syscalls.nx"
33import "nx_search_inverted.nx"
34
35// ===== Sealed verdict surface (charter §9: 800-819 reserved) =================================================
36const NX_ARCH_OK: i64 = 0
37const NX_ARCH_BAD_INPUT: i64 = 800
38const NX_ARCH_SNAPSHOT_PENDING: i64 = 800
39const NX_ARCH_SNAPSHOT_FAILED: i64 = 801
40const NX_ARCH_BLOCKED_BY_ROBOTS: i64 = 802
41const NX_ARCH_RATE_LIMITED: i64 = 803
42const NX_ARCH_BAD_URL: i64 = 804
43const NX_ARCH_KV_FULL: i64 = 805
44const NX_ARCH_BLOB_FULL: i64 = 806
45const NX_ARCH_COMMIT_NOT_FOUND: i64 = 807
46const NX_ARCH_REPO_NOT_INDEXED: i64 = 808
47const NX_ARCH_M11_VIOLATION: i64 = 809
48const NX_ARCH_M12_VIOLATION: i64 = 810
49const NX_ARCH_M13_VIOLATION: i64 = 811
50// Local-to-router (above the reserved range to avoid collision):
51const NX_ARCH_NOT_FOUND: i64 = 815
52const NX_ARCH_RESP_OVERFLOW: i64 = 816
53const NX_ARCH_RENDER_FAILED: i64 = 817
54
55// ===== Named constants (M7) =================================================
56const NX_ARCH_MAX_SNAPSHOTS: i64 = 1000 // V1 cap
57const NX_ARCH_URLS_POOL_CAP: i64 = 65536 // 64 KB packed URLs
58const NX_ARCH_DATES_POOL_CAP: i64 = 32768 // 32 KB packed dates
59const NX_ARCH_BODIES_POOL_CAP: i64 = 16777216 // 16 MB packed bodies
60const NX_ARCH_INDEX_PAGE_CAP: i64 = 524288 // 512 KB index page
61const NX_ARCH_HASH_HEX_LEN: i64 = 16 // 16 hex chars per hash
62const NX_ARCH_INT_BUF_CAP: i64 = 32
63
64// ===== NxArchiveStore (in-memory; parallel pools) =================================================
65
66struct NxArchiveStore {
67 hashes: *i64 // FNV-1a 64 of original URL
68 urls_pool: *u8
69 urls_pool_used: i64
70 urls_offs: *i64
71 urls_lens: *i64
72 dates_pool: *u8
73 dates_pool_used: i64
74 dates_offs: *i64
75 dates_lens: *i64
76 bodies_pool: *u8
77 bodies_pool_used: i64
78 bodies_offs: *i64
79 bodies_lens: *i64
80 content_types: *i64 // 0 = text/html; 1 = text/plain; V2 sealed enum
81 snapshot_count: i64
82 snapshots_cap: i64
83 valid: i64
84}
85
86const NX_ARCH_CT_HTML: i64 = 0
87const NX_ARCH_CT_PLAIN: i64 = 1
88const NX_ARCH_CT_JSON: i64 = 2
89
90func nx_arch_ct_string(ct: i64) -> *u8 {
91 if ct == NX_ARCH_CT_HTML { return "text/html; charset=utf-8" as *u8 }
92 if ct == NX_ARCH_CT_PLAIN { return "text/plain; charset=utf-8" as *u8 }
93 if ct == NX_ARCH_CT_JSON { return "application/json" as *u8 }
94 return "application/octet-stream" as *u8
95}
96
97func nx_arch_ct_string_n(ct: i64) -> i64 {
98 if ct == NX_ARCH_CT_HTML { return 24 }
99 if ct == NX_ARCH_CT_PLAIN { return 25 }
100 if ct == NX_ARCH_CT_JSON { return 16 }
101 return 24
102}
103
104func nx_archive_store_init(s: *NxArchiveStore, snapshots_cap: i64) -> i64 {
105 if (s as i64) == 0 { return 0 - NX_ARCH_BAD_INPUT }
106 if snapshots_cap < 1 { return 0 - NX_ARCH_BAD_INPUT }
107 if snapshots_cap > NX_ARCH_MAX_SNAPSHOTS { return 0 - NX_ARCH_KV_FULL }
108
109 s.hashes = (sys_mmap(snapshots_cap * 8)) as *i64
110 s.urls_pool = sys_mmap(NX_ARCH_URLS_POOL_CAP)
111 s.urls_pool_used = 0
112 s.urls_offs = (sys_mmap(snapshots_cap * 8)) as *i64
113 s.urls_lens = (sys_mmap(snapshots_cap * 8)) as *i64
114 s.dates_pool = sys_mmap(NX_ARCH_DATES_POOL_CAP)
115 s.dates_pool_used = 0
116 s.dates_offs = (sys_mmap(snapshots_cap * 8)) as *i64
117 s.dates_lens = (sys_mmap(snapshots_cap * 8)) as *i64
118 s.bodies_pool = sys_mmap(NX_ARCH_BODIES_POOL_CAP)
119 s.bodies_pool_used = 0
120 s.bodies_offs = (sys_mmap(snapshots_cap * 8)) as *i64
121 s.bodies_lens = (sys_mmap(snapshots_cap * 8)) as *i64
122 s.content_types = (sys_mmap(snapshots_cap * 8)) as *i64
123 s.snapshot_count = 0
124 s.snapshots_cap = snapshots_cap
125 s.valid = 1
126 return NX_ARCH_OK
127}
128
129// Add a snapshot: caller supplies original URL bytes + snapshot ISO8601
130// date + body bytes + content-type kind. Returns the URL's hash on
131// success (>= 0 always since FNV-1a 64 is non-zero for non-empty
132// inputs), or -verdict.
133func nx_archive_store_add(s: *NxArchiveStore,
134 url: *u8, url_n: i64,
135 date: *u8, date_n: i64,
136 body: *u8, body_n: i64,
137 content_type: i64) -> i64 {
138 if s.valid != 1 { return 0 - NX_ARCH_BAD_INPUT }
139 if s.snapshot_count >= s.snapshots_cap { return 0 - NX_ARCH_KV_FULL }
140 if (url as i64) == 0 { return 0 - NX_ARCH_BAD_URL }
141 if url_n < 1 { return 0 - NX_ARCH_BAD_URL }
142 if url_n > 2048 { return 0 - NX_ARCH_BAD_URL }
143 if date_n < 1 { return 0 - NX_ARCH_BAD_INPUT }
144 if date_n > 32 { return 0 - NX_ARCH_BAD_INPUT }
145 if body_n < 0 { return 0 - NX_ARCH_BAD_INPUT }
146 if body_n > NX_ARCH_BODIES_POOL_CAP / 4 { return 0 - NX_ARCH_BLOB_FULL }
147
148 // Compute URL hash.
149 let h: i64 = nx_inv_hash_bytes_lower(url, url_n)
150
151 let idx: i64 = s.snapshot_count
152 s.hashes[idx] = h
153
154 // URL pool
155 if s.urls_pool_used + url_n > NX_ARCH_URLS_POOL_CAP { return 0 - NX_ARCH_KV_FULL }
156 let u_off: i64 = s.urls_pool_used
157 var i: i64 = 0
158 while i < url_n { s.urls_pool[u_off + i] = url[i]; i = i + 1 }
159 s.urls_pool_used = s.urls_pool_used + url_n
160 s.urls_offs[idx] = u_off
161 s.urls_lens[idx] = url_n
162
163 // Date pool
164 if s.dates_pool_used + date_n > NX_ARCH_DATES_POOL_CAP { return 0 - NX_ARCH_KV_FULL }
165 let d_off: i64 = s.dates_pool_used
166 i = 0
167 while i < date_n { s.dates_pool[d_off + i] = date[i]; i = i + 1 }
168 s.dates_pool_used = s.dates_pool_used + date_n
169 s.dates_offs[idx] = d_off
170 s.dates_lens[idx] = date_n
171
172 // Body pool
173 if s.bodies_pool_used + body_n > NX_ARCH_BODIES_POOL_CAP { return 0 - NX_ARCH_BLOB_FULL }
174 let b_off: i64 = s.bodies_pool_used
175 i = 0
176 while i < body_n { s.bodies_pool[b_off + i] = body[i]; i = i + 1 }
177 s.bodies_pool_used = s.bodies_pool_used + body_n
178 s.bodies_offs[idx] = b_off
179 s.bodies_lens[idx] = body_n
180
181 s.content_types[idx] = content_type
182 s.snapshot_count = s.snapshot_count + 1
183 return h
184}
185
186// Lookup by URL hash; returns idx (>= 0) or -1.
187func nx_archive_store_find_by_hash(s: *NxArchiveStore, h: i64) -> i64 {
188 if s.valid != 1 { return 0 - 1 }
189 var i: i64 = 0
190 while i < s.snapshot_count {
191 if s.hashes[i] == h { return i }
192 i = i + 1
193 }
194 return 0 - 1
195}
196
197// ===== Hex parser: 16 hex chars -> i64 =================================================
198
199func nx_arch_hex_to_i64(src: *u8, src_n: i64) -> i64 {
200 if src_n != NX_ARCH_HASH_HEX_LEN { return 0 }
201 var v: i64 = 0
202 var i: i64 = 0
203 while i < src_n {
204 let c: i64 = src[i] as i64
205 var d: i64 = 0 - 1
206 if c >= 0x30 { if c <= 0x39 { d = c - 0x30 } }
207 if c >= 0x61 { if c <= 0x66 { d = c - 0x57 } }
208 if c >= 0x41 { if c <= 0x46 { d = c - 0x37 } }
209 if d < 0 { return 0 }
210 v = (v << 4) | d
211 i = i + 1
212 }
213 return v
214}
215
216// ===== Hex emit: i64 -> 16 lowercase hex chars =================================================
217
218func nx_arch_i64_to_hex(v: i64, out: *u8) -> i64 {
219 var i: i64 = 0
220 while i < NX_ARCH_HASH_HEX_LEN {
221 let nibble: i64 = (v >> ((15 - i) * 4)) & 0xF
222 var c: i64 = 0x30 + nibble
223 if nibble >= 10 { c = 0x61 + nibble - 10 }
224 out[i] = (c & 0xff) as u8
225 i = i + 1
226 }
227 return NX_ARCH_HASH_HEX_LEN
228}
229
230// ===== Decimal int helper =================================================
231
232func nx_arch_int_to_dec(v: i64, out: *u8, cap: i64) -> i64 {
233 if cap < 2 { return 0 }
234 if v == 0 { out[0] = 0x30 as u8; return 1 }
235 var n: i64 = v
236 let tmp: *u8 = sys_mmap(NX_ARCH_INT_BUF_CAP)
237 var k: i64 = 0
238 while n > 0 { tmp[k] = (0x30 + (n % 10)) as u8; n = n / 10; k = k + 1 }
239 var off: i64 = 0
240 var j: i64 = k - 1
241 while j >= 0 { out[off] = tmp[j]; off = off + 1; j = j - 1 }
242 return off
243}
244
245// ===== /wiki/archive index page =================================================
246//
247// Renders all snapshots as an HTML list with clickable /wiki/wayback/<hash> links.
248
249func nx_arch_serve_index(s: *NxArchiveStore,
250 resp_buf: *u8, resp_cap: i64,
251 out_resp_n: *i64) -> i64 {
252 let body_buf: *u8 = sys_mmap(NX_ARCH_INDEX_PAGE_CAP)
253 var body_off: i64 = 0
254
255 let head: *u8 = "<!DOCTYPE html>\n<html lang=\"en\"><head><meta charset=\"utf-8\"><title>Nishi Archive Index | Nishi Wiki</title></head><body><header><a href=\"/wiki/\">Wiki</a><a href=\"/wiki/dashboard\">Dashboard</a><a href=\"/wiki/search\">Search</a><a href=\"/wiki/archive\">Archive</a></header><main><h1>Nishi Archive Index</h1>\n<p>Cite-time snapshot store per <a href=\"/wiki/nishi-archive-integration-charter\">ARCHIVE_INTEGRATION_CHARTER</a>. " as *u8
256 let head_n: i64 = 437
257 var i: i64 = 0
258 while i < head_n { body_buf[body_off + i] = head[i]; i = i + 1 }
259 body_off = body_off + head_n
260
261 // Snapshot count
262 let cnt_lit: *u8 = "Snapshots stored: <b>" as *u8
263 i = 0
264 while i < 21 { body_buf[body_off + i] = cnt_lit[i]; i = i + 1 }
265 body_off = body_off + 21
266 let cnt_buf: *u8 = sys_mmap(NX_ARCH_INT_BUF_CAP)
267 let cnt_n: i64 = nx_arch_int_to_dec(s.snapshot_count, cnt_buf, NX_ARCH_INT_BUF_CAP)
268 i = 0
269 while i < cnt_n { body_buf[body_off + i] = cnt_buf[i]; i = i + 1 }
270 body_off = body_off + cnt_n
271 let cnt_close: *u8 = "</b></p>\n<ul>\n" as *u8
272 i = 0
273 while i < 14 { body_buf[body_off + i] = cnt_close[i]; i = i + 1 }
274 body_off = body_off + 14
275
276 // Per-snapshot li
277 var si: i64 = 0
278 while si < s.snapshot_count {
279 let li_open: *u8 = "<li><a href=\"/wiki/wayback/" as *u8
280 i = 0
281 while i < 27 { body_buf[body_off + i] = li_open[i]; i = i + 1 }
282 body_off = body_off + 27
283 // Hash hex
284 let hex_buf: *u8 = sys_mmap(17)
285 nx_arch_i64_to_hex(s.hashes[si], hex_buf)
286 i = 0
287 while i < NX_ARCH_HASH_HEX_LEN { body_buf[body_off + i] = hex_buf[i]; i = i + 1 }
288 body_off = body_off + NX_ARCH_HASH_HEX_LEN
289 let mid: *u8 = "\">" as *u8
290 i = 0
291 while i < 2 { body_buf[body_off + i] = mid[i]; i = i + 1 }
292 body_off = body_off + 2
293 // URL bytes
294 let url_p: *u8 = ((s.urls_pool as i64) + s.urls_offs[si]) as *u8
295 let url_n: i64 = s.urls_lens[si]
296 i = 0
297 while i < url_n { body_buf[body_off + i] = url_p[i]; i = i + 1 }
298 body_off = body_off + url_n
299 let close_a: *u8 = "</a> <small>(" as *u8
300 i = 0
301 while i < 13 { body_buf[body_off + i] = close_a[i]; i = i + 1 }
302 body_off = body_off + 13
303 // Date
304 let date_p: *u8 = ((s.dates_pool as i64) + s.dates_offs[si]) as *u8
305 let date_n: i64 = s.dates_lens[si]
306 i = 0
307 while i < date_n { body_buf[body_off + i] = date_p[i]; i = i + 1 }
308 body_off = body_off + date_n
309 let close_li: *u8 = ")</small></li>\n" as *u8
310 i = 0
311 while i < 15 { body_buf[body_off + i] = close_li[i]; i = i + 1 }
312 body_off = body_off + 15
313 si = si + 1
314 }
315
316 let foot: *u8 = "</ul>\n<p><em>V2 adds live fetch via nx_http_client + robots.txt + crawl policy. V1 ships in-memory store + hardcoded seed.</em></p>\n</main></body></html>" as *u8
317 i = 0
318 while i < 156 { body_buf[body_off + i] = foot[i]; i = i + 1 }
319 body_off = body_off + 156
320
321 // HTTP wrap
322 let hdr: *u8 = "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: " as *u8
323 let hdr_n: i64 = 71
324 let crlf2: *u8 = "\r\n\r\n" as *u8
325 let len_buf: *u8 = sys_mmap(NX_ARCH_INT_BUF_CAP)
326 let ld: i64 = nx_arch_int_to_dec(body_off, len_buf, NX_ARCH_INT_BUF_CAP)
327 let need: i64 = hdr_n + ld + 4 + body_off
328 if need > resp_cap { return 0 - NX_ARCH_RESP_OVERFLOW }
329 var off: i64 = 0
330 i = 0
331 while i < hdr_n { resp_buf[off + i] = hdr[i]; i = i + 1 }
332 off = off + hdr_n
333 i = 0
334 while i < ld { resp_buf[off + i] = len_buf[i]; i = i + 1 }
335 off = off + ld
336 i = 0
337 while i < 4 { resp_buf[off + i] = crlf2[i]; i = i + 1 }
338 off = off + 4
339 i = 0
340 while i < body_off { resp_buf[off + i] = body_buf[i]; i = i + 1 }
341 off = off + body_off
342 out_resp_n[0] = off
343 return NX_ARCH_OK
344}
345
346// ===== /wiki/wayback/<hash> -- serve a stored snapshot =================================================
347
348func nx_arch_serve_wayback(s: *NxArchiveStore, hash_hex: *u8, hash_hex_n: i64,
349 resp_buf: *u8, resp_cap: i64,
350 out_resp_n: *i64) -> i64 {
351 if hash_hex_n != NX_ARCH_HASH_HEX_LEN { return 0 - NX_ARCH_NOT_FOUND }
352 let h: i64 = nx_arch_hex_to_i64(hash_hex, hash_hex_n)
353 let idx: i64 = nx_archive_store_find_by_hash(s, h)
354 if idx < 0 { return 0 - NX_ARCH_NOT_FOUND }
355
356 let body_p: *u8 = ((s.bodies_pool as i64) + s.bodies_offs[idx]) as *u8
357 let body_n: i64 = s.bodies_lens[idx]
358 let url_p: *u8 = ((s.urls_pool as i64) + s.urls_offs[idx]) as *u8
359 let url_n: i64 = s.urls_lens[idx]
360 let date_p: *u8 = ((s.dates_pool as i64) + s.dates_offs[idx]) as *u8
361 let date_n: i64 = s.dates_lens[idx]
362 let ct: i64 = s.content_types[idx]
363
364 // HTTP headers (per charter §5):
365 // HTTP/1.1 200 OK
366 // Content-Type: <preserved>
367 // X-Archive-Original-URL: <url>
368 // X-Archive-Snapshot-Date: <date>
369 // Content-Length: <body_n>
370 let hdr1: *u8 = "HTTP/1.1 200 OK\r\nContent-Type: " as *u8
371 let hdr1_n: i64 = 31
372 let ct_str: *u8 = nx_arch_ct_string(ct)
373 let ct_n: i64 = nx_arch_ct_string_n(ct)
374 let hdr2: *u8 = "\r\nX-Archive-Original-URL: " as *u8
375 let hdr2_n: i64 = 26
376 let hdr3: *u8 = "\r\nX-Archive-Snapshot-Date: " as *u8
377 let hdr3_n: i64 = 27
378 let hdr4: *u8 = "\r\nContent-Length: " as *u8
379 let hdr4_n: i64 = 18
380 let crlf2: *u8 = "\r\n\r\n" as *u8
381
382 let len_buf: *u8 = sys_mmap(NX_ARCH_INT_BUF_CAP)
383 let ld: i64 = nx_arch_int_to_dec(body_n, len_buf, NX_ARCH_INT_BUF_CAP)
384
385 let need: i64 = hdr1_n + ct_n + hdr2_n + url_n + hdr3_n + date_n + hdr4_n + ld + 4 + body_n
386 if need > resp_cap { return 0 - NX_ARCH_RESP_OVERFLOW }
387
388 var off: i64 = 0
389 var i: i64 = 0
390 while i < hdr1_n { resp_buf[off + i] = hdr1[i]; i = i + 1 }
391 off = off + hdr1_n
392 i = 0; while i < ct_n { resp_buf[off + i] = ct_str[i]; i = i + 1 }; off = off + ct_n
393 i = 0; while i < hdr2_n { resp_buf[off + i] = hdr2[i]; i = i + 1 }; off = off + hdr2_n
394 i = 0; while i < url_n { resp_buf[off + i] = url_p[i]; i = i + 1 }; off = off + url_n
395 i = 0; while i < hdr3_n { resp_buf[off + i] = hdr3[i]; i = i + 1 }; off = off + hdr3_n
396 i = 0; while i < date_n { resp_buf[off + i] = date_p[i]; i = i + 1 }; off = off + date_n
397 i = 0; while i < hdr4_n { resp_buf[off + i] = hdr4[i]; i = i + 1 }; off = off + hdr4_n
398 i = 0; while i < ld { resp_buf[off + i] = len_buf[i]; i = i + 1 }; off = off + ld
399 i = 0; while i < 4 { resp_buf[off + i] = crlf2[i]; i = i + 1 }; off = off + 4
400 i = 0; while i < body_n { resp_buf[off + i] = body_p[i]; i = i + 1 }; off = off + body_n
401
402 out_resp_n[0] = off
403 return NX_ARCH_OK
404}
405
406// ===== Top-level dispatcher: /wiki/archive vs /wiki/wayback/<hash> =================================================
407
408func nx_wiki_archive_route(s: *NxArchiveStore,
409 url_path: *u8, url_path_n: i64,
410 resp_buf: *u8, resp_cap: i64,
411 out_resp_n: *i64) -> i64 {
412 if (s as i64) == 0 { return 0 - NX_ARCH_NOT_FOUND }
413 if s.valid != 1 { return 0 - NX_ARCH_NOT_FOUND }
414
415 // /wiki/archive (index)
416 if url_path_n == 13 {
417 var matched: i64 = 1
418 let target: *u8 = "/wiki/archive" as *u8
419 var i: i64 = 0
420 // Flat byte-compare (was a dense one-liner with the loop-var-jump +
421 // nested-if-increment shape that the native compiler miscompiles --
422 // T#native-codegen-context-sensitive-miscompile / nested-if-incr).
423 // Comparing all 13 bytes is identical for a matched check: once matched
424 // is cleared it stays cleared.
425 while i < 13 {
426 if url_path[i] != target[i] { matched = 0 }
427 i = i + 1
428 }
429 if matched == 1 { return nx_arch_serve_index(s, resp_buf, resp_cap, out_resp_n) }
430 }
431
432 // /wiki/wayback/<hash> (16 hex chars expected)
433 // Path: "/wiki/wayback/" = 14 bytes; total expected = 14 + 16 = 30
434 if url_path_n == 30 {
435 var matched: i64 = 1
436 let target: *u8 = "/wiki/wayback/" as *u8
437 var i: i64 = 0
438 // Flat byte-compare (see note above; same miscompile-avoidance reshape).
439 while i < 14 {
440 if url_path[i] != target[i] { matched = 0 }
441 i = i + 1
442 }
443 if matched == 1 {
444 let hash_ptr: *u8 = (url_path as i64 + 14) as *u8
445 return nx_arch_serve_wayback(s, hash_ptr, 16, resp_buf, resp_cap, out_resp_n)
446 }
447 }
448
449 return 0 - NX_ARCH_NOT_FOUND
450}