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/browse.rs

//! Browsing a repository's contents.
//!
//! Every route here takes its path in a **query parameter**, never in a path
//! segment. That is not a style choice: `auth::middleware::is_public_asset`
//! exempts any GET whose path ends in `.js`, `.css` or `.svg` from
//! authentication, because trunk fetches the console's own assets without
//! credentials. A route shaped `/repo/ns/name/blob/main/web/app.js` would land
//! inside that exemption and hand a private repository's file to anyone who
//! asked. Under `/api/` with the path in the query string, it cannot.

use axum::extract::{Path, Query, State};
use axum::Json;
use http::StatusCode;
use serde::{Deserialize, Serialize};

use crate::api::{ApiError, ApiResult, Reader};
use crate::app::AppState;
use crate::git::{self, highlight, Located, Repository, MAX_BLOB_BYTES};

/// `?rev=&path=`
#[derive(Debug, Clone, Deserialize)]
pub struct At {
    /// A reference name or object id. Empty means the default branch.
    #[serde(default)]
    pub rev: String,
    #[serde(default)]
    pub path: String,
}

/// A directory listing.
#[derive(Debug, Clone, Serialize)]
pub struct Tree {
    pub revision: git::Revision,
    pub path: String,
    /// The path split into its parts, for a breadcrumb.
    pub crumbs: Vec<Crumb>,
    pub entries: Vec<git::Entry>,
    /// How many objects the console has left to spend on this repository before
    /// it starts refusing. Shown so an operator can see the cost of browsing.
    pub budget_left: usize,
}

/// One step of a path.
#[derive(Debug, Clone, Serialize)]
pub struct Crumb {
    pub name: String,
    /// The full path up to and including this step.
    pub path: String,
}

fn crumbs(path: &str) -> Vec<Crumb> {
    let mut out = Vec::new();
    let mut so_far = String::new();
    for segment in path.split('/').filter(|s| !s.is_empty()) {
        if !so_far.is_empty() {
            so_far.push('/');
        }
        so_far.push_str(segment);
        out.push(Crumb {
            name: segment.to_owned(),
            path: so_far.clone(),
        });
    }
    out
}

async fn open(
    reader: &Reader,
    state: &AppState,
    namespace: &str,
    name: &str,
) -> Result<Repository, ApiError> {
    // The CHANNEL comes from the reader and the object cache from the app.
    //
    // Those two halves answer different questions and it matters which is
    // which. The channel decides what gunnar will serve, so it must be the
    // guest key for an anonymous visitor. The cache only decides what is
    // re-served, and it is shared on purpose: see
    // [`crate::git::Repository::read_object`] for why a shared object cache is
    // safe here when the shared `Caches::repos` is not.
    Repository::open(&reader.gunnar, namespace, name, state.objects.clone()).await
}

/// Whatever a path named.
///
/// One shape rather than two endpoints: a link to a path carries no hint about
/// whether it is a directory or a file, so asking the browser to guess would
/// cost a wasted round trip and a refusal in the log every time it guessed
/// wrong.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum Found {
    Tree(Tree),
    Blob(Blob),
}

/// `GET /api/repos/{namespace}/{name}/browse?rev=&path=`
pub async fn browse(
    reader: Reader,
    State(state): State<AppState>,
    Path((namespace, name)): Path<(String, String)>,
    Query(at): Query<At>,
) -> ApiResult<Found> {
    let repo = open(&reader, &state, &namespace, &name).await?;
    let revision = repo.resolve(&at.rev).await?;

    match repo.walk(&revision.tree_oid, &at.path).await? {
        Located::Tree { oid, path } => Ok(Json(Found::Tree(Tree {
            entries: repo.read_tree(&oid).await?,
            crumbs: crumbs(&path),
            path,
            revision,
            budget_left: repo.remaining_budget(),
        }))),
        Located::Entry { entry, path } => Ok(Json(Found::Blob(
            read_blob(&repo, revision, entry, path).await?,
        ))),
    }
}

