nx_jpeg_ascii.nx source
↩ module page · 389 lines · 16804 B
1// nx_jpeg_ascii.nx -- bridge: baseline-JPEG bytes -> luminance ASCII.
2//
3// Decodes a baseline JPEG, takes the Y (luma) plane, box-downscales
4// it to (target_w x target_h), and writes it into a caller
5// Framebuffer as grayscale RGBA so nx_paint_fb_ascii_dump / _write
6// can render the image in a terminal. This is the last brick of the
7// image pipeline: real bytes off the wire -> visible pixels, every
8// layer NishiLang.
9//
10// Why luma-only: ASCII art needs luminance, and the JPEG Y component
11// IS luminance at full resolution. We deliberately skip chroma
12// upsample + YCbCr->RGB -- fewer bytes between the silicon and the
13// pixel, exactly the bits-up discipline. A future colour path
14// (ANSI true-colour cells) composes the existing nx_jpeg_upsample +
15// nx_jpeg_ycbcr bricks on top of this same decode.
16//
17// hardens:
18// CVE-2014-9707 (libjpeg-turbo MCU overflow on malformed DCT)
19// -> dimensions bounded by NX_JPEG_ASCII_MAX_DIM with a sealed
20// OVERSIZE verdict BEFORE any plane allocation.
21// CVE-2018-13785 (libpng length-add integer overflow)
22// -> i64 dims + explicit bound check before alloc; never trust
23// attacker-supplied dimensions to fit a budget.
24// NISHI_BROWSER_KNOWN_LESSONS.md §4 (image decoders) + §12
25// (bounded loops): every loop below is bounded by image or
26// target dimensions; no open-ended scan.
27//
28// genealogy_id: nishi_browser_image_pipeline_phase_5
29// lineage_id: nishi_jpeg_ascii_v1
30// license_tier: ORIGINAL
31
32import "nx_syscalls.nx"
33import "nx_jpeg_marker.nx"
34import "nx_jpeg_sof.nx"
35import "nx_jpeg_decode.nx"
36import "nx_jpeg_progressive.nx"
37import "nx_jpeg_upsample.nx"
38import "nx_jpeg_ycbcr.nx"
39import "nx_paint_solid_rect.nx"
40import "nx_paint_fb_ascii_dump.nx"
41
42// ---- sealed verdicts ----
43const NX_JPEG_ASCII_OK: i64 = 0
44const NX_JPEG_ASCII_OVERSIZE: i64 = 100
45const NX_JPEG_ASCII_NO_SOF: i64 = 101
46const NX_JPEG_ASCII_DECODE_FAIL: i64 = 102
47const NX_JPEG_ASCII_BAD_TARGET: i64 = 103
48
49// Bounded maximum image dimension. Per CVE-2014-9707: cap the
50// attacker-controlled dimensions BEFORE allocating any plane.
51const NX_JPEG_ASCII_MAX_DIM: i64 = 8192
52
53// Walk JPEG segments to the SOF0 marker and parse it into ctx.frame
54// so the caller can size sample planes before the full decode.
55// Returns NX_JPEG_ASCII_OK or NX_JPEG_ASCII_NO_SOF. Bounded by the
56// segment walker's own EOF termination.
57func _jpeg_ascii_probe_sof(jpeg: *u8, jpeg_len: i64, ctx: *NxJpegDecCtx) -> i64 {
58 let cur: *NxJpegCursor = sys_mmap(NX_JPEG_CURSOR_BYTES) as *NxJpegCursor
59 nx_jpeg_seg_init(cur, jpeg, jpeg_len)
60 let seg: *NxJpegSegment = sys_mmap(NX_JPEG_SEG_BYTES) as *NxJpegSegment
61 var walking: i64 = 1
62 var found: i64 = 0
63 while walking == 1 {
64 let rc: i64 = nx_jpeg_seg_next(cur, seg)
65 if rc == NX_JPEG_SEG_EOF { walking = 0 }
66 if rc != NX_JPEG_SEG_OK { if rc != NX_JPEG_SEG_EOF { walking = 0 } }
67 if walking == 1 {
68 if seg.kind == NX_JPEG_M_SOF0 {
69 let sr: i64 = nx_jpeg_sof_parse(jpeg + seg.payload_off, seg.payload_len, ctx.frame)
70 if sr == NX_JPEG_SOF_OK { found = 1 }
71 walking = 0
72 }
73 if seg.kind == NX_JPEG_M_SOF2 { // progressive: identical SOF payload -> parse dims the same
74 let sr2: i64 = nx_jpeg_sof_parse(jpeg + seg.payload_off, seg.payload_len, ctx.frame)
75 if sr2 == NX_JPEG_SOF_OK { found = 1 }
76 walking = 0
77 }
78 }
79 }
80 if found == 1 { return NX_JPEG_ASCII_OK }
81 return NX_JPEG_ASCII_NO_SOF
82}
83
84// Decode a baseline JPEG to its full-resolution luma (Y) plane.
85// On NX_JPEG_ASCII_OK, writes the plane pointer (as i64) to
86// *out_plane and the image width / height / plane stride to the
87// matching out-params. On any other verdict the out-params are
88// left untouched. This is the single decode path; both the ASCII
89// renderer and the PGM proof dumper go through it, so proving this
90// proves both.
91func nx_jpeg_decode_luma(jpeg: *u8, jpeg_len: i64,
92 out_plane: *i64, out_w: *i64,
93 out_h: *i64, out_stride: *i64) -> i64 {
94 let ctx: *NxJpegDecCtx = sys_mmap(NX_JPEG_DEC_CTX_BYTES) as *NxJpegDecCtx
95 nx_jpeg_dec_ctx_init(ctx)
96
97 let prc: i64 = _jpeg_ascii_probe_sof(jpeg, jpeg_len, ctx)
98 if prc != NX_JPEG_ASCII_OK { return prc }
99
100 let width: i64 = ctx.frame.width
101 let height: i64 = ctx.frame.height
102 if width > NX_JPEG_ASCII_MAX_DIM { return NX_JPEG_ASCII_OVERSIZE }
103 if height > NX_JPEG_ASCII_MAX_DIM { return NX_JPEG_ASCII_OVERSIZE }
104
105 let nf: i64 = ctx.frame.n_components
106 let max_h: i64 = ctx.frame.max_h
107 let max_v: i64 = ctx.frame.max_v
108 let mcu_w: i64 = 8 * max_h
109 let mcu_h: i64 = 8 * max_v
110 let mcus_x: i64 = (width + mcu_w - 1) / mcu_w
111 let mcus_y: i64 = (height + mcu_h - 1) / mcu_h
112
113 // One MCU-aligned sample plane per component; the decoder writes
114 // every scan component, so all must be allocated even though the
115 // luma path only reads component 0.
116 let planes_arr: *i64 = sys_mmap(8 * 4) as *i64
117 let strides: *i64 = sys_mmap(8 * 4) as *i64
118 let plane_bytes: *i64 = sys_mmap(8 * 4) as *i64 // exact lengths for the exit-path frees
119 var luma_stride: i64 = mcus_x * 8
120 var j: i64 = 0
121 while j < nf {
122 let comp: *NxJpegSofComponent = (ctx.frame.components as i64 + j * NX_JPEG_SOF_COMP_BYTES) as *NxJpegSofComponent
123 let pw: i64 = mcus_x * 8 * comp.hi
124 let ph: i64 = mcus_y * 8 * comp.vi
125 let plane: *u8 = sys_mmap(pw * ph + 16) as *u8
126 planes_arr[j] = plane as i64
127 strides[j] = pw
128 plane_bytes[j] = pw * ph + 16
129 if j == 0 { luma_stride = pw }
130 j = j + 1
131 }
132
133 // SOF2 progressive MUST branch here exactly as nx_jpeg_decode_rgb does. This branch existed in the RGB
134 // sibling and was MISSING here, so every progressive JPEG failed DECODE_FAIL on the LUMA path -- which is
135 // the path nx_img_to_gray, and therefore the whole image indexer, actually uses. MEASURED on the first
136 // real web corpus (nx_jpegprobe over 64 gathered images): progressive=52, luma_fail=52,
137 // baseline_and_luma_fail=0, luma_fail_but_rgb_ok=52. One branch, 81% of the corpus.
138 var drc: i64 = 0
139 if nx_jpeg_is_progressive(jpeg, jpeg_len) == 1 {
140 drc = nx_jpeg_prog_decode(jpeg, jpeg_len, ctx, planes_arr as *u8, strides)
141 } else {
142 drc = nx_jpeg_decode(jpeg, jpeg_len, ctx, planes_arr as *u8, strides)
143 }
144 if drc != NX_JPEG_DEC_TOP_OK {
145 var fjl: i64 = 0
146 while fjl < nf { sys_munmap(planes_arr[fjl] as *u8, plane_bytes[fjl]); fjl = fjl + 1 }
147 return NX_JPEG_ASCII_DECODE_FAIL
148 }
149
150 // Free the chroma planes the luma path never returns (components 1..nf-1). Component 0 is
151 // handed to the caller, who frees it; its exact length travels nowhere, so the ascii consumer
152 // frees height*stride (a deliberate, bounded under-free -- see nx_jpeg_to_ascii_fb).
153 var fjs: i64 = 1
154 while fjs < nf { sys_munmap(planes_arr[fjs] as *u8, plane_bytes[fjs]); fjs = fjs + 1 }
155
156 out_plane[0] = planes_arr[0]
157 out_w[0] = width
158 out_h[0] = height
159 out_stride[0] = luma_stride
160 return NX_JPEG_ASCII_OK
161}
162
163// Decode `jpeg` and render its luminance into `fb_out` as grayscale
164// RGBA, box-downscaled to (target_w x target_h). fb_out.pixels is
165// allocated here (target_w*target_h*4 bytes). Returns
166// NX_JPEG_ASCII_OK or a sealed verdict.
167func nx_jpeg_to_ascii_fb(jpeg: *u8, jpeg_len: i64,
168 target_w: i64, target_h: i64,
169 fb_out: *Framebuffer) -> i64 {
170 if target_w <= 0 { return NX_JPEG_ASCII_BAD_TARGET }
171 if target_h <= 0 { return NX_JPEG_ASCII_BAD_TARGET }
172
173 let pp_plane: *i64 = sys_mmap(8) as *i64
174 let pp_w: *i64 = sys_mmap(8) as *i64
175 let pp_h: *i64 = sys_mmap(8) as *i64
176 let pp_s: *i64 = sys_mmap(8) as *i64
177 let dl: i64 = nx_jpeg_decode_luma(jpeg, jpeg_len, pp_plane, pp_w, pp_h, pp_s)
178 if dl != NX_JPEG_ASCII_OK { return dl }
179 let luma: *u8 = pp_plane[0] as *u8
180 let width: i64 = pp_w[0]
181 let height: i64 = pp_h[0]
182 let luma_stride: i64 = pp_s[0]
183
184 fb_out.width = target_w
185 fb_out.height = target_h
186 let px: *u8 = sys_mmap(target_w * target_h * 4 + 16) as *u8
187 fb_out.pixels = px
188
189 // Box-average downscale luma (width x height) into the target
190 // grid. Bounded by target_h * target_w * source-block size.
191 var ty: i64 = 0
192 while ty < target_h {
193 let sy0: i64 = ty * height / target_h
194 var sy1: i64 = (ty + 1) * height / target_h
195 if sy1 <= sy0 { sy1 = sy0 + 1 }
196 var tx: i64 = 0
197 while tx < target_w {
198 let sx0: i64 = tx * width / target_w
199 var sx1: i64 = (tx + 1) * width / target_w
200 if sx1 <= sx0 { sx1 = sx0 + 1 }
201 var sum: i64 = 0
202 var cnt: i64 = 0
203 var yy: i64 = sy0
204 while yy < sy1 {
205 var xx: i64 = sx0
206 while xx < sx1 {
207 sum = sum + ((luma[yy * luma_stride + xx] as i64) & 255)
208 cnt = cnt + 1
209 xx = xx + 1
210 }
211 yy = yy + 1
212 }
213 var lv: i64 = 128
214 if cnt > 0 { lv = sum / cnt }
215 let o: i64 = (ty * target_w + tx) * 4
216 px[o + 0] = lv as u8
217 px[o + 1] = lv as u8
218 px[o + 2] = lv as u8
219 px[o + 3] = 255 as u8
220 tx = tx + 1
221 }
222 ty = ty + 1
223 }
224 return NX_JPEG_ASCII_OK
225}
226
227// One-call convenience: decode + downscale + dump ASCII to stdout.
228// Returns NX_JPEG_ASCII_OK or a sealed verdict.
229func nx_jpeg_render_ascii(jpeg: *u8, jpeg_len: i64,
230 target_w: i64, target_h: i64) -> i64 {
231 let fb: *Framebuffer = sys_mmap(NX_FRAMEBUFFER_BYTES) as *Framebuffer
232 let rc: i64 = nx_jpeg_to_ascii_fb(jpeg, jpeg_len, target_w, target_h, fb)
233 if rc != NX_JPEG_ASCII_OK { return rc }
234 nx_paint_fb_ascii_write(fb)
235 return NX_JPEG_ASCII_OK
236}
237
238// Aspect-correct convenience: pick the target grid from the image's
239// own dimensions so the ASCII art isn't squished. Terminal cells
240// are ~2x taller than wide, so vertical resolution is halved.
241// `max_cols` bounds the width; height follows the aspect ratio.
242// Returns NX_JPEG_ASCII_OK or a sealed verdict.
243func nx_jpeg_render_ascii_fit(jpeg: *u8, jpeg_len: i64, max_cols: i64) -> i64 {
244 if max_cols <= 0 { return NX_JPEG_ASCII_BAD_TARGET }
245
246 let ctx: *NxJpegDecCtx = sys_mmap(NX_JPEG_DEC_CTX_BYTES) as *NxJpegDecCtx
247 nx_jpeg_dec_ctx_init(ctx)
248 let prc: i64 = _jpeg_ascii_probe_sof(jpeg, jpeg_len, ctx)
249 if prc != NX_JPEG_ASCII_OK { return prc }
250
251 let w: i64 = ctx.frame.width
252 let h: i64 = ctx.frame.height
253 var tw: i64 = max_cols
254 if w < max_cols { tw = w }
255 var th: i64 = h * tw / (w * 2)
256 if th < 1 { th = 1 }
257 return nx_jpeg_render_ascii(jpeg, jpeg_len, tw, th)
258}
259
260// Decode a baseline JPEG to a packed full-resolution RGB buffer
261// (R G B R G B ..., width*height*3 bytes). Composes the decode with
262// chroma upsampling (nx_jpeg_upsample_nearest) + YCbCr->RGB
263// (nx_jpeg_ycbcr_to_rgb_one). Handles 1-component (grayscale) and
264// 3-component (YCbCr) baseline JPEGs. On NX_JPEG_ASCII_OK, writes
265// the RGB buffer pointer (as i64) to *out_rgb and dims to *out_w /
266// *out_h. Bounded by image dimensions throughout (§12).
267func nx_jpeg_decode_rgb(jpeg: *u8, jpeg_len: i64,
268 out_rgb: *i64, out_w: *i64, out_h: *i64) -> i64 {
269 let ctx: *NxJpegDecCtx = sys_mmap(NX_JPEG_DEC_CTX_BYTES) as *NxJpegDecCtx
270 nx_jpeg_dec_ctx_init(ctx)
271
272 let prc: i64 = _jpeg_ascii_probe_sof(jpeg, jpeg_len, ctx)
273 if prc != NX_JPEG_ASCII_OK { return prc }
274
275 let width: i64 = ctx.frame.width
276 let height: i64 = ctx.frame.height
277 if width > NX_JPEG_ASCII_MAX_DIM { return NX_JPEG_ASCII_OVERSIZE }
278 if height > NX_JPEG_ASCII_MAX_DIM { return NX_JPEG_ASCII_OVERSIZE }
279
280 let nf: i64 = ctx.frame.n_components
281 let max_h: i64 = ctx.frame.max_h
282 let max_v: i64 = ctx.frame.max_v
283 let mcu_w: i64 = 8 * max_h
284 let mcu_h: i64 = 8 * max_v
285 let mcus_x: i64 = (width + mcu_w - 1) / mcu_w
286 let mcus_y: i64 = (height + mcu_h - 1) / mcu_h
287 let full_w: i64 = mcus_x * 8 * max_h
288 let full_h: i64 = mcus_y * 8 * max_v
289
290 let planes_arr: *i64 = sys_mmap(8 * 4) as *i64
291 let strides: *i64 = sys_mmap(8 * 4) as *i64
292 // Exact per-plane byte lengths, recorded AT allocation so every exit path can free with the
293 // length that was actually mapped -- freeing with a recomputed guess risks a partial or an
294 // over-length munmap (2026-08-23 leak fix; measured 3.2 GB/decode with the per-block IDCT and
295 // zigzag-table page leaks; these planes were the remaining ~84 MB/call).
296 let plane_bytes: *i64 = sys_mmap(8 * 4) as *i64
297 var j: i64 = 0
298 while j < nf {
299 let comp: *NxJpegSofComponent = (ctx.frame.components as i64 + j * NX_JPEG_SOF_COMP_BYTES) as *NxJpegSofComponent
300 let pw: i64 = mcus_x * 8 * comp.hi
301 let ph: i64 = mcus_y * 8 * comp.vi
302 let plane: *u8 = sys_mmap(pw * ph + 16) as *u8
303 planes_arr[j] = plane as i64
304 strides[j] = pw
305 plane_bytes[j] = pw * ph + 16
306 j = j + 1
307 }
308
309 var drc: i64 = 0
310 if nx_jpeg_is_progressive(jpeg, jpeg_len) == 1 {
311 drc = nx_jpeg_prog_decode(jpeg, jpeg_len, ctx, planes_arr as *u8, strides) // ★eat-the-debt: SOF2 path
312 } else {
313 drc = nx_jpeg_decode(jpeg, jpeg_len, ctx, planes_arr as *u8, strides)
314 }
315 if drc != NX_JPEG_DEC_TOP_OK {
316 var fjf: i64 = 0
317 while fjf < nf { sys_munmap(planes_arr[fjf] as *u8, plane_bytes[fjf]); fjf = fjf + 1 }
318 return NX_JPEG_ASCII_DECODE_FAIL
319 }
320
321 let rgb: *u8 = sys_mmap(width * height * 3 + 16) as *u8
322 let y_plane: *u8 = planes_arr[0] as *u8
323 let y_stride: i64 = strides[0]
324
325 if nf == 1 {
326 var yy: i64 = 0
327 while yy < height {
328 var xx: i64 = 0
329 while xx < width {
330 let v: i64 = (y_plane[yy * y_stride + xx] as i64) & 255
331 let o: i64 = (yy * width + xx) * 3
332 rgb[o] = v as u8
333 rgb[o + 1] = v as u8
334 rgb[o + 2] = v as u8
335 xx = xx + 1
336 }
337 yy = yy + 1
338 }
339 var fj1: i64 = 0
340 while fj1 < nf { sys_munmap(planes_arr[fj1] as *u8, plane_bytes[fj1]); fj1 = fj1 + 1 }
341 out_rgb[0] = rgb as i64
342 out_w[0] = width
343 out_h[0] = height
344 return NX_JPEG_ASCII_OK
345 }
346
347 // 3-component: upsample Cb (comp 1) + Cr (comp 2) to full Y res.
348 let c1: *NxJpegSofComponent = (ctx.frame.components as i64 + 1 * NX_JPEG_SOF_COMP_BYTES) as *NxJpegSofComponent
349 let c2: *NxJpegSofComponent = (ctx.frame.components as i64 + 2 * NX_JPEG_SOF_COMP_BYTES) as *NxJpegSofComponent
350 let cb_full: *u8 = sys_mmap(full_w * full_h + 16) as *u8
351 let cr_full: *u8 = sys_mmap(full_w * full_h + 16) as *u8
352 nx_jpeg_upsample_nearest(planes_arr[1] as *u8, mcus_x * 8 * c1.hi, mcus_y * 8 * c1.vi,
353 strides[1], c1.hi, c1.vi, max_h, max_v, cb_full, full_w)
354 nx_jpeg_upsample_nearest(planes_arr[2] as *u8, mcus_x * 8 * c2.hi, mcus_y * 8 * c2.vi,
355 strides[2], c2.hi, c2.vi, max_h, max_v, cr_full, full_w)
356
357 let rp: *i64 = sys_mmap(8) as *i64
358 let gp: *i64 = sys_mmap(8) as *i64
359 let bp: *i64 = sys_mmap(8) as *i64
360 var y2: i64 = 0
361 while y2 < height {
362 var x2: i64 = 0
363 while x2 < width {
364 let yv: i64 = (y_plane[y2 * y_stride + x2] as i64) & 255
365 let cbv: i64 = (cb_full[y2 * full_w + x2] as i64) & 255
366 let crv: i64 = (cr_full[y2 * full_w + x2] as i64) & 255
367 nx_jpeg_ycbcr_to_rgb_one(yv, cbv, crv, rp, gp, bp)
368 let o: i64 = (y2 * width + x2) * 3
369 rgb[o] = rp[0] as u8
370 rgb[o + 1] = gp[0] as u8
371 rgb[o + 2] = bp[0] as u8
372 x2 = x2 + 1
373 }
374 y2 = y2 + 1
375 }
376
377 // Free every decode intermediate: only the RGB buffer outlives this call (the caller frees
378 // it, length width*height*3+16). Component planes + upsample planes were the non-page-leak
379 // half of the per-call growth (~84 MB at 4096x4096) -- exact lengths, recorded at allocation.
380 var fj2: i64 = 0
381 while fj2 < nf { sys_munmap(planes_arr[fj2] as *u8, plane_bytes[fj2]); fj2 = fj2 + 1 }
382 sys_munmap(cb_full, full_w * full_h + 16)
383 sys_munmap(cr_full, full_w * full_h + 16)
384
385 out_rgb[0] = rgb as i64
386 out_w[0] = width
387 out_h[0] = height
388 return NX_JPEG_ASCII_OK
389}