Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions crates/tikzarium-app/QA_PREVIEW_IMAGE_LIMITS.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 8 additions & 11 deletions crates/tikzarium-app/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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};

Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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());
Expand Down
127 changes: 127 additions & 0 deletions crates/tikzarium-app/src/preview_image.rs
Original file line number Diff line number Diff line change
@@ -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<image::RgbaImage, String> {
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<u8> {
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());
}
}
27 changes: 27 additions & 0 deletions insights/2026-10-31_B06_PREVIEW_DECODE_QA.md
Original file line number Diff line number Diff line change
@@ -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.
27 changes: 27 additions & 0 deletions plans/2026-10-31_B06_BOUNDED_PNG_DECODE.md
Original file line number Diff line number Diff line change
@@ -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.
Loading