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());
}
}