nx_nishi_page_validator.nx source
↩ module page · 478 lines · 19468 B
1// nx_nishi_page_validator.nx -- HUB primitive; conformance validator
2// for Nishi Page Format V1. Reference: NISHI_PAGE_FORMAT_V1.md §5.
3//
4// COMPOSES (HUB primitives only):
5// nx_html_extract (case-insensitive byte match + parse helpers)
6//
7// COMPOSED BY:
8// (future) hub/nx_nishi_page_emit (emitter cross-checks own output)
9// (future) hub/nx_search_offsite_crawler (skips non-conformant)
10// (future) wiki/nx_wiki_index_builder (skips non-conformant)
11// (future) bin/nishi-validate CLI
12//
13// V1 SCOPE per NISHI_PAGE_FORMAT_V1.md §1:
14// - C0 (MUST): 9 required <meta name="nishi-*"> tags present + per-tag
15// rules pass
16// - C1 (SHOULD): C0 + body conventions (h1 matches title; section ids)
17// - C2 (MAY): C1 + semantic enrichment (time datetime, dl, address, cite)
18//
19// V2 SCOPE (TODO):
20// - Body-convention deep validation (C1+C2 partial; structural now)
21// - Anti-pattern detection (§8): canonical-mismatch, content-hash
22// drift, tag-stuffing, license-omission, author-impersonation
23// - SemVer-compatible version dispatch when V1.1/V2.0 ship
24//
25// Status: V1. 2026-05-27.
26
27import "nx_syscalls.nx"
28import "nx_html_extract.nx"
29
30// ===== Sealed verdict surface (codes 2300-2319) -- spec §5 reserved =================================================
31const NX_NPV_OK: i64 = 0 // C0-conformant (V1)
32const NX_NPV_BAD_INPUT: i64 = 2300
33const NX_NPV_INPUT_TOO_LARGE: i64 = 2301
34const NX_NPV_NO_HEAD_FOUND: i64 = 2302
35const NX_NPV_MISSING_CANONICAL: i64 = 2303
36const NX_NPV_MISSING_TITLE: i64 = 2304
37const NX_NPV_MISSING_SUMMARY: i64 = 2305
38const NX_NPV_MISSING_TAGS: i64 = 2306
39const NX_NPV_MISSING_LAST_MODIFIED: i64 = 2307
40const NX_NPV_MISSING_CONTENT_HASH: i64 = 2308
41const NX_NPV_MISSING_PAGE_VERSION: i64 = 2309
42const NX_NPV_MISSING_LICENSE: i64 = 2310
43const NX_NPV_MISSING_AUTHOR: i64 = 2311
44const NX_NPV_MALFORMED_CANONICAL: i64 = 2312
45const NX_NPV_MALFORMED_LAST_MODIFIED: i64 = 2313
46const NX_NPV_MALFORMED_CONTENT_HASH: i64 = 2314
47const NX_NPV_MALFORMED_PAGE_VERSION: i64 = 2315
48const NX_NPV_VERSION_INCOMPATIBLE: i64 = 2316
49const NX_NPV_LENGTH_OUT_OF_RANGE: i64 = 2317
50const NX_NPV_LOOP_BUDGET: i64 = 2318
51const NX_NPV_TOO_MANY_TAGS: i64 = 2319
52
53// ===== Named constants (M7) =================================================
54const NX_NPV_MAX_INPUT_BYTES: i64 = 1048576 // 1 MB
55const NX_NPV_HEAD_SCAN_LIMIT: i64 = 65536 // first 64 KB
56const NX_NPV_LOOP_BUDGET_CAP: i64 = 1000000
57const NX_NPV_CONTENT_HASH_LEN: i64 = 16 // 16 hex chars per spec §2.6
58const NX_NPV_MAX_TAG_COUNT: i64 = 50 // per §2.4
59const NX_NPV_MAX_INDEXER_MAJOR: i64 = 1 // we speak V1.x
60const NX_NPV_MIN_INDEXER_MAJOR: i64 = 1
61
62// Per-tag length bounds (per NISHI_PAGE_FORMAT_V1 §2).
63const NX_NPV_TITLE_MIN: i64 = 1
64const NX_NPV_TITLE_MAX: i64 = 200
65const NX_NPV_SUMMARY_MIN: i64 = 1
66const NX_NPV_SUMMARY_MAX: i64 = 500
67const NX_NPV_AUTHOR_MIN: i64 = 1
68const NX_NPV_AUTHOR_MAX: i64 = 100
69const NX_NPV_LICENSE_MIN: i64 = 1
70const NX_NPV_LICENSE_MAX: i64 = 64
71const NX_NPV_CANONICAL_MAX: i64 = 2048
72const NX_NPV_TAGS_MAX: i64 = 1024
73const NX_NPV_LAST_MOD_MIN: i64 = 20 // "1970-01-01T00:00:00Z"
74const NX_NPV_LAST_MOD_MAX: i64 = 32
75
76// ASCII (M7).
77const NX_NPV_ASCII_LT: i64 = 0x3C
78const NX_NPV_ASCII_GT: i64 = 0x3E
79const NX_NPV_ASCII_QT: i64 = 0x22 // '"'
80
81// ===== Validation report struct (rich; not just rc) =================================================
82//
83// The validator returns rc (sealed verdict; 0 = OK) AND fills a
84// detailed report so callers (crawler, index builder) can log which
85// specific tag failed without re-parsing.
86
87struct NxNishiPageReport {
88 rc: i64 // sealed verdict (NX_NPV_*)
89 found_canonical: i64
90 found_title: i64
91 found_summary: i64
92 found_tags: i64
93 found_last_mod: i64
94 found_content_hash: i64
95 found_page_version: i64
96 found_license: i64
97 found_author: i64
98 page_version_maj: i64 // parsed from nishi-page-version
99 page_version_min: i64
100 page_version_pat: i64
101 tag_count: i64 // parsed comma-count of nishi-tags
102 valid: i64
103}
104
105func nx_npv_report_init(r: *NxNishiPageReport) -> i64 {
106 if (r as i64) == 0 { return 0 - NX_NPV_BAD_INPUT }
107 r.rc = NX_NPV_OK
108 r.found_canonical = 0
109 r.found_title = 0
110 r.found_summary = 0
111 r.found_tags = 0
112 r.found_last_mod = 0
113 r.found_content_hash = 0
114 r.found_page_version = 0
115 r.found_license = 0
116 r.found_author = 0
117 r.page_version_maj = 0
118 r.page_version_min = 0
119 r.page_version_pat = 0
120 r.tag_count = 0
121 r.valid = 1
122 return NX_NPV_OK
123}
124
125// ===== Find <meta name="X" content="Y"> in src head =================================================
126//
127// Returns 1 + fills (val_start, val_len) if found; 0 otherwise.
128// Walks src linearly; bounded by head_limit. Case-insensitive on
129// tag/attribute names; case-sensitive on content value.
130
131func nx_npv_find_meta(src: *u8, src_n: i64, head_limit: i64,
132 name_z: *u8, name_n: i64,
133 out_val_off: *i64, out_val_len: *i64) -> i64 {
134 if (out_val_off as i64) == 0 { return 0 }
135 if (out_val_len as i64) == 0 { return 0 }
136 out_val_off[0] = 0
137 out_val_len[0] = 0
138 if src_n < 1 { return 0 }
139 var limit: i64 = src_n
140 if limit > head_limit { limit = head_limit }
141
142 var i: i64 = 0
143 var iter: i64 = 0
144 while i < limit {
145 if iter >= NX_NPV_LOOP_BUDGET_CAP { return 0 }
146 iter = iter + 1
147 if (src[i] as i64) == NX_NPV_ASCII_LT {
148 if nx_he_match_ci(src, i + 1, src_n, "meta" as *u8, 4) == 1 {
149 // Found <meta. Look for name="X".
150 var j: i64 = i + 5
151 var name_match: i64 = 0
152 var content_off: i64 = 0
153 var content_len: i64 = 0
154 // Scan attributes up to '>'.
155 while j < limit {
156 if iter >= NX_NPV_LOOP_BUDGET_CAP { return 0 }
157 iter = iter + 1
158 if (src[j] as i64) == NX_NPV_ASCII_GT { j = limit + 1 }
159 if j <= limit { if j < limit {
160 // Look for name=
161 if nx_he_match_ci(src, j, src_n, "name=\"" as *u8, 6) == 1 {
162 let nstart: i64 = j + 6
163 // Compare name_z bytes.
164 if nx_he_match_ci(src, nstart, src_n, name_z, name_n) == 1 {
165 // After name's last byte, must be '"'.
166 if nstart + name_n < limit {
167 if (src[nstart + name_n] as i64) == NX_NPV_ASCII_QT {
168 name_match = 1
169 }
170 }
171 }
172 j = nstart + name_n
173 }
174 if nx_he_match_ci(src, j, src_n, "content=\"" as *u8, 9) == 1 {
175 content_off = j + 9
176 // Find closing '"'.
177 var k: i64 = content_off
178 while k < limit {
179 if (src[k] as i64) == NX_NPV_ASCII_QT {
180 content_len = k - content_off
181 j = k + 1
182 k = limit + 1
183 }
184 if k <= limit { if k < limit {
185 if (src[k] as i64) != NX_NPV_ASCII_QT { k = k + 1 }
186 }}
187 }
188 }
189 if name_match == 0 { j = j + 1 }
190 if name_match == 1 { if content_len == 0 { j = j + 1 } }
191 }}
192 // Tag-end check
193 if j < limit {
194 if (src[j] as i64) == NX_NPV_ASCII_GT {
195 if name_match == 1 {
196 if content_len > 0 {
197 out_val_off[0] = content_off
198 out_val_len[0] = content_len
199 return 1
200 }
201 }
202 j = limit + 1
203 }
204 }
205 }
206 }
207 }
208 i = i + 1
209 }
210 return 0
211}
212
213// ===== Per-tag validation rules (per spec §2) =================================================
214
215// Is the value an absolute URL? V1: starts with "http://" or "https://".
216func nx_npv_is_absolute_url(src: *u8, off: i64, n: i64) -> i64 {
217 if n < 7 { return 0 }
218 if n > NX_NPV_CANONICAL_MAX { return 0 }
219 // Match "http://" (7) or "https://" (8).
220 let p: *u8 = (src as i64 + off) as *u8
221 if p[0] != (0x68 as u8) { return 0 } // 'h'
222 if p[1] != (0x74 as u8) { return 0 } // 't'
223 if p[2] != (0x74 as u8) { return 0 }
224 if p[3] != (0x70 as u8) { return 0 } // 'p'
225 if p[4] == (0x73 as u8) {
226 // https
227 if n < 8 { return 0 }
228 if p[5] != (0x3A as u8) { return 0 } // ':'
229 if p[6] != (0x2F as u8) { return 0 } // '/'
230 if p[7] != (0x2F as u8) { return 0 }
231 return 1
232 }
233 if p[4] == (0x3A as u8) {
234 // http
235 if p[5] != (0x2F as u8) { return 0 }
236 if p[6] != (0x2F as u8) { return 0 }
237 return 1
238 }
239 return 0
240}
241
242// Hex byte (0-9 / a-f / A-F)?
243func nx_npv_is_hex(c: i64) -> i64 {
244 if c >= 0x30 { if c <= 0x39 { return 1 } }
245 if c >= 0x61 { if c <= 0x66 { return 1 } }
246 if c >= 0x41 { if c <= 0x46 { return 1 } }
247 return 0
248}
249
250// Validates exactly 16 lowercase hex chars (per spec §2.6).
251func nx_npv_is_content_hash(src: *u8, off: i64, n: i64) -> i64 {
252 if n != NX_NPV_CONTENT_HASH_LEN { return 0 }
253 var i: i64 = 0
254 while i < n {
255 let c: i64 = src[off + i] as i64
256 if c >= 0x30 { if c <= 0x39 { i = i + 1 } }
257 if c >= 0x61 {
258 if c <= 0x66 { i = i + 1 }
259 if c > 0x66 { return 0 }
260 }
261 if c < 0x30 { return 0 }
262 if c > 0x39 { if c < 0x61 { return 0 } }
263 }
264 return 1
265}
266
267// ISO 8601 sanity (V1 loose): at least "YYYY-MM-DDTHH:MM:SS" prefix + Z or +/- timezone.
268func nx_npv_is_iso8601(src: *u8, off: i64, n: i64) -> i64 {
269 if n < NX_NPV_LAST_MOD_MIN { return 0 }
270 if n > NX_NPV_LAST_MOD_MAX { return 0 }
271 let p: *u8 = (src as i64 + off) as *u8
272 // Year-month-day
273 if p[0] < (0x30 as u8) { return 0 }
274 if p[0] > (0x39 as u8) { return 0 }
275 if p[4] != (0x2D as u8) { return 0 } // '-'
276 if p[7] != (0x2D as u8) { return 0 }
277 if p[10] != (0x54 as u8) { return 0 } // 'T'
278 if p[13] != (0x3A as u8) { return 0 } // ':'
279 if p[16] != (0x3A as u8) { return 0 }
280 // Last byte must be Z or part of ±HH:MM tz.
281 let last: i64 = p[n - 1] as i64
282 if last == 0x5A { return 1 } // 'Z'
283 if n >= 25 {
284 let plus_pos: i64 = n - 6
285 let pp: i64 = p[plus_pos] as i64
286 if pp == 0x2B { return 1 } // '+'
287 if pp == 0x2D { return 1 } // '-'
288 }
289 return 0
290}
291
292// SemVer X.Y.Z (V1 loose: 1+ digit . 1+ digit . 1+ digit).
293func nx_npv_parse_semver(src: *u8, off: i64, n: i64,
294 out_maj: *i64, out_min: *i64, out_pat: *i64) -> i64 {
295 if n < 5 { return 0 }
296 if n > 16 { return 0 }
297 if (out_maj as i64) == 0 { return 0 }
298 if (out_min as i64) == 0 { return 0 }
299 if (out_pat as i64) == 0 { return 0 }
300 out_maj[0] = 0
301 out_min[0] = 0
302 out_pat[0] = 0
303 var i: i64 = 0
304 var part: i64 = 0
305 var v: i64 = 0
306 var digits: i64 = 0
307 while i < n {
308 let c: i64 = src[off + i] as i64
309 if c == 0x2E { // '.'
310 if digits == 0 { return 0 }
311 if part == 0 { out_maj[0] = v }
312 if part == 1 { out_min[0] = v }
313 part = part + 1
314 v = 0
315 digits = 0
316 }
317 if c >= 0x30 {
318 if c <= 0x39 {
319 v = v * 10 + (c - 0x30)
320 digits = digits + 1
321 }
322 }
323 if c < 0x30 { if c != 0x2E { return 0 } }
324 if c > 0x39 { return 0 }
325 i = i + 1
326 }
327 if part != 2 { return 0 }
328 if digits == 0 { return 0 }
329 out_pat[0] = v
330 return 1
331}
332
333// Count comma-separated tags (returns 0 + bail if any tag > 32 bytes).
334func nx_npv_count_tags(src: *u8, off: i64, n: i64) -> i64 {
335 if n == 0 { return 0 }
336 var count: i64 = 1
337 var tag_len: i64 = 0
338 var i: i64 = 0
339 while i < n {
340 if i >= NX_NPV_TAGS_MAX { return count }
341 let c: i64 = src[off + i] as i64
342 if c == 0x2C { // ','
343 if tag_len < 1 { return 0 - NX_NPV_TOO_MANY_TAGS }
344 if tag_len > 32 { return 0 - NX_NPV_TOO_MANY_TAGS }
345 count = count + 1
346 if count > NX_NPV_MAX_TAG_COUNT { return 0 - NX_NPV_TOO_MANY_TAGS }
347 tag_len = 0
348 }
349 if c != 0x2C { tag_len = tag_len + 1 }
350 i = i + 1
351 }
352 if tag_len < 1 { return 0 - NX_NPV_TOO_MANY_TAGS }
353 if tag_len > 32 { return 0 - NX_NPV_TOO_MANY_TAGS }
354 return count
355}
356
357// ===== Top-level validator =================================================
358
359func nx_nishi_page_validate(src: *u8, src_n: i64,
360 r: *NxNishiPageReport) -> i64 {
361 if (src as i64) == 0 { return 0 - NX_NPV_BAD_INPUT }
362 if src_n < 1 { return 0 - NX_NPV_BAD_INPUT }
363 if src_n > NX_NPV_MAX_INPUT_BYTES { return 0 - NX_NPV_INPUT_TOO_LARGE }
364 let rc_r: i64 = nx_npv_report_init(r)
365 if rc_r != NX_NPV_OK { return rc_r }
366
367 let off_p: *i64 = (sys_mmap(8)) as *i64
368 let len_p: *i64 = (sys_mmap(8)) as *i64
369
370 // §2.1 canonical
371 if nx_npv_find_meta(src, src_n, NX_NPV_HEAD_SCAN_LIMIT,
372 "nishi-canonical" as *u8, 15, off_p, len_p) == 1 {
373 r.found_canonical = 1
374 if nx_npv_is_absolute_url(src, off_p[0], len_p[0]) != 1 {
375 r.rc = NX_NPV_MALFORMED_CANONICAL
376 return 0 - NX_NPV_MALFORMED_CANONICAL
377 }
378 }
379 if r.found_canonical == 0 { r.rc = NX_NPV_MISSING_CANONICAL; return 0 - NX_NPV_MISSING_CANONICAL }
380
381 // §2.2 title
382 if nx_npv_find_meta(src, src_n, NX_NPV_HEAD_SCAN_LIMIT,
383 "nishi-title" as *u8, 11, off_p, len_p) == 1 {
384 r.found_title = 1
385 if len_p[0] < NX_NPV_TITLE_MIN { r.rc = NX_NPV_LENGTH_OUT_OF_RANGE; return 0 - NX_NPV_LENGTH_OUT_OF_RANGE }
386 if len_p[0] > NX_NPV_TITLE_MAX { r.rc = NX_NPV_LENGTH_OUT_OF_RANGE; return 0 - NX_NPV_LENGTH_OUT_OF_RANGE }
387 }
388 if r.found_title == 0 { r.rc = NX_NPV_MISSING_TITLE; return 0 - NX_NPV_MISSING_TITLE }
389
390 // §2.3 summary
391 if nx_npv_find_meta(src, src_n, NX_NPV_HEAD_SCAN_LIMIT,
392 "nishi-summary" as *u8, 13, off_p, len_p) == 1 {
393 r.found_summary = 1
394 if len_p[0] < NX_NPV_SUMMARY_MIN { r.rc = NX_NPV_LENGTH_OUT_OF_RANGE; return 0 - NX_NPV_LENGTH_OUT_OF_RANGE }
395 if len_p[0] > NX_NPV_SUMMARY_MAX { r.rc = NX_NPV_LENGTH_OUT_OF_RANGE; return 0 - NX_NPV_LENGTH_OUT_OF_RANGE }
396 }
397 if r.found_summary == 0 { r.rc = NX_NPV_MISSING_SUMMARY; return 0 - NX_NPV_MISSING_SUMMARY }
398
399 // §2.4 tags
400 if nx_npv_find_meta(src, src_n, NX_NPV_HEAD_SCAN_LIMIT,
401 "nishi-tags" as *u8, 10, off_p, len_p) == 1 {
402 r.found_tags = 1
403 let tc: i64 = nx_npv_count_tags(src, off_p[0], len_p[0])
404 if tc < 0 { r.rc = NX_NPV_TOO_MANY_TAGS; return tc }
405 r.tag_count = tc
406 }
407 if r.found_tags == 0 { r.rc = NX_NPV_MISSING_TAGS; return 0 - NX_NPV_MISSING_TAGS }
408
409 // §2.5 last-modified
410 if nx_npv_find_meta(src, src_n, NX_NPV_HEAD_SCAN_LIMIT,
411 "nishi-last-modified" as *u8, 19, off_p, len_p) == 1 {
412 r.found_last_mod = 1
413 if nx_npv_is_iso8601(src, off_p[0], len_p[0]) != 1 {
414 r.rc = NX_NPV_MALFORMED_LAST_MODIFIED
415 return 0 - NX_NPV_MALFORMED_LAST_MODIFIED
416 }
417 }
418 if r.found_last_mod == 0 { r.rc = NX_NPV_MISSING_LAST_MODIFIED; return 0 - NX_NPV_MISSING_LAST_MODIFIED }
419
420 // §2.6 content-hash
421 if nx_npv_find_meta(src, src_n, NX_NPV_HEAD_SCAN_LIMIT,
422 "nishi-content-hash" as *u8, 18, off_p, len_p) == 1 {
423 r.found_content_hash = 1
424 if nx_npv_is_content_hash(src, off_p[0], len_p[0]) != 1 {
425 r.rc = NX_NPV_MALFORMED_CONTENT_HASH
426 return 0 - NX_NPV_MALFORMED_CONTENT_HASH
427 }
428 }
429 if r.found_content_hash == 0 { r.rc = NX_NPV_MISSING_CONTENT_HASH; return 0 - NX_NPV_MISSING_CONTENT_HASH }
430
431 // §2.7 page-version
432 if nx_npv_find_meta(src, src_n, NX_NPV_HEAD_SCAN_LIMIT,
433 "nishi-page-version" as *u8, 18, off_p, len_p) == 1 {
434 r.found_page_version = 1
435 let maj_p: *i64 = (sys_mmap(8)) as *i64
436 let min_p: *i64 = (sys_mmap(8)) as *i64
437 let pat_p: *i64 = (sys_mmap(8)) as *i64
438 if nx_npv_parse_semver(src, off_p[0], len_p[0], maj_p, min_p, pat_p) != 1 {
439 r.rc = NX_NPV_MALFORMED_PAGE_VERSION
440 return 0 - NX_NPV_MALFORMED_PAGE_VERSION
441 }
442 r.page_version_maj = maj_p[0]
443 r.page_version_min = min_p[0]
444 r.page_version_pat = pat_p[0]
445 // Indexer-aware version dispatch (per spec §7).
446 if r.page_version_maj < NX_NPV_MIN_INDEXER_MAJOR {
447 r.rc = NX_NPV_VERSION_INCOMPATIBLE
448 return 0 - NX_NPV_VERSION_INCOMPATIBLE
449 }
450 if r.page_version_maj > NX_NPV_MAX_INDEXER_MAJOR {
451 r.rc = NX_NPV_VERSION_INCOMPATIBLE
452 return 0 - NX_NPV_VERSION_INCOMPATIBLE
453 }
454 }
455 if r.found_page_version == 0 { r.rc = NX_NPV_MISSING_PAGE_VERSION; return 0 - NX_NPV_MISSING_PAGE_VERSION }
456
457 // §2.8 license
458 if nx_npv_find_meta(src, src_n, NX_NPV_HEAD_SCAN_LIMIT,
459 "nishi-license" as *u8, 13, off_p, len_p) == 1 {
460 r.found_license = 1
461 if len_p[0] < NX_NPV_LICENSE_MIN { r.rc = NX_NPV_LENGTH_OUT_OF_RANGE; return 0 - NX_NPV_LENGTH_OUT_OF_RANGE }
462 if len_p[0] > NX_NPV_LICENSE_MAX { r.rc = NX_NPV_LENGTH_OUT_OF_RANGE; return 0 - NX_NPV_LENGTH_OUT_OF_RANGE }
463 }
464 if r.found_license == 0 { r.rc = NX_NPV_MISSING_LICENSE; return 0 - NX_NPV_MISSING_LICENSE }
465
466 // §2.9 author
467 if nx_npv_find_meta(src, src_n, NX_NPV_HEAD_SCAN_LIMIT,
468 "nishi-author" as *u8, 12, off_p, len_p) == 1 {
469 r.found_author = 1
470 if len_p[0] < NX_NPV_AUTHOR_MIN { r.rc = NX_NPV_LENGTH_OUT_OF_RANGE; return 0 - NX_NPV_LENGTH_OUT_OF_RANGE }
471 if len_p[0] > NX_NPV_AUTHOR_MAX { r.rc = NX_NPV_LENGTH_OUT_OF_RANGE; return 0 - NX_NPV_LENGTH_OUT_OF_RANGE }
472 }
473 if r.found_author == 0 { r.rc = NX_NPV_MISSING_AUTHOR; return 0 - NX_NPV_MISSING_AUTHOR }
474
475 // All 9 required tags present + valid -> C0 conformant.
476 r.rc = NX_NPV_OK
477 return NX_NPV_OK
478}