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 }