/// **`GET /api/repos/{namespace}/{name}/raw?rev=&path=` — one file's bytes, as
/// the file.**
///
/// What a README's link to its own PDF opens (`readme::links`): before it
/// existed the console could draw a tree and highlight source, and had no way to
/// hand a browser a file to show. The path is in the query string for the
/// reason at the top of this module, and the read is the same walk, budget and
/// [`Reader`] credential `browse` uses — so a guest gets exactly the files
/// `browse` would already have shown them, as bytes rather than as lines.
///
/// # What a repository's bytes are allowed to be, on the console's origin
///
/// This is somebody's file served from the origin the console's session cookie
/// lives on, so the content type is decided HERE, from the name, off a short
/// list — never sniffed, and never taken from the file:
///
/// * a PDF is `application/pdf`, inline, so the browser's viewer opens it. Its
///   policy only forbids framing: a PDF viewer runs in its own process and
///   origin, and Chrome refuses to draw one at all under `sandbox`;
/// * a picture is its image type, inline, under `sandbox`;
/// * an `.svg` in a **public** repository is `image/svg+xml`, inline, under a
///   policy with no script source at all and `sandbox` ([`RawKind::Svg`],
///   BADGE-7): gunnar.rs draws the OpenSSF badge copies in `nordisk/ci-status`
///   from its own origin. A picture an `<img>` draws runs no script; the policy
///   keeps the file inert when it is opened by itself. Public is gunnar's answer
///   (`Repos.Get`), never the reader's guest-ness: the owner reading his own
///   public README sees the same badge;
/// * **everything else is `application/octet-stream` and an attachment** —
///   including `.html`, and `.svg` in a private repository: the two that would
///   run script if they were drawn here. The file view is where a text file is
///   read.
///
/// `nosniff` is on every response already (`security::apply_security_headers`),
/// and this route's own policy is kept by `security::csp_layer` rather than
/// overwritten with the console's.
///
/// # A refusal is a sentence, never gunnar's JSON (2026-10-15)
///
/// This route is opened by a browser — a click on a README's link, or an
/// `<img>` — and never by the console's own code, so an `ApiError` body
/// (`{"code":"resourceexhausted","message":"resource exhausted: LFS object …"}`)
/// reached a person as raw JSON. Every refusal here is `text/plain` in the
/// console's words ([`Refusal`]); a picture that is refused is an `<img>` that
/// fails, and the browser draws its alt text.
pub async fn raw(
    reader: Result<Reader, ApiError>,
    State(state): State<AppState>,
    Path((namespace, name)): Path<(String, String)>,
    Query(at): Query<At>,
) -> axum::response::Response {
    let asked = at.path.clone();
    let answer = match reader {
        Ok(reader) => serve_raw(reader, state, &namespace, &name, at).await,
        Err(error) => Err(Refusal::from(error)),
    };
    answer.unwrap_or_else(|refusal| refusal.into_response_for(&asked))
}

