code wiki / hub / nx_nishi_page_validator.nx

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}