Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

crates/gunnar-ui-server/src/api/render.rs

//! **`GET …/render` and `GET …/pdf` — any markdown file in a tree, typeset.**
//!
//! ```text
//!                        ┌─ /render?as=svg   ─▶ the typeset page   (anonymous: yes)
//!   any *.md in a tree ──┼─ /render?as=html  ─▶ the plain page     (anonymous: yes)
//!                        └─ /pdf             ─▶ application/pdf    (anonymous: NO)
//! ```
//!
//! # What this is, and what [`crate::api::readme`] is
//!
//! They are the same pipeline pointed at different files. `readme` answers a
//! question about a *repository* — which of `CANDIDATES` is at the root — and
//! this one answers a question about a *path*: the caller already knows which
//! file it wants. Everything after "here is some markdown" is shared, in
//! [`page`], so a document rendered through either route is the same document:
//! one cache, one queue, one sanitiser, one link rewriter.
//!
//! It exists because of what gunnar.rs did with
//! `/repo/gunnar/gunnar/files?path=.nornir/gunnar-quickstart.md`: it showed a
//! quickstart guide as 400 lines of markdown source, on a console that was
//! already typesetting the README two clicks away.
//!
//! # The two doors are not the same door, and that is deliberate
//!
//! The **rendered page** is on `is_anonymous_read`, beside `readme`, and by the
//! same argument: it is a public repository's own documentation, it reaches
//! gunnar through exactly the calls `browse` already makes for the same visitor,
//! and a private repository is refused by gunnar against the guest principal's
//! absent grant. A stranger reading a project's quickstart is the whole point of
//! publishing one.
//!
//! The **PDF** is not. It is the most expensive thing this console can be asked
//! to do — a full paged typst compile, cover, outline and all — and it is a
//! convenience for somebody who is going to keep the file, not a way to read a
//! page. So it needs a session, twice over: the route is off the allow-list, so
//! the middleware refuses a visitor with no session before a handler exists, and
//! [`crate::reading::Audience`] refuses again in the handler. The console does
//! not draw a button for it either — the same [`Offer`](crate::reading::Offer)
//! decides both, which is why the button and the route cannot disagree.

use axum::extract::{Path, Query, State};
use axum::Json;
use nornir_docbook::ImageSet;
use serde::Deserialize;

use crate::api::browse::{LfsPointer, RawKind};
use crate::api::{ApiError, ApiResult, Reader};
use crate::app::AppState;
use crate::git::{self, Located, Repository, MAX_BLOB_BYTES, MAX_RAW_BYTES};
use crate::grpc::Fail;
use crate::reading::Audience;
use crate::readme::{self, Cache, Pictures, Readme, Shape};

/// The largest image the console will pull out of a tree to place in a
/// document.
///
/// Below `MAX_BLOB_BYTES` on purpose: a 2 MB screenshot becomes a 2 MB base64
/// data URL inside the page, and the page is inlined into the console's DOM. A
/// document carrying one is a document with a missing picture and a perfectly
/// good alt text.
pub const MAX_IMAGE_BYTES: u64 = 512 * 1024;

/// `?rev=&path=&as=`
#[derive(Debug, Clone, Deserialize)]
pub struct At {
    /// A reference name or an object id. Empty means the default branch.
    ///
    /// `rev` here rather than `readme`'s `ref`, because this route's neighbours
    /// on the file view — `browse`, `raw`, `diff` — all spell it `rev` and the
    /// console builds all four URLs from the same query.
    #[serde(default)]
    pub rev: String,
    /// The file. Never a path segment, for the reason at the top of
    /// [`crate::api::browse`].
    #[serde(default)]
    pub path: String,
    /// `svg` (the default) or `html`, exactly as on the README route.
    ///
    /// `as` on the wire, `as_shape` in Rust — `as` is a keyword.
    #[serde(default, rename = "as")]
    pub as_shape: Option<String>,
}

impl At {
    fn shape(&self) -> Shape {
        match self.as_shape.as_deref() {
            Some("html") => Shape::Html,
            _ => Shape::Svg,
        }
    }
}

/// Which document, where, for the link rewriter and the cache key.
#[derive(Debug, Clone, Copy)]
pub struct Document<'a> {
    pub namespace: &'a str,
    pub name: &'a str,
    /// The revision as the caller asked for it — part of where a link goes,
    /// never part of what the page looks like.
    pub reference: &'a str,
    /// The file's path in the tree.
    pub path: &'a str,
    /// The blob's object id, which is the cache key.
    pub oid: &'a str,
}