async fn serve_raw(
    reader: Reader,
    state: AppState,
    namespace: &str,
    name: &str,
    at: At,
) -> Result<axum::response::Response, Refusal> {
    use axum::response::IntoResponse;
    use http::header;

    let repo = open(&reader, &state, namespace, name).await?;
    let revision = repo.resolve(&at.rev).await?;
    let (entry, path) = match repo.walk(&revision.tree_oid, &at.path).await? {
        Located::Entry { entry, path } if entry.kind != "submodule" => (entry, path),
        Located::Entry { path, .. } => {
            return Err(Refusal::plain(
                StatusCode::BAD_REQUEST,
                format!("{path} is a submodule: its files live in another repository."),
            ))
        }
        Located::Tree { path, .. } => {
            return Err(Refusal::plain(
                StatusCode::BAD_REQUEST,
                format!("{path} is a folder, not a file."),
            ))
        }
    };
    let kind = match RawKind::of(&path) {
        RawKind::Download if is_svg(&path) && is_public(&reader, namespace, name).await => RawKind::Svg,
        kind => kind,
    };
    let object = repo.read_object(&entry.oid, git::MAX_RAW_BYTES).await?;
    if object.truncated || object.size > git::MAX_RAW_BYTES {
        return Err(Refusal::too_large(&path, object.size, false));
    }
    // **An LFS file is a pointer in the tree**, and the file is in gunnar's LFS
    // store. Served as it stands, a 131-byte pointer would go out as
    // `application/pdf` and the browser would call the repository's PDF broken
    // — which is what gunnar.rs did with its own `docs/gunnar-fast-setup.pdf`.
    // `Refs.ReadLfsObject` answers on the same `Read` grant this walk already
    // spent, so following the pointer costs the visitor nothing they did not
    // already have.
    //
    // gunnar answers one read with at most [`git::MAX_RAW_BYTES`]. An object
    // that fits is read whole; a larger one is STREAMED, range after range
    // (`Refs.ReadLfsObject` with `offset`/`length`, gunnar 2026-10-15) — and
    // against a gunnar older than ranged reads it is refused from the size the
    // pointer states, in plain words, never as gunnar's JSON.
    let body = match LfsPointer::parse(&object.data) {
        None => object.data.clone(),
        Some(pointer) => {
            if pointer.size > git::MAX_RAW_BYTES {
                let (oid, size) = (pointer.oid.to_owned(), pointer.size);
                return stream_lfs(repo, &path, kind, oid, size).await;
            }
            match repo.read_lfs_object(pointer.oid, git::MAX_RAW_BYTES).await {
                Ok(data) => data,
                // **An older gunnar does not have this RPC**, and the console
                // is deployed on its own timetable — the two versions skew by
                // design, and `tests/contract.rs` exists because of it.
                Err(status) if status.code() == tonic::Code::Unimplemented => {
                    return Err(Refusal::plain(
                        StatusCode::BAD_REQUEST,
                        format!(
                            "{path} is kept in Git LFS (sha256:{}, {}), and the Gunnar behind \
                             this console cannot hand LFS files to a browser yet. Clone the \
                             repository and run `git lfs pull` to get it.",
                            pointer.oid,
                            megabytes(pointer.size)
                        ),
                    ))
                }
                Err(status) => return Err(ApiError::from(status).into()),
            }
        }
    };

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

/// **An LFS object larger than one gunnar answer, handed over as it arrives.**
///
/// The first range is read BEFORE a header is sent: that is where a gunnar
/// older than ranged reads shows itself (it answers whole-or-refused, and the
/// object is over its ceiling), and a refusal can still be a 413 sentence
/// rather than a broken download. After that the body is the ranges in order,
/// each at most [`git::MAX_RAW_BYTES`], under a `Content-Length` of the whole
/// object — so a range that fails mid-way ends the response short and the
/// browser knows the file is incomplete instead of keeping a truncated one.
async fn stream_lfs(
    repo: Repository,
    path: &str,
    kind: RawKind,
    oid: String,
    size: u64,
) -> Result<axum::response::Response, Refusal> {
    use axum::response::IntoResponse;
    use http::header;

    let first = match repo.read_lfs_range(&oid, 0, git::MAX_RAW_BYTES).await {
        Ok(range) if range.size == size && !range.data.is_empty() => range.data,
        // A store whose object is not the size its pointer says, or a gunnar
        // that does not read ranges: the same answer as before ranges existed.
        Ok(_) => return Err(Refusal::too_large(path, size, true)),
        Err(status)
            if matches!(
                status.code(),
                tonic::Code::ResourceExhausted
                    | tonic::Code::FailedPrecondition
                    | tonic::Code::Unimplemented
            ) =>
        {
            return Err(Refusal::too_large(path, size, true))
        }
        Err(status) => return Err(ApiError::from(status).into()),
    };

    let body = async_stream::try_stream! {
        let mut offset = first.len() as u64;
        yield axum::body::Bytes::from(first);
        while offset < size {
            let range = repo
                .read_lfs_range(&oid, offset, git::MAX_RAW_BYTES)
                .await
                .map_err(|status| std::io::Error::other(status.message().to_owned()))?;
            if range.data.is_empty() || range.size != size {
                Err(std::io::Error::other("the LFS object changed while it was being read"))?;
            }
            offset += range.data.len() as u64;
            yield axum::body::Bytes::from(range.data);
        }
    };
    let body: std::pin::Pin<
        Box<dyn futures_util::Stream<Item = Result<axum::body::Bytes, std::io::Error>> + Send>,
    > = Box::pin(body);

    let file = path.rsplit('/').next().unwrap_or(path);
    let disposition = format!(
        "{}; filename*=UTF-8''{}",
        kind.disposition(),
        percent_encoding::utf8_percent_encode(file, percent_encoding::NON_ALPHANUMERIC)
    );
    Ok((
        [
            (header::CONTENT_TYPE, kind.content_type().to_owned()),
            (header::CONTENT_LENGTH, size.to_string()),
            (header::CONTENT_DISPOSITION, disposition),
            (header::CONTENT_SECURITY_POLICY, kind.policy().to_owned()),
        ],
        axum::body::Body::from_stream(body),
    )
        .into_response())
}

/// **Why the raw route did not hand over a file, in the console's words.**
#[derive(Debug)]
pub enum Refusal {
    /// A sentence this module wrote, and its status.
    Plain(StatusCode, String),
    /// gunnar's (or the console's) refusal, said in words at the end.
    Api(ApiError),
}

impl From<ApiError> for Refusal {
    fn from(error: ApiError) -> Refusal {
        Refusal::Api(error)
    }
}

impl Refusal {
    fn plain(status: StatusCode, words: String) -> Refusal {
        Refusal::Plain(status, words)
    }

    /// A file over [`git::MAX_RAW_BYTES`].
    pub fn too_large(path: &str, size: u64, lfs: bool) -> Refusal {
        let how = if lfs {
            "Clone the repository and run `git lfs pull` to get it"
        } else {
            "Clone the repository to get it"
        };
        Refusal::plain(
            StatusCode::PAYLOAD_TOO_LARGE,
            format!(
                "{path} is {}{}, and this console hands a browser files of up to {}. {how}.",
                megabytes(size),
                if lfs { ", kept in Git LFS" } else { "" },
                megabytes(git::MAX_RAW_BYTES),
            ),
        )
    }

    /// The status and the sentence. Never gunnar's message: that is written
    /// for an operator and can name a credential, a method or a ceiling.
    pub fn words(&self, asked: &str) -> (StatusCode, String) {
        let fail = match self {
            Refusal::Plain(status, words) => return (*status, words.clone()),
            Refusal::Api(ApiError(fail)) => fail,
        };
        let status = fail.http_status();
        let file = if asked.is_empty() { "That file" } else { asked };
        let words = if fail.is_restoring() {
            "Gunnar is being restored. Try again in a few minutes.".to_owned()
        } else if fail.is_infrastructure_fault() {
            "This console cannot reach Gunnar right now. Try again in a few minutes.".to_owned()
        } else {
            match status {
                StatusCode::NOT_FOUND => {
                    format!("{file} is not in this repository at this revision.")
                }
                StatusCode::FORBIDDEN | StatusCode::UNAUTHORIZED => format!(
                    "{file} is in a repository you cannot read. Log in with an account that can."
                ),
                StatusCode::PAYLOAD_TOO_LARGE => format!(
                    "{file} is too large for this console to hand a browser. Clone the \
                     repository to get it (and run `git lfs pull` if it is kept in Git LFS)."
                ),
                StatusCode::TOO_MANY_REQUESTS => {
                    "Gunnar is busy. Try again in a moment.".to_owned()
                }
                StatusCode::BAD_REQUEST => {
                    format!("{file} is not a file this repository has at that revision.")
                }
                StatusCode::SERVICE_UNAVAILABLE | StatusCode::GATEWAY_TIMEOUT => {
                    "Gunnar did not answer in time. Try again in a moment.".to_owned()
                }
                _ => format!("{file} could not be read just now. Try again later."),
            }
        };
        (status, words)
    }

    /// `text/plain`, the sentence and a line break.
    pub fn into_response_for(self, asked: &str) -> axum::response::Response {
        use axum::response::IntoResponse;
        let (status, words) = self.words(asked);
        (
            status,
            [(http::header::CONTENT_TYPE, "text/plain; charset=utf-8")],
            format!("{words}\n"),
        )
            .into_response()
    }
}

/// `5968091` → `5.7 MB` (MiB, as a person reads a file manager).
fn megabytes(bytes: u64) -> String {
    let mb = bytes as f64 / (1024.0 * 1024.0);
    if mb >= 10.0 || (mb - mb.round()).abs() < 0.05 {
        format!("{} MB", mb.round() as u64)
    } else {
        format!("{mb:.1} MB")
    }
}

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

/// A Git LFS pointer file: what the tree holds in place of an LFS object.
///
/// The shape is the spec's (`git-lfs/docs/spec.md`): under 1024 bytes, the
/// `version` line first, then `oid sha256:<64 hex>` and `size <n>`. Anything
/// that is not all three is a file that merely talks about LFS.
#[derive(Debug, PartialEq, Eq)]
pub struct LfsPointer<'a> {
    pub oid: &'a str,
    pub size: u64,
}

