nx_klipper_gcode_validator.nx source
↩ module page · 267 lines · 9222 B
1// nx_klipper_gcode_validator.nx -- Phase B1 of S-class hardening.
2//
3// Real-input validation that the substrate's emitted G-code parses
4// as syntactically valid Klipper commands. Walks the emitted byte
5// stream + verifies every non-comment line follows G/M/T + space-
6// separated letter+number parameters.
7//
8// Per [[feedback-no-false-ok-substrate-honesty-audit]] + the
9// S-class honest audit: emitted G-code MUST be parseable by Klipper
10// before we send it to the Qidi. If our slicer emits something
11// Klipper rejects mid-job, that's a half-print failure (operator's
12// "spaghetti or where it prints halfway" failure class). This
13// validator is the substrate-side guarantee that emitted bytes ARE
14// valid before any filament moves.
15//
16// Validates:
17// - Non-empty + non-comment lines must start with G/M/T
18// - Command (after G/M/T) must be a decimal number
19// - Parameters: letter (A-Z) followed by signed numeric value
20// - No control bytes (< 0x09) outside CR/LF/TAB
21// - F (feedrate) parameter, when present, must be positive
22//
23// Does NOT validate:
24// - Command semantics (G99 wouldn't be flagged even though Klipper
25// might reject it; substrate trusts caller's command set)
26// - Numeric ranges (axes within build volume = simulator's job)
27// - Klipper extended commands (SET_*, BED_MESH_*, etc.)
28//
29// license_tier: ORIGINAL
30
31import "nx_syscalls.nx"
32
33const NX_KGV_OK: i64 = 0
34const NX_KGV_BAD_LINE: i64 = 1
35const NX_KGV_BAD_PARAM: i64 = 2
36const NX_KGV_BAD_CONTROL_CHAR: i64 = 3
37const NX_KGV_BAD_FEEDRATE: i64 = 4
38const NX_KGV_BAD_INPUT: i64 = 5
39const NX_KGV_N: i64 = 6
40
41func nx_kgv_is_valid(v: i64) -> i64 {
42 if v < 0 { return 0 }
43 if v >= NX_KGV_N { return 0 }
44 return 1
45}
46
47// Returns 1 if byte is decimal digit '0'..'9'.
48func kgv_is_digit(b: i64) -> i64 {
49 if b < 0x30 { return 0 }
50 if b > 0x39 { return 0 }
51 return 1
52}
53
54// Returns 1 if byte is letter A-Z.
55func kgv_is_upper(b: i64) -> i64 {
56 if b < 0x41 { return 0 }
57 if b > 0x5A { return 0 }
58 return 1
59}
60
61// Validate a single line buf[start..end] (end exclusive).
62// Returns NX_KGV_OK or a non-OK verdict.
63func kgv_validate_line(buf: *u8, start: i64, end: i64) -> i64 {
64 if start >= end { return NX_KGV_OK } // empty line
65
66 // Check for control chars (< 0x09 except CR/LF) anywhere in line.
67 var ci: i64 = start
68 while ci < end {
69 let bc: i64 = buf[ci] as i64
70 if bc < 0x09 {
71 if bc != 0x0A {
72 if bc != 0x0D {
73 return NX_KGV_BAD_CONTROL_CHAR
74 }
75 }
76 }
77 ci = ci + 1
78 }
79
80 // Skip leading whitespace. Track the first-non-space position
81 // explicitly (don't use pp-1 tricks that get the position wrong
82 // when the break sets pp = end+1).
83 var pp: i64 = start
84 var nonspace_at: i64 = -1
85 while pp < end {
86 let bws: i64 = buf[pp] as i64
87 if bws == 0x20 {
88 pp = pp + 1
89 }
90 else {
91 nonspace_at = pp
92 pp = end // break
93 }
94 }
95 if nonspace_at < 0 { return NX_KGV_OK } // empty / all spaces
96 let real_start: i64 = nonspace_at
97
98 let first: i64 = buf[real_start] as i64
99
100 // Comment line: starts with ';'
101 if first == 0x3B { return NX_KGV_OK }
102
103 // Must start with an uppercase letter (G/M/T for numeric commands
104 // OR any uppercase letter to start a Klipper extended macro like
105 // BED_MESH_CALIBRATE / SET_*).
106 if kgv_is_upper(first) == 0 { return NX_KGV_BAD_LINE }
107
108 // Numeric G/M/T command path: G/M/T followed by digit.
109 // Klipper macro path: anything else uppercase (BED_MESH_*, SET_*).
110 //
111 // If line is G/M/T alone or G/M/T followed by non-digit, that's
112 // BAD_LINE (no valid numeric command).
113 var is_gmt: i64 = 0
114 if first == 0x47 { is_gmt = 1 }
115 if first == 0x4D { is_gmt = 1 }
116 if first == 0x54 { is_gmt = 1 }
117
118 let cp_after_first: i64 = real_start + 1
119 if is_gmt == 1 {
120 if cp_after_first >= end { return NX_KGV_BAD_LINE }
121 if kgv_is_digit(buf[cp_after_first] as i64) == 0 {
122 return NX_KGV_BAD_LINE
123 }
124 // Fall through to numeric-command parser.
125 }
126 else {
127 // Klipper extended macro line. v1: control-char check
128 // already passed; trust the rest as macro-name + args.
129 return NX_KGV_OK
130 }
131 let bf2: i64 = buf[cp_after_first] as i64
132 var is_macro: i64 = 0
133
134 if is_macro == 1 {
135 // Klipper extended macro line. v1 contract: control-char
136 // check already passed; trust the rest of the line as a
137 // macro-name + arg=value pairs. Strict macro grammar parse
138 // queued for v2 (would compose nx_yaml_subset-style key=value).
139 return NX_KGV_OK
140 }
141
142 // Numeric command: walk command digits.
143 var cp: i64 = cp_after_first
144 if kgv_is_digit(buf[cp] as i64) == 0 { return NX_KGV_BAD_LINE }
145 // Advance through digits using found-flag for explicit position.
146 var first_nondigit: i64 = end
147 while cp < end {
148 if kgv_is_digit(buf[cp] as i64) == 1 {
149 cp = cp + 1
150 }
151 else {
152 first_nondigit = cp
153 cp = end
154 }
155 }
156 cp = first_nondigit
157
158 // Walk parameters: skip whitespace; expect letter+number repeating.
159 while cp < end {
160 let bs: i64 = buf[cp] as i64
161 if bs == 0x20 {
162 cp = cp + 1
163 }
164 else {
165 if bs == 0x3B {
166 cp = end // inline comment, valid
167 }
168 else {
169 if kgv_is_upper(bs) == 0 { return NX_KGV_BAD_PARAM }
170 let param_letter: i64 = bs
171 cp = cp + 1
172 if cp >= end { return NX_KGV_BAD_PARAM }
173 // Optional leading '-' sign
174 let bn: i64 = buf[cp] as i64
175 if bn == 0x2D { cp = cp + 1 }
176 if cp >= end { return NX_KGV_BAD_PARAM }
177 // First numeric char: digit or '.'
178 let bfirst: i64 = buf[cp] as i64
179 if kgv_is_digit(bfirst) == 0 {
180 if bfirst != 0x2E { return NX_KGV_BAD_PARAM }
181 }
182 // F feedrate check: must be positive nonzero
183 if param_letter == 0x46 {
184 if bn == 0x2D { return NX_KGV_BAD_FEEDRATE }
185 if bfirst == 0x30 {
186 // F0 -- followed by something other than '.'
187 // means zero feedrate
188 if cp + 1 >= end {
189 return NX_KGV_BAD_FEEDRATE
190 }
191 let next_f: i64 = buf[cp + 1] as i64
192 if next_f != 0x2E {
193 if kgv_is_digit(next_f) == 0 {
194 return NX_KGV_BAD_FEEDRATE
195 }
196 }
197 }
198 }
199 // Consume digits + optional single decimal point.
200 var saw_dot: i64 = 0
201 var ate_nondig: i64 = end
202 while cp < end {
203 let bdig: i64 = buf[cp] as i64
204 if kgv_is_digit(bdig) == 1 {
205 cp = cp + 1
206 }
207 else {
208 if bdig == 0x2E {
209 if saw_dot == 1 {
210 ate_nondig = cp
211 cp = end
212 }
213 else {
214 saw_dot = 1
215 cp = cp + 1
216 }
217 }
218 else {
219 ate_nondig = cp
220 cp = end
221 }
222 }
223 }
224 cp = ate_nondig
225 }
226 }
227 }
228 return NX_KGV_OK
229}
230
231// Walks the buffer line-by-line. Returns NX_KGV_OK if every line
232// is valid; otherwise the first non-OK verdict + sets *out_line_idx
233// to the 0-based line number where it failed.
234
235func nx_klipper_gcode_validate(buf: *u8, len: i64,
236 out_line_idx: *i64) -> i64 {
237 if (buf as i64) == 0 { return NX_KGV_BAD_INPUT }
238 if len <= 0 { return NX_KGV_BAD_INPUT }
239 if (out_line_idx as i64) == 0 { return NX_KGV_BAD_INPUT }
240 *out_line_idx = -1
241
242 var line_idx: i64 = 0
243 var line_start: i64 = 0
244 var p: i64 = 0
245 while p < len {
246 let b: i64 = buf[p] as i64
247 if b == 0x0A {
248 let v: i64 = kgv_validate_line(buf, line_start, p)
249 if v != NX_KGV_OK {
250 *out_line_idx = line_idx
251 return v
252 }
253 line_idx = line_idx + 1
254 line_start = p + 1
255 }
256 p = p + 1
257 }
258 // Last line without trailing \n.
259 if line_start < len {
260 let vl: i64 = kgv_validate_line(buf, line_start, len)
261 if vl != NX_KGV_OK {
262 *out_line_idx = line_idx
263 return vl
264 }
265 }
266 return NX_KGV_OK
267}