code wiki / (root) / nx_artifact_id.nx

nx_artifact_id.nx source

↩ module page · 177 lines · 7518 B

1// nx_artifact_id.nx -- name a built artifact by its CONTENT so build and deploy can agree on WHICH BYTES. 2// 3// THE RACE THIS CLOSES (measured 2026-07-30, and it shipped foreign bytes to production): 4// nx_sov_build_run writes _build/<t>.sov.elf. A separate step later copies that file to a staged name and 5// promotes it. ANY sibling session that rebuilds the same target in between OVERWRITES _build/<t>.sov.elf. 6// I built nx_mgmt_api at 578865, verified it, and by deploy time _build held a sibling's 579263 -- WHICH IS 7// WHAT WENT LIVE. I verified one artifact and shipped another, and nothing anywhere detected it. 8// 9// It is a TOCTOU on the build output. The `build-<target>` lease does not cover it: that lease is released 10// when the COMPILE ends, so the whole build->deploy handoff is unprotected by design. 11// 12// THE FIX IS THE CONTAINER-ECOSYSTEM ONE: DEPLOY A DIGEST, NOT A TAG. A path is a mutable tag; a 13// (size, content-hash) pair is an immutable name. Bind them and the race becomes DETECTABLE instead of 14// silent -- `verify` REFUSES rather than shipping whatever happens to be sitting at the path. 15// 16// COMPOSES THE CANONICAL HASH. nx_fnv.nx's own header carries the cardinal law: "All other primitives 17// needing FNV-1a MUST import nx_fnv.nx ... never re-implement a hash inline." A tree-wide grep today found 18// ~25 hand-rolled FNV variants ignoring exactly that, so this organ composes rather than adding a 26th. 19// FNV-1a is a FINGERPRINT, not a cryptographic seal -- it detects an ACCIDENTAL swap by a sibling build, 20// which is the measured failure. It is NOT a defence against a deliberately crafted collision; that rung is 21// sha256 via the signing path, and this file must not be described as more than it is. 22// 23// VERBS 24// nx_artifact_id id <path> -> `ARTIFACT size=<n> fnv=<16hex> path=<p>`, exit 0 25// nx_artifact_id verify <path> <size> <fnvhex> -> exit 0 MATCH / exit 3 MISMATCH (prints BOTH) / exit 4 unreadable 26// license_tier: ORIGINAL No hw writes (Rule 26). expect_exit: 0 27import "nx_syscalls.nx" 28import "nx_fnv.nx" 29 30const AI_CHUNK: i64 = 262144 31const AI_EXIT_MISMATCH: i64 = 3 32const AI_EXIT_UNREADABLE: i64 = 4 33const AI_EXIT_USAGE: i64 = 2 34 35func ai_len(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } return n } 36func ai_w(s: *u8) -> i64 { sys_write(1, s, ai_len(s)); return 0 } 37func ai_wn(v: i64) -> i64 { 38 let t: *u8 = sys_mmap(32) 39 var m: i64 = v 40 if m < 0 { sys_write(1, "-" as *u8, 1); m = 0 - m } 41 var k: i64 = 0 42 if m == 0 { t[0] = 48 as u8; k = 1 } 43 while m > 0 { t[k] = (48 + (m % 10)) as u8; m = m / 10; k = k + 1 } 44 let o: *u8 = sys_mmap(32) 45 var i: i64 = 0 46 while i < k { o[i] = t[k-1-i]; i = i + 1 } 47 sys_write(1, o, k) 48 return 0 49} 50// 16 lowercase hex digits, most-significant first. Fixed width so string compare is exact. 51func ai_wx(v: i64) -> i64 { 52 let o: *u8 = sys_mmap(24) 53 var i: i64 = 0 54 while i < 16 { 55 let sh: i64 = (15 - i) * 4 56 let d: i64 = (v >> sh) & 15 57 if d < 10 { o[i] = (48 + d) as u8 } 58 if d >= 10 { o[i] = (87 + d) as u8 } 59 i = i + 1 60 } 61 sys_write(1, o, 16) 62 return 0 63} 64func ai_hexval(c: i64) -> i64 { 65 if c >= 48 { if c <= 57 { return c - 48 } } 66 if c >= 97 { if c <= 102 { return c - 87 } } 67 if c >= 65 { if c <= 70 { return c - 55 } } 68 return 0 - 1 69} 70func ai_parse_hex(s: *u8) -> i64 { 71 var v: i64 = 0 72 var i: i64 = 0 73 let n: i64 = ai_len(s) 74 if n == 0 { return 0 - 1 } 75 while i < n { 76 let d: i64 = ai_hexval(s[i] as i64) 77 if d < 0 { return 0 - 1 } 78 v = (v * 16) + d 79 i = i + 1 80 } 81 return v 82} 83func ai_parse_dec(s: *u8) -> i64 { 84 var v: i64 = 0 85 var i: i64 = 0 86 let n: i64 = ai_len(s) 87 if n == 0 { return 0 - 1 } 88 while i < n { 89 let c: i64 = s[i] as i64 90 if c < 48 { return 0 - 1 } 91 if c > 57 { return 0 - 1 } 92 v = (v * 10) + (c - 48) 93 i = i + 1 94 } 95 return v 96} 97 98// Stream the file through the CANONICAL rolling FNV-1a. Chunked so an arbitrarily large elf never needs to 99// be held whole in memory -- a 578KB mgmt binary is small, but the toolchain artifacts are not. 100// Returns 0 on success and writes size+hash through the out params; -1 if the path cannot be read. 101func ai_digest(path: *u8, out_size: *i64, out_hash: *i64) -> i64 { 102 let fd: i64 = sys_openat_rd(path) 103 if fd < 0 { return 0 - 1 } 104 let buf: *u8 = sys_mmap(AI_CHUNK) 105 var total: i64 = 0 106 var h: i64 = fnv1a_init() 107 var go: i64 = 1 108 while go == 1 { 109 let r: i64 = sys_read(fd, buf, AI_CHUNK) 110 if r <= 0 { go = 0 } 111 if r > 0 { 112 h = fnv1a_update(h, buf, r) 113 total = total + r 114 } 115 } 116 sys_close(fd) 117 *out_size = total 118 *out_hash = h 119 return 0 120} 121 122func main(argc: i64, argv: *i64) -> i64 { 123 if argc < 3 { 124 ai_w("usage: nx_artifact_id id <path> | nx_artifact_id verify <path> <size> <fnvhex>\n" as *u8) 125 ai_w(" BIND A BUILD TO ITS DEPLOY: run `id` right after the build, pass those two numbers to\n" as *u8) 126 ai_w(" `verify` right before the promote. A sibling rebuild between them becomes a REFUSAL,\n" as *u8) 127 ai_w(" not a silent swap of the bytes you tested for bytes you never saw.\n" as *u8) 128 sys_exit(AI_EXIT_USAGE) 129 return AI_EXIT_USAGE 130 } 131 let verb: *u8 = argv[1] as *u8 132 let path: *u8 = argv[2] as *u8 133 let szp: *i64 = sys_mmap(16) as *i64 134 let hsp: *i64 = sys_mmap(16) as *i64 135 if ai_digest(path, szp, hsp) < 0 { 136 ai_w("ARTIFACT-UNREADABLE path=" as *u8); ai_w(path); ai_w("\n" as *u8) 137 sys_exit(AI_EXIT_UNREADABLE) 138 return AI_EXIT_UNREADABLE 139 } 140 if verb[0] == (105 as u8) { 141 ai_w("ARTIFACT size=" as *u8); ai_wn(szp[0]) 142 ai_w(" fnv=" as *u8); ai_wx(hsp[0]) 143 ai_w(" path=" as *u8); ai_w(path); ai_w("\n" as *u8) 144 return 0 145 } 146 if argc < 5 { 147 ai_w("usage: nx_artifact_id verify <path> <size> <fnvhex>\n" as *u8) 148 sys_exit(AI_EXIT_USAGE) 149 return AI_EXIT_USAGE 150 } 151 let esz: i64 = ai_parse_dec(argv[3] as *u8) 152 let ehx: i64 = ai_parse_hex(argv[4] as *u8) 153 if esz < 0 { 154 ai_w("ARTIFACT-BADARG expected size is not a decimal number\n" as *u8) 155 sys_exit(AI_EXIT_USAGE) 156 return AI_EXIT_USAGE 157 } 158 var ok: i64 = 1 159 if szp[0] != esz { ok = 0 } 160 if hsp[0] != ehx { ok = 0 } 161 if ok == 1 { 162 ai_w("ARTIFACT-MATCH size=" as *u8); ai_wn(szp[0]) 163 ai_w(" fnv=" as *u8); ai_wx(hsp[0]) 164 ai_w(" path=" as *u8); ai_w(path); ai_w("\n" as *u8) 165 return 0 166 } 167 // Print BOTH sides. A refusal that does not say what it saw forces the caller to go and look, which is 168 // the retry-hammering failure mode -- name the drift here so one line of output ends the investigation. 169 ai_w("ARTIFACT-MISMATCH -- the bytes at this path are NOT the ones you built.\n" as *u8) 170 ai_w(" expected size=" as *u8); ai_wn(esz); ai_w(" fnv=" as *u8); ai_wx(ehx); ai_w("\n" as *u8) 171 ai_w(" actual size=" as *u8); ai_wn(szp[0]); ai_w(" fnv=" as *u8); ai_wx(hsp[0]); ai_w("\n" as *u8) 172 ai_w(" path=" as *u8); ai_w(path); ai_w("\n" as *u8) 173 ai_w(" MOST LIKELY CAUSE: a sibling session rebuilt this target between your build and your deploy.\n" as *u8) 174 ai_w(" DO NOT PROMOTE. Rebuild, re-run `id`, and deploy the artifact you actually verified.\n" as *u8) 175 sys_exit(AI_EXIT_MISMATCH) 176 return AI_EXIT_MISMATCH 177}