impl<'a> LfsPointer<'a> {
    pub fn parse(data: &'a [u8]) -> Option<LfsPointer<'a>> {
        if data.len() >= 1024 {
            return None;
        }
        let text = std::str::from_utf8(data).ok()?;
        let mut lines = text.lines();
        if lines.next()? != "version https://git-lfs.github.com/spec/v1" {
            return None;
        }
        let (mut oid, mut size) = (None, None);
        for line in lines {
            if let Some(hex) = line.strip_prefix("oid sha256:") {
                oid =
                    Some(hex).filter(|h| h.len() == 64 && h.bytes().all(|b| b.is_ascii_hexdigit()));
            } else if let Some(n) = line.strip_prefix("size ") {
                size = n.parse().ok();
            }
        }
        Some(LfsPointer {
            oid: oid?,
            size: size?,
        })
    }
}

/// How [`raw`] serves a file, decided by its name alone.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RawKind {
    Pdf,
    Image(&'static str),
    /// An SVG picture of a PUBLIC repository (BADGE-7). [`RawKind::of`] never
    /// answers it — it knows the name only, and an `.svg` of a private
    /// repository stays a download — `raw` does, after asking gunnar.
    Svg,
    /// Markdown or plain text (GUN-LANDING): `text/plain; charset=utf-8`, inline, sandboxed.
    Text,
    Download,
}

impl RawKind {
    pub fn of(path: &str) -> RawKind {
        let ext = path
            .rsplit_once('.')
            .map(|(_, ext)| ext.to_ascii_lowercase())
            .unwrap_or_default();
        match ext.as_str() {
            "pdf" => RawKind::Pdf,
            "png" => RawKind::Image("image/png"),
            "jpg" | "jpeg" => RawKind::Image("image/jpeg"),
            "gif" => RawKind::Image("image/gif"),
            "webp" => RawKind::Image("image/webp"),
            "avif" => RawKind::Image("image/avif"),
            // GUN-LANDING: read as text, never drawn as a page.
            "md" | "markdown" | "txt" => RawKind::Text,
            _ => RawKind::Download,
        }
    }

