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}