code wiki / (root) / nx_jpeg_ascii.nx

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}