code wiki / _hdl_build / nx_wiki_publish_guard.nx

nx_wiki_publish_guard.nx source

↩ module page · 275 lines · 14834 B

1// nx_wiki_publish_guard.nx -- THE PRE-PUBLISH GUARD (the wiki's "liar-kill"): a fail-closed POLICY 2// DECISION POINT that REFUSES to publish a wiki page unless it passes every integrity check. Mirrors 3// the access-wall PDP discipline (nx_access_wall): DENY-BY-DEFAULT + FAIL-CLOSED -- a page is ALLOWed 4// only when ALL checks pass; if a check cannot even be EVALUATED, the verdict is REJECT (never a 5// silent pass). It is PURE, DETERMINISTIC and READ-ONLY: it only INSPECTS the page bytes (and the 6// content-addressed archive, to test whether a citation RESOLVES); it does NOT push anything. 7// 8// THE CHECKS (ALL must pass to ALLOW; first failure wins, with a machine-readable reason code so the 9// publisher/audit logs WHY a page was refused): 10// PG_REJECT_NO_FRESHNESS the page has no freshness/epoch stamp in its footer (un-stamped page = 11// we cannot prove it is fresh -> fail-closed REJECT). 12// PG_REJECT_DANGLING_CITE a [[cite:<cid>]] token's cid does NOT resolve in the content-addressed 13// archive (war_get_by_cid <= 0). The cite would render "(archiving 14// pending)" -- a dangling source -> REJECT. (This REUSES the exact cite 15// resolution mechanism nx_wiki_cite_render uses: war_get_by_cid.) 16// PG_REJECT_DEAD_LINK an internal href to /wiki/<x>.html targets a slug NOT in the provided 17// corpus set -> a broken on-site link -> REJECT. 18// PG_REJECT_PLACEHOLDER the body is empty, or contains an obvious placeholder marker 19// (TODO / PLACEHOLDER / lorem) -> not publish-ready -> REJECT. Conservative. 20// PG_ALLOW every check passed. 21// 22// WHY THE CORPUS IS A SET PASSED IN (not the archive): a dead on-site link is about what pages WILL be 23// PUBLISHED together (the live corpus), which the publisher knows; the archive is the cite substrate. 24// corpus = parallel arrays (slug ptr[], slug len[], count) -- the same array idiom nx_access_wall_gate 25// uses for policy (and NishiLang caps a call's arg count, so a set is passed as arrays). 26// 27// IMPORTS: nx_wiki_archive.nx (war_get_by_cid + WAR_PREFIX; transitively nx_seg_store + nx_canon_cid, 28// each spliced ONCE by module-identity dedup) and nx_syscalls.nx. Pure NishiLang, NO SQL, no .sh/.py/.js, 29// no new .tsv/.conf. license_tier: ORIGINAL 30import "nx_wiki_archive.nx" 31import "nx_syscalls.nx" 32 33// ===== sealed verdict codes (ALLOW positive; each REJECT a distinct negative) ==================== 34const PG_ALLOW: i64 = 1 35const PG_REJECT_NO_FRESHNESS: i64 = 0 - 1 36const PG_REJECT_DANGLING_CITE:i64 = 0 - 2 37const PG_REJECT_DEAD_LINK: i64 = 0 - 3 38const PG_REJECT_PLACEHOLDER: i64 = 0 - 4 39 40// archive segment-scan cap for cite resolution (matches the cite-render caller; the archive is a 41// handful of committed segments, this is generous headroom). No magic number buried elsewhere. 42const PG_SCAN_CAP: i64 = 64 43 44// max cid length we accept (a CID is "nxc1-"+64hex ~= 69 bytes; this is headroom). A longer-than-cap 45// cid is treated as unresolvable (fail-closed), never overflowing the cid scratch buffer. 46const PG_MAX_CID: i64 = 128 47 48// ASCII byte literals as named constants (no magic numbers in the scanners). 49const PG_LBRACKET: i64 = 0x5B // '[' 50const PG_RBRACKET: i64 = 0x5D // ']' 51const PG_COLON: i64 = 0x3A // ':' 52const PG_HASH: i64 = 0x23 // '#' 53const PG_DQUOTE: i64 = 0x22 // '"' 54 55// ---- tiny local helpers (pg_ namespace; no clash with war_/ss_/cc_) ---- 56func pg_len(s: *u8) -> i64 { 57 var n: i64 = 0 58 while s[n] != (0 as u8) { n = n + 1 } 59 return n 60} 61 62// does the NUL-terminated needle occur as a substring of buf[0..n]? (bounded brute force; pages are 63// small and this runs once per publish). Returns 1 if present, 0 if not. 64func pg_contains(buf: *u8, n: i64, needle: *u8) -> i64 { 65 let m: i64 = pg_len(needle) 66 if m == 0 { return 0 } 67 if m > n { return 0 } 68 var i: i64 = 0 69 let last: i64 = n - m 70 while i <= last { 71 var j: i64 = 0 72 var hit: i64 = 1 73 while j < m { 74 if buf[i + j] != needle[j] { hit = 0; j = m } 75 else { j = j + 1 } 76 } 77 if hit == 1 { return 1 } 78 i = i + 1 79 } 80 return 0 81} 82 83// counted-region equals NUL-terminated key? (slug compare without copying) 84func pg_region_eq(buf: *u8, off: i64, rn: i64, key: *u8) -> i64 { 85 var i: i64 = 0 86 while i < rn { 87 if key[i] == (0 as u8) { return 0 } // key shorter than region 88 if buf[off + i] != key[i] { return 0 } 89 i = i + 1 90 } 91 if key[rn] != (0 as u8) { return 0 } // key longer than region 92 return 1 93} 94 95// ===== CHECK 1: freshness ========================================================================= 96// A published page carries a freshness stamp in its footer: the shell stamps class="fresh" and an 97// "epoch=" marker (see nx_wiki_shell sh_footer / nx_wiki_nist_stem_page). We require the "epoch=" 98// marker -- the machine-checkable freshness anchor. Returns 1 if fresh-stamped, 0 if not. 99func pg_has_freshness(body: *u8, n: i64) -> i64 { 100 if pg_contains(body, n, "epoch=" as *u8) == 1 { return 1 } 101 return 0 102} 103 104// ===== CHECK 4: placeholder / empty =============================================================== 105// Conservative: empty body, or an obvious placeholder marker, is not publish-ready. Returns 1 if a 106// placeholder/empty condition is detected (=> REJECT), 0 if the body looks like real content. 107func pg_is_placeholder(body: *u8, n: i64) -> i64 { 108 if n <= 0 { return 1 } 109 if pg_contains(body, n, "TODO" as *u8) == 1 { return 1 } 110 if pg_contains(body, n, "PLACEHOLDER" as *u8) == 1 { return 1 } 111 if pg_contains(body, n, "lorem" as *u8) == 1 { return 1 } 112 return 0 113} 114 115// ===== CHECK 2: every [[cite:<cid>]] resolves ===================================================== 116// Walk the body once; for each [[cite:<cid>]] token, resolve <cid> against the content-addressed 117// archive via war_get_by_cid (the SAME mechanism nx_wiki_cite_render uses). A non-resolving cid 118// (war_get_by_cid <= 0: absent or tombstoned) is a DANGLING citation. Returns 1 if ALL cites resolve 119// (or there are none), 0 if ANY cite dangles. prefix = archive store prefix. Scan-then-process per 120// token (no break-in-loop; mirrors the cite-render walk). Fail-closed: an unscannable token is treated 121// as dangling. 122func pg_all_cites_resolve(prefix: *u8, body: *u8, n: i64) -> i64 { 123 let cidbuf: *u8 = sys_mmap(PG_MAX_CID + 16) 124 let pp: *i64 = sys_mmap(16) as *i64 125 var i: i64 = 0 126 while i < n { 127 var advanced: i64 = 0 128 // detect "[[cite:" at i (need "[[cite:" (7) + at least one cid byte + "]]" headroom) 129 if i + 7 < n { 130 if body[i] == (PG_LBRACKET as u8) { 131 if body[i + 1] == (PG_LBRACKET as u8) { 132 if body[i + 2] == (99 as u8) { // 'c' 133 if body[i + 3] == (105 as u8) { // 'i' 134 if body[i + 4] == (116 as u8) { // 't' 135 if body[i + 5] == (101 as u8) { // 'e' 136 if body[i + 6] == (PG_COLON as u8) { // ':' 137 // PHASE 1: pure scan for the closing "]]" 138 let cid_start: i64 = i + 7 139 var end: i64 = cid_start 140 var found: i64 = 0 141 var scan: i64 = cid_start 142 while scan < n - 1 { 143 if found == 0 { 144 if body[scan] == (PG_RBRACKET as u8) { 145 if body[scan + 1] == (PG_RBRACKET as u8) { 146 found = 1 147 end = scan 148 } 149 } 150 } 151 scan = scan + 1 152 } 153 // PHASE 2: process once 154 if found == 1 { 155 let cid_n: i64 = end - cid_start 156 // fail-closed: empty or over-long cid is unresolvable 157 if cid_n <= 0 { return 0 } 158 if cid_n >= PG_MAX_CID { return 0 } 159 var c: i64 = 0 160 while c < cid_n { cidbuf[c] = body[cid_start + c]; c = c + 1 } 161 cidbuf[cid_n] = 0 as u8 162 // REUSED cite-resolution: >0 present, 0 tombstoned, -1 absent 163 let got: i64 = war_get_by_cid(prefix, cidbuf, pp, PG_SCAN_CAP) 164 if got <= 0 { return 0 } // DANGLING 165 i = end + 2 166 advanced = 1 167 } 168 // unterminated [[cite: -> fail-closed (a malformed cite) 169 if found == 0 { return 0 } 170 } 171 } 172 } 173 } 174 } 175 } 176 } 177 } 178 if advanced == 0 { i = i + 1 } 179 } 180 return 1 181} 182 183// ===== CHECK 3: every internal /wiki/<x>.html href targets a corpus slug =========================== 184// Walk the body once; for each href to the EXACT shape /wiki/<slug>.html (terminated by '"', '#', 185// or the closing of the literal), extract <slug> and require it to be in the provided corpus set. A 186// slug absent from the corpus is a DEAD on-site link. Returns 1 if ALL internal links resolve (or 187// there are none), 0 if ANY is dead. corpus = parallel arrays cs_ptr[i]/cs_len[i], ncorpus entries. 188// Fail-closed: a malformed /wiki/... with no terminator before end-of-body is treated as dead. 189func pg_all_links_live(body: *u8, n: i64, cs_ptr: *i64, cs_len: *i64, ncorpus: i64) -> i64 { 190 let prefixlit: *u8 = "/wiki/" as *u8 191 let pfx_n: i64 = pg_len(prefixlit) // 6 192 var i: i64 = 0 193 while i < n { 194 var advanced: i64 = 0 195 // match the "/wiki/" prefix at i 196 if i + pfx_n < n { 197 var k: i64 = 0 198 var pfxhit: i64 = 1 199 while k < pfx_n { 200 if body[i + k] != prefixlit[k] { pfxhit = 0; k = pfx_n } 201 else { k = k + 1 } 202 } 203 if pfxhit == 1 { 204 let slug_start: i64 = i + pfx_n 205 // scan forward for the ".html" marker that ENDS the slug, but stop at a quote/hash/ 206 // slash (so /wiki/edit?p=... or /wiki/foo/bar are NOT treated as a <slug>.html link). 207 var p: i64 = slug_start 208 var dot: i64 = 0 - 1 209 var stop: i64 = 0 210 while p + 4 < n { 211 if stop == 0 { 212 let c0: u8 = body[p] 213 // a slug char ends the slug at the FIRST '.', '"', '#', or '/' 214 if c0 == (46 as u8) { // '.' 215 // is it ".html" ? 216 if body[p + 1] == (104 as u8) { // h 217 if body[p + 2] == (116 as u8) { // t 218 if body[p + 3] == (109 as u8) { // m 219 if body[p + 4] == (108 as u8) { // l 220 dot = p 221 } 222 } 223 } 224 } 225 stop = 1 226 } 227 if c0 == (PG_DQUOTE as u8) { stop = 1 } 228 if c0 == (PG_HASH as u8) { stop = 1 } 229 if c0 == (47 as u8) { stop = 1 } // '/' 230 } 231 p = p + 1 232 } 233 if dot > slug_start { 234 // a genuine /wiki/<slug>.html link -- check the slug against the corpus 235 let slug_n: i64 = dot - slug_start 236 var inset: i64 = 0 237 var ci: i64 = 0 238 while ci < ncorpus { 239 if pg_region_eq(body, slug_start, slug_n, cs_ptr[ci] as *u8) == 1 { inset = 1 } 240 ci = ci + 1 241 } 242 if inset == 0 { return 0 } // DEAD LINK 243 // advance past the ".html" we matched 244 i = dot + 5 245 advanced = 1 246 } 247 // if dot <= slug_start it was a /wiki/edit? or /wiki/foo/ form (not a page link) -> 248 // not our concern; fall through to single-byte advance. 249 } 250 } 251 if advanced == 0 { i = i + 1 } 252 } 253 return 1 254} 255 256// ===== THE DECISION: fail-closed, deny-by-default, first-failure-wins ============================== 257// Returns PG_ALLOW only when EVERY check passes; otherwise the specific PG_REJECT_* code. Order is 258// cheap-structural first (freshness, placeholder), then the archive cite resolution, then the corpus 259// link check. prefix = the archive store prefix (production callers pass WAR_PREFIX). corpus = the set 260// of slugs that will be published together (parallel arrays + count). READ-ONLY: nothing is written. 261func pg_decide(prefix: *u8, body: *u8, body_n: i64, 262 cs_ptr: *i64, cs_len: *i64, ncorpus: i64) -> i64 { 263 // CHECK 4 first: an empty/placeholder body is the cheapest, most fundamental refusal. 264 if pg_is_placeholder(body, body_n) == 1 { return PG_REJECT_PLACEHOLDER } 265 // CHECK 1: freshness stamp present? 266 if pg_has_freshness(body, body_n) != 1 { return PG_REJECT_NO_FRESHNESS } 267 // CHECK 2: every [[cite:<cid>]] resolves in the archive? 268 if pg_all_cites_resolve(prefix, body, body_n) != 1 { return PG_REJECT_DANGLING_CITE } 269 // CHECK 3: every internal /wiki/<x>.html href targets a corpus slug? 270 if pg_all_links_live(body, body_n, cs_ptr, cs_len, ncorpus) != 1 { return PG_REJECT_DEAD_LINK } 271 return PG_ALLOW 272} 273 274// boolean convenience for the publisher (the enforcement point): ALLOW->1, any reject->0. 275func pg_allowed(verdict: i64) -> i64 { if verdict == PG_ALLOW { return 1 } return 0 }