nx_jpeg_ascii.nx source
↩ module page · 357 lines · 14934 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 var luma_stride: i64 = mcus_x * 8
119 var j: i64 = 0
120 while j < nf {
121 let comp: *NxJpegSofComponent = (ctx.frame.components as i64 + j * NX_JPEG_SOF_COMP_BYTES) as *NxJpegSofComponent
122 let pw: i64 = mcus_x * 8 * comp.hi
123 let ph: i64 = mcus_y * 8 * comp.vi
124 let plane: *u8 = sys_mmap(pw * ph + 16) as *u8
125 planes_arr[j] = plane as i64
126 strides[j] = pw
127 if j == 0 { luma_stride = pw }
128 j = j + 1
129 }
130
131 // SOF2 progressive MUST branch here exactly as nx_jpeg_decode_rgb does. This branch existed in the RGB
132 // sibling and was MISSING here, so every progressive JPEG failed DECODE_FAIL on the LUMA path -- which is
133 // the path nx_img_to_gray, and therefore the whole image indexer, actually uses. MEASURED on the first
134 // real web corpus (nx_jpegprobe over 64 gathered images): progressive=52, luma_fail=52,
135 // baseline_and_luma_fail=0, luma_fail_but_rgb_ok=52. One branch, 81% of the corpus.
136 var drc: i64 = 0
137 if nx_jpeg_is_progressive(jpeg, jpeg_len) == 1 {
138 drc = nx_jpeg_prog_decode(jpeg, jpeg_len, ctx, planes_arr as *u8, strides)
139 } else {
140 drc = nx_jpeg_decode(jpeg, jpeg_len, ctx, planes_arr as *u8, strides)
141 }
142 if drc != NX_JPEG_DEC_TOP_OK { return NX_JPEG_ASCII_DECODE_FAIL }
143
144 out_plane[0] = planes_arr[0]
145 out_w[0] = width
146 out_h[0] = height
147 out_stride[0] = luma_stride
148 return NX_JPEG_ASCII_OK
149}
150
151// Decode `jpeg` and render its luminance into `fb_out` as grayscale
152// RGBA, box-downscaled to (target_w x target_h). fb_out.pixels is
153// allocated here (target_w*target_h*4 bytes). Returns
154// NX_JPEG_ASCII_OK or a sealed verdict.
155func nx_jpeg_to_ascii_fb(jpeg: *u8, jpeg_len: i64,
156 target_w: i64, target_h: i64,
157 fb_out: *Framebuffer) -> i64 {
158 if target_w <= 0 { return NX_JPEG_ASCII_BAD_TARGET }
159 if target_h <= 0 { return NX_JPEG_ASCII_BAD_TARGET }
160
161 let pp_plane: *i64 = sys_mmap(8) as *i64
162 let pp_w: *i64 = sys_mmap(8) as *i64
163 let pp_h: *i64 = sys_mmap(8) as *i64
164 let pp_s: *i64 = sys_mmap(8) as *i64
165 let dl: i64 = nx_jpeg_decode_luma(jpeg, jpeg_len, pp_plane, pp_w, pp_h, pp_s)
166 if dl != NX_JPEG_ASCII_OK { return dl }
167 let luma: *u8 = pp_plane[0] as *u8
168 let width: i64 = pp_w[0]
169 let height: i64 = pp_h[0]
170 let luma_stride: i64 = pp_s[0]
171
172 fb_out.width = target_w
173 fb_out.height = target_h
174 let px: *u8 = sys_mmap(target_w * target_h * 4 + 16) as *u8
175 fb_out.pixels = px
176
177 // Box-average downscale luma (width x height) into the target
178 // grid. Bounded by target_h * target_w * source-block size.
179 var ty: i64 = 0
180 while ty < target_h {
181 let sy0: i64 = ty * height / target_h
182 var sy1: i64 = (ty + 1) * height / target_h
183 if sy1 <= sy0 { sy1 = sy0 + 1 }
184 var tx: i64 = 0
185 while tx < target_w {
186 let sx0: i64 = tx * width / target_w
187 var sx1: i64 = (tx + 1) * width / target_w
188 if sx1 <= sx0 { sx1 = sx0 + 1 }
189 var sum: i64 = 0
190 var cnt: i64 = 0
191 var yy: i64 = sy0
192 while yy < sy1 {
193 var xx: i64 = sx0
194 while xx < sx1 {
195 sum = sum + ((luma[yy * luma_stride + xx] as i64) & 255)
196 cnt = cnt + 1
197 xx = xx + 1
198 }
199 yy = yy + 1
200 }
201 var lv: i64 = 128
202 if cnt > 0 { lv = sum / cnt }
203 let o: i64 = (ty * target_w + tx) * 4
204 px[o + 0] = lv as u8
205 px[o + 1] = lv as u8
206 px[o + 2] = lv as u8
207 px[o + 3] = 255 as u8
208 tx = tx + 1
209 }
210 ty = ty + 1
211 }
212 return NX_JPEG_ASCII_OK
213}
214
215// One-call convenience: decode + downscale + dump ASCII to stdout.
216// Returns NX_JPEG_ASCII_OK or a sealed verdict.
217func nx_jpeg_render_ascii(jpeg: *u8, jpeg_len: i64,
218 target_w: i64, target_h: i64) -> i64 {
219 let fb: *Framebuffer = sys_mmap(NX_FRAMEBUFFER_BYTES) as *Framebuffer
220 let rc: i64 = nx_jpeg_to_ascii_fb(jpeg, jpeg_len, target_w, target_h, fb)
221 if rc != NX_JPEG_ASCII_OK { return rc }
222 nx_paint_fb_ascii_write(fb)
223 return NX_JPEG_ASCII_OK
224}
225
226// Aspect-correct convenience: pick the target grid from the image's
227// own dimensions so the ASCII art isn't squished. Terminal cells
228// are ~2x taller than wide, so vertical resolution is halved.
229// `max_cols` bounds the width; height follows the aspect ratio.
230// Returns NX_JPEG_ASCII_OK or a sealed verdict.
231func nx_jpeg_render_ascii_fit(jpeg: *u8, jpeg_len: i64, max_cols: i64) -> i64 {
232 if max_cols <= 0 { return NX_JPEG_ASCII_BAD_TARGET }
233
234 let ctx: *NxJpegDecCtx = sys_mmap(NX_JPEG_DEC_CTX_BYTES) as *NxJpegDecCtx
235 nx_jpeg_dec_ctx_init(ctx)
236 let prc: i64 = _jpeg_ascii_probe_sof(jpeg, jpeg_len, ctx)
237 if prc != NX_JPEG_ASCII_OK { return prc }
238
239 let w: i64 = ctx.frame.width
240 let h: i64 = ctx.frame.height
241 var tw: i64 = max_cols
242 if w < max_cols { tw = w }
243 var th: i64 = h * tw / (w * 2)
244 if th < 1 { th = 1 }
245 return nx_jpeg_render_ascii(jpeg, jpeg_len, tw, th)
246}
247
248// Decode a baseline JPEG to a packed full-resolution RGB buffer
249// (R G B R G B ..., width*height*3 bytes). Composes the decode with
250// chroma upsampling (nx_jpeg_upsample_nearest) + YCbCr->RGB
251// (nx_jpeg_ycbcr_to_rgb_one). Handles 1-component (grayscale) and
252// 3-component (YCbCr) baseline JPEGs. On NX_JPEG_ASCII_OK, writes
253// the RGB buffer pointer (as i64) to *out_rgb and dims to *out_w /
254// *out_h. Bounded by image dimensions throughout (§12).
255func nx_jpeg_decode_rgb(jpeg: *u8, jpeg_len: i64,
256 out_rgb: *i64, out_w: *i64, out_h: *i64) -> i64 {
257 let ctx: *NxJpegDecCtx = sys_mmap(NX_JPEG_DEC_CTX_BYTES) as *NxJpegDecCtx
258 nx_jpeg_dec_ctx_init(ctx)
259
260 let prc: i64 = _jpeg_ascii_probe_sof(jpeg, jpeg_len, ctx)
261 if prc != NX_JPEG_ASCII_OK { return prc }
262
263 let width: i64 = ctx.frame.width
264 let height: i64 = ctx.frame.height
265 if width > NX_JPEG_ASCII_MAX_DIM { return NX_JPEG_ASCII_OVERSIZE }
266 if height > NX_JPEG_ASCII_MAX_DIM { return NX_JPEG_ASCII_OVERSIZE }
267
268 let nf: i64 = ctx.frame.n_components
269 let max_h: i64 = ctx.frame.max_h
270 let max_v: i64 = ctx.frame.max_v
271 let mcu_w: i64 = 8 * max_h
272 let mcu_h: i64 = 8 * max_v
273 let mcus_x: i64 = (width + mcu_w - 1) / mcu_w
274 let mcus_y: i64 = (height + mcu_h - 1) / mcu_h
275 let full_w: i64 = mcus_x * 8 * max_h
276 let full_h: i64 = mcus_y * 8 * max_v
277
278 let planes_arr: *i64 = sys_mmap(8 * 4) as *i64
279 let strides: *i64 = sys_mmap(8 * 4) as *i64
280 var j: i64 = 0
281 while j < nf {
282 let comp: *NxJpegSofComponent = (ctx.frame.components as i64 + j * NX_JPEG_SOF_COMP_BYTES) as *NxJpegSofComponent
283 let pw: i64 = mcus_x * 8 * comp.hi
284 let ph: i64 = mcus_y * 8 * comp.vi
285 let plane: *u8 = sys_mmap(pw * ph + 16) as *u8
286 planes_arr[j] = plane as i64
287 strides[j] = pw
288 j = j + 1
289 }
290
291 var drc: i64 = 0
292 if nx_jpeg_is_progressive(jpeg, jpeg_len) == 1 {
293 drc = nx_jpeg_prog_decode(jpeg, jpeg_len, ctx, planes_arr as *u8, strides) // ★eat-the-debt: SOF2 path
294 } else {
295 drc = nx_jpeg_decode(jpeg, jpeg_len, ctx, planes_arr as *u8, strides)
296 }
297 if drc != NX_JPEG_DEC_TOP_OK { return NX_JPEG_ASCII_DECODE_FAIL }
298
299 let rgb: *u8 = sys_mmap(width * height * 3 + 16) as *u8
300 let y_plane: *u8 = planes_arr[0] as *u8
301 let y_stride: i64 = strides[0]
302
303 if nf == 1 {
304 var yy: i64 = 0
305 while yy < height {
306 var xx: i64 = 0
307 while xx < width {
308 let v: i64 = (y_plane[yy * y_stride + xx] as i64) & 255
309 let o: i64 = (yy * width + xx) * 3
310 rgb[o] = v as u8
311 rgb[o + 1] = v as u8
312 rgb[o + 2] = v as u8
313 xx = xx + 1
314 }
315 yy = yy + 1
316 }
317 out_rgb[0] = rgb as i64
318 out_w[0] = width
319 out_h[0] = height
320 return NX_JPEG_ASCII_OK
321 }
322
323 // 3-component: upsample Cb (comp 1) + Cr (comp 2) to full Y res.
324 let c1: *NxJpegSofComponent = (ctx.frame.components as i64 + 1 * NX_JPEG_SOF_COMP_BYTES) as *NxJpegSofComponent
325 let c2: *NxJpegSofComponent = (ctx.frame.components as i64 + 2 * NX_JPEG_SOF_COMP_BYTES) as *NxJpegSofComponent
326 let cb_full: *u8 = sys_mmap(full_w * full_h + 16) as *u8
327 let cr_full: *u8 = sys_mmap(full_w * full_h + 16) as *u8
328 nx_jpeg_upsample_nearest(planes_arr[1] as *u8, mcus_x * 8 * c1.hi, mcus_y * 8 * c1.vi,
329 strides[1], c1.hi, c1.vi, max_h, max_v, cb_full, full_w)
330 nx_jpeg_upsample_nearest(planes_arr[2] as *u8, mcus_x * 8 * c2.hi, mcus_y * 8 * c2.vi,
331 strides[2], c2.hi, c2.vi, max_h, max_v, cr_full, full_w)
332
333 let rp: *i64 = sys_mmap(8) as *i64
334 let gp: *i64 = sys_mmap(8) as *i64
335 let bp: *i64 = sys_mmap(8) as *i64
336 var y2: i64 = 0
337 while y2 < height {
338 var x2: i64 = 0
339 while x2 < width {
340 let yv: i64 = (y_plane[y2 * y_stride + x2] as i64) & 255
341 let cbv: i64 = (cb_full[y2 * full_w + x2] as i64) & 255
342 let crv: i64 = (cr_full[y2 * full_w + x2] as i64) & 255
343 nx_jpeg_ycbcr_to_rgb_one(yv, cbv, crv, rp, gp, bp)
344 let o: i64 = (y2 * width + x2) * 3
345 rgb[o] = rp[0] as u8
346 rgb[o + 1] = gp[0] as u8
347 rgb[o + 2] = bp[0] as u8
348 x2 = x2 + 1
349 }
350 y2 = y2 + 1
351 }
352
353 out_rgb[0] = rgb as i64
354 out_w[0] = width
355 out_h[0] = height
356 return NX_JPEG_ASCII_OK
357}