/// `GET /api/repos/{namespace}/{name}/render?rev=&path=&as=`
pub async fn render(
    reader: Reader,
    State(state): State<AppState>,
    Path((namespace, name)): Path<(String, String)>,
    Query(at): Query<At>,
) -> ApiResult<Readme> {
    let repo = Repository::open(&reader.gunnar, &namespace, &name, state.objects.clone()).await?;
    let revision = repo.resolve(&at.rev).await?;
    let found = markdown_at(&repo, &revision.tree_oid, &at.path).await?;

    Ok(Json(
        page(
            &state,
            &repo,
            &revision.tree_oid,
            Document {
                namespace: &namespace,
                name: &name,
                reference: &at.rev,
                path: &found.path,
                oid: &found.oid,
            },
            &found.source,
            at.shape(),
        )
        .await?,
    ))
}

/// **`GET /api/repos/{namespace}/{name}/pdf?rev=&path=` — the same document,
/// typeset as a document.**
///
/// Served exactly the way [`crate::api::browse::raw`] serves a PDF that is in
/// the tree, off the same [`RawKind`]: `application/pdf`, `inline`, the
/// filename in a `Content-Disposition`, and a policy that forbids framing and
/// nothing else — a PDF viewer runs in its own process and Chrome refuses to
/// draw one at all under `sandbox`. Reusing `RawKind` rather than restating
/// those three strings is the point: a console with two opinions about how a
/// PDF is served has one of them wrong.
pub async fn pdf(
    audience: Audience,
    reader: Reader,
    State(state): State<AppState>,
    Path((namespace, name)): Path<(String, String)>,
    Query(at): Query<At>,
) -> Result<axum::response::Response, ApiError> {
    use axum::response::IntoResponse;
    use http::header;

    // **The second refusal.** The first is `auth::middleware`: this path is not
    // on `is_anonymous_read`, so a visitor with no session never arrives. This
    // one holds even if somebody adds it there by mistake, and it is the same
    // answer that decides whether the button is drawn.
    if !audience.offer(crate::reading::store(&state)).pdf {
        return Err(ApiError(Fail {
            code: "permissiondenied".to_owned(),
            message: "this console typesets PDFs for people who are logged in".to_owned(),
        }));
    }

    let repo = Repository::open(&reader.gunnar, &namespace, &name, state.objects.clone()).await?;
    let revision = repo.resolve(&at.rev).await?;
    let found = markdown_at(&repo, &revision.tree_oid, &at.path).await?;

    let key = Cache::pdf_key(&namespace, &name, &found.oid, &found.path);
    let label = format!("{namespace}/{name} {}", found.path);
    let source = found.source.clone();
    // The same pictures the typeset page draws, fetched the same way: against
    // the tree at this revision, beside the document. See `page`.
    let directory = readme::directory_of(&found.path);
    let repo_ref = &repo;
    let tree_oid = revision.tree_oid.clone();
    let body: Vec<u8> = state
        .readmes
        .get_or_render(&key, || async move {
            let pictures = fetch_images(repo_ref, &tree_oid, &source, directory, For::Pdf).await;
            readme::queue::shared()
                .typeset(move || readme::render_pdf(&source, &label, &pictures.set))
                .await
                .map_err(readme::Trouble::Busy)?
                .map_err(readme::Trouble::Document)
        })
        .await
        .map_err(|trouble| match trouble {
            readme::Trouble::Busy(refused) => ApiError(refused.fail()),
            // **A PDF has no lesser grade to fall back to.** The typeset page
            // is what a reader who only wants to read it should have been
            // looking at; this route was asked for a file, and half a file is
            // not one. The refusal carries the typesetter's own words.
            readme::Trouble::Document(why) => ApiError(Fail {
                code: "failedprecondition".to_owned(),
                message: format!("{} did not typeset to a PDF: {why}", found.path),
            }),
        })?;

    let file = found.path.rsplit('/').next().unwrap_or(&found.path);
    let file = format!(
        "{}.pdf",
        file.rsplit_once('.').map_or(file, |(stem, _)| stem)
    );
    let disposition = format!(
        "{}; filename*=UTF-8''{}",
        RawKind::Pdf.disposition(),
        percent_encoding::utf8_percent_encode(&file, percent_encoding::NON_ALPHANUMERIC)
    );
    Ok((
        [
            (header::CONTENT_TYPE, RawKind::Pdf.content_type().to_owned()),
            (header::CONTENT_DISPOSITION, disposition),
            (
                header::CONTENT_SECURITY_POLICY,
                RawKind::Pdf.policy().to_owned(),
            ),
        ],
        body,
    )
        .into_response())
}

