crates/gunnar-ui-server/src/readme/mod.rs
//! **A repository's README, typeset — in two shapes, from one source.**
//!
//! ```text
//! ┌─ export_svg_column ─▶ dressed ─▶ kind "svg" (default)
//! README.md (a blob) ────┤
//! └─ export_html ─▶ ammonia ─▶ kind "html" (?as=html)
//! ```
//!
//! The markdown→typst engine is **edda's `nornir-docbook`**, not this crate's.
//! It is the constellation's documentation render primitive and it already had
//! every part of this: pulldown-cmark events into typst markup, a typst-as-lib
//! engine with fonts embedded, out as HTML or SVG. A second translator here
//! would have been a second one to keep correct, and docbook's has scar tissue
//! this one would have had to grow again — its escaping is the way it is
//! because a `**SIGKILL**s` in one file once took down a 297 KB render and
//! named no line.
//!
//! What this module owns is the part that is a *forge's* problem and not a doc
//! engine's: which of a tree's files is the README, which blobs its images are,
//! how big a page a browser may be handed, and what happens when none of it
//! works.
//!
//! # Two grades, and which one is the default — it changed, and here is why
//!
//! **`svg` is the default and is what the console shows.** It is the document
//! actually typeset: a real measure, real tables, the page a person would print.
//!
//! It used to be the toggle, for one reason that no longer holds. `typst-svg`
//! outlines every glyph to a `<path>`, so the page had **no text in it at all** —
//! not selectable, not searchable, invisible to a screen reader beyond the
//! `<title>`. A console cannot lead with a page a blind reader finds empty, so it
//! led with the HTML instead. docbook 0.1.2's `TextLayer` closes that hole: an
//! invisible `<text>` element rides over each shaped run, at the baseline and the
//! advance width the typesetter measured, so the words are selectable, copyable,
//! findable with the browser's search and readable aloud, while the glyphs a
//! reader SEES are still the typeset ones.
//!
//! What it still costs is bytes — roughly **130 bytes of SVG per character of
//! README** — and one click: an `<a>` under the text layer no longer takes them,
//! because the layer has to be on top for a drag to select. Both are why the HTML
//! view is still offered rather than removed.
//!
//! **`html` is asked for with `?as=html`** and is behind the console's "HTML"
//! button. typst's HTML target, sanitised through `ammonia` on the way out — not
//! because typst's elements are suspect, they are ours, but because the **link
//! and image targets inside them came out of somebody's repository**. It is the
//! plainer page, it is a thirtieth of the bytes, and it is where a reader goes to
//! click a link.
//!
//! # The four kinds
//!
//! | kind | when |
//! |---|---|
//! | `svg` | the ordinary answer: it typeset and fits [`MAX_SVG_BYTES`] |
//! | `html` | `?as=html`, and the fallback whenever the typeset page fails |
//! | `text` | the README is not markdown (a bare `README`), or is not text |
//! | `none` | the tree root has no README |
//!
//! **None of them is a 500.** A README is somebody else's file and the console
//! is not entitled to fall over on it.
pub mod cache;
pub mod links;
pub mod pictures;
pub mod queue;
pub use cache::Cache;
pub use pictures::Pictures;
use nornir_docbook::{export_html, export_pdf, export_svg_column, ExportMeta, ImageSet, TextLayer};
use serde::{Deserialize, Serialize};
/// The largest typeset page this console will hand a browser.
///
/// Past this the render is thrown away and the HTML grade is served instead,
/// with the size in `warning`. 4 MiB is about a **32 KB README** — it was 40 KB
/// before docbook 0.1.2's text layer added its ~23%, and the limit was left
/// where it is rather than raised to keep the same README inside it: 32 KB is
/// still far past anything anyone reads on a repository page, and the number
/// that matters is what a phone pays for one `innerHTML`, not how much of a
/// document we would like to fit.
///
/// It also matters more than it did, because this is now the DEFAULT grade: a
/// visitor who asks for nothing is the one who can reach the fallback.
pub const MAX_SVG_BYTES: usize = 4 * 1024 * 1024;
/// The column the typeset page is set to, in points.
///
/// Wide enough for a table of routes at 11 pt, and the SVG is scaled to
/// whatever the reader actually has — the number decides the *measure*, not the
/// size on screen.
pub const COLUMN_PT: f64 = 720.0;
/// The README the console looks for, in order, at the tree root.
///
/// The order is the answer to a repository holding two: the first match wins
/// and nothing scans further, so which one is drawn is a fact and not a
/// coincidence of tree ordering.
pub const CANDIDATES: &[&str] = &["README.md", "readme.md", "README.markdown", "README"];
/// True when this name goes through the markdown pipeline rather than being
/// served as plain text.
///
/// A bare `README` is NOT markdown. GitHub treats it as text and so does this:
/// a file that was never written as markdown renders badly as markdown, and
/// "badly" here means headings appearing where somebody drew a line of `===`.
pub fn is_markdown(path: &str) -> bool {
let lower = path.to_ascii_lowercase();
lower.ends_with(".md") || lower.ends_with(".markdown")
}
/// Which shape the caller asked for.
///
/// A query parameter and not two routes, because it is one document and one
/// cache entry family — and because the console's toggle should change a word
/// in a URL rather than navigate somewhere else.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Shape {
/// The typeset page, as one tall SVG with docbook's selectable text layer
/// over it. **The default** — see the module docs for what changed.
#[default]
Svg,
/// typst's HTML target: plainer, far smaller, and the one whose links take a
/// click. Also the fallback whenever the typeset page cannot be served.
Html,
}
impl Shape {
/// The word that names this shape in a cache key and in an answer.
pub fn name(self) -> &'static str {
match self {
Shape::Html => "html",
Shape::Svg => "svg",
}
}
}
/// One README, as the console shows it.
#[derive(Debug, Clone, Serialize)]
pub struct Readme {
/// `html` | `svg` | `text` | `none`. See the module docs.
pub kind: &'static str,
/// The sanitised HTML, the SVG, or the plain text. Empty for `none`.
pub body: String,
/// Where it was found. Empty for `none`.
pub path: String,
/// The blob's object id — which is also the cache key, and what a client
/// can use to know two renders are the same document.
pub oid: String,
/// Why this is not what was asked for. `None` on the happy path, so a
/// client can tell "there is no README" from "the typeset page was too
/// large" without parsing prose.
pub warning: Option<String>,
}
impl Readme {
/// The repository has no README at the tree root.
pub fn none() -> Readme {
Readme {
kind: "none",
body: String::new(),
path: String::new(),
oid: String::new(),
warning: None,
}
}
}
/// **Everything that goes through the console's `inner_html`, cleaned first.**
///
/// `ammonia`'s default allow-list keeps the document elements — headings,
/// paragraphs, lists, tables, code, links, images — and drops `script`,
/// `style`, `iframe`, `object`, every `on*` attribute and every URL scheme that
/// is not http/https/mailto. It also drops `<html>`, `<head>` and `<body>`
/// while keeping what they wrapped, which is what turns docbook's full document
/// into the fragment a panel wants.
///
/// Applied to typst's HTML output even though those elements are **ours**: the
/// `href` and `src` values inside them are not. A README can write
/// `[click](javascript:…)` and typst will faithfully typeset a link to it.
///
/// # `title` is on the CONTENT list, and finding out why cost a test
///
/// ammonia's default removes the `<title>` **element** and keeps its text,
/// because that is right for a fragment somebody pasted. Here it is wrong:
/// docbook hands back a whole document whose `<title>` is the label this crate
/// put on it, so the default turned every README into one that began
/// "ns/name README.md" as a stray line of prose. `clean_content_tags` is the
/// distinction — remove the element *and* what it wrapped — and `head` is on
/// the list for the same reason.
///
/// # `rel` says `external`, and that is for the console's router
///
/// [`links::rewrite`] points a README's links at console URLs, and the router
/// claims every same-origin `<a>` click unless the anchor opts out. A link to
/// `/api/…/raw` is a file, not a route: claimed, it would move the URL bar and
/// draw nothing — the same bug the Log in button had. `external` hands every
/// README link back to the browser, which loads a console route as well as it
/// loads a PDF.
pub fn sanitise(html: &str) -> String {
let mut builder = ammonia::Builder::default();
builder.clean_content_tags(std::collections::HashSet::from([
"script", "style", "title", "head",
]));
builder.link_rel(Some("noopener noreferrer external"));
builder.clean(html).to_string()
}
/// The metadata docbook's templates read.
///
/// `title` is the only field that reaches the page here: `column.typ` puts it in
/// the SVG's `<title>` and `aria-label`, and `web.typ` in `<title>`. The
/// repository and file, because "README" alone in a browser tab full of them
/// says nothing. `version` and `generated` belong to a book's cover, which
/// neither of these templates draws.
pub fn meta(label: &str) -> ExportMeta {
ExportMeta {
title: label.to_owned(),
version: String::new(),
generated: String::new(),
cover_image: String::new(),
}
}
/// Render one README into the shape that was asked for.
///
/// **Synchronous and CPU-bound** — [`crate::api::readme`] calls it on
/// `tokio::task::spawn_blocking`, because a typeset that takes tens of
/// milliseconds on a runtime worker is time every other request on that worker
/// waits.
///
/// # Errors
///
/// The typesetter's first diagnostic, or the size the page came to when it was
/// over [`MAX_SVG_BYTES`]. Either way the caller falls back rather than failing
/// the request.
pub fn render(
markdown: &str,
label: &str,
pictures: &Pictures,
shape: Shape,
) -> Result<String, String> {
let meta = meta(label);
let images = &pictures.set;
match shape {
Shape::Html => {
let html = export_html(markdown, &meta, images).map_err(|e| e.to_string())?;
// Before the sanitiser, which drops every `data:` URL: see
// `pictures`.
// A small SVG picture is carried as a data URL past it (an
// `<img>` never runs an SVG's script): see `pictures`.
let (html, carried) = pictures::hold_svg_data(&pictures.point(html, shape));
Ok(pictures::restore_svg_data(sanitise(&html), &carried))
}
Shape::Svg => {
// `TextLayer::On` is what makes this grade servable as the default:
// without it the page is a picture of writing, and this console
// would be leading with something a screen reader reads as empty.
let svg = export_svg_column(markdown, &meta, images, COLUMN_PT, TextLayer::On)
.map_err(|e| e.to_string())?;
let svg = pictures.point(svg, shape);
if svg.len() > MAX_SVG_BYTES {
return Err(format!(
"the typeset page came to {} bytes, over the {MAX_SVG_BYTES}-byte limit \
this console will send a browser",
svg.len()
));
}
Ok(svg)
}
}
}
/// The largest PDF this console will typeset and hand over.
///
/// A PDF is not put through `inner_html` — the browser's own viewer opens it —
/// so the reason for the ceiling is not a phone's DOM but the same one
/// [`MAX_BLOB_BYTES`](crate::git::MAX_BLOB_BYTES) has: one request must not be
/// able to ask this process for an unbounded allocation. 16 MiB is far past any
/// document in a repository and comfortably inside what the cache will hold.
pub const MAX_PDF_BYTES: usize = 16 * 1024 * 1024;
/// **The same markdown, typeset to a PDF.**
///
/// docbook's `report.typ`: a cover with the title, an outline, running headers
/// and page numbers — a *document*, which is what somebody asking for a PDF
/// wants, rather than the web column [`render`] draws.
///
/// Both `None`s are load-bearing. The first is docbook's remote-image cache
/// directory, and passing one would turn a repository's `` into
/// an outbound HTTP request made by this process on a stranger's say-so — the
/// server-side request forgery `crate::api::readme` refuses at length. The
/// second is a typst filesystem root, and there is none: this console has no
/// checkout, only blobs over gRPC.
///
/// The PDF carries the same pictures the typeset page does: docbook 0.1.4's
/// `export_pdf` takes the [`ImageSet`] the column export always took. Until it
/// existed this path drew every image as its alt text — measured on gunnar.rs
/// 2026-09-12 as "View PDF" on any README with a badge — and the fix went into
/// docbook rather than into a forge, because that is where the paged pipeline is.
///
/// **Synchronous and CPU-bound**, like [`render`], and goes through
/// [`queue`] for the same reason.
///
/// # Errors
///
/// The typesetter's first diagnostic, or the size the PDF came to when it was
/// over [`MAX_PDF_BYTES`].
pub fn render_pdf(markdown: &str, label: &str, images: &ImageSet) -> Result<Vec<u8>, String> {
let pdf = export_pdf(markdown, &meta(label), images).map_err(|e| e.to_string())?;
if pdf.len() > MAX_PDF_BYTES {
return Err(format!(
"the typeset PDF came to {} bytes, over the {MAX_PDF_BYTES}-byte limit this console \
will send a browser",
pdf.len()
));
}
Ok(pdf)
}
/// **Why a typeset did not arrive — and whose fault it is.**
///
/// The two answers are not alike and the console must not confuse them. A
/// document the typesetter refuses is a fact about that document, it will be
/// refused again in an hour, and the reader is served the plain grade with the
/// reason attached. A console that is busy is a fact about the *box* at this
/// second: the document is fine, there is nothing to fall back to, and the
/// honest answer is a 503 or a 504 and a suggestion to come back.
///
/// Answering [`Trouble::Busy`] with the plain-HTML fallback would be a console
/// that responds to load by doing MORE work per request, which is the shape of
/// every cascading failure.
#[derive(Debug)]
pub enum Trouble {
/// The typesetter refused this document, or its page was over the limit.
Document(String),
/// The console would not typeset anything right now.
Busy(queue::Refused),
}
impl std::fmt::Display for Trouble {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Trouble::Document(why) => f.write_str(why),
Trouble::Busy(refused) => write!(f, "{refused}"),
}
}
}
/// The directory one path lives in: everything before the last `/`, and empty
/// for a file at the tree root.
///
/// One definition, used for both halves of "relative to this document" — the
/// links ([`links::Base::directory`]) and the images — because the two
/// disagreeing means a picture fetched from one blob and linked to another.
pub fn directory_of(path: &str) -> &str {
path.rsplit_once('/').map_or("", |(directory, _)| directory)
}
/// **The last grade: markdown to HTML with no typst in the way.**
///
/// Reached when typst refuses the document, or when the typeset page was too
/// large. It is the same sanitiser and a much simpler front end, so a README
/// that defeats the typesetter still reaches the reader.
pub fn plain_html(markdown: &str) -> String {
let mut options = pulldown_cmark::Options::empty();
options.insert(pulldown_cmark::Options::ENABLE_TABLES);
options.insert(pulldown_cmark::Options::ENABLE_STRIKETHROUGH);
options.insert(pulldown_cmark::Options::ENABLE_TASKLISTS);
let mut html = String::with_capacity(markdown.len() * 2);
pulldown_cmark::html::push_html(
&mut html,
pulldown_cmark::Parser::new_ext(markdown, options),
);
sanitise(&html)
}
#[cfg(test)]
mod tests {
use super::*;
const SOURCE: &str = "# The Heading\n\nSome prose with `code_here` and a \
[link](https://example.test).\n\n\
| A | B |\n| - | - |\n| 1 | 2 |\n\n```rust\nfn f() {}\n```\n";
#[test]
fn the_candidates_are_tried_in_a_fixed_order() {
assert_eq!(CANDIDATES[0], "README.md");
assert_eq!(CANDIDATES.len(), 4);
}
#[test]
fn a_bare_readme_is_not_markdown() {
assert!(is_markdown("README.md"));
assert!(is_markdown("readme.md"));
assert!(is_markdown("README.markdown"));
assert!(!is_markdown("README"));
}
/// **The default is the typeset page**, and it stayed nameable as `svg` in
/// the cache key and in the answer — the two grades did not swap names when
/// they swapped places, or every cached page in every deployment would have
/// been served under the other one's.
#[test]
fn the_default_shape_is_the_typeset_one() {
assert_eq!(Shape::default(), Shape::Svg);
assert_eq!(Shape::Svg.name(), "svg");
assert_eq!(Shape::Html.name(), "html");
}
/// **The HTML grade keeps the words as words**, which is the whole reason
/// it is the default. Asserted as plain text in the body, because
/// "selectable" means a browser can find the characters.
#[test]
fn the_html_grade_carries_the_heading_text_and_no_script() {
let html =
render(SOURCE, "ns/name README.md", &Pictures::new(), Shape::Html).expect("it renders");
assert!(
html.contains("The Heading"),
"the heading must be text: {html}"
);
assert!(html.contains("Some prose"), "{html}");
assert!(
html.contains("code_here"),
"an identifier keeps its underscore: {html}"
);
assert!(!html.contains("<script"), "{html}");
assert!(!html.contains("javascript:"), "{html}");
// Sanitised down to a fragment: the document wrapper is gone and what
// it wrapped is not.
assert!(!html.contains("<html"), "{html}");
assert!(!html.contains("<head"), "{html}");
// …and the document TITLE is gone with its element, rather than left
// behind as a stray first line of prose.
assert!(
!html.contains("ns/name README.md"),
"the document title leaked into the fragment: {html}"
);
}
/// **And the SVG grade carries the words too, now.**
///
/// This assertion used to read the other way — "the glyphs are paths; if the
/// heading text appears the exporter changed" — and it was right about the
/// exporter and was the reason this grade could not be the default. docbook
/// 0.1.2 changed exactly that, so the assertion is inverted rather than
/// deleted: the heading is in the page, as text, in the text layer.
#[test]
fn the_svg_grade_is_a_typeset_page_whose_words_are_still_words() {
let svg =
render(SOURCE, "ns/name README.md", &Pictures::new(), Shape::Svg).expect("it renders");
assert!(svg.starts_with("<svg "), "{}", &svg[..60.min(svg.len())]);
assert!(
svg.contains("viewBox="),
"without a viewBox it cannot scale"
);
assert!(svg.contains("width=\"100%\""), "it must fit a phone");
assert!(
!svg.contains("<script"),
"an inline SVG must carry no script"
);
assert!(
!svg.contains("javascript:"),
"{}",
&svg[..200.min(svg.len())]
);
assert!(svg.contains("<title>ns/name README.md</title>"), "no title");
// The layer, and the heading in it.
assert!(svg.contains("<text "), "no text layer in the typeset page");
assert!(svg.contains("class=\"text-layer\""), "no text layer");
assert!(
svg.contains("The Heading"),
"the heading must be selectable text, not only paths"
);
// It is a document a screen reader can read, and the drawing under it is
// hidden so the same words are not offered twice.
assert!(
svg.contains("role=\"document\""),
"{}",
&svg[..200.min(svg.len())]
);
assert!(
svg.contains("aria-hidden=\"true\""),
"the glyphs stay hidden"
);
// The `<style>` that makes the layer selectable is INSIDE the SVG, and
// the console's CSP must keep `style-src 'unsafe-inline'` for it —
// `security::tests` is where that is nailed down.
assert!(
svg.contains("user-select:text"),
"the layer must be selectable"
);
}
/// **Every colour the typeset page paints is one the console's stylesheet
/// maps onto the theme** (owner, 2026-10-06: the rendered README must
/// follow light, dark and surprise). docbook's template paints in fixed
/// colours, typst writes each as a `fill="#…"` / `stroke="#…"` attribute,
/// and `console.css` repaints each by that exact attribute. A colour a
/// docbook or typst upgrade starts painting that the stylesheet does not
/// map would stay white-paper black in a dark panel; this names it.
#[test]
fn every_colour_the_typeset_page_paints_is_mapped_onto_the_theme() {
let source = "# Heading\n\nProse with `inline` and a [link](https://example.test).\n\n\
> quoted\n\n| A | B |\n| - | - |\n| 1 | 2 |\n\n\
```rust\n// a comment\nfn f() -> &'static str { let n = 1; \"text\" }\n```\n\n---\n\nend\n";
let svg = render(source, "ns/name README.md", &Pictures::new(), Shape::Svg).expect("it renders");
let css = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../gunnar-ui-web/styles/console.css"));
let mut painted: Vec<String> = Vec::new();
for attr in ["fill", "stroke"] {
let open = format!("{attr}=\"#");
for (i, _) in svg.match_indices(&open) {
let hex: String = svg[i + open.len()..].chars().take_while(|c| c.is_ascii_hexdigit()).collect();
let one = format!("{attr}=\"#{hex}\"");
if !painted.contains(&one) {
painted.push(one);
}
}
}
// The page itself, the words, the link, the code box and the rules are
// all in this source; the stylesheet must carry each.
for must in ["fill=\"#ffffff\"", "fill=\"#16191d\"", "fill=\"#0a58ca\"", "fill=\"#f5f6f8\"", "fill=\"#f2f2f4\"", "stroke=\"#cfd4da\""] {
assert!(painted.iter().any(|p| p == must), "docbook no longer paints {must}: {painted:?}");
}
let unmapped: Vec<&String> = painted.iter().filter(|p| !css.contains(&format!(".readme-page svg [{p}]"))).collect();
assert!(unmapped.is_empty(), "the typeset page paints {unmapped:?}, which console.css does not map onto the theme");
}
/// **The PDF carries the picture the typeset page carries.** RED before
/// docbook 0.1.4: `render_pdf` had no image argument and every badge in a
/// README came out as its alt text in "View PDF" — measured on gunnar.rs
/// 2026-09-12. Counted rather than detected, because docbook's report
/// template places a cover picture of its own: with the badge there are
/// strictly more image XObjects than without (a PNG with alpha is two — the
/// raster and its soft mask).
#[test]
fn the_pdf_grade_embeds_the_pictures_it_was_handed() {
const PNG_1PX: &[u8] = &[
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48,
0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00,
0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41, 0x54, 0x78,
0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00,
0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
];
let md = "# Badge\n\n\n\nProse.\n";
let mut images = ImageSet::new();
assert!(images.insert("docs/badge.png", PNG_1PX.to_vec()));
let count = |pdf: &[u8]| pdf.windows(6).filter(|w| *w == b"/Image").count();
let with = render_pdf(md, "ns/name README.md", &images).expect("renders");
let without = render_pdf(md, "ns/name README.md", &ImageSet::new()).expect("renders");
assert!(
count(&with) > count(&without),
"the PDF must embed the badge it was handed: {} image XObjects with, {} without",
count(&with),
count(&without)
);
}
/// ★ **The PDF grade is a PDF**, from the same markdown and the same
/// engine.
///
/// Red-first: there was no third grade at all, and the console's answer to
/// "give me this document as a PDF" was the raw route serving the markdown
/// as an attachment. The assertion is on the file's own magic rather than on
/// a length, because "some bytes came back" is what a stub would also
/// satisfy.
#[test]
fn the_pdf_grade_is_a_document_a_viewer_will_open() {
let pdf = render_pdf(SOURCE, "ns/name .nornir/quickstart.md", &ImageSet::new())
.expect("it renders");
assert!(
pdf.starts_with(b"%PDF-"),
"not a PDF: {:?}",
&pdf[..8.min(pdf.len())]
);
assert!(
pdf.ends_with(b"%%EOF\n") || pdf.ends_with(b"%%EOF"),
"truncated"
);
// A page of prose, a table and a code block is thousands of bytes, not
// an empty document.
assert!(pdf.len() > 1_000, "{} bytes", pdf.len());
}
#[test]
fn a_document_s_directory_is_where_its_neighbours_are() {
assert_eq!(directory_of("README.md"), "");
assert_eq!(directory_of(".nornir/gunnar-quickstart.md"), ".nornir");
assert_eq!(directory_of("docs/a/b.md"), "docs/a");
}
/// A typst-special character in a README is drawn, not executed — docbook's
/// string-literal escaping, seen from this side of the wall.
#[test]
fn a_readme_that_reads_like_typst_is_drawn_rather_than_run() {
let hostile = "# Costs $5 @ #1\n\nCall MAX_BLOB_BYTES.\n\n\
#import \"@preview/cmarker:0.1.0\": *\n";
let html =
render(hostile, "ns/n README.md", &Pictures::new(), Shape::Html).expect("it renders");
assert!(
html.contains("MAX_BLOB_BYTES"),
"the underscores survived: {html}"
);
assert!(html.contains("Costs $5 @ #1"), "{html}");
assert!(
html.contains("@preview/cmarker"),
"the import is drawn as text: {html}"
);
}
#[test]
fn a_page_over_the_size_limit_is_refused_rather_than_sent() {
// 60 KB of prose is roughly 6 MB of glyph paths, past MAX_SVG_BYTES.
let huge = "lorem ipsum dolor sit amet consectetur adipiscing elit\n\n".repeat(1_100);
let refused = render(&huge, "ns/name README.md", &Pictures::new(), Shape::Svg)
.expect_err("a page this size must be refused");
assert!(refused.contains("over the"), "{refused}");
}
/// **The sanitiser is the point**, and it is applied to both grades.
#[test]
fn the_sanitiser_drops_script_style_and_handlers() {
let hostile = "<script>fetch('/api/keys')</script>\
<style>body{display:none}</style>\
<iframe src=\"https://evil.test\"></iframe>\
<img src=x onerror=\"alert(1)\">\
<a href=\"javascript:alert(1)\">x</a>\
<p><strong>kept</strong></p>";
let html = sanitise(hostile);
assert!(!html.contains("<script"), "{html}");
assert!(!html.contains("<style"), "{html}");
assert!(!html.contains("<iframe"), "{html}");
assert!(!html.contains("onerror"), "{html}");
assert!(!html.contains("javascript:"), "{html}");
assert!(html.contains("<strong>kept</strong>"), "{html}");
}
#[test]
fn the_last_grade_still_reaches_the_reader() {
let html = plain_html("# Title\n\n<script>x</script>\n\n**kept**\n");
assert!(html.contains("<h1>Title</h1>"), "{html}");
assert!(html.contains("<strong>kept</strong>"), "{html}");
assert!(!html.contains("<script"), "{html}");
}
/// **Warm is a read transaction and nothing else**, which is the claim the
/// cache makes. Measured rather than asserted as a wall-clock bound,
/// because a loaded CI box is not this laptop — the assertion is the one
/// that would still be true there.
#[tokio::test]
async fn a_warm_read_is_a_read_transaction_and_nothing_else() {
async fn never() -> Result<String, ()> {
panic!("a warm read must not render")
}
let dir = tempfile::tempdir().expect("a temp dir");
let store = crate::state::StateStore::open(dir.path().join("state.redb")).expect("a store");
let cache = Cache::new(Some(store));
let key = Cache::key("ns", "n", "oid", "README.md", Shape::Html);
let big = "x".repeat(347 * 1024);
cache
.get_or_render(&key, || async { Ok::<_, ()>(big.clone()) })
.await
.expect("the cold render");
let start = std::time::Instant::now();
for _ in 0..20 {
let held = cache.get_or_render(&key, never).await.expect("a warm read");
assert_eq!(held.len(), big.len());
}
let each = start.elapsed() / 20;
assert_eq!(cache.compiles(), 1);
assert!(
each < std::time::Duration::from_millis(10),
"a warm read took {each:?}, which is not a read transaction"
);
}
/// **The two shapes are two entries**, or the toggle would serve one of
/// them under the other's name.
#[tokio::test]
async fn the_shape_is_part_of_the_cache_key() {
let dir = tempfile::tempdir().expect("a temp dir");
let store = crate::state::StateStore::open(dir.path().join("state.redb")).expect("a store");
let cache = Cache::new(Some(store));
let html = cache
.get_or_render(
&Cache::key("ns", "n", "oid", "README.md", Shape::Html),
|| async { Ok::<_, ()>("the html".to_owned()) },
)
.await
.expect("html");
let svg = cache
.get_or_render(
&Cache::key("ns", "n", "oid", "README.md", Shape::Svg),
|| async { Ok::<_, ()>("the svg".to_owned()) },
)
.await
.expect("svg");
assert_eq!((html.as_str(), svg.as_str()), ("the html", "the svg"));
assert_eq!(cache.compiles(), 2);
}
}