From 493a7c69406e1e978b6ee4a9e1b4ce50bb1aa4e9 Mon Sep 17 00:00:00 2001 From: i-shutov Date: Sat, 31 Oct 2026 11:19:19 +0300 Subject: [PATCH 1/5] feat(B06): add bounded PNG-only preview decoder with resource tests --- crates/tikzarium-app/src/preview_image.rs | 127 ++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 crates/tikzarium-app/src/preview_image.rs diff --git a/crates/tikzarium-app/src/preview_image.rs b/crates/tikzarium-app/src/preview_image.rs new file mode 100644 index 00000000..6e90b04c --- /dev/null +++ b/crates/tikzarium-app/src/preview_image.rs @@ -0,0 +1,127 @@ +//! B06: bounded, PNG-only decode for every native egui preview path. +//! PDF bytes and XeLaTeX output remain authoritative and unchanged. +use std::io::Cursor; + +const PNG_SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n"; +const MAX_COMPRESSED_BYTES: usize = 64 * 1024 * 1024; +const MAX_EDGE: u32 = 8_192; +const MAX_PIXELS: u64 = 8 * 1024 * 1024; +const MAX_DECODER_ALLOC: u64 = 96 * 1024 * 1024; + +fn check_compressed_size(bytes: usize) -> Result<(), String> { + if bytes > MAX_COMPRESSED_BYTES { + return Err(format!( + "PNG preview exceeds compressed size budget ({} > {} bytes)", + bytes, MAX_COMPRESSED_BYTES + )); + } + Ok(()) +} + +fn check_dimensions(width: u32, height: u32) -> Result<(), String> { + let pixels = u64::from(width) * u64::from(height); + if width == 0 || height == 0 || width > MAX_EDGE || height > MAX_EDGE || pixels > MAX_PIXELS { + return Err(format!( + "PNG preview dimensions {width}x{height} exceed safety budget (edge <= {MAX_EDGE}, pixels <= {MAX_PIXELS})" + )); + } + Ok(()) +} + +/// Reject oversized or malformed PNG headers before invoking any image decoder. +/// The eventual decoder still validates CRC, chunks and actual image data. +fn validate_header(png: &[u8]) -> Result<(), String> { + check_compressed_size(png.len())?; + if png.len() < 33 || !png.starts_with(PNG_SIGNATURE) { + return Err("PNG preview has an invalid or truncated signature/header".into()); + } + let length = u32::from_be_bytes([png[8], png[9], png[10], png[11]]); + if length != 13 || &png[12..16] != b"IHDR" { + return Err("PNG preview is missing a valid first IHDR chunk".into()); + } + let width = u32::from_be_bytes([png[16], png[17], png[18], png[19]]); + let height = u32::from_be_bytes([png[20], png[21], png[22], png[23]]); + check_dimensions(width, height) +} + +/// Shared by initial XeLaTeX page, page navigation, and font-proof previews. +/// No user-visible texture is replaced if this returns an error. +pub(crate) fn decode_preview_png(png: &[u8]) -> Result { + validate_header(png)?; + + // Width/height limits are strict in image-rs; max_alloc is best-effort. + // Our independent IHDR pixel-count check additionally bounds RGBA allocation. + let mut limits = image::Limits::default(); + limits.max_image_width = Some(MAX_EDGE); + limits.max_image_height = Some(MAX_EDGE); + limits.max_alloc = Some(MAX_DECODER_ALLOC); + let mut reader = image::ImageReader::with_format(Cursor::new(png), image::ImageFormat::Png); + reader.limits(limits); + reader + .decode() + .map(|image| image.to_rgba8()) + .map_err(|error| format!("PNG preview decode: {error}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use image::ImageEncoder; + + fn header(width: u32, height: u32) -> Vec { + let mut png = vec![0; 33]; + png[..8].copy_from_slice(PNG_SIGNATURE); + png[8..12].copy_from_slice(&13u32.to_be_bytes()); + png[12..16].copy_from_slice(b"IHDR"); + png[16..20].copy_from_slice(&width.to_be_bytes()); + png[20..24].copy_from_slice(&height.to_be_bytes()); + png[24] = 8; // 8-bit depth + png[25] = 6; // RGBA + png + } + + #[test] + fn valid_rgba_png_decodes_without_changing_pixels() { + let mut png = Vec::new(); + image::codecs::png::PngEncoder::new(&mut png) + .write_image(&[10, 20, 30, 255], 1, 1, image::ExtendedColorType::Rgba8) + .unwrap(); + let rgba = decode_preview_png(&png).unwrap(); + assert_eq!(rgba.dimensions(), (1, 1)); + assert_eq!(rgba.as_raw().as_slice(), &[10, 20, 30, 255]); + } + + #[test] + fn fail_closed_on_decompression_bomb_header() { + let err = validate_header(&header(8192, 8192)).unwrap_err(); + assert!(err.contains("dimensions")); + assert!(validate_header(&header(8192, 1024)).is_ok()); + assert!(validate_header(&header(8193, 1)).is_err()); + assert!(validate_header(&header(1, 8193)).is_err()); + assert!(validate_header(&header(u32::MAX, u32::MAX)).is_err()); + assert!(validate_header(&header(0, 10)).is_err()); + assert!(validate_header(&header(10, 0)).is_err()); + } + + #[test] + fn malicious_and_truncated_header_is_rejected() { + assert!(validate_header(&[]).is_err()); + assert!(validate_header(b"not a PNG").is_err()); + assert!(validate_header(&header(1, 1)[..32]).is_err()); + let mut png = header(1, 1); + png[12..16].copy_from_slice(b"IDAT"); + assert!(validate_header(&png).is_err()); + let mut png = header(1, 1); + png[11] = 12; // invalid IHDR length + assert!(validate_header(&png).is_err()); + } + + #[test] + fn compressed_file_budget_checks_bounds_without_large_allocations() { + assert!(check_compressed_size(MAX_COMPRESSED_BYTES).is_ok()); + assert!(check_compressed_size(MAX_COMPRESSED_BYTES + 1).is_err()); + assert!(check_dimensions(1, 1).is_ok()); + assert!(check_dimensions(4096, 2048).is_ok()); + assert!(check_dimensions(4096, 2049).is_err()); + } +} From f547e3f9f931d1fc09e125c4e1505ee76001a418 Mon Sep 17 00:00:00 2001 From: i-shutov Date: Sat, 31 Oct 2026 11:19:27 +0300 Subject: [PATCH 2/5] feat(B06): gate all three egui PNG decode paths before texture allocation --- crates/tikzarium-app/src/main.rs | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/crates/tikzarium-app/src/main.rs b/crates/tikzarium-app/src/main.rs index 9d1b5f6b..5a076c1d 100644 --- a/crates/tikzarium-app/src/main.rs +++ b/crates/tikzarium-app/src/main.rs @@ -13,6 +13,7 @@ use tikzarium_extensions::{ExtensionStatus, Registry}; mod font_proof; mod pdf_navigation; +mod preview_image; mod source_fonts; mod ui_state; mod ui_theme; @@ -25,6 +26,7 @@ use font_proof::{FontProofResult, spawn_font_proof}; use pdf_navigation::{ PageCountReceiver, PageRenderReceiver, can_navigate, spawn_page_count, spawn_page_render, }; +use preview_image::decode_preview_png; use source_fonts::scan_explicit_font_hints; use ui_theme::{STORAGE_KEY, UiPreferences}; @@ -307,9 +309,8 @@ impl TikzariumApp { self.preview_texture = None; self.preview_status = "Exact PDF created by XeLaTeX".into(); match artifact.preview { - Ok(Some(bitmap)) => match image::load_from_memory(&bitmap.png) { - Ok(image) => { - let rgba = image.to_rgba8(); + Ok(Some(bitmap)) => match decode_preview_png(&bitmap.png) { + Ok(rgba) => { let size = [rgba.width() as usize, rgba.height() as usize]; let color = egui::ColorImage::from_rgba_unmultiplied(size, rgba.as_raw()); @@ -440,11 +441,8 @@ impl TikzariumApp { if let Some((epoch, page, result)) = render_result { self.page_render_rx = None; if epoch == self.preview_epoch { - match result.and_then(|png| { - image::load_from_memory(&png).map_err(|error| format!("PNG decode: {error}")) - }) { - Ok(image) => { - let rgba = image.to_rgba8(); + match result.and_then(|png| decode_preview_png(&png)) { + Ok(rgba) => { let size = [rgba.width() as usize, rgba.height() as usize]; let color = egui::ColorImage::from_rgba_unmultiplied(size, rgba.as_raw()); self.preview_texture = Some(ctx.load_texture( @@ -658,9 +656,8 @@ impl TikzariumApp { self.font_proof_status = proof.description; self.font_proof_report = proof.font_report; if let Some(bytes) = proof.preview_png { - match image::load_from_memory(&bytes) { - Ok(image) => { - let rgba = image.to_rgba8(); + match decode_preview_png(&bytes) { + Ok(rgba) => { let size = [rgba.width() as usize, rgba.height() as usize]; let color = egui::ColorImage::from_rgba_unmultiplied(size, rgba.as_raw()); From 2e2cb51932bc8c3503407c4aceb3b8f8396488f3 Mon Sep 17 00:00:00 2001 From: i-shutov Date: Sat, 31 Oct 2026 11:19:52 +0300 Subject: [PATCH 3/5] docs(B06): 2026-10-31_B06_BOUNDED_PNG_DECODE.md acceptance and QA ledger --- plans/2026-10-31_B06_BOUNDED_PNG_DECODE.md | 27 ++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 plans/2026-10-31_B06_BOUNDED_PNG_DECODE.md diff --git a/plans/2026-10-31_B06_BOUNDED_PNG_DECODE.md b/plans/2026-10-31_B06_BOUNDED_PNG_DECODE.md new file mode 100644 index 00000000..fba63ea3 --- /dev/null +++ b/plans/2026-10-31_B06_BOUNDED_PNG_DECODE.md @@ -0,0 +1,27 @@ +# B06 — fail-closed bounded PNG decode for all egui preview surfaces +Date: 2026-10-31. Independent lane B. Exact base main: `8764a732b9a846a6ba1f111c9b656781195a6412`. + +## Background and ownership +At start of work, draft PRs #25 (B05 Poppler timeout/PNG **compressed** size), #26 (C), #27 (D), #28 (A) were open. PR #24 integration is merged; source-accurate log navigation was already delivered. #16 themes, #19 pagination, #20 font hints and #23 I04 multipage are already in main. Do not duplicate them. + +This B06 patch changes only `crates/tikzarium-app/src/main.rs`, new `src/preview_image.rs` and B06-specific `plans/`, `insights/`, `QA_PREVIEW_IMAGE_LIMITS.md`. **No change to `tikzarium-core`, PDF data, source TeX, GUI themes or extensions.** It can merge independently of #25 because it touches neither `pdf_navigation.rs` nor B05 documents. + +## Threat / acceptance contract +A 64 MiB PNG on disk can describe enormous image dimensions and exhaust memory in egui. All three preview decode entry points previously called `image::load_from_memory` with no explicit pixel bound: +1. First page from XeLaTeX build artifact. +2. Navigated PDF page from Poppler worker. +3. Figure-font proof bitmap. + +- [x] One PNG-only decode helper used by all three paths. +- [x] Reject malformed/truncated PNG signature/IHDR, dimensions 0, edge > 8192, or > 8,388,608 pixels before handing data to the image decoder. +- [x] Bound compressed preview bytes at 64 MiB even for page-one and font-proof paths, independent of #25. +- [x] Configure image-rs strict width/height limits and best-effort allocation limit (96 MiB). +- [x] Preserve last-good navigated page/texture and visible explicit errors on rejection. No mutation of PDF/TeX. +- [x] Regression unit tests for valid RGBA bytes, fake high-compression-bomb header, bad/truncated headers, width/height/pixel/byte boundaries. +- [ ] Exact-head Rust fmt, Clippy and tests on GitHub Actions — **pending** until checked. +- [ ] Live Windows 10 22H2 x64 and physical macOS ARM64 GUI, native dialogs, scaling, IME and screenshots — **NOT_RUN**. + +**Limits:** 8,388,608 pixels implies at most 32 MiB for one RGBA8 bitmap; peak app memory is larger because decoder intermediates, egui CPU image, GPU texture, existing cached previews and allocator overhead are separate. `max_alloc` is non-strict and not a process-memory cap. Accepted high-resolution figures exceeding policy show a clear error instead of rendering; original PDF remains exportable and unmodified. + +## Next small workpack +After exact SHA CI succeeds, request platform-actual QA using `crates/tikzarium-app/QA_PREVIEW_IMAGE_LIMITS.md` with hashes, screenshots and precise host facts. If large-document workflows need >8M pixels, design an opt-in downscaling/tiled renderer using worker-side memory accounting; do **not** silently lift caps. If B05 merges, separately check any new image-decoding path uses this helper. From 6f2ddf30e9ee12beba57955821e078b398c72a81 Mon Sep 17 00:00:00 2001 From: i-shutov Date: Sat, 31 Oct 2026 11:19:54 +0300 Subject: [PATCH 4/5] docs(B06): 2026-10-31_B06_PREVIEW_DECODE_QA.md acceptance and QA ledger --- insights/2026-10-31_B06_PREVIEW_DECODE_QA.md | 27 ++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 insights/2026-10-31_B06_PREVIEW_DECODE_QA.md diff --git a/insights/2026-10-31_B06_PREVIEW_DECODE_QA.md b/insights/2026-10-31_B06_PREVIEW_DECODE_QA.md new file mode 100644 index 00000000..75dd0045 --- /dev/null +++ b/insights/2026-10-31_B06_PREVIEW_DECODE_QA.md @@ -0,0 +1,27 @@ +# B06 — preview decode QA evidence and integration handoff +Date: 2026-10-31. Base `main` SHA `8764a732b9a846a6ba1f111c9b656781195a6412`; own branch `feat/b06-bounded-png-decode`. + +## Starting observations +- B05 draft [PR #25](https://github.com/i-shutov/tikzarium/pull/25) HEAD `f1fae6633ba71fdc005b3e8b9cdae54560152fb6`: three exact-head hosted workflows showed `completed/success` on 2026-10-31, [CI 41180141533](https://github.com/i-shutov/tikzarium/actions/runs/41180141533), [D 41180141552](https://github.com/i-shutov/tikzarium/actions/runs/41180141552), [font gate 41180141517](https://github.com/i-shutov/tikzarium/actions/runs/41180141517). Physical Win10/Mac GUI remains NOT_RUN. This does not constitute B06 QA. +- B05's 64 MiB compressed file cap does not prevent dimension-based decompression bombs. Existing main decoded PNG via `image::load_from_memory` in three GUI flows without preflight. +- Rust toolchain (`cargo`, `rustc`) and physical Windows/macOS desktop sessions are unavailable in this executor. Local Rust runtime/GUI screenshots: **NOT_RUN**. + +## B06 source-level implementation +- New `preview_image.rs`: PNG signature and IHDR length/type validation; size/edge/pixel guards; image-rs reader with strict image dimension caps plus best-effort `max_alloc`; returns RGBA8 only on success. +- `main.rs` routes initial XeLaTeX page, navigated page and font proof through the same guard. No change to figure contents, theme, UI persistence, page numbers or core API. +- Four unit tests added for valid pixel preservation, giant-dimension/zero-dimension rejection, malformed/truncated headers, compressed and pixel limit boundaries. + +## Evidence ledger (update only on observed exact-head checks) +| Gate | Status | Evidence | +| --- | --- | --- | +| GitHub Rust cargo fmt/test/clippy | PENDING | inspect exact branch SHA Actions | +| GitHub D QA multi-host | PENDING | inspect exact branch SHA Actions | +| GitHub font/figure integrity | PENDING | inspect exact branch SHA Actions | +| Local cargo/rustc | NOT_RUN | toolchain unavailable | +| Physical Windows 10 x64 egui/dialog/IME/screenshot | NOT_RUN | no native session | +| Physical macOS M1/M4 Retina/egui/dialog/screenshot | NOT_RUN | no native session | +| Malformed PNG injection into real GUI | NOT_RUN | only unit fixtures prepared | +| TeX/PDF hash invariance in live GUI | NOT_RUN | QA protocol supplied | + +## Known limitations and handoff +A valid 8M-pixel image can still use multiple RGBA copies and GPU memory; these checks **bound image dimensions**, not entire process RSS, rendering time or GPU memory. The image decoder's `max_alloc` is non-strict. Future worker-side decoding or tiling requires a separate review. No physical platform PASS is inferred from Windows Server 2022 or hosted Mac ARM runners. From f76eef6012b7889e6139f199c5eb0e7be3d9d6ce Mon Sep 17 00:00:00 2001 From: i-shutov Date: Sat, 31 Oct 2026 11:19:56 +0300 Subject: [PATCH 5/5] docs(B06): QA_PREVIEW_IMAGE_LIMITS.md acceptance and QA ledger --- .../tikzarium-app/QA_PREVIEW_IMAGE_LIMITS.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 crates/tikzarium-app/QA_PREVIEW_IMAGE_LIMITS.md diff --git a/crates/tikzarium-app/QA_PREVIEW_IMAGE_LIMITS.md b/crates/tikzarium-app/QA_PREVIEW_IMAGE_LIMITS.md new file mode 100644 index 00000000..a292e7c3 --- /dev/null +++ b/crates/tikzarium-app/QA_PREVIEW_IMAGE_LIMITS.md @@ -0,0 +1,18 @@ +# B06 — native screenshot & hostile PNG preview QA +`STATUS: NOT_RUN` on actual Windows 10 x64 and physical macOS ARM64. + +Record before each run: B06 exact HEAD SHA, OS name/build and CPU, GPU/backend, display resolution and DPI/Retina scaling, XeLaTeX/Poppler versions, app hash, source TeX SHA-256, generated PDF SHA-256, UI theme/density, timestamp, screenshots and screenshot SHA-256. **Do not substitute Windows Server CI or hosted macOS compilation for physical interactive tests.** + +| Case | Procedure | Acceptance criteria (not observations) | +| --- | --- | --- | +| B06-01 | Build valid 2-page `fixtures/two_pages.tex` with XeLaTeX; switch page 1→2→1 at different zooms | Both distinct pages render; no wrong-page stale epoch; TeX/PDF hashes unchanged | +| B06-02 | Generate small valid RGBA PNG fixture and feed to page-one, page-two, and font-proof preview paths in controlled test harness | Image pixels preserved, no spurious rejection; native GUI updates | +| B06-03 | Inject PNG with valid signature/IHDR dimensions 8192×8192 but tiny compressed payload into controlled preview test | Immediate rejection before decode; text error visible, old page retained | +| B06-04 | Inject truncated header, wrong IHDR type, zero dimension, oversized compressed PNG | Explicit failure with no panic, crash, phantom PASS or source/PDF mutation | +| B06-05 | Repeat Light/Dark/Midnight/Paper × compact/comfortable, switch back and restart | Preferences persist locally, PDF and TikZ content/colors never change | +| B06-06 | On Windows 10 use real Open/Save As dialogs, Cyrillic input/IME; on Mac ARM test Retina scaling and Unicode | Dialogs functional, error panel reachable; no overflow at 1366×768 | +| B06-07 | Render accepted large bitmap near 8,388,608 pixels; inspect process memory and UI responsiveness | No unbounded growth or freeze; capture measured RAM/VRAM, not inferred limits | + +Use isolated/test-only renderer injection: never modify user TeX installation or persistent system PATH. Avoid saving hostile PNG as an accepted work product. For each case record PASS/FAIL/NOT_RUN with actual observation, precise build SHA and hashes. No B06 platform acceptance until physical screenshots and logs are attached. + +Guard details: compressed PNG <=64 MiB, width/height 1..=8192, pixel count <=8,388,608, image-rs decode max_alloc hint 96 MiB (best effort). PNG dimensions are checked before decompression, but egui GPU and CPU copies can exceed a single bitmap's size; no total RSS guarantee.