/// One markdown blob, read out of a tree.
pub struct Found {
    /// The path as the tree spells it, which is not always as the caller did.
    pub path: String,
    pub oid: String,
    pub source: String,
}

/// Walk to `path`, and refuse everything that is not a markdown file this
/// console can typeset.
///
/// Every refusal names the path and says which kind of thing it was, because
/// the alternative — one "cannot render that" for a directory, a submodule, a
/// PNG and a 4 MB file — is a console that makes an operator guess.
async fn markdown_at(repo: &Repository, tree_oid: &str, path: &str) -> Result<Found, ApiError> {
    let (entry, path) = match repo.walk(tree_oid, path).await? {
        Located::Entry { entry, path } if entry.kind != "submodule" => (entry, path),
        Located::Entry { path, .. } => {
            return Err(refused(
                "failedprecondition",
                format!("{path} is a submodule: its contents live in another repository"),
            ))
        }
        Located::Tree { path, .. } => {
            return Err(refused(
                "invalidargument",
                format!("{path:?} is a directory, not a document"),
            ))
        }
    };
    if !readme::is_markdown(&path) {
        return Err(refused(
            "invalidargument",
            format!(
                "{path} is not markdown, so there is nothing to typeset; the file view shows it \
                 as it was written"
            ),
        ));
    }
    let object = repo.read_object(&entry.oid, MAX_BLOB_BYTES).await?;
    if object.size > MAX_BLOB_BYTES {
        return Err(refused(
            "resourceexhausted",
            format!(
                "object {path} is {} bytes, over the {MAX_BLOB_BYTES}-byte limit the console \
                 fetches",
                object.size
            ),
        ));
    }
    if git::looks_binary(&object.data, object.truncated) {
        return Err(refused(
            "failedprecondition",
            format!("{path} is named like markdown and is not text"),
        ));
    }
    Ok(Found {
        path,
        oid: object.oid.clone(),
        source: String::from_utf8_lossy(&object.data).into_owned(),
    })
}

fn refused(code: &str, message: String) -> ApiError {
    ApiError(Fail {
        code: code.to_owned(),
        message,
    })
}

/// **Typeset one markdown blob into the answer the console draws** — the whole
/// of the shared pipeline, used by this module and by
/// [`crate::api::readme`].
///
/// The order is load-bearing and is the same as it has always been: the cache
/// is consulted **before the images are fetched**, so a warm hit costs one read
/// transaction and not a tree walk per badge. The links are rewritten **after**
/// the cache, because `?rev=` is part of where a link goes and not of what the
/// page looks like.
///
/// # Errors
///
/// Only when the console is too busy to typeset at all (503/504). A document
/// the typesetter refuses is not an error: the reader gets the plain grade and
/// the reason, exactly as a README does.
pub async fn page(
    state: &AppState,
    repo: &Repository,
    tree_oid: &str,
    doc: Document<'_>,
    source: &str,
    shape: Shape,
) -> Result<Readme, ApiError> {
    let directory = readme::directory_of(doc.path);
    let key = Cache::key(doc.namespace, doc.name, doc.oid, doc.path, shape);
    let label = format!("{}/{} {}", doc.namespace, doc.name, doc.path);
    let rendered = state
        .readmes
        .get_or_render(&key, || async move {
            let images = fetch_images(repo, tree_oid, source, directory, For::Page).await;
            let source = source.to_owned();
            // typst is synchronous and CPU-bound. On a runtime worker a 50 ms
            // typeset is 50 ms every other request on that worker waits, so it
            // goes to the blocking pool — through the bounded door in front of
            // it, because the pool itself grows to meet whatever it is asked.
            readme::queue::shared()
                .typeset(move || readme::render(&source, &label, &images, shape))
                .await
                .map_err(readme::Trouble::Busy)?
                .map_err(readme::Trouble::Document)
        })
        .await;

    let links = readme::links::Base {
        namespace: doc.namespace,
        name: doc.name,
        reference: doc.reference,
        directory,
    };
    match rendered {
        Ok(body) => Ok(Readme {
            kind: shape.name(),
            body: readme::links::rewrite(&body, &links),
            path: doc.path.to_owned(),
            oid: doc.oid.to_owned(),
            warning: None,
        }),
        // The console is busy, which is not this document's fault and has no
        // cheaper grade to fall back to — see `readme::Trouble`.
        Err(readme::Trouble::Busy(refused)) => Err(ApiError(refused.fail())),
        // **Never a 500.** The typesetter refused, or the page was too large;
        // either way the reader gets the document and the reason, through
        // markdown straight to sanitised HTML with no typst in the way.
        Err(readme::Trouble::Document(why)) => {
            tracing::info!(
                repo = %format!("{}/{}", doc.namespace, doc.name),
                path = %doc.path,
                shape = shape.name(),
                reason = %why,
                "a document did not typeset; serving the plain HTML instead"
            );
            Ok(Readme {
                kind: "html",
                body: readme::links::rewrite(&readme::plain_html(source), &links),
                path: doc.path.to_owned(),
                oid: doc.oid.to_owned(),
                warning: Some(why),
            })
        }
    }
}