    pub fn content_type(self) -> &'static str {
        match self {
            RawKind::Pdf => "application/pdf",
            RawKind::Image(mime) => mime,
            RawKind::Svg => "image/svg+xml",
            RawKind::Text => "text/plain; charset=utf-8",
            RawKind::Download => "application/octet-stream",
        }
    }

    pub fn disposition(self) -> &'static str {
        match self {
            RawKind::Pdf | RawKind::Image(_) | RawKind::Svg | RawKind::Text => "inline",
            RawKind::Download => "attachment",
        }
    }

    pub fn policy(self) -> &'static str {
        match self {
            RawKind::Pdf => "frame-ancestors 'none'",
            RawKind::Image(_) => {
                "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox; \
                 frame-ancestors 'none'"
            }
            // no script source at all: opened by itself the file is an inert document
            RawKind::Svg => "default-src 'none'; style-src 'unsafe-inline'; sandbox; frame-ancestors 'none'",
            RawKind::Text | RawKind::Download => "default-src 'none'; sandbox; frame-ancestors 'none'",
        }
    }
}

/// A file.
#[derive(Debug, Clone, Serialize)]
pub struct Blob {
    pub revision: git::Revision,
    pub path: String,
    pub crumbs: Vec<Crumb>,
    pub oid: String,
    /// The file's real size, whatever was fetched.
    pub size: u64,
    /// True when only the first bytes came back.
    pub truncated: bool,
    /// True when the file is larger than the console will fetch at all.
    pub too_large: bool,
    /// True when the bytes are not text.
    pub binary: bool,
    /// The syntax that was recognised, when there is text.
    pub syntax: Option<String>,
    /// One entry per line.
    pub lines: Vec<Vec<highlight::Token>>,
    /// The bytes are a gunnar-crypt envelope — an encrypted repository's file.
    /// A FORMAT fact, read off the first bytes; the console holds no key and
    /// opens nothing. The browser opens it, with the reader's own key ring.
    pub sealed: bool,
    /// **The file is kept in Git LFS**: the tree holds a pointer, and
    /// everything above describes the FILE the pointer names — its size, and
    /// its lines when it is text — never the pointer's 131 bytes.
    pub lfs: Option<LfsFile>,
}

/// What the file view knows about a file kept in Git LFS.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct LfsFile {
    /// The sha256 the pointer names: the LFS store's key for the file.
    pub sha256: String,
    /// How the page shows it: `image` (inline, from the raw route), `pdf`
    /// (Open / Download), `text` (its lines are in [`Blob::lines`]) or
    /// `download` (a link to the raw route, nothing drawn).
    pub shown: &'static str,
    /// The pointer as the tree holds it, for a reader who wants to see it — a
    /// detail, never the file's content.
    pub pointer: String,
}

fn is_svg(path: &str) -> bool {
    path.rsplit_once('.').is_some_and(|(_, ext)| ext.eq_ignore_ascii_case("svg"))
}

/// **Does gunnar call this repository public?** `Repos.Get` on the reader's own
/// credential, as `public_site::snapshot` asks it; anything but a `Public`
/// answer is `false`, so a failed question keeps the file a download.
async fn is_public(reader: &Reader, namespace: &str, name: &str) -> bool {
    use crate::pb;
    match reader.gunnar.repos().get(pb::GetRepoRequest { path: Some(crate::api::repo_path(namespace, name)) }).await {
        Ok(answer) => answer.into_inner().repo.is_some_and(|r| r.visibility == pb::Visibility::Public as i32),
        Err(_) => false,
    }
}

/// How a file kept in LFS is shown, from its name and, for anything that is
/// not a picture or a PDF, its bytes when the console could read them (`None`
/// when it could not, or the file is past the text view's limit).
pub fn lfs_shown(path: &str, content: Option<&[u8]>) -> &'static str {
    match RawKind::of(path) {
        RawKind::Image(_) | RawKind::Svg => "image",
        RawKind::Pdf => "pdf",
        RawKind::Text | RawKind::Download => match content {
            Some(bytes) if !git::looks_binary(bytes, false) => "text",
            _ => "download",
        },
    }
}

