# Security invariants for contributors
The user-facing summary of edamame's threat model lives in [`../security.md`](../security.md). This page is the contributor half: how each protection is implemented, the reasoning behind it, and the checklist of things a change must not regress.
## Threat model
The primary adversary is **the author of a Markdown document you open**. edamame is a viewer/editor, so the realistic attack is: someone sends you a `.md` file (or you open one from an AI agent, a repo, a download), and the file — together with any local or remote resources it references — tries to do something you didn't intend.
Document content reaches several non-trivial subsystems:
- **Image decoding** — embedded/referenced PNG/JPEG/GIF/BMP/WEBP and SVG.
- **Remote fetches** — `http(s)` image URLs.
- **Mermaid diagrams** — fenced ` ```mermaid ` blocks rendered to images.
- **Display math** — `$$...$$` blocks rendered to images.
- **Syntax highlighting** — fenced code-block bodies parsed by TextMate grammars.
- **Link opening** — clicking a link hands a URL/path to the OS.
- **HTML export** — the document is serialized to a shareable HTML file.
- **Subprocess spawning** — `$EDITOR`, custom export commands.
Out of scope: a malicious *local config* (`config.toml`, `keybindings.toml`, custom export commands, `$EDITOR`) is trusted, as is the terminal emulator.
Two principles run through the design:
1. **Document content never reaches a shell.** Subprocesses are always spawned with an explicit argv vector (`open::that`, `Command::new(...) .arg(...)`), never via a shell string. No attacker-controlled bytes are interpolated into a command line.
2. **Cost and capability are bounded before content is trusted.** Decodes are size-limited, network access is consent-gated, and the export path sanitizes anything executable.
## Hardening in place
### Image decoding is bounded (decompression bombs)
A highly compressed raster expands enormously on decode — PNG zlib ratios routinely exceed 1000:1 — so a small file can ask the decoder to allocate gigabytes. The decode worker runs under `catch_unwind`, which contains a *panic* but **not** an OOM: a large enough allocation aborts the whole process.
Mitigations (`src/image/loader.rs`):
- Raster decoding goes through `image::ImageReader` with `image::Limits` in force: `max_image_width`/`max_image_height` capped at 50 000 px and `max_alloc` at 256 MB. A decode bomb returns a `Decode` error instead of exhausting memory. **Do not** revert to `image::load_from_memory`, which allocates the full pixel buffer with no ceiling — and note the cap has to be enforced *at decode time*, because the loader's `pre_resize` downscale runs only *after* the full buffer already exists.
- Local image files are size-capped (64 MB) via a `metadata` check before `std::fs::read`. Remote bodies are already capped at 10 MB by ureq.
- SVG rasterization (`src/image/svg.rs`) clamps the output pixmap to the cell envelope when the caller supplies one, and to an absolute ceiling (8192 px per side, 4 M pixels total) in every case — the image loader may pass no envelope, and then a document's declared SVG size would otherwise be the allocation. An over-size SVG is scaled down to fit rather than refused, so a giant declared size costs resolution, not an unbounded allocation.
### Remote access is consent-gated and IP-filtered
Remote image fetching is off unless the user allows it. The remote-image policy defaults to **Ask**: edamame prompts on document load and only fetches `http(s)` URLs after the user opts in (`src/image/loader.rs`, `RemoteImagePolicy`). Fetches are bounded by a 10 s timeout across connect / receive-response / receive-body and a 10 MB body cap. ureq uses rustls (no system OpenSSL).
Even after consent, fetches are filtered against internal address ranges to prevent SSRF. `PublicOnlyResolver` (`src/image/loader.rs`) wraps ureq's default resolver and drops any *resolved* address that is loopback / RFC1918 private / link-local (including the `169.254.169.254` cloud-metadata endpoint) / carrier-grade NAT / unique-local or link-local IPv6 / unspecified / broadcast / documentation; a host that resolves only to internal addresses is reported `HostNotFound`. Filtering the resolved IP (not the hostname) defeats literal-IP URLs, DNS names pointed at private space (DNS rebinding), and `3xx` redirects uniformly — ureq re-resolves every hop through the same resolver. IPv4-mapped IPv6 addresses are unwrapped and re-checked so `::ffff:127.0.0.1` cannot slip past.
### HTML export removes executable content
The exported HTML is the one artifact a user typically **shares**, so the exporter (`src/export/html.rs`) is the most safety-critical path. Three layers protect it, and the threat they answer is narrow: script in a local `file://` page gets an opaque origin and cannot read the user's files, so the realistic harms are tracking, phishing UI, and XSS once an export is hosted on a website. Each layer therefore removes only what runs, and keeps everything a document legitimately uses.
- **The body is sanitized with an allowlist, not stripped.** `sanitize_body` runs the serialized body through `ammonia` (html5ever-based; the sanitizer crates.io and docs.rs use), so raw HTML a README relies on — `<details>`, `<kbd>`, `<sub>`, `<img width>`, `<p align>` — survives, and nothing that runs does: `<script>` and `<style>` go with their contents, `on*=` handlers and every unlisted attribute are dropped, comments are removed, and unlisted tags are unwrapped. The additions to ammonia's defaults are each inert without script, which the export carries none of: `id`/`class`/`align` anywhere (heading anchors, footnotes, code languages), `name` on `a`, `open` on `details`, an `input` restricted to `type="checkbox"` (task lists), and `style` on `th`/`td` filtered to `text-align` (pulldown's column alignment) — every other CSS property is dropped, since CSS can fetch (`url()`) and overlay the page.
- **Link schemes are a denylist: `javascript`, `vbscript`, `data`.** Those are the only schemes a browser executes, and the set is closed; an allowlist broke every app link (`obsidian:`, `zotero:`, `vscode:`, `file:`) it didn't anticipate. ammonia takes only an allowlist, so `url_schemes_in` collects every scheme-shaped token in the body and subtracts `BLOCKED_URL_SCHEMES`. Over-collection is harmless; **the subtraction is the safety boundary** — ammonia parses each URL as a browser does (entities decoded, tab/newline stripped, scheme lowercased), so a parsed `javascript` is never in the set however the source spells it. A refused link loses its `href` and keeps its text. `data` is re-added for `img src` only (the self-contained export's embedded images; an SVG loaded as an image runs no script), and the attribute filter removes it from every other URL attribute (`has_data_scheme` mirrors the browser's normalization).
- **Highlighted code is escaped before the sanitizer sees it, and the sanitizer still sees it.** `highlight_code_blocks` writes its spans as `Event::Html` into the stream *before* `sanitize_body`, unlike figures: `span` and `class` are already in the allowlist, so it needs no new permission, and every byte of code text goes through `html_escape` first (`highlighted_code_cannot_inject_markup`). `highlight_block_unbudgeted` keeps the byte and line caps per block, so each block's parse is bounded, but the total scales with document size, and grammar compilation is not rationed by `MAX_HIGHLIGHT_GRAMMARS` (that budget protects the render thread's frame time) — acceptable on the export's own thread. It must not mark grammars warm in the shared budget (see [media-export.md](media-export.md#export)).
- **Figures are added after sanitizing, as SVG images, never inline.** Inline `<svg>` can carry `<script>`, `foreignObject`, and `on*=` handlers. `svg_figure` embeds a Mermaid diagram / `$$...$$` formula as `<img src="data:image/svg+xml;base64,…">`: an SVG loaded as an image runs no script and fetches nothing, by browser design. Second layer: `image::normalize_svg` re-serializes it through usvg's tree, which cannot express script, `foreignObject`, handlers, CSS, or external references, and outlines text (which is also what makes a formula's KaTeX glyphs display in a browser at all). The figure markup never passes through the sanitizer: each figure enters the stream as a placeholder element, `<div class="edamame-figure-{nonce}-{n}"></div>` (`Figures`), and is spliced in after `sanitize_body`. The per-export random nonce keeps a document from forging a placeholder; the placeholder's own `"` and `<` keep the real one from being spliced anywhere but element content — if a document's unclosed attribute or text context swallows it, the sanitizer escapes or drops it, it no longer matches, and the figure is left out (`figure_placeholders_stay_out_of_attribute_values`). On render failure each falls back to an HTML-escaped code block (`language-mermaid` / `language-math`), so the source is never lost. Inline `$...$` math is always emitted as literal source, never a math span.
### Exports ask before embedding files from outside the document's folder
A self-contained export base64-embeds referenced images into the output. Because that output may be shared, an embedded file *leaves the trust boundary*, unlike on-screen rendering, where the bytes never leave the victim's machine. A hostile `` could exfiltrate an image file of the victim's into an artifact the victim sends back.
Every `<img src>` — a Markdown image or raw HTML alike — is decided in one place: `ImageResolver` (`src/export/html/images.rs`), run as the sanitizer's `img src` hook on the serialized body, under one of three `ImageHandling` modes. **Link** (HTML, Inline images off) leaves sources as written for the browser. **Embed** (HTML, Inline images on) embeds approved local images and leaves the rest. **Sealed** (every custom format) embeds what it may and removes every other reference.
- **Only image files.** A reference is embedded only if it is an existing file with an image extension (`png`, `jpg`/`jpeg`, `gif`, `webp`, `bmp`, `svg`), so `../../.ssh/id_rsa` is never a candidate. The extension decides only whether a file is a candidate; the embedded type comes from the contents (`bytes_data_uri`), never the extension, because a converter handed an SVG labeled `image/png` may still parse it as SVG (WeasyPrint falls back to SVG when its raster decoder fails) and follow its references (`a_disguised_svg_is_typed_by_its_content`). Files over 64 MiB are not read. An embedded SVG goes through `normalize_svg` first, so it carries no `<image href>` to a local file.
- **Images outside the document's folder are listed and confirmed.** Before exporting, `outside_images` (`src/export/html.rs`) collects every image that resolves *outside* — through `..`, by absolute path or `file:` URL, or via a symlink leading out, since containment is checked after `canonicalize` — and the export modal lists each by its full canonical path. **Embed** adds exactly those paths to `approved_outside`; **Don't embed** leaves them as links (Embed mode) or removes them (Sealed). It reads the sources through the same sanitizer hook `render_html` uses, so it lists exactly what the export would reach, raw-HTML `<img>` included. References are percent-decoded and stripped of `?query`/`#fragment` before resolution (`local_path`), as a browser resolving them would. A reference naming another host — `file://host/…`, or a path starting with two slashes or backslashes (after decoding) — is never local: on Windows it is a UNC path, and `canonicalize` would open an SMB connection to it.
- **A converter is handed no reference to follow.** A custom format's converter (weasyprint, pandoc, …) resolves every `<img src>` itself — relative and absolute paths, `file:` URLs, remote URLs — and writes what it reads into the output, with none of edamame's gates: no confirmation, no remote consent, no `PublicOnlyResolver`. So a custom export is always Sealed, whatever the Inline images toggle says, and always runs the outside-images confirmation. Sealed embeds approved local images; fetches remote ones through `image::fetch_remote` (the SSRF-guarded path) only when images are on and remote images are permitted (`App::images_permitted` and `App::remote_images_permitted`: the on-screen consent test, minus terminal capability), typing the bytes by content and normalizing an SVG; keeps a `data:` URI only if its decoded *contents* are raster, re-labeled by them; and removes every other `src`, including SVG `data:` URIs, which could reference files. Each removal is counted (`ImageResolver::left_out`) and reported in the success message, so a PDF missing images is never silent.
An image whose `canonicalize()`d path stays under the document's folder is embedded without a confirmation.
### Subprocesses take no document-controlled input
- **External editor / custom export** (`src/app/external_editor.rs`, `src/export/custom.rs`): commands come from `$EDITOR`/`$VISUAL` and user config — both trusted — and are exec'd as an argv vector with no shell. No document data reaches argv.
- **Link/file opening** (`src/app/nav.rs`, `src/editor/link.rs`): the URL or path is passed to `open::that`, which spawns `xdg-open`/`open`/`start` with the target as a single argument — again, no shell interpolation.
### Mermaid rendering cannot execute code or touch the network
`mermaid-rs-renderer` is pure Rust: its dependency set contains no JS engine, no headless browser, and no HTTP client. Mermaid source cannot execute code, read files, or make network requests through the renderer. It is pinned exactly (`=0.2.2`) because it is pre-1.0 with known panic bugs; those panics are contained by `catch_unwind` at two layers (`src/diagram/mermaid.rs`).
The renderer has no internal length, node-count, or timeout bound, so a pathological diagram could otherwise drive unbounded CPU/RAM on the decode worker. `render_mermaid_svg` rejects any source over 64 KiB before dispatch (`MAX_MERMAID_SOURCE_BYTES`) — a single choke point covering both the TUI raster path and the HTML exporter — and an over-cap block falls back to the plain code block.
### Display math shares the same protections
`$$...$$` blocks are rendered by RaTeX (`src/diagram/math.rs`), a pure-Rust KaTeX engine with no JS engine, headless browser, or HTTP client. The KaTeX fonts are compiled into the binary (`src/assets/katex/`) and registered in the shared fontdb, so nothing is fetched or read from disk to render a formula. Otherwise it mirrors Mermaid: pre-1.0 with known panic bugs, so wrapped in `catch_unwind` with a `terminal::ExpectedPanic` guard; no internal size bound, so `render_latex_svg` rejects sources over 64 KiB (`MAX_LATEX_SOURCE_BYTES`) at the one choke point for TUI and export; rasterized through the same `src/image/svg.rs` clamps; exported only as a normalized SVG `<img>` (`mermaid_export_never_emits_raw_svg_or_script`, `display_math_exports_as_an_svg_figure`).
### Syntax highlighting is bounded and cannot execute code
Fenced code-block bodies are attacker-controlled text, and syntax highlighting hands them to `syntect`'s TextMate grammars — regex programs running on a backtracking engine, which is a classic denial-of-service shape. The grammars are data, not code: they cannot execute anything, read files, or make network requests, and syntect is built with `default-features = false` so neither its `.tmTheme`/`.sublime-syntax` *runtime* loaders (`plist-load`, `yaml-load`) nor its HTML writer are compiled in. The only dumps it deserializes are `include_bytes!` blobs fixed at build time.
Three caps in `src/markdown/highlight.rs` bound the cost, and all three bound **color, never content** — over any of them the block still renders every byte, unhighlighted, because a user must always be able to read their own code:
- `MAX_HIGHLIGHT_SOURCE_BYTES` (64 KiB) bounds the *cold* parse; no parser is constructed for a larger block.
- `MAX_HIGHLIGHT_LINE_CHARS` (2 000) bounds the *per-keystroke* cost, which the byte cap cannot: incremental reuse needs an unchanged prefix, and a one-line block (a minified bundle, a pasted base64 blob) has none.
- `MAX_HIGHLIGHT_GRAMMARS` (24) bounds *grammar compilation*, which scales with how many languages a document names rather than block size (fifty one-line fences in fifty languages pass both other caps and still cost ~430 ms of compiles). Compilation runs on the warm worker (`highlight::spawn_warm_worker`), so the cap bounds the background CPU and queue memory one document can claim, as a refilling burst rather than a lifetime limit.
The warm worker's compile has no time limit either: a stuck one occupies the worker and leaves every grammar queued behind it plain, and the event loop's poll of it backs off to once a second after `SYNTAX_WARM_STALL` rather than waking an idle editor at frame rate (see [syntax-highlighting.md](syntax-highlighting.md)).
The caps were sized against measurement — the `#[ignore]`d `throughput` test in that module. *Parsing* runs synchronously on the render thread, so there is no worker to absorb a slow parse; the tokenizer is wrapped in `catch_unwind` (with an `ExpectedPanic` guard, see the checklist) so a grammar bug degrades one block to plain text. The design behind the caps, the budget, and the sync/async split is in [syntax-highlighting.md](syntax-highlighting.md).
### SVG parsing reads no external entities, network, or local files
`usvg`/`roxmltree` (`src/image/svg.rs`) does not expand external DTD entities (no XXE), performs no network requests, and ignores `http(s)` `<image href>` (no SSRF). usvg's default string resolver *would* read local files referenced by `<image href="/abs/path">`, so every usvg parse (`usvg_options`, shared by the rasterizer and `normalize_svg`) installs a custom `image_href_resolver.resolve_string` that refuses every path/URL href (embedded `data:` images still resolve). This removes local-file reads from untrusted SVGs entirely — render-only on the TUI path, but on the export path a resolved file would be written into the shared SVG, so the resolver is also what closes that exfiltration channel.
### Other bounded surfaces
- **Update check** (`src/app/update_check/`): a hardcoded `https://api.github.com` URL (not influenceable by the open document or by config), a 256 KB body cap, a 10 s timeout across connect / receive-response / receive-body, and a detached worker. Nothing is downloaded or executed, and the request carries no identity beyond an `edamame/<version>` User-Agent.
It is the one network request made without a per-use prompt: automatic at startup, at most once per 24 h, gated by `editor.check_for_updates` (default on). On a first run it waits for the welcome screen's answer, so a decline there means no request at all. It is treated differently from remote images because nothing about it derives from the opened file, so there is no tracking-pixel equivalent to gate. An explicit "Check for updates" ignores both the setting and the throttle.
The two response fields read are remote text, bounded on the worker before anything else sees them. `tag_name` is capped at 64 bytes and restricted to the semver alphabet, else the release is discarded, because it is interpolated into the URL handed to the browser. The `body` is cut at `## Install`, stripped of control and invisible formatting characters (bidi overrides, zero-width joiners), and capped in lines and bytes; it is rendered **a line at a time, never re-parsed as Markdown or HTML**, so it cannot inject links, images, or layout. Details and the enforcing functions are in [update-check.md](update-check.md).
- **Image cache** (`src/image/cache.rs`): keyed by literal URL (raster) or content `sha256` (diagrams); per-`EditorState`, GC'd to the live URL set each reparse (bounded growth), failed decodes memoized (no retry storm).
- **File watcher** (`src/watcher/file_watcher.rs`): reads only the user's own opened file; never writes; no exploitable check-then-act gap.
- **Atomic export writes** (`src/export/runner.rs`): `write_atomically` creates its temp file with a random, `O_EXCL` name via `NamedTempFile` in the target directory, then `persist`s (same-dir rename) over the output. No predictable sibling name a local attacker could pre-plant as a symlink to redirect the write.
## Things to watch out for (future hardening)
These are not zero-interaction vulnerabilities today — each is gated by consent, a deliberate click, or trusted config — but each would raise the floor, and a careless change nearby could turn one into a real hole.
### No confirmation before opening risky link targets
Following a link auto-opens with no prompt. Any URI scheme is handed to the OS handler (`vscode://`, `tel:`, custom schemes), and a non-Markdown local file — including a `file://`-rerouted `.desktop` file or executable — is passed to `xdg-open`, which may *launch* rather than view it. This requires a deliberate click on a visible target (and involves no shell), so it mirrors standard Markdown-viewer behavior. The user page warns about it under "Links open without asking".
> **Future work:** a confirmation modal showing the resolved destination for non-`http(s)` schemes and non-Markdown local files would close the "bundled malicious sibling file" scenario. Scope it narrowly — open `http(s)` and `.md`/image targets directly, prompt only for other schemes and local non-Markdown/non-image files — so it doesn't become a nag. See `src/app/nav.rs`, `src/editor/link.rs`.
## Invariants checklist
When touching any content-handling path — image/SVG decode, remote fetch, Mermaid, display math, link opening, HTML export, subprocess spawning — keep these from regressing:
- **Decode through `ImageReader` + `Limits`.** A new raster decode site must keep the limits. Never call `image::load_from_memory` on bytes that came from outside the process — a file, a socket, a document. (The one in-tree call, at the end of `image::svg::rasterize_svg`, decodes the PNG it encoded two lines earlier from an already-bounded pixmap; that is the only shape of exemption there is.)
- **Never spawn a shell with document data.** Use argv vectors. If you add a new subprocess, audit every argument's provenance.
- **The HTML exporter's three layers are load-bearing.** Everything the document controls must reach the body *before* `sanitize_body`; only markup edamame generates itself may be added after, and only through `Figures`. A new post-sanitize insertion is a bypass — re-derive its safety explicitly and add a regression test. Never add `javascript`, `vbscript` or `data` to the ammonia scheme set, and don't widen `style` beyond `text-align` or `data:` beyond `img src`. The existing tests in `src/export/html.rs` (`neutralizes_obfuscated_script_schemes`, `strips_css_except_table_alignment`, `figure_placeholders_cannot_be_forged`, `figure_placeholders_stay_out_of_attribute_values`, `mermaid_export_never_emits_raw_svg_or_script`, `inline_images_skips_an_unapproved_absolute_path`, …) are the template.
- **Keep network access consent-gated *and* IP-filtered.** A new fetch path must respect the remote-image policy (or introduce its own explicit gate) and route through `PublicOnlyResolver` (or an equivalent resolved-IP filter); don't add a silent or unfiltered `http` request. The update check (`src/app/update_check/fetch.rs`) deliberately has neither: its gate is its own setting, and its host is a compile-time literal, so there is no attacker-chosen name to rebind. A new fetch that takes its host from anywhere else owes the resolver.
- **Keep the SVG pixmap budget between the caller and the allocation.** `src/image/svg.rs` clamps to the cell envelope only when the caller supplies one (the image loader may pass none, `max_cells: None`), so `MAX_RASTER_DIM` / `MAX_RASTER_PIXELS` are what bound every other path. They are applied to the *rounded* dimensions and folded back into the render transform — a refactor that clamps the pixmap without the transform crops the drawing, and one that clamps before `ceil` lets the budget be exceeded by a pixel per axis.
- **An out-of-folder image reaches an export only by its confirmed canonical path.** `export::html::images::local_image` checks containment *after* `canonicalize`, and an outside path is embedded only if it is in `HtmlExportOptions::approved_outside`, filled from exactly the list `outside_images` produced and the user saw. Don't replace that list with a blanket "allow outside" flag — the list is what keeps approval from reaching a file the prompt never showed — and keep the embed restricted to existing image-extension files. A new way to embed local files into an export owes the same list-and-confirm step, and a new way for image references to reach the body must go through the sanitizer's `img src` hook, or `outside_images` won't see it (`inline_images_skips_unapproved_parent_traversal`, `approval_does_not_extend_to_an_unlisted_outside_image`, `raw_html_images_are_listed_and_embedded_like_markdown_ones` and `outside_images_sees_through_a_symlink_leading_out` are the template).
- **A format whose consumer resolves references itself is Sealed.** Anything other than a browser opening the HTML — a converter today — must get `ImageHandling::Sealed`, so the output contains no image reference but embedded data. Don't widen what Sealed keeps (`data:` whose contents are raster, typed by those contents) or let it leave a path "because the converter needs it": the converter is exactly the reader the outside-images list and the remote gate can't see (`a_sealed_export_leaves_no_reference_for_a_converter_to_follow`). Non-image references matter too: WeasyPrint embeds the target of `<a rel="attachment">` / `<link rel="attachment">` into the PDF, which is closed only because `rel` is off the `<a>` allowlist and `<link>` isn't an allowed tag. Don't allow either (`no_attachment_link_reaches_a_converter`).
- **A pasted image's path is relative, and the write never replaces an existing file.** `image::paste::resolve_destination` refuses absolute paths, `:` and control characters, and a taken name; `..` is allowed, for documents in subfolders that share an image folder. `image::paste::write` opens with `create_new`, so a file that appeared since the check is refused rather than clobbered, and a symlink at the final name is refused rather than followed. Nothing confines the path to a directory — neither `..` nor a symlinked directory along it — so an untrusted document can have `infer_dir` propose an odd location. That is accepted because the user sees and confirms the full path, the extension is forced to `.png`, and nothing is overwritten. A new way to write a file the user did not name in full owes the same two rules.
- **Bound new external input.** Size, timeout, and allocation caps before the input is trusted — mirror the image-loader, Mermaid-source, LaTeX-source (`MAX_LATEX_SOURCE_BYTES`), and update-check patterns.
- **Keep all three syntax-highlighting caps, and keep them bounding color rather than content.** They are not redundant (see above), and over any cap the block must still render every byte — a code block that hides its own text to stay safe is worse than an uncolored one. *Parsing* runs synchronously on the render thread, so a new grammar source, a raised cap, or a switch to a different regex backend owes a fresh run of the `throughput` test in `src/markdown/highlight.rs`, not just a review. *Compiling* is on the warm worker and `MAX_HIGHLIGHT_GRAMMARS` bounds it; don't move it back onto the render thread to make a test deterministic — `warm_inline` exists for that.
- **A `catch_unwind` over untrusted content owes its thread a `terminal::ExpectedPanic` guard.** The process panic hook restores the terminal and prints the payload to stderr, and it runs *before* unwinding, so it fires for a panic that is about to be caught — leaving the app running with no alt screen and no raw mode, which is strictly worse than the clean crash the hook exists to give. Ten sites have it today (the highlighter's tokenizer and warm worker, Mermaid's renderer and `warm_fontdb`, the display-math renderer, the capability probe, and four steps of the image decode worker in `src/app/image_dispatch.rs`). It applies to *any* `catch_unwind` on a thread alongside a live TUI, not only over untrusted content — `warm_fontdb` renders a hardcoded diagram and still needs it, because the hazard is the hook rather than the input. Scope it to the `catch_unwind` that does the catching and nothing else — a guard left live over the code *after* the catch silences a panic nobody is going to catch, which on the main thread means unwinding out of `main` with the terminal unrestored and nothing printed.
- **Keep syntect's feature list narrow.** `plist-load` and `yaml-load` compile in runtime parsers for `.tmTheme` / `.sublime-syntax` files, and `html` adds an output layer we do not use. Only pre-built, compile-time dumps are ever deserialized. Cargo unifies features across the graph, so a new dependency on syntect (or on `two-face`) can widen this silently — check with `cargo tree -e features -i syntect`, the same trap the `image` / `ratatui-image` pair documents. The feature we depend on is `parsing` (which implies `dump-load`), **not** `default-syntaxes`: `two_face::syntax::extra_newlines` replaces syntect's bundled set rather than extending it, so that feature's asset dumps would be embedded and never read. `Cargo.toml` carries the full argument; don't add it back because a grammar appears to be missing — check the info-string token against `find_syntax_by_token` first.
When you fix or add one of the "Things to watch out for" items above, move it into "Hardening in place", add the regression test alongside, and update the user-facing summary in [`../security.md`](../security.md) — in plain terms, without limits, constants, or code paths.