/// What a picture is wanted for: a page in a browser, or a PDF.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum For {
    /// The typeset or plain page: a picture too large to carry is named by
    /// URL instead (`readme::pictures`).
    Page,
    /// A PDF, which has no browser to fetch a URL: a picture is carried or not
    /// drawn.
    Pdf,
}

/// Resolve every relative image in the markdown against the tree — **from the
/// document's own directory** — and fetch what it can, up to [`ImageSet::MAX`].
///
/// Failure is never an error here: an image that is not there, is not an image,
/// or is too large simply does not get a slot, and docbook draws its alt text. A
/// document with a broken badge is not a document the console refuses.
///
/// A Git LFS pointer is followed (`Refs.ReadLfsObject`, the grant the walk
/// already spent), and a picture over [`MAX_IMAGE_BYTES`] is, on a page, named
/// rather than carried — see `readme::pictures` for gunnar.rs's nordisk/knut,
/// where both were true of the README's one picture and the page drew nothing.
pub async fn fetch_images(
    repo: &Repository,
    tree_oid: &str,
    markdown: &str,
    directory: &str,
    purpose: For,
) -> Pictures {
    let mut pictures = Pictures::new();
    for Destination { dest, alt } in destinations(markdown) {
        if pictures.len() >= ImageSet::MAX {
            break;
        }
        let Some(fetched) = fetch_one(repo, tree_oid, &dest, directory).await else {
            continue;
        };
        // `embed`/`refer` sniff the bytes and refuse anything that is not an
        // image typst can place, so a `.png` that is really something else
        // gets no slot rather than a compile error. They are keyed by the
        // destination AS WRITTEN, because that is what docbook will look up —
        // the resolution decides which blob, not which name.
        match placement(fetched.whole, fetched.size, fetched.streamable, purpose) {
            Placement::Carry => {
                pictures.embed(&dest, &fetched.path, fetched.bytes);
            }
            Placement::Name => {
                pictures.refer(&dest, &fetched.path, &fetched.bytes);
            }
            // A markdown `![alt](…)` with no slot is drawn as its alt text by
            // docbook. A raw `<img>` with no slot is drawn as NOTHING, so it
            // gets a box with its alt text in it — and, in the HTML grade, an
            // `<img>` that names the file and carries the alt (`pictures`).
            Placement::TooLarge => {
                if let Some(alt) = alt {
                    pictures.unavailable(&dest, &fetched.path, &alt);
                }
            }
            Placement::Skip => {}
        }
    }
    pictures
}

/// How one fetched picture goes on the page.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Placement {
    /// Its bytes go into the page.
    Carry,
    /// A stand-in its size goes into the page, and the page names the file.
    Name,
    /// Larger than the raw route will hand a browser
    /// ([`git::MAX_RAW_BYTES`]): naming it would draw an empty box, so the
    /// alt text is drawn instead.
    TooLarge,
    /// Nothing is drawn.
    Skip,
}