/// Read one file, at a path already known to name one.
async fn read_blob(
    repo: &Repository,
    revision: git::Revision,
    entry: git::Entry,
    path: String,
) -> Result<Blob, ApiError> {
    if entry.kind == "submodule" {
        // Its object lives in another repository entirely, so there is nothing
        // here to read.
        return Ok(Blob {
            revision,
            crumbs: crumbs(&path),
            path,
            oid: entry.oid,
            size: 0,
            truncated: false,
            too_large: false,
            binary: true,
            syntax: None,
            lines: Vec::new(),
            sealed: false,
            lfs: None,
        });
    }

    let object = repo.read_object(&entry.oid, MAX_BLOB_BYTES).await?;
    // **A file kept in Git LFS is a pointer in the tree.** Shown as it stands
    // it is "131 B · Plain Text · version https://git-lfs…" where a 186 KB PDF
    // should be (mikronova/modgunn docs/idiot_guide.pdf on gunnar.rs,
    // 2026-10-15). The page describes the file the pointer names instead.
    if !object.truncated {
        if let Some(pointer) = LfsPointer::parse(&object.data) {
            let (sha256, size) = (pointer.oid.to_owned(), pointer.size);
            let text = String::from_utf8_lossy(&object.data).into_owned();
            return read_lfs_blob(repo, revision, path, object.oid.clone(), sha256, size, text)
                .await;
        }
    }
    let too_large = object.size > MAX_BLOB_BYTES;
    let binary = git::looks_binary(&object.data, object.truncated);
    let sealed = gunnar_crypt::envelope::has_magic(&object.data);

    // **Off the executor, and under a ceiling.** Marking up source is pure
    // compute and `syntect`'s constant is large: measured in release, 3.6
    // seconds for a 512 KiB file and 9.6 for a 2 MiB one, which is what
    // `MAX_BLOB_BYTES` allows. Run where it used to be — inline in an `async
    // fn` — that is not one slow request, it is every other future on the same
    // worker thread stopped for the duration, because nothing here awaits and
    // so nothing can yield. `spawn_blocking` moves it to the pool that exists
    // for exactly this, and `highlight::mark` declines to parse a file over
    // [`highlight::HIGHLIGHT_CEILING`] at all. See that constant for the table.
    let (syntax, lines) = if binary || too_large {
        (None, Vec::new())
    } else {
        mark_up(&path, &object.data).await?
    };

    Ok(Blob {
        revision,
        crumbs: crumbs(&path),
        path,
        oid: object.oid.clone(),
        size: object.size,
        truncated: object.truncated,
        too_large,
        binary,
        syntax,
        lines,
        sealed,
        lfs: None,
    })
}

/// Source marked up for the page, off the executor (see `read_blob`).
async fn mark_up(
    path: &str,
    data: &[u8],
) -> Result<(Option<String>, Vec<Vec<highlight::Token>>), ApiError> {
    let text = String::from_utf8_lossy(data).into_owned();
    let named = path.to_owned();
    let marked = tokio::task::spawn_blocking(move || highlight::mark(&named, &text))
        .await
        .map_err(|_| {
            refused(
                "internal",
                format!("the console could not mark up {path} and did not finish the read"),
            )
        })?;
    Ok((Some(marked.syntax), marked.lines))
}

/// **The file a pointer names**, for the file view: its own size, and its
/// lines when it is text the view would show (up to [`MAX_BLOB_BYTES`], the
/// same limit as any file). A picture and a PDF are not read here at all —
/// the page shows them from the raw route, which streams any size. When the
/// object cannot be read (an older gunnar, a refusal) the page offers the
/// download, and never the pointer as the content.
async fn read_lfs_blob(
    repo: &Repository,
    revision: git::Revision,
    path: String,
    pointer_oid: String,
    sha256: String,
    size: u64,
    pointer: String,
) -> Result<Blob, ApiError> {
    let wants_bytes = RawKind::of(&path) == RawKind::Download && size <= MAX_BLOB_BYTES;
    let content = if wants_bytes {
        repo.read_lfs_object(&sha256, git::MAX_RAW_BYTES)
            .await
            .ok()
            .filter(|data| data.len() as u64 == size)
    } else {
        None
    };
    let shown = lfs_shown(&path, content.as_deref());
    let (syntax, lines) = match (shown, &content) {
        ("text", Some(data)) => mark_up(&path, data).await?,
        _ => (None, Vec::new()),
    };
    Ok(Blob {
        revision,
        crumbs: crumbs(&path),
        path,
        oid: pointer_oid,
        size,
        truncated: false,
        too_large: false,
        binary: shown != "text",
        syntax,
        lines,
        sealed: false,
        lfs: Some(LfsFile {
            sha256,
            shown,
            pointer,
        }),
    })
}

/// `?from=&to=&path=`
#[derive(Debug, Clone, Deserialize)]
pub struct Range {
    pub from: String,
    pub to: String,
    #[serde(default)]
    pub path: String,
}

/// A comparison of two revisions.
#[derive(Debug, Clone, Serialize)]
pub struct Diff {
    pub from: git::Revision,
    pub to: git::Revision,
    pub path: String,
    #[serde(flatten)]
    pub comparison: crate::git::diff::Comparison,
    pub budget_left: usize,
}

/// `GET /api/repos/{namespace}/{name}/diff?from=&to=&path=`
///
/// gunnar's own `Refs.Diff` answers `Unimplemented`; this walks both trees and
/// compares them here instead.
pub async fn diff(
    reader: Reader,
    State(state): State<AppState>,
    Path((namespace, name)): Path<(String, String)>,
    Query(range): Query<Range>,
) -> ApiResult<Diff> {
    let repo = open(&reader, &state, &namespace, &name).await?;
    let from = repo.resolve(&range.from).await?;
    let to = repo.resolve(&range.to).await?;

    let comparison =
        crate::git::diff::compare(&repo, &from.tree_oid, &to.tree_oid, &range.path).await?;

    Ok(Json(Diff {
        from,
        to,
        path: range.path,
        comparison,
        budget_left: repo.remaining_budget(),
    }))
}

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

    /// **What a repository's bytes may be on the console's origin.** The two
    /// that would run script if drawn — a page and a drawing — are downloads,
    /// whatever case their name is in.
    #[test]
    fn a_raw_file_is_shown_only_when_it_cannot_run_script() {
        assert_eq!(RawKind::of("docs/guide.pdf"), RawKind::Pdf);
        assert_eq!(RawKind::of("docs/GUIDE.PDF"), RawKind::Pdf);
        assert_eq!(RawKind::of("a/logo.png"), RawKind::Image("image/png"));
        assert_eq!(RawKind::of("a/photo.JPEG"), RawKind::Image("image/jpeg"));
        for download in [
            "x.html", "x.HTM", "x.svg", "x.xhtml", "x.xml", "x.js", "Makefile", "x",
        ] {
            assert_eq!(RawKind::of(download), RawKind::Download, "{download}");
        }
        // GUN-LANDING: Markdown and plain text are read as text, inline, never as a page.
        for text in ["docs/v2/README.md", "NOTES.TXT", "a/b.markdown"] {
            assert_eq!(RawKind::of(text), RawKind::Text, "{text}");
        }
        assert_eq!(RawKind::Text.content_type(), "text/plain; charset=utf-8");
        assert_eq!(RawKind::Text.disposition(), "inline");
        assert!(RawKind::Text.policy().contains("sandbox") && RawKind::Text.policy().contains("default-src 'none'"));
        assert_eq!(RawKind::Download.content_type(), "application/octet-stream");
        assert_eq!(RawKind::Download.disposition(), "attachment");
        assert_eq!(RawKind::Pdf.disposition(), "inline");
    }

    const GUNNAR_LFS_REFUSAL: &str = "resource exhausted: LFS object \
        c75426139936bb491e350391570fdfd4cb606b386cfb0ce6697d210cbfef3c60 is 5968091 bytes, over \
        the 2097152-byte ceiling for this request. An LFS object is served whole or not at all \
        — a prefix of one is not a smaller file — so no max_bytes makes this request smaller. \
        `git lfs` streams it.";

    async fn text_of(r: axum::response::Response) -> (StatusCode, String, String) {
        let status = r.status();
        let kind = r
            .headers()
            .get(http::header::CONTENT_TYPE)
            .and_then(|v| v.to_str().ok())
            .unwrap_or("")
            .to_owned();
        let bytes = axum::body::to_bytes(r.into_body(), 64 * 1024)
            .await
            .unwrap();
        (status, kind, String::from_utf8(bytes.to_vec()).unwrap())
    }

    /// ★ **The owner's 5.7 MB README picture (2026-10-15)**: the raw route
    /// handed the browser gunnar's JSON. A file past what the route serves is
    /// refused before gunnar is asked, in the console's words, as text.
    #[tokio::test]
    async fn an_lfs_object_over_the_ceiling_is_refused_in_plain_words() {
        let (status, kind, body) =
            text_of(Refusal::too_large("assets/huge.png", 5_968_091, true).into_response_for("x"))
                .await;

        assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE);
        assert_eq!(kind, "text/plain; charset=utf-8");
        assert_eq!(
            body,
            "assets/huge.png is 5.7 MB, kept in Git LFS, and this console hands a browser files \
             of up to 4 MB. Clone the repository and run `git lfs pull` to get it.\n"
        );
    }

    /// Whatever gunnar says, the browser gets a sentence — never its JSON,
    /// its code word or its operator's prose.
    #[tokio::test]
    async fn gunnar_s_refusals_reach_the_browser_as_sentences() {
        let fail = |code: &str, message: &str| {
            Refusal::from(ApiError(crate::grpc::Fail {
                code: code.to_owned(),
                message: message.to_owned(),
            }))
        };
        for (refusal, want_status, want) in [
            (
                fail("resourceexhausted", GUNNAR_LFS_REFUSAL),
                StatusCode::PAYLOAD_TOO_LARGE,
                "too large for this console",
            ),
            (
                fail("notfound", "object not found"),
                StatusCode::NOT_FOUND,
                "is not in this repository",
            ),
            (
                fail("permissiondenied", "Refs.ReadObject needs Read on a/b"),
                StatusCode::FORBIDDEN,
                "you cannot read",
            ),
        ] {
            let (status, kind, body) = text_of(refusal.into_response_for("assets/huge.png")).await;
            assert_eq!(status, want_status, "{body}");
            assert_eq!(kind, "text/plain; charset=utf-8");
            assert!(body.contains(want), "{body}");
            for leak in [
                "{",
                "resource exhausted",
                "resourceexhausted",
                "ceiling",
                "Refs.",
                "sha256",
            ] {
                assert!(!body.contains(leak), "{leak:?} reached the browser: {body}");
            }
        }
    }

    #[test]
    fn a_size_is_said_as_a_person_reads_it() {
        assert_eq!(megabytes(5_968_091), "5.7 MB");
        assert_eq!(megabytes(git::MAX_RAW_BYTES), "4 MB");
        assert_eq!(megabytes(30 * 1024 * 1024 + 1), "30 MB");
    }

    /// ★ **An LFS file is shown as the file it is** (mikronova/modgunn
    /// docs/idiot_guide.pdf on gunnar.rs, 2026-10-15, was "131 B · Plain Text
    /// · version https://git-lfs…"): a picture inline, a PDF opened, text as
    /// text when its bytes are text, anything else a download.
    #[test]
    fn an_lfs_file_is_shown_by_what_it_is_not_by_its_pointer() {
        assert_eq!(lfs_shown("docs/idiot_guide.pdf", None), "pdf");
        assert_eq!(lfs_shown("assets/knut.PNG", None), "image");
        assert_eq!(lfs_shown("data/notes.txt", Some(b"hello\nworld\n")), "text");
        assert_eq!(
            lfs_shown("data/blob.bin", Some(b"\0\x01\x02binary")),
            "download"
        );
        assert_eq!(
            lfs_shown("data/big.csv", None),
            "download",
            "unread is a download"
        );
        assert_eq!(
            lfs_shown("page.html", Some(b"<p>hi</p>")),
            "text",
            "shown as source, never run"
        );
    }

    /// The pointer gunnar's own `docs/gunnar-fast-setup.pdf` is in the tree —
    /// the file that sent this route looking for LFS at all.
    #[test]
    fn an_lfs_pointer_is_recognised_and_a_file_about_lfs_is_not() {
        let pointer = b"version https://git-lfs.github.com/spec/v1\n\
                        oid sha256:f201ed8cc8eb3015c0fec78ee06c43b8aad946e6d18df917b251d7f640cafd2c\n\
                        size 202972\n";
        assert_eq!(
            LfsPointer::parse(pointer),
            Some(LfsPointer {
                oid: "f201ed8cc8eb3015c0fec78ee06c43b8aad946e6d18df917b251d7f640cafd2c",
                size: 202972,
            })
        );
        assert_eq!(LfsPointer::parse(b"%PDF-1.7\n..."), None);
        assert_eq!(
            LfsPointer::parse(b"version https://git-lfs.github.com/spec/v1\nsize 3\n"),
            None,
            "no oid"
        );
        assert_eq!(
            LfsPointer::parse(
                b"version https://git-lfs.github.com/spec/v1\noid sha256:abc\nsize 3\n"
            ),
            None,
            "an oid that is not one"
        );
    }

    /// A PDF's policy must not be one Chrome refuses to draw a PDF under, and a
    /// picture's and a download's must keep the document sandboxed.
    #[test]
    fn only_the_pdf_goes_unsandboxed_and_nothing_can_be_framed() {
        assert!(!RawKind::Pdf.policy().contains("sandbox"));
        assert!(!RawKind::Pdf.policy().contains("object-src"));
        assert!(RawKind::Image("image/png").policy().contains("sandbox"));
        assert!(RawKind::Download.policy().contains("sandbox"));
        assert!(RawKind::Svg.policy().contains("sandbox"));
        assert!(RawKind::Svg.policy().contains("default-src 'none'"));
        assert!(!RawKind::Svg.policy().contains("script-src"));
        assert_eq!(RawKind::of("badges/openssf-gunnar.svg"), RawKind::Download, "public is gunnar's answer, not the name's");
        for kind in [RawKind::Pdf, RawKind::Image("image/png"), RawKind::Svg, RawKind::Download] {
            assert!(kind.policy().contains("frame-ancestors 'none'"), "{kind:?}");
        }
    }

    #[test]
    fn a_breadcrumb_names_every_step_with_its_full_path() {
        let trail = crumbs("src/git/mod.rs");

        assert_eq!(trail.len(), 3);
        assert_eq!(trail[0].name, "src");
        assert_eq!(trail[0].path, "src");
        assert_eq!(trail[2].name, "mod.rs");
        assert_eq!(
            trail[2].path, "src/git/mod.rs",
            "each crumb links to itself, not to its own name"
        );
    }

    #[test]
    fn the_repository_root_has_no_crumbs() {
        assert!(crumbs("").is_empty());
        assert!(crumbs("/").is_empty());
    }
}