/// `whole` is false when only the start of the file arrived; `size` is the
/// file's own size (an LFS object's, from its pointer); `streamable` is true
/// when the raw route can hand the browser a file past one gunnar answer
/// (gunnar read its head as a range, so it reads ranges).
fn placement(whole: bool, size: u64, streamable: bool, purpose: For) -> Placement {
    match purpose {
        _ if whole && size <= MAX_IMAGE_BYTES => Placement::Carry,
        For::Page if size <= MAX_RAW_BYTES || streamable => Placement::Name,
        For::Page => Placement::TooLarge,
        For::Pdf => Placement::Skip,
    }
}

/// How much of a picture past one gunnar answer is read to learn its size:
/// a PNG says it in 24 bytes, a JPEG after its EXIF block, which a camera can
/// make long.
const HEAD_BYTES: u64 = 256 * 1024;

/// One picture's bytes, and the tree path they are at.
struct Fetched {
    path: String,
    /// The whole file, or its first [`MAX_IMAGE_BYTES`] when `whole` is false —
    /// enough for the header a stand-in is sized from. Empty for an LFS object
    /// past [`MAX_RAW_BYTES`], which is not fetched at all.
    bytes: Vec<u8>,
    whole: bool,
    /// The file's size.
    size: u64,
    /// The raw route can stream it although it is past one gunnar answer.
    streamable: bool,
}

async fn fetch_one(
    repo: &Repository,
    tree_oid: &str,
    dest: &str,
    directory: &str,
) -> Option<Fetched> {
    // `#anchor` and `?query` are URL syntax, not path syntax, and a tree has
    // neither. Trimming them here is what makes `logo.png?raw=true` work.
    let path = dest.split(['?', '#']).next().unwrap_or(dest);
    // An absolute URL is somebody else's host: not fetched, not resolved, not
    // looked for in the tree. docbook draws the alt text.
    if path.is_empty() || is_absolute(dest) {
        return None;
    }
    // **Beside the document, not beside the README.** The same resolution the
    // links get (`readme::links::normalise`), so a picture is fetched from the
    // blob a click on it would open: a leading `/` is the tree root, anything
    // else is relative to the directory the document is in, and a `..` past the
    // root stays at the root rather than leaving the tree.
    let path = if path.starts_with('/') || directory.is_empty() {
        readme::links::normalise(path)
    } else {
        readme::links::normalise(&format!("{directory}/{path}"))
    };
    if path.is_empty() {
        return None;
    }
    let Located::Entry { entry, path } = repo.walk(tree_oid, &path).await.ok()? else {
        return None;
    };
    if entry.is_dir() || entry.kind == "submodule" {
        return None;
    }
    let object = repo.read_object(&entry.oid, MAX_IMAGE_BYTES).await.ok()?;
    let whole = !object.truncated && object.size <= MAX_IMAGE_BYTES;
    // **A Git LFS file is a pointer in the tree** — 131 bytes of text that no
    // sniff calls a picture. The picture is in gunnar's LFS store, served whole
    // or refused, up to gunnar's own ceiling for one answer.
    // Past that ceiling only the HEAD is read, as a range — enough to size the
    // picture, which the page then names and the raw route streams. A gunnar
    // older than ranged reads refuses the range, and the page draws the alt.
    if whole {
        if let Some(pointer) = LfsPointer::parse(&object.data) {
            if pointer.size > MAX_RAW_BYTES {
                let head = repo
                    .read_lfs_range(pointer.oid, 0, HEAD_BYTES)
                    .await
                    .ok()
                    .filter(|range| range.size == pointer.size);
                return Some(Fetched {
                    path,
                    streamable: head.is_some(),
                    bytes: head.map(|range| range.data).unwrap_or_default(),
                    whole: false,
                    size: pointer.size,
                });
            }
            let bytes = repo
                .read_lfs_object(pointer.oid, MAX_RAW_BYTES)
                .await
                .ok()?;
            let whole = bytes.len() as u64 == pointer.size;
            return Some(Fetched {
                path,
                bytes,
                whole,
                size: pointer.size,
                streamable: false,
            });
        }
    }
    Some(Fetched {
        path,
        bytes: object.data.clone(),
        whole,
        size: object.size,
        streamable: false,
    })
}

/// True for a destination this console will not resolve against the tree.
///
/// Scheme-relative `//host/x` is included: it is an absolute URL wearing a
/// relative one's clothes, and treating it as a path would send the tree walk
/// looking for a directory called `host`.
fn is_absolute(dest: &str) -> bool {
    dest.starts_with("//")
        || dest
            .split_once("://")
            .is_some_and(|(scheme, _)| !scheme.is_empty() && !scheme.contains('/'))
        || dest.starts_with("data:")
        || dest.starts_with("mailto:")
}

/// Every image destination in the markdown, in the order they appear —
/// markdown's `![](…)` and a raw `<img src="…">` alike.
///
/// Parsed with the same parser docbook translates with rather than with a
/// regular expression, so the two cannot disagree about what is an image: a
/// `![…](…)` inside a code fence is not one, and this does not think it is.
/// The `<img>` half matters because that is how a README centres its badge on a
/// git host, and docbook recovers those too — a scan that missed them would
/// leave exactly those images unfetched.
fn destinations(markdown: &str) -> Vec<Destination> {
    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 out = Vec::new();
    for event in pulldown_cmark::Parser::new_ext(markdown, options) {
        match event {
            pulldown_cmark::Event::Start(pulldown_cmark::Tag::Image { dest_url, .. }) => {
                out.push(Destination {
                    dest: dest_url.to_string(),
                    alt: None,
                });
            }
            pulldown_cmark::Event::Html(html) | pulldown_cmark::Event::InlineHtml(html) => {
                if let Some(src) = img_src(&html) {
                    // The words to draw when the picture cannot be: the
                    // author's `alt`, else the file's name.
                    let alt = img_attr(&html, "alt")
                        .filter(|alt| !alt.trim().is_empty())
                        .unwrap_or_else(|| {
                            let file = src.split(['?', '#']).next().unwrap_or(&src);
                            file.rsplit('/').next().unwrap_or(file).to_owned()
                        });
                    out.push(Destination {
                        dest: src,
                        alt: Some(alt),
                    });
                }
            }
            _ => {}
        }
    }
    out.dedup();
    out
}

/// One image destination, as written.
#[derive(Debug, Clone, PartialEq, Eq)]
struct Destination {
    dest: String,
    /// `Some` for a raw `<img>`, which docbook draws as nothing at all when it
    /// has no picture; a markdown image's alt text is docbook's to draw.
    alt: Option<String>,
}

/// One attribute of an `<img>` chunk, `name="…"` or `name='…'`, entities as
/// written.
fn img_attr(html: &str, name: &str) -> Option<String> {
    let lower = html.to_ascii_lowercase();
    let at = lower.find("<img")?;
    let mut from = at;
    while let Some(found) = lower[from..].find(&format!("{name}=")) {
        let start = from + found;
        from = start + name.len() + 1;
        if !lower[..start].ends_with(char::is_whitespace) {
            continue;
        }
        let rest = &html[from..];
        let quote = rest.chars().next()?;
        if quote != '"' && quote != '\'' {
            return None;
        }
        let end = rest[1..].find(quote)?;
        return Some(rest[1..1 + end].to_owned());
    }
    None
}

/// The first `src="…"` of an `<img>` chunk, if it is one.
///
/// Deliberately the same shape as docbook's own `extract_img_src`: this decides
/// what to FETCH and that decides what to DRAW, and the two disagreeing means an
/// image that was fetched and then not placed, or placed and then missing.
fn img_src(html: &str) -> Option<String> {
    let lower = html.to_ascii_lowercase();
    if !lower.contains("<img") {
        return None;
    }
    let at = lower.find("src=")? + 4;
    let rest = &html[at..];
    let quote = rest.chars().next()?;
    if quote != '"' && quote != '\'' {
        return None;
    }
    let end = rest[1..].find(quote)?;
    Some(rest[1..1 + end].to_owned())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn image_destinations_come_out_in_order_and_ignore_code_fences() {
        let source = "![a](one.png)\n\n```\n![b](two.png)\n```\n\n![c](three.png)\n";

        let dests: Vec<String> = destinations(source).into_iter().map(|d| d.dest).collect();
        assert_eq!(dests, vec!["one.png", "three.png"]);
    }

    /// **A README's badge is usually a raw `<img>`**, because that is the only
    /// way to centre one on a git host. docbook places those; this has to fetch
    /// them, or they are the images that silently do not appear.
    #[test]
    fn a_raw_html_img_block_is_a_destination_too() {
        let source = "<p align=\"center\"><img src=\"docs/badge.png\" width=\"90\"></p>\n\n\
                      text with an <img src='inline.svg'> in it\n";

        assert_eq!(
            destinations(source),
            vec![
                Destination {
                    dest: "docs/badge.png".to_owned(),
                    alt: Some("badge.png".to_owned()),
                },
                Destination {
                    dest: "inline.svg".to_owned(),
                    alt: Some("inline.svg".to_owned()),
                },
            ]
        );
    }

    #[test]
    fn img_src_handles_both_quote_styles_and_no_image() {
        assert_eq!(img_src("<img src=\"a/b.png\">").as_deref(), Some("a/b.png"));
        assert_eq!(
            img_src("<img class=x src='c.svg' />").as_deref(),
            Some("c.svg")
        );
        assert_eq!(img_src("<p>no image</p>"), None);
    }

    /// ★ **nordisk/knut on gunnar.rs (2026-10-15)**: the README's picture is a
    /// 3.9 MB LFS object. A page names it rather than drawing nothing; a PDF,
    /// with no browser to fetch it, still skips it; a small one is carried.
    /// ★ And the owner's 5.7 MB one (5 968 091 bytes): past what the raw route
    /// hands a browser, so the page draws its alt text rather than an empty box
    /// over a URL that refuses.
    #[test]
    fn a_picture_too_large_to_carry_is_named_on_a_page() {
        assert_eq!(
            placement(true, 3_976_594, false, For::Page),
            Placement::Name
        );
        assert_eq!(
            placement(false, MAX_IMAGE_BYTES + 1, false, For::Page),
            Placement::Name
        );
        assert_eq!(placement(true, 3_976_594, false, For::Pdf), Placement::Skip);
        assert_eq!(placement(true, 70, false, For::Page), Placement::Carry);
        assert_eq!(placement(true, 70, false, For::Pdf), Placement::Carry);
        // Past one gunnar answer: named when gunnar read its head as a range
        // (the raw route streams it), the alt text against an older gunnar.
        assert_eq!(
            placement(false, 5_968_091, true, For::Page),
            Placement::Name
        );
        assert_eq!(
            placement(false, 5_968_091, false, For::Page),
            Placement::TooLarge
        );
        assert_eq!(placement(false, 5_968_091, true, For::Pdf), Placement::Skip);
    }

    #[test]
    fn a_raw_img_s_alt_is_read_and_a_lookalike_is_not() {
        let html = "<p align=\"center\"><img data-alt=\"no\" src=\"a.png\" alt=\"knut — a knytpunkt\" width=\"560\"></p>";
        assert_eq!(img_attr(html, "alt").as_deref(), Some("knut — a knytpunkt"));
        assert_eq!(img_attr("<img src='a.png'>", "alt"), None);
        assert_eq!(
            destinations("<img src=\"docs/x.png\" alt=\"\">")[0]
                .alt
                .as_deref(),
            Some("x.png"),
            "an empty alt is the file's name"
        );
    }

    /// The LFS ceiling the console asks for is gunnar's own, and knut's
    /// picture fits under it (it did not under the 2 MiB blob limit).
    #[test]
    fn the_lfs_ceiling_holds_the_picture_that_was_refused() {
        const {
            assert!(3_976_594 <= MAX_RAW_BYTES);
            assert!(MAX_RAW_BYTES > MAX_BLOB_BYTES);
            assert!(MAX_RAW_BYTES as usize <= crate::grpc::MAX_DECODE);
        }
    }

    #[test]
    fn a_scheme_relative_url_counts_as_absolute() {
        assert!(is_absolute("//cdn.example.test/a.png"));
        assert!(is_absolute("https://example.test/a.png"));
        assert!(is_absolute("data:image/png;base64,AAA"));
        assert!(!is_absolute("docs/a.png"));
        assert!(!is_absolute("./docs/a.png"));
    }

    /// The query parameter names a rendering, so an unknown one is the default
    /// rather than a 400 at somebody who typed a URL — and the default is the
    /// typeset page.
    #[test]
    fn the_shape_defaults_to_svg_and_only_html_changes_it() {
        let at = |value: Option<&str>| At {
            rev: String::new(),
            path: "a.md".to_owned(),
            as_shape: value.map(str::to_owned),
        };

        assert_eq!(at(None).shape(), Shape::Svg);
        assert_eq!(at(Some("html")).shape(), Shape::Html);
        assert_eq!(
            at(Some("pdf")).shape(),
            Shape::Svg,
            "the PDF is its own route"
        );
    }
}