Luigit
repositories / bugabinga.net

bugabinga.net

personal infrastructure for bugabinga!

owned by admin

services/luigit/src/main.rs

Raw
use std::{
    collections::{BTreeMap, HashSet},
    fs,
    io::Write,
    path::{Path, PathBuf},
    sync::{
        Arc,
        atomic::{AtomicU64, Ordering},
    },
    time::SystemTime,
};

use anyhow::{Context, Result, anyhow, bail};
use axum::{
    Router,
    body::Body,
    extract::{Path as AxumPath, Query, State},
    http::{HeaderValue, StatusCode, header},
    response::{Html, IntoResponse, Response},
    routing::get,
};
use chrono::{DateTime, Utc};
use gix::bstr::ByteSlice;
use imara_diff::{Algorithm, BasicLineDiffPrinter, Diff, InternedInput, UnifiedDiffConfig};
use regex::RegexBuilder;
use rusqlite::{Connection, OpenFlags};
use serde::{Deserialize, Serialize};
use syntect::{
    easy::HighlightLines,
    highlighting::ThemeSet,
    html::{IncludeBackground, styled_line_to_highlighted_html},
    parsing::SyntaxSet,
    util::LinesWithEndings,
};
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
use tower_http::{compression::CompressionLayer, limit::RequestBodyLimitLayer, trace::TraceLayer};
use tracing::{error, info};

const STYLE: &str = concat!(include_str!("nugu.css"), "\n", include_str!("style.css"));
const PLEX_SANS_REGULAR: &[u8] = include_bytes!("../assets/fonts/IBMPlexSans-Regular.woff2");
const PLEX_SANS_SEMIBOLD: &[u8] = include_bytes!("../assets/fonts/IBMPlexSans-SemiBold.woff2");
const PLEX_MONO_REGULAR: &[u8] = include_bytes!("../assets/fonts/IBMPlexMono-Regular.woff2");
const MAX_OBJECT_BYTES: usize = 4 * 1024 * 1024;
const MAX_DIFF_BYTES: usize = 8 * 1024 * 1024;
const MAX_SEARCH_BYTES: usize = 64 * 1024 * 1024;
const MAX_RESULTS: usize = 200;
const MAX_ENTRIES: usize = 100_000;
const MAX_COMMITS: usize = 100_000;
const MAX_ARCHIVE_ENTRIES: usize = 10_000;
const MAX_ARCHIVE_BYTES: usize = 64 * 1024 * 1024;
const MAX_DIFF_ENTRIES: usize = 10_000;
const MAX_DIFF_FILES: usize = 5_000;
const MAX_DIFF_INPUT_BYTES: usize = 32 * 1024 * 1024;
const MAX_DIFF_OUTPUT_BYTES: usize = 32 * 1024 * 1024;
const MAX_DIFF_LINES: usize = 100_000;
const MAX_EXPENSIVE_REQUESTS: usize = 2;
const MAX_REF_SCAN_ENTRIES: usize = 1_000_000;
const MAX_PATH_BYTES: usize = 16 * 1024;
const MAX_ENTRY_METADATA_BYTES: usize = 4 * 1024 * 1024;

struct AppState {
    repos_root: PathBuf,
    manifest: PathBuf,
    clone_host: String,
    last_generation: AtomicU64,
    expensive_requests: Arc<Semaphore>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Manifest {
    version: u32,
    source_version: String,
    schema_fingerprint: String,
    generation: u64,
    created_at: DateTime<Utc>,
    expires_at: DateTime<Utc>,
    repositories: Vec<ManifestRepo>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct ManifestRepo {
    name: String,
    owner: String,
    description: String,
    hidden: bool,
}

#[derive(Debug)]
struct PublicRepo {
    meta: ManifestRepo,
    path: PathBuf,
}

#[derive(Debug)]
struct HttpError(StatusCode, String);

type HttpResult = Result<Response, HttpError>;

impl IntoResponse for HttpError {
    fn into_response(self) -> Response {
        let title = self.0.canonical_reason().unwrap_or("Request failed");
        (
            self.0,
            Html(page(
                title,
                &format!(
                    "<div class=status><strong>{}</strong><p>{}</p></div>",
                    esc(title),
                    esc(&self.1)
                ),
            )),
        )
            .into_response()
    }
}

impl From<anyhow::Error> for HttpError {
    fn from(err: anyhow::Error) -> Self {
        error!(error = %err, "request failed");
        HttpError(
            StatusCode::INTERNAL_SERVER_ERROR,
            "The repository data could not be read.".into(),
        )
    }
}

macro_rules! internal_errors {
    ($($kind:ty),+ $(,)?) => {$(
        impl From<$kind> for HttpError {
            fn from(err: $kind) -> Self { anyhow::Error::from(err).into() }
        }
    )+};
}
internal_errors!(
    std::io::Error,
    gix::object::commit::Error,
    gix::object::find::existing::with_conversion::Error,
    gix::object::peel::to_kind::Error,
    gix::revision::walk::Error,
    gix::revision::walk::iter::Error,
    gix_object::decode::Error,
    gix_object::find::existing::Error,
);

#[derive(Deserialize, Default)]
struct SearchQuery {
    q: Option<String>,
    regex: Option<bool>,
    #[serde(rename = "type")]
    _type: Option<String>,
}

#[derive(Deserialize, Default)]
struct DiffQuery {
    view: Option<String>,
    parent: Option<usize>,
}

#[derive(Clone)]
struct FlatEntry {
    path: String,
    id: gix::ObjectId,
    kind: gix::object::tree::EntryKind,
    mode: gix::object::tree::EntryMode,
}

async fn run_expensive<T>(
    state: &Arc<AppState>,
    work: impl FnOnce() -> Result<T, HttpError> + Send + 'static,
) -> Result<T, HttpError>
where
    T: Send + 'static,
{
    let permit: OwnedSemaphorePermit = state
        .expensive_requests
        .clone()
        .try_acquire_owned()
        .map_err(|_| {
            HttpError(
                StatusCode::TOO_MANY_REQUESTS,
                "Repository work is busy. Retry shortly.".into(),
            )
        })?;
    tokio::task::spawn_blocking(move || {
        let _permit = permit;
        work()
    })
    .await
    .map_err(|_| {
        HttpError(
            StatusCode::INTERNAL_SERVER_ERROR,
            "Repository work stopped.".into(),
        )
    })?
}

#[derive(Default)]
struct WorkBudget {
    entries: usize,
    metadata_bytes: usize,
    files: usize,
    input_bytes: usize,
    output_bytes: usize,
}

impl WorkBudget {
    fn charge(used: &mut usize, amount: usize, max: usize, label: &str) -> Result<(), HttpError> {
        *used = used
            .checked_add(amount)
            .filter(|used| *used <= max)
            .ok_or_else(|| {
                HttpError(
                    StatusCode::PAYLOAD_TOO_LARGE,
                    format!("{label} exceeds the request limit."),
                )
            })?;
        Ok(())
    }

    fn entry(&mut self, path: &str, max: usize) -> Result<(), HttpError> {
        if path.len() > MAX_PATH_BYTES {
            return Err(HttpError(
                StatusCode::PAYLOAD_TOO_LARGE,
                "Repository path exceeds the request path limit.".into(),
            ));
        }
        Self::charge(&mut self.entries, 1, max, "Repository entry count")?;
        Self::charge(
            &mut self.metadata_bytes,
            path.len(),
            MAX_ENTRY_METADATA_BYTES,
            "Repository entry metadata",
        )
    }

    fn file(&mut self) -> Result<(), HttpError> {
        Self::charge(&mut self.files, 1, MAX_DIFF_FILES, "Changed file count")
    }

    fn input(&mut self, bytes: usize) -> Result<(), HttpError> {
        Self::charge(
            &mut self.input_bytes,
            bytes,
            MAX_DIFF_INPUT_BYTES,
            "Diff input",
        )
    }

    fn output(&mut self, bytes: usize) -> Result<(), HttpError> {
        Self::charge(
            &mut self.output_bytes,
            bytes,
            MAX_DIFF_OUTPUT_BYTES,
            "Diff output",
        )
    }
}

#[tokio::main]
async fn main() -> Result<()> {
    tracing_subscriber::fmt()
        .json()
        .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
        .init();
    let mut args = std::env::args().skip(1);
    if matches!(args.next().as_deref(), Some("export-manifest")) {
        let db = required_arg(&mut args, "database")?;
        let repos = required_arg(&mut args, "repositories")?;
        let output = required_arg(&mut args, "output")?;
        return export_manifest(Path::new(&db), Path::new(&repos), Path::new(&output));
    }

    let state = Arc::new(AppState {
        repos_root: env_path("LUIGIT_REPOSITORIES", "/repos"),
        manifest: env_path("LUIGIT_MANIFEST", "/manifest/public.json"),
        clone_host: std::env::var("LUIGIT_CLONE_HOST")
            .unwrap_or_else(|_| "vcs.bugabinga.net".into()),
        last_generation: AtomicU64::new(0),
        expensive_requests: Arc::new(Semaphore::new(MAX_EXPENSIVE_REQUESTS)),
    });
    let listen = std::env::var("LUIGIT_LISTEN").unwrap_or_else(|_| "0.0.0.0:8080".into());
    let app = build_router(state);
    let listener = tokio::net::TcpListener::bind(&listen).await?;
    info!(%listen, "luigit listening");
    axum::serve(listener, app)
        .with_graceful_shutdown(async {
            let _ = tokio::signal::ctrl_c().await;
        })
        .await?;
    Ok(())
}

fn build_router(state: Arc<AppState>) -> Router {
    Router::new()
        .route("/", get(catalog))
        .route("/healthz", get(health))
        .route("/readyz", get(ready))
        .route("/assets/style.css", get(style))
        .route("/assets/{font}", get(font))
        .route("/{repo}/", get(repository_landing))
        .route("/{repo}/refs", get(refs_view))
        .route("/{repo}/log/{rev}", get(log_view))
        .route("/{repo}/commit/{oid}", get(commit_view))
        .route("/{repo}/tree/{rev}", get(tree_root))
        .route("/{repo}/tree/{rev}/{*path}", get(tree_view))
        .route("/{repo}/blob/{rev}/{*path}", get(blob_view))
        .route("/{repo}/raw/{rev}/{*path}", get(raw_view))
        .route("/{repo}/compare/{spec}", get(compare_view))
        .route("/{repo}/notes", get(notes_view))
        .route("/{repo}/search", get(search_view))
        .route("/{repo}/feed.atom", get(feed_view))
        .route("/{repo}/patch/{file}", get(patch_view))
        .route("/{repo}/archive/{file}", get(archive_view))
        .layer(RequestBodyLimitLayer::new(16 * 1024))
        .layer(CompressionLayer::new())
        .layer(TraceLayer::new_for_http())
        .with_state(state)
}

fn required_arg(args: &mut impl Iterator<Item = String>, name: &str) -> Result<String> {
    args.next()
        .ok_or_else(|| anyhow!("missing {name} argument"))
}

fn env_path(name: &str, default: &str) -> PathBuf {
    PathBuf::from(std::env::var(name).unwrap_or_else(|_| default.into()))
}

async fn health() -> &'static str {
    "ok\n"
}

async fn ready(State(state): State<Arc<AppState>>) -> impl IntoResponse {
    match load_manifest(&state) {
        Ok(_) => (StatusCode::OK, "ready\n"),
        Err(err) => {
            error!(error=%err, "not ready");
            (StatusCode::SERVICE_UNAVAILABLE, "not ready\n")
        }
    }
}

async fn style() -> impl IntoResponse {
    (
        [
            (header::CONTENT_TYPE, "text/css; charset=utf-8"),
            (header::CACHE_CONTROL, "public, max-age=300"),
        ],
        STYLE,
    )
}

async fn font(AxumPath(font): AxumPath<String>) -> Response {
    let data = match font.as_str() {
        "IBMPlexSans-Regular.woff2" => PLEX_SANS_REGULAR,
        "IBMPlexSans-SemiBold.woff2" => PLEX_SANS_SEMIBOLD,
        "IBMPlexMono-Regular.woff2" => PLEX_MONO_REGULAR,
        _ => return StatusCode::NOT_FOUND.into_response(),
    };
    (
        [
            (header::CONTENT_TYPE, "font/woff2"),
            (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
        ],
        data,
    )
        .into_response()
}

fn load_manifest(state: &AppState) -> Result<Manifest> {
    let bytes = fs::read(&state.manifest).context("read public manifest")?;
    if bytes.len() > 1024 * 1024 {
        bail!("manifest exceeds one MiB")
    }
    let manifest: Manifest = serde_json::from_slice(&bytes).context("parse public manifest")?;
    if manifest.version != 1 {
        bail!("unsupported manifest version")
    }
    let now = Utc::now();
    if manifest.created_at > now + chrono::Duration::seconds(5) {
        bail!("manifest is future-dated")
    }
    if manifest.expires_at <= now || manifest.expires_at <= manifest.created_at {
        bail!("manifest is expired")
    }
    let mut names = HashSet::new();
    for repo in &manifest.repositories {
        validate_repo_name(&repo.name)?;
        if repo.owner.trim().is_empty() || !names.insert(repo.name.clone()) {
            bail!("ambiguous manifest repository")
        }
    }
    let previous = state
        .last_generation
        .fetch_max(manifest.generation, Ordering::AcqRel);
    if manifest.generation < previous {
        bail!("manifest generation regressed")
    }
    Ok(manifest)
}

fn validate_repo_name(name: &str) -> Result<()> {
    if name.is_empty()
        || name.len() > 200
        || name.starts_with('/')
        || name.ends_with('/')
        || name
            .split('/')
            .any(|p| p.is_empty() || p == "." || p == ".." || p.ends_with(".git"))
    {
        bail!("invalid repository name")
    }
    Ok(())
}

fn public_repo(state: &AppState, name: &str) -> Result<PublicRepo, HttpError> {
    let manifest = load_manifest(state).map_err(|_| {
        HttpError(
            StatusCode::SERVICE_UNAVAILABLE,
            "Publication metadata is unavailable or stale.".into(),
        )
    })?;
    let meta = manifest
        .repositories
        .into_iter()
        .find(|r| r.name == name)
        .ok_or_else(|| HttpError(StatusCode::NOT_FOUND, "Repository not found.".into()))?;
    let path = state.repos_root.join(format!("{}.git", meta.name));
    if !path.is_dir() || !path.join("git-daemon-export-ok").is_file() {
        return Err(HttpError(
            StatusCode::NOT_FOUND,
            "Repository is not currently public.".into(),
        ));
    }
    Ok(PublicRepo { meta, path })
}

fn open_repo(public: &PublicRepo) -> Result<gix::Repository> {
    gix::open_opts(&public.path, gix::open::Options::isolated()).context("open repository")
}

async fn catalog(
    State(state): State<Arc<AppState>>,
    Query(query): Query<SearchQuery>,
) -> HttpResult {
    let manifest = load_manifest(&state).map_err(|_| {
        HttpError(
            StatusCode::SERVICE_UNAVAILABLE,
            "Publication metadata is unavailable or stale.".into(),
        )
    })?;
    let needle = query.q.unwrap_or_default().to_lowercase();
    let mut rows = String::new();
    for meta in manifest
        .repositories
        .iter()
        .filter(|r| !r.hidden)
        .filter(|r| {
            needle.is_empty()
                || r.name.to_lowercase().contains(&needle)
                || r.description.to_lowercase().contains(&needle)
        })
    {
        let public = match public_repo(&state, &meta.name) {
            Ok(r) => r,
            Err(_) => continue,
        };
        let (branch, activity) = match open_repo(&public).map(|repo| {
            let branch = default_branch(&repo);
            let activity = repo
                .head_commit()
                .ok()
                .and_then(|c| c.time().ok())
                .map(|t| format_time(t.seconds))
                .unwrap_or_else(|| "empty".into());
            (branch, activity)
        }) {
            Ok(v) => v,
            Err(_) => ("unborn".into(), "empty".into()),
        };
        rows.push_str(&format!("<li><a href=\"/{0}/\"><strong>{0}</strong></a><span>{1}</span><span class=owner>{2}</span><span>{3} · {4}</span></li>", url(&meta.name), esc(&meta.description), esc(&meta.owner), esc(&branch), esc(&activity)));
    }
    let body = format!(
        "<header class=headline><div><h1>Repositories</h1><p>Public source, history, notes, and exact Git state.</p></div></header><form class=search-form action=/ method=get><input name=q aria-label=\"Search repositories\" value=\"{}\" placeholder=\"Search repositories…\"><button>Search</button></form><ul class=\"rows catalog\">{}</ul>",
        esc(&needle),
        rows
    );
    Ok(Html(page("Repositories", &body)).into_response())
}

async fn repository_landing(
    State(state): State<Arc<AppState>>,
    AxumPath(name): AxumPath<String>,
) -> HttpResult {
    let public = public_repo(&state, &name)?;
    let repo = open_repo(&public)?;
    let branch = default_branch(&repo);
    let clone = format!("git clone ssh://{}/{}", state.clone_host, public.meta.name);
    let body = match repo.head_commit() {
        Ok(commit) => {
            let id = commit.id().to_string();
            let title = commit_title(&commit);
            let readme = find_readme(&commit)?
                .map(|(path, data)| render_markdown(&data, &public.meta.name, &id, &path))
                .unwrap_or_else(|| "<div class=status>No README found.</div>".into());
            let tree = render_tree_rows(&repo, &commit, "", &public.meta.name, &id)?;
            format!(
                "{}<div class=layout><article class=document>{}</article><aside class=rail><section><h2>Clone over SSH</h2><div class=clone><code>{}</code><button data-copy=\"{}\" aria-label=\"Copy clone command\">Copy</button></div></section><section><h2>Default branch</h2><div><span class=ref>{}</span></div></section><section><h2>Latest change</h2><a href=\"/{}/commit/{}\">{}</a><div class=muted>{} · {}</div></section></aside><section style=\"grid-column:1/-1\"><h2 class=section-title>Default branch source</h2><ul class=rows>{}</ul></section></div>",
                repo_header(&public, "README"),
                readme,
                esc(&clone),
                esc(&clone),
                esc(&branch),
                url(&name),
                id,
                esc(&title),
                short(&id),
                format_time(commit.time()?.seconds),
                tree
            )
        }
        Err(_) => format!(
            "{}<div class=status><strong>Empty repository</strong><p>No commits exist yet.</p><p><code>{}</code></p></div>",
            repo_header(&public, "README"),
            esc(&clone)
        ),
    };
    Ok(Html(page(&public.meta.name, &body)).into_response())
}

async fn tree_root(
    State(state): State<Arc<AppState>>,
    AxumPath((name, rev)): AxumPath<(String, String)>,
) -> HttpResult {
    tree_response(&state, &name, &rev, "")
}
async fn tree_view(
    State(state): State<Arc<AppState>>,
    AxumPath((name, rev, path)): AxumPath<(String, String, String)>,
) -> HttpResult {
    tree_response(&state, &name, &rev, &path)
}

fn tree_response(state: &AppState, name: &str, rev: &str, path: &str) -> HttpResult {
    let public = public_repo(state, name)?;
    let repo = open_repo(&public)?;
    let commit = resolve_commit(&repo, rev)?;
    let tree = if path.is_empty() {
        commit.tree()?
    } else {
        commit
            .tree()?
            .lookup_entry_by_path(path)?
            .ok_or_else(not_found)?
            .object()?
            .try_into_tree()
            .map_err(|_| not_found())?
    };
    let mut rows = String::new();
    if !path.is_empty() {
        let parent = path.rsplit_once('/').map(|x| x.0).unwrap_or("");
        rows.push_str(&format!(
            "<li><a href=\"/{}/tree/{}/{}\">../</a><span></span><span></span></li>",
            url(name),
            commit.id(),
            url_path(parent)
        ));
    }
    for entry in tree.iter().take(MAX_ENTRIES) {
        let entry = entry?;
        let filename = entry.filename().to_str_lossy();
        let full = if path.is_empty() {
            filename.to_string()
        } else {
            format!("{path}/{filename}")
        };
        let kind = entry.kind();
        let route = if kind == gix::object::tree::EntryKind::Tree {
            "tree"
        } else {
            "blob"
        };
        rows.push_str(&format!(
            "<li><a href=\"/{}/{}/{}/{}\">{}{}</a><span class=oid>{}</span><span>{}</span></li>",
            url(name),
            route,
            commit.id(),
            url_path(&full),
            esc(&filename),
            if kind == gix::object::tree::EntryKind::Tree {
                "/"
            } else {
                ""
            },
            short(&entry.id().to_string()),
            esc(&format!("{kind:?}"))
        ));
    }
    let body = format!(
        "{}<div class=source-head><h2>{}</h2><span class=ref>{}</span></div><ul class=rows>{}</ul>",
        repo_header(&public, "Source"),
        esc(if path.is_empty() { "/" } else { path }),
        short(&commit.id().to_string()),
        rows
    );
    Ok(Html(page(&format!("{} · {}", public.meta.name, path), &body)).into_response())
}

async fn blob_view(
    State(state): State<Arc<AppState>>,
    AxumPath((name, rev, path)): AxumPath<(String, String, String)>,
) -> HttpResult {
    let public = public_repo(&state, &name)?;
    let repo = open_repo(&public)?;
    let commit = resolve_commit(&repo, &rev)?;
    let data = blob_at(&commit, &path)?;
    if data.len() > MAX_OBJECT_BYTES {
        return Err(HttpError(
            StatusCode::PAYLOAD_TOO_LARGE,
            "File exceeds the inline rendering limit. Raw download remains available.".into(),
        ));
    }
    let rendered = if is_text(&data) {
        if is_markdown(&path) {
            format!(
                "<details><summary>Rendered preview</summary><article class=document>{}</article></details>{}",
                render_markdown(&data, &name, &commit.id().to_string(), &path),
                highlight(&path, &data)
            )
        } else {
            highlight(&path, &data)
        }
    } else {
        format!(
            "<div class=status>Binary content, {} bytes. <a href=\"/{}/raw/{}/{}\">Download raw</a>.</div>",
            data.len(),
            url(&name),
            commit.id(),
            url_path(&path)
        )
    };
    let body = format!(
        "{}<div class=source-head><h2>{}</h2><a href=\"/{}/raw/{}/{}\">Raw</a></div>{}",
        repo_header(&public, "Source"),
        esc(&path),
        url(&name),
        commit.id(),
        url_path(&path),
        rendered
    );
    Ok(Html(page(&format!("{} · {}", name, path), &body)).into_response())
}

async fn raw_view(
    State(state): State<Arc<AppState>>,
    AxumPath((name, rev, path)): AxumPath<(String, String, String)>,
) -> HttpResult {
    let public = public_repo(&state, &name)?;
    let repo = open_repo(&public)?;
    let commit = resolve_commit(&repo, &rev)?;
    let data = blob_at(&commit, &path)?;
    if data.len() > 64 * 1024 * 1024 {
        return Err(HttpError(
            StatusCode::PAYLOAD_TOO_LARGE,
            "Object exceeds the raw response limit.".into(),
        ));
    }
    let content_type = if is_text(&data) {
        "text/plain; charset=utf-8"
    } else {
        "application/octet-stream"
    };
    Ok((
        [
            (header::CONTENT_TYPE, content_type),
            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
        ],
        data,
    )
        .into_response())
}

async fn refs_view(
    State(state): State<Arc<AppState>>,
    AxumPath(name): AxumPath<String>,
) -> HttpResult {
    let public = public_repo(&state, &name)?;
    let repo = open_repo(&public)?;
    let mut groups: BTreeMap<&str, String> = BTreeMap::new();
    for (refname, id) in advertised_refs(&repo)? {
        let (group, target, label) = if refname.starts_with("refs/heads/") {
            (
                "Branches",
                format!("/{}/log/{}", url(&name), url(&refname)),
                "history",
            )
        } else if refname.starts_with("refs/tags/") {
            (
                "Tags",
                format!("/{}/log/{}", url(&name), url(&refname)),
                "history",
            )
        } else {
            ("Notes", format!("/{}/notes", url(&name)), "notes")
        };
        groups.entry(group).or_default().push_str(&format!(
            "<li><span class=ref>{}</span><span class=oid>{}</span><a href=\"{}\">{}</a></li>",
            esc(&refname),
            short(&id.to_string()),
            target,
            label
        ));
    }
    let content = groups
        .into_iter()
        .map(|(g, r)| format!("<section><h2>{}</h2><ul class=rows>{}</ul></section>", g, r))
        .collect::<String>();
    Ok(Html(page(
        &format!("{} refs", name),
        &format!("{}{}", repo_header(&public, "Refs"), content),
    ))
    .into_response())
}

async fn log_view(
    State(state): State<Arc<AppState>>,
    AxumPath((name, rev)): AxumPath<(String, String)>,
) -> HttpResult {
    let public = public_repo(&state, &name)?;
    let repo = open_repo(&public)?;
    let commit = resolve_commit(&repo, &rev)?;
    let mut rows = String::new();
    for info in commit.ancestors().all()?.take(500) {
        let info = info?;
        let c = repo.find_commit(info.id)?;
        let id = c.id().to_string();
        let parents = c.parent_ids().count();
        rows.push_str(&format!("<li><a href=\"/{}/commit/{}\">{}</a><span class=muted>{}</span><span>{} · {} parent{}</span></li>",url(&name),id,esc(&commit_title(&c)),esc(&actor(&c)),short(&id),parents,if parents==1{""}else{"s"}));
    }
    let body = format!(
        "{}<div class=source-head><h2>History</h2><span class=ref>{}</span></div><ul class=rows>{}</ul>",
        repo_header(&public, "History"),
        esc(&rev),
        rows
    );
    Ok(Html(page(&format!("{} history", name), &body)).into_response())
}

async fn commit_view(
    State(state): State<Arc<AppState>>,
    AxumPath((name, oid)): AxumPath<(String, String)>,
    Query(query): Query<DiffQuery>,
) -> HttpResult {
    let worker_state = state.clone();
    run_expensive(&state, move || {
        commit_view_sync(&worker_state, &name, &oid, query)
    })
    .await
}

fn commit_view_sync(state: &AppState, name: &str, oid: &str, query: DiffQuery) -> HttpResult {
    let public = public_repo(state, name)?;
    let repo = open_repo(&public)?;
    let commit = resolve_commit(&repo, oid)?;
    let message = commit.message()?;
    let author = commit.author()?;
    let parent_num = query.parent.unwrap_or(1);
    let parents: Vec<_> = commit.parent_ids().collect();
    let before = if parents.is_empty() {
        BTreeMap::new()
    } else {
        let id = *parents.get(parent_num.saturating_sub(1)).ok_or_else(|| {
            HttpError(
                StatusCode::BAD_REQUEST,
                "Selected merge parent does not exist.".into(),
            )
        })?;
        flatten_commit_limited(&repo.find_commit(id)?, MAX_DIFF_ENTRIES)?
    };
    let after = flatten_commit_limited(&commit, MAX_DIFF_ENTRIES)?;
    let diff = render_comparison(&repo, name, &before, &after, query.view.as_deref())?;
    let sig = signature_state(&commit);
    let body = format!(
        "{}<article><h2>{}</h2><p class=oid>{}</p><p>{} &lt;<a href=\"mailto:{}\">{}</a>&gt; · {}</p><p><span class=badge>{}</span> {} parent{}</p><pre class=code>{}</pre></article>{}",
        repo_header(&public, "History"),
        esc(message.title.to_str_lossy().as_ref()),
        commit.id(),
        esc(author.name.to_str_lossy().as_ref()),
        url(author.email.to_str_lossy().as_ref()),
        esc(author.email.to_str_lossy().as_ref()),
        format_time(commit.time()?.seconds),
        sig,
        parents.len(),
        if parents.len() == 1 { "" } else { "s" },
        esc(message
            .body
            .map(|b| b.to_str_lossy())
            .unwrap_or_default()
            .as_ref()),
        diff
    );
    Ok(Html(page(
        &format!("{} · {}", name, short(&commit.id().to_string())),
        &body,
    ))
    .into_response())
}

async fn compare_view(
    State(state): State<Arc<AppState>>,
    AxumPath((name, spec)): AxumPath<(String, String)>,
    Query(query): Query<DiffQuery>,
) -> HttpResult {
    let worker_state = state.clone();
    run_expensive(&state, move || {
        compare_view_sync(&worker_state, &name, &spec, query)
    })
    .await
}

fn compare_view_sync(state: &AppState, name: &str, spec: &str, query: DiffQuery) -> HttpResult {
    let public = public_repo(state, name)?;
    let repo = open_repo(&public)?;
    let (base, head) = spec
        .split_once("...")
        .or_else(|| spec.split_once(".."))
        .ok_or_else(|| {
            HttpError(
                StatusCode::BAD_REQUEST,
                "Comparison must be base...head or base..head.".into(),
            )
        })?;
    let exact = spec.contains("..").then_some(()).is_some() && !spec.contains("...");
    let base_c = resolve_commit(&repo, base)?;
    let head_c = resolve_commit(&repo, head)?;
    let effective_base = if exact {
        base_c
    } else {
        merge_base(&repo, &base_c, &head_c).unwrap_or(base_c)
    };
    let before = flatten_commit_limited(&effective_base, MAX_DIFF_ENTRIES)?;
    let after = flatten_commit_limited(&head_c, MAX_DIFF_ENTRIES)?;
    let diff = render_comparison(&repo, name, &before, &after, query.view.as_deref())?;
    let body = format!(
        "{}<h2>Compare {} → {}</h2><div class=status>Semantics: {}. Base <span class=oid>{}</span>, head <span class=oid>{}</span>.</div>{}",
        repo_header(&public, "Compare"),
        esc(base),
        esc(head),
        if exact {
            "exact snapshots"
        } else {
            "changes introduced since merge base"
        },
        short(&effective_base.id().to_string()),
        short(&head_c.id().to_string()),
        diff
    );
    Ok(Html(page(&format!("{} comparison", name), &body)).into_response())
}

async fn notes_view(
    State(state): State<Arc<AppState>>,
    AxumPath(name): AxumPath<String>,
    Query(query): Query<SearchQuery>,
) -> HttpResult {
    let public = public_repo(&state, &name)?;
    let repo = open_repo(&public)?;
    let needle = query.q.unwrap_or_default().to_lowercase();
    let mut namespaces = String::new();
    let mut notes = String::new();
    for (refname, id) in advertised_refs(&repo)?
        .into_iter()
        .filter(|(n, _)| n.starts_with("refs/notes/"))
    {
        let object = repo.find_object(id)?.peel_to_tree()?;
        let entries = flatten_tree(&repo, &object, "", MAX_ENTRIES)?;
        let mut count = 0;
        for e in entries {
            if e.kind == gix::object::tree::EntryKind::Tree {
                continue;
            }
            let hex = e.path.replace('/'.to_string().as_str(), "");
            if hex.len() != id.kind().len_in_hex() || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
                continue;
            }
            let data = repo
                .find_object(e.id)?
                .try_into_blob()
                .map_err(|_| anyhow!("note is not blob"))?
                .data
                .clone();
            if !needle.is_empty()
                && !String::from_utf8_lossy(&data)
                    .to_lowercase()
                    .contains(&needle)
            {
                continue;
            }
            count += 1;
            let reachable = resolve_commit(&repo, &hex).is_ok();
            notes.push_str(&format!("<article class=note><div><span class=badge>object</span> <span class=oid>{}</span> {}</div><div class=document>{}</div><footer class=muted>{} · current content</footer></article>",short(&hex),if reachable{""}else{"target not publicly reachable"},if is_text(&data){render_markdown(&data,&name,&hex,"")}else{"<p>Binary note content. Download unavailable in this view.</p>".into()},esc(&refname)));
        }
        namespaces.push_str(&format!(
            "<li><span class=ref>{}</span><span>{} note{}</span></li>",
            esc(&refname),
            count,
            if count == 1 { "" } else { "s" }
        ));
    }
    let body = format!(
        "{}<form class=search-form><input name=q value=\"{}\" aria-label=\"Search current notes\" placeholder=\"Search current notes…\"><button>Search</button></form><h2>Namespaces</h2><ul class=rows>{}</ul><section>{}</section>",
        repo_header(&public, "Notes"),
        esc(&needle),
        namespaces,
        notes
    );
    Ok(Html(page(&format!("{} notes", name), &body)).into_response())
}

async fn search_view(
    State(state): State<Arc<AppState>>,
    AxumPath(name): AxumPath<String>,
    Query(query): Query<SearchQuery>,
) -> HttpResult {
    let public = public_repo(&state, &name)?;
    let repo = open_repo(&public)?;
    let q = query.q.unwrap_or_default();
    if q.len() > 256 {
        return Err(HttpError(
            StatusCode::BAD_REQUEST,
            "Query exceeds 256 bytes.".into(),
        ));
    }
    let matcher = if query.regex.unwrap_or(false) {
        Some(
            RegexBuilder::new(&q)
                .case_insensitive(true)
                .size_limit(1 << 20)
                .dfa_size_limit(1 << 20)
                .build()
                .map_err(|_| {
                    HttpError(
                        StatusCode::BAD_REQUEST,
                        "Invalid or excessive regular expression.".into(),
                    )
                })?,
        )
    } else {
        None
    };
    let literal = q.to_lowercase();
    let mut rows = String::new();
    let mut searched = 0usize;
    let mut count = 0usize;
    let mut partial = false;
    let refs = advertised_refs(&repo)?;
    let mut seen = HashSet::new();
    'outer: for (refname, id) in refs
        .into_iter()
        .filter(|(n, _)| n.starts_with("refs/heads/") || n.starts_with("refs/tags/"))
    {
        let commit = match repo.find_object(id)?.peel_to_commit() {
            Ok(c) => c,
            Err(_) => continue,
        };
        for e in flatten_commit(&commit)?.into_values() {
            if e.kind == gix::object::tree::EntryKind::Tree || !seen.insert(e.id) {
                continue;
            }
            let data = repo
                .find_object(e.id)?
                .try_into_blob()
                .map_err(|_| anyhow!("blob expected"))?
                .data
                .clone();
            if !is_text(&data) || data.len() > MAX_OBJECT_BYTES {
                continue;
            }
            searched += data.len();
            if searched > MAX_SEARCH_BYTES {
                partial = true;
                break 'outer;
            }
            let text = String::from_utf8_lossy(&data);
            let matched = matcher
                .as_ref()
                .map(|r| r.is_match(&text))
                .unwrap_or_else(|| text.to_lowercase().contains(&literal));
            if matched {
                count += 1;
                rows.push_str(&format!("<li><a href=\"/{}/blob/{}/{}\">{}</a><span>{}</span><span class=oid>{}</span></li>",url(&name),commit.id(),url_path(&e.path),esc(&e.path),esc(&refname),short(&e.id.to_string())));
                if count >= MAX_RESULTS {
                    partial = true;
                    break 'outer;
                }
            }
        }
    }
    let body = format!(
        "{}<form class=search-form><input name=q value=\"{}\" aria-label=\"Search repository\" placeholder=\"Search paths and selected snapshots…\"><label><input type=checkbox name=regex value=true {}> regex</label><button>Search</button></form>{}<p>{} result{}, {} MiB verified. Coverage: advertised branch and tag snapshots.</p><ul class=rows>{}</ul>",
        repo_header(&public, "Search"),
        esc(&q),
        if matcher.is_some() { "checked" } else { "" },
        if partial {
            "<div class=status>Partial results: work or result budget exhausted.</div>"
        } else {
            ""
        },
        count,
        if count == 1 { "" } else { "s" },
        searched / (1024 * 1024),
        rows
    );
    Ok(Html(page(&format!("{} search", name), &body)).into_response())
}

async fn feed_view(
    State(state): State<Arc<AppState>>,
    AxumPath(name): AxumPath<String>,
) -> HttpResult {
    let public = public_repo(&state, &name)?;
    let repo = open_repo(&public)?;
    let commit = repo.head_commit().map_err(|_| not_found())?;
    let mut entries = String::new();
    for info in commit.ancestors().all()?.take(30) {
        let c = repo.find_commit(info?.id)?;
        let id = c.id().to_string();
        entries.push_str(&format!("<entry><id>urn:git:{}</id><title>{}</title><updated>{}</updated><link href=\"https://vcs.bugabinga.net/{}/commit/{}\"/></entry>",id,esc_xml(&commit_title(&c)),chrono::DateTime::from_timestamp(c.time()?.seconds,0).unwrap_or_default().to_rfc3339(),url(&name),id));
    }
    let xml = format!(
        "<?xml version=\"1.0\" encoding=\"utf-8\"?><feed xmlns=\"http://www.w3.org/2005/Atom\"><id>https://vcs.bugabinga.net/{0}/</id><title>{1} commits</title><updated>{2}</updated>{3}</feed>",
        url(&name),
        esc_xml(&name),
        Utc::now().to_rfc3339(),
        entries
    );
    Ok((
        [(header::CONTENT_TYPE, "application/atom+xml; charset=utf-8")],
        xml,
    )
        .into_response())
}

async fn patch_view(
    State(state): State<Arc<AppState>>,
    AxumPath((name, file)): AxumPath<(String, String)>,
) -> HttpResult {
    let worker_state = state.clone();
    run_expensive(&state, move || patch_view_sync(&worker_state, &name, &file)).await
}

fn patch_view_sync(state: &AppState, name: &str, file: &str) -> HttpResult {
    let oid = required_suffix(file, ".patch")?;
    let public = public_repo(state, name)?;
    let repo = open_repo(&public)?;
    let commit = resolve_commit(&repo, oid)?;
    let before = commit
        .parent_ids()
        .next()
        .map(|id| repo.find_commit(id))
        .transpose()?
        .map(|c| flatten_commit_limited(&c, MAX_DIFF_ENTRIES))
        .transpose()?
        .unwrap_or_default();
    let after = flatten_commit_limited(&commit, MAX_DIFF_ENTRIES)?;
    let patch = text_patch(&repo, &before, &after)?;
    Ok((
        [
            (header::CONTENT_TYPE, "text/x-patch; charset=utf-8"),
            (header::CONTENT_DISPOSITION, "attachment"),
        ],
        patch,
    )
        .into_response())
}

async fn archive_view(
    State(state): State<Arc<AppState>>,
    AxumPath((name, file)): AxumPath<(String, String)>,
) -> HttpResult {
    let worker_state = state.clone();
    run_expensive(&state, move || {
        archive_view_sync(&worker_state, &name, &file)
    })
    .await
}

fn archive_view_sync(state: &AppState, name: &str, file: &str) -> HttpResult {
    let rev = required_suffix(file, ".tar")?;
    let public = public_repo(state, name)?;
    let repo = open_repo(&public)?;
    let commit = resolve_commit(&repo, rev)?;
    let entries = flatten_commit_limited(&commit, MAX_ARCHIVE_ENTRIES)?;
    let mut archive_bytes = 1024usize;
    for e in entries.values() {
        if e.kind == gix::object::tree::EntryKind::Tree {
            continue;
        }
        let bytes = blob_size(&repo, e.id)?;
        require_blob_within(
            bytes,
            MAX_ARCHIVE_BYTES,
            "Archive contains a file exceeding the archive limit.",
        )?;
        let record_bytes = archive_record_bytes(bytes)?;
        archive_bytes = archive_bytes
            .checked_add(record_bytes)
            .filter(|bytes| *bytes <= MAX_ARCHIVE_BYTES)
            .ok_or_else(|| {
                HttpError(
                    StatusCode::PAYLOAD_TOO_LARGE,
                    "Archive exceeds the total archive limit; no archive was generated.".into(),
                )
            })?;
    }
    let mut out = Vec::new();
    {
        let mut tar = tar::Builder::new(&mut out);
        for e in entries.values() {
            if e.kind == gix::object::tree::EntryKind::Tree {
                continue;
            }
            let data = repo
                .find_object(e.id)?
                .try_into_blob()
                .map_err(|_| anyhow!("archive object is not blob"))?
                .data
                .clone();
            let mut h = tar::Header::new_gnu();
            h.set_size(data.len() as u64);
            h.set_mode(if e.mode.is_executable() { 0o755 } else { 0o644 });
            h.set_mtime(commit.time()?.seconds.max(0) as u64);
            h.set_cksum();
            tar.append_data(
                &mut h,
                format!("{}-{}/{}", name, short(&commit.id().to_string()), e.path),
                data.as_slice(),
            )?;
        }
        tar.finish()?;
    }
    let disposition = format!(
        "attachment; filename=\"{}-{}.tar\"",
        name.replace('"', ""),
        short(&commit.id().to_string())
    );
    let mut response = Body::from(out).into_response();
    response.headers_mut().insert(
        header::CONTENT_TYPE,
        HeaderValue::from_static("application/x-tar"),
    );
    response.headers_mut().insert(
        header::CONTENT_DISPOSITION,
        HeaderValue::from_str(&disposition).map_err(|_| {
            HttpError(
                StatusCode::INTERNAL_SERVER_ERROR,
                "Invalid archive name.".into(),
            )
        })?,
    );
    Ok(response)
}

fn blob_size(repo: &gix::Repository, id: gix::ObjectId) -> Result<usize, HttpError> {
    let header = repo.find_header(id)?;
    if header.kind() != gix_object::Kind::Blob {
        return Err(HttpError(
            StatusCode::INTERNAL_SERVER_ERROR,
            "Repository object is not a blob.".into(),
        ));
    }
    usize::try_from(header.size()).map_err(|_| {
        HttpError(
            StatusCode::PAYLOAD_TOO_LARGE,
            "Repository object exceeds the request limit.".into(),
        )
    })
}

fn require_blob_within(bytes: usize, max: usize, message: &str) -> Result<(), HttpError> {
    (bytes <= max)
        .then_some(())
        .ok_or_else(|| HttpError(StatusCode::PAYLOAD_TOO_LARGE, message.into()))
}

fn archive_record_bytes(bytes: usize) -> Result<usize, HttpError> {
    bytes
        .checked_add(511)
        .and_then(|bytes| bytes.checked_div(512))
        .and_then(|blocks| blocks.checked_add(1))
        .and_then(|blocks| blocks.checked_mul(512))
        .ok_or_else(|| {
            HttpError(
                StatusCode::PAYLOAD_TOO_LARGE,
                "Archive exceeds the total archive limit; no archive was generated.".into(),
            )
        })
}

fn required_suffix<'a>(value: &'a str, suffix: &str) -> Result<&'a str, HttpError> {
    value
        .strip_suffix(suffix)
        .filter(|value| !value.is_empty())
        .ok_or_else(not_found)
}

fn resolve_commit<'a>(repo: &'a gix::Repository, rev: &str) -> Result<gix::Commit<'a>, HttpError> {
    if rev.is_empty()
        || rev.len() > 256
        || rev.contains("..")
        || rev.contains(['^', '~', ':', '@', '{', '}'])
    {
        return Err(not_found());
    }
    let refs = advertised_refs(repo)?;
    let selected = if rev == "HEAD" {
        repo.head_id().ok().map(|id| id.detach())
    } else {
        refs.iter()
            .find(|(n, _)| matches_public_selector(n, rev))
            .map(|(_, id)| *id)
    };
    if let Some(id) = selected {
        let object = repo.find_object(id).map_err(|_| not_found())?;
        let commit = object.peel_to_commit().map_err(|_| not_found())?;
        return commit_reachable(repo, commit.id, refs.iter().map(|(_, id)| *id))?
            .then_some(commit)
            .ok_or_else(not_found);
    }
    if !rev.bytes().all(|b| b.is_ascii_hexdigit()) || rev.len() < 7 {
        return Err(not_found());
    }
    let parsed = repo.rev_parse_single(rev).map_err(|_| not_found())?;
    let object = parsed.object().map_err(|_| not_found())?;
    let id = object.peel_to_commit().map_err(|_| not_found())?;
    if commit_reachable(repo, id.id, refs.iter().map(|(_, x)| *x))? {
        Ok(id)
    } else {
        Err(not_found())
    }
}

fn commit_reachable(
    repo: &gix::Repository,
    want: gix::ObjectId,
    roots: impl Iterator<Item = gix::ObjectId>,
) -> Result<bool> {
    let mut seen = HashSet::new();
    let mut stack = Vec::new();
    for root in roots {
        if let Ok(object) = repo.find_object(root)
            && let Ok(c) = object.peel_to_commit()
        {
            stack.push(c.id)
        }
    }
    while let Some(id) = stack.pop() {
        if !seen.insert(id) || seen.len() > MAX_COMMITS {
            continue;
        }
        if id == want {
            return Ok(true);
        }
        let c = repo.find_commit(id)?;
        stack.extend(c.parent_ids().map(|p| p.detach()));
    }
    Ok(false)
}

fn advertised_refs(repo: &gix::Repository) -> Result<Vec<(String, gix::ObjectId)>> {
    let mut out = Vec::new();
    let references = repo.references()?;
    let iter = references.all()?.peeled()?;
    for (scanned, item) in iter.enumerate() {
        if scanned >= MAX_REF_SCAN_ENTRIES {
            bail!("reference enumeration exceeds request limit")
        }
        let mut r = item.map_err(|err| anyhow!(err.to_string()))?;
        let name = r.name().as_bstr().to_str_lossy().to_string();
        if !is_public_ref(&name) {
            continue;
        }
        if let Ok(id) = r.peel_to_id() {
            out.push((name, id.detach()));
            if out.len() >= MAX_ENTRIES {
                break;
            }
        }
    }
    out.sort_by(|a, b| a.0.cmp(&b.0));
    Ok(out)
}

#[cfg(test)]
fn public_ref_names<'a>(names: impl IntoIterator<Item = &'a str>, limit: usize) -> Vec<&'a str> {
    names
        .into_iter()
        .filter(|name| is_public_ref(name))
        .take(limit)
        .collect()
}

fn is_public_ref(name: &str) -> bool {
    name.starts_with("refs/heads/")
        || name.starts_with("refs/tags/")
        || name.starts_with("refs/notes/")
}

fn matches_public_selector(name: &str, selector: &str) -> bool {
    name == selector
        || ["refs/heads/", "refs/tags/", "refs/notes/"]
            .iter()
            .any(|prefix| name.strip_prefix(prefix) == Some(selector))
}

fn default_branch(repo: &gix::Repository) -> String {
    repo.head_name()
        .ok()
        .flatten()
        .map(|n| n.shorten().to_str_lossy().to_string())
        .unwrap_or_else(|| "HEAD".into())
}
fn find_readme(commit: &gix::Commit<'_>) -> Result<Option<(String, Vec<u8>)>> {
    let tree = commit.tree()?;
    for name in ["README.md", "README.markdown", "README", "README.txt"] {
        if let Some(e) = tree.find_entry(name)
            && let Ok(blob) = e.object()?.try_into_blob()
        {
            return Ok(Some((name.into(), blob.data.clone())));
        }
    }
    Ok(None)
}
fn blob_at(commit: &gix::Commit<'_>, path: &str) -> Result<Vec<u8>, HttpError> {
    if path.is_empty()
        || path
            .split('/')
            .any(|p| p.is_empty() || p == "." || p == "..")
    {
        return Err(not_found());
    }
    let e = commit
        .tree()?
        .lookup_entry_by_path(path)?
        .ok_or_else(not_found)?;
    let blob = e.object()?.try_into_blob().map_err(|_| not_found())?;
    Ok(blob.data.clone())
}

fn flatten_commit(commit: &gix::Commit<'_>) -> Result<BTreeMap<String, FlatEntry>> {
    flatten_commit_limited(commit, MAX_ENTRIES).map_err(|err| anyhow!(err.1))
}

fn flatten_commit_limited(
    commit: &gix::Commit<'_>,
    limit: usize,
) -> Result<BTreeMap<String, FlatEntry>, HttpError> {
    let tree = commit.tree()?;
    let entries = flatten_tree(commit.repo, &tree, "", limit.saturating_add(1)).map_err(|err| {
        if err.to_string() == "tree path exceeds request path limit" {
            HttpError(
                StatusCode::PAYLOAD_TOO_LARGE,
                "Repository path exceeds the request path limit.".into(),
            )
        } else {
            err.into()
        }
    })?;
    let mut budget = WorkBudget::default();
    for entry in &entries {
        budget.entry(&entry.path, limit)?;
    }
    Ok(entries.into_iter().map(|e| (e.path.clone(), e)).collect())
}
fn flatten_tree(
    repo: &gix::Repository,
    tree: &gix::Tree<'_>,
    prefix: &str,
    limit: usize,
) -> Result<Vec<FlatEntry>> {
    let mut out = Vec::new();
    let mut stack = vec![(prefix.to_string(), tree.id().detach())];
    while let Some((base, id)) = stack.pop() {
        let tree = repo.find_tree(id)?;
        for item in tree.iter() {
            let e = item?;
            let name = e.filename().to_str_lossy();
            let path = if base.is_empty() {
                name.to_string()
            } else {
                format!("{base}/{name}")
            };
            if path.len() > MAX_PATH_BYTES {
                bail!("tree path exceeds request path limit")
            }
            let flat = FlatEntry {
                path: path.clone(),
                id: e.id().detach(),
                kind: e.kind(),
                mode: e.mode(),
            };
            if e.kind() == gix::object::tree::EntryKind::Tree {
                stack.push((path, e.id().detach()));
            }
            out.push(flat);
            if out.len() >= limit {
                return Ok(out);
            }
        }
    }
    out.sort_by(|a, b| a.path.cmp(&b.path));
    Ok(out)
}

fn render_tree_rows(
    _repo: &gix::Repository,
    commit: &gix::Commit<'_>,
    path: &str,
    name: &str,
    rev: &str,
) -> Result<String> {
    let tree = if path.is_empty() {
        commit.tree()?
    } else {
        commit
            .tree()?
            .lookup_entry_by_path(path)?
            .ok_or_else(|| anyhow!("tree path absent"))?
            .object()?
            .try_into_tree()
            .map_err(|_| anyhow!("not tree"))?
    };
    let mut rows = String::new();
    for item in tree.iter().take(1000) {
        let e = item?;
        let filename = e.filename().to_str_lossy();
        let full = if path.is_empty() {
            filename.to_string()
        } else {
            format!("{path}/{filename}")
        };
        let route = if e.kind() == gix::object::tree::EntryKind::Tree {
            "tree"
        } else {
            "blob"
        };
        rows.push_str(&format!("<li><a href=\"/{}/{}/{}/{}\">{}{}</a><span class=muted>{}</span><span class=oid>{}</span></li>",url(name),route,rev,url_path(&full),esc(&filename),if e.kind() == gix::object::tree::EntryKind::Tree{"/"}else{""},esc(&format!("{:?}",e.kind())),short(&e.id().to_string())));
    }
    Ok(rows)
}

fn render_comparison(
    repo: &gix::Repository,
    _name: &str,
    before: &BTreeMap<String, FlatEntry>,
    after: &BTreeMap<String, FlatEntry>,
    view: Option<&str>,
) -> Result<String, HttpError> {
    let mut budget = WorkBudget::default();
    let paths: std::collections::BTreeSet<_> = before.keys().chain(after.keys()).cloned().collect();
    let changed: Vec<_> = paths
        .into_iter()
        .filter(|p| before.get(p).map(|e| e.id) != after.get(p).map(|e| e.id))
        .collect();
    let mut nav = String::new();
    let mut files = String::new();
    for (idx, path) in changed.iter().enumerate() {
        budget.file()?;
        let navigation = format!("<a href=\"#file-{}\">{}</a> ", idx, esc(path));
        budget.output(navigation.len())?;
        nav.push_str(&navigation);
        let old = load_text(repo, before.get(path), &mut budget)?;
        let new = load_text(repo, after.get(path), &mut budget)?;
        let state = if old.is_none() || new.is_none() {
            "binary or oversized"
        } else {
            "ready"
        };
        let rendered = match (old, new) {
            (Some(a), Some(b)) => render_diff(&a, &b, view, &mut budget)?,
            _ => format!(
                "<div class=status>{}. Complete patch represents binary entries by metadata only.</div>",
                state
            ),
        };
        let section = format!(
            "<section class=file-diff id=file-{}><h3>{}</h3><span class=badge>{}</span>{}</section>",
            idx,
            esc(path),
            state,
            rendered
        );
        budget.output(section.len())?;
        files.push_str(&section);
    }
    let rendered = format!(
        "<section><h2>Changed files ({})</h2><div class=diff-nav>{}</div>{}</section>",
        changed.len(),
        nav,
        files
    );
    budget.output(rendered.len())?;
    Ok(rendered)
}
fn load_text(
    repo: &gix::Repository,
    e: Option<&FlatEntry>,
    budget: &mut WorkBudget,
) -> Result<Option<String>, HttpError> {
    match e {
        None => Ok(Some(String::new())),
        Some(e) => {
            let bytes = blob_size(repo, e.id)?;
            require_blob_within(
                bytes,
                MAX_DIFF_BYTES,
                "Diff blob exceeds the request limit.",
            )?;
            budget.input(bytes)?;
            let blob = repo
                .find_object(e.id)?
                .try_into_blob()
                .map_err(|_| anyhow!("not blob"))?;
            if !is_text(&blob.data) {
                Ok(None)
            } else {
                String::from_utf8(blob.data.clone()).map(Some).map_err(|_| {
                    HttpError(
                        StatusCode::INTERNAL_SERVER_ERROR,
                        "Repository text could not be decoded.".into(),
                    )
                })
            }
        }
    }
}
fn diff_line_count(text: &str) -> usize {
    text.bytes().filter(|byte| *byte == b'\n').count() + usize::from(!text.ends_with('\n'))
}

fn guard_diff_lines(old: &str, new: &str) -> Result<(), HttpError> {
    if diff_line_count(old) > MAX_DIFF_LINES || diff_line_count(new) > MAX_DIFF_LINES {
        return Err(HttpError(
            StatusCode::PAYLOAD_TOO_LARGE,
            "Diff line count exceeds the request limit.".into(),
        ));
    }
    Ok(())
}

fn render_diff(
    old: &str,
    new: &str,
    view: Option<&str>,
    budget: &mut WorkBudget,
) -> Result<String, HttpError> {
    guard_diff_lines(old, new)?;
    let input = InternedInput::new(old, new);
    let mut diff = Diff::compute(Algorithm::Histogram, &input);
    diff.postprocess_lines(&input);
    let text = diff
        .unified_diff(
            &BasicLineDiffPrinter(&input.interner),
            UnifiedDiffConfig::default(),
            &input,
        )
        .to_string();
    budget.output(text.len())?;
    let mut html = String::from("<div class=diff>");
    if view == Some("split") {
        html.push_str(
            "<div>Side-by-side preference selected. Canonical unified line facts follow.</div>",
        )
    }
    for line in text.lines() {
        let class = if line.starts_with('+') && !line.starts_with("+++") {
            "add"
        } else if line.starts_with('-') && !line.starts_with("---") {
            "del"
        } else {
            ""
        };
        html.push_str(&format!("<div class=\"{}\">{}</div>", class, esc(line)));
    }
    html.push_str("</div>");
    budget.output(html.len())?;
    Ok(html)
}
fn text_patch(
    repo: &gix::Repository,
    before: &BTreeMap<String, FlatEntry>,
    after: &BTreeMap<String, FlatEntry>,
) -> Result<String, HttpError> {
    let paths: std::collections::BTreeSet<_> = before.keys().chain(after.keys()).cloned().collect();
    let mut budget = WorkBudget::default();
    let mut out = String::new();
    for path in paths {
        if before.get(&path).map(|e| e.id) == after.get(&path).map(|e| e.id) {
            continue;
        }
        budget.file()?;
        let header = format!("diff --luigit a/{0} b/{0}\n--- a/{0}\n+++ b/{0}\n", path);
        budget.output(header.len())?;
        out.push_str(&header);
        match (
            load_text(repo, before.get(&path), &mut budget)?,
            load_text(repo, after.get(&path), &mut budget)?,
        ) {
            (Some(a), Some(b)) => {
                guard_diff_lines(&a, &b)?;
                let input = InternedInput::new(a.as_str(), b.as_str());
                let mut d = Diff::compute(Algorithm::Histogram, &input);
                d.postprocess_lines(&input);
                let diff = d
                    .unified_diff(
                        &BasicLineDiffPrinter(&input.interner),
                        UnifiedDiffConfig::default(),
                        &input,
                    )
                    .to_string();
                budget.output(diff.len())?;
                out.push_str(&diff)
            }
            _ => {
                budget.output("Binary files differ\n".len())?;
                out.push_str("Binary files differ\n")
            }
        }
    }
    Ok(out)
}

fn merge_base<'a>(
    repo: &'a gix::Repository,
    a: &gix::Commit<'_>,
    b: &gix::Commit<'_>,
) -> Option<gix::Commit<'a>> {
    let ancestors: HashSet<_> = a
        .ancestors()
        .all()
        .ok()?
        .take(MAX_COMMITS)
        .filter_map(Result::ok)
        .map(|i| i.id)
        .collect();
    for info in b.ancestors().all().ok()?.take(MAX_COMMITS) {
        let id = info.ok()?.id;
        if ancestors.contains(&id) {
            return repo.find_commit(id).ok();
        }
    }
    None
}
fn signature_state(commit: &gix::Commit<'_>) -> &'static str {
    match commit.signature() {
        Ok(Some((sig, _))) => {
            let s = sig.to_str_lossy();
            if s.contains("BEGIN SSH SIGNATURE") {
                "unverifiable SSH"
            } else if s.contains("BEGIN PGP SIGNATURE") {
                "unverifiable OpenPGP"
            } else if s.contains("BEGIN SIGNED MESSAGE") {
                "unverifiable S/MIME"
            } else {
                "unverifiable signature"
            }
        }
        _ => "unsigned",
    }
}
fn render_markdown(data: &[u8], repo: &str, rev: &str, path: &str) -> String {
    let source = String::from_utf8_lossy(data);
    let mut options = comrak::Options::default();
    options.extension.table = true;
    options.extension.strikethrough = true;
    options.extension.tasklist = true;
    options.extension.autolink = true;
    options.extension.footnotes = true;
    options.render.r#unsafe = true;
    let raw = comrak::markdown_to_html(&source, &options);
    ammonia::Builder::default()
        .add_tags(["details", "summary", "kbd", "sub", "sup", "abbr"])
        .url_relative(ammonia::UrlRelative::RewriteWithBase(
            url::Url::parse(&format!(
                "https://vcs.bugabinga.net/{}/raw/{}/{}/",
                url(repo),
                url(rev),
                url_path(path)
            ))
            .unwrap_or_else(|_| url::Url::parse("https://vcs.bugabinga.net/").unwrap()),
        ))
        .clean(&raw)
        .to_string()
}
fn highlight(path: &str, data: &[u8]) -> String {
    let text = String::from_utf8_lossy(data);
    let ps = SyntaxSet::load_defaults_newlines();
    let ts = ThemeSet::load_defaults();
    let syntax = Path::new(path)
        .extension()
        .and_then(|e| e.to_str())
        .and_then(|e| ps.find_syntax_by_extension(e))
        .unwrap_or_else(|| ps.find_syntax_plain_text());
    let theme = ts
        .themes
        .get("base16-ocean.dark")
        .or_else(|| ts.themes.values().next())
        .unwrap();
    let mut h = HighlightLines::new(syntax, theme);
    let mut out = String::from("<pre class=code><code>");
    for line in LinesWithEndings::from(&text) {
        match h.highlight_line(line, &ps) {
            Ok(ranges) => out.push_str(
                &styled_line_to_highlighted_html(&ranges, IncludeBackground::No)
                    .unwrap_or_else(|_| esc(line)),
            ),
            Err(_) => out.push_str(&esc(line)),
        }
    }
    out.push_str("</code></pre>");
    out
}

fn repo_header(repo: &PublicRepo, active: &str) -> String {
    let tabs = [
        ("README", format!("/{}/", url(&repo.meta.name))),
        ("Source", format!("/{}/tree/HEAD", url(&repo.meta.name))),
        ("History", format!("/{}/log/HEAD", url(&repo.meta.name))),
        ("Refs", format!("/{}/refs", url(&repo.meta.name))),
        (
            "Compare",
            format!("/{}/compare/HEAD...HEAD", url(&repo.meta.name)),
        ),
        ("Notes", format!("/{}/notes", url(&repo.meta.name))),
        ("Search", format!("/{}/search", url(&repo.meta.name))),
    ];
    let nav = tabs
        .into_iter()
        .map(|(label, href)| {
            format!(
                "<a{} href=\"{}\">{}</a>",
                if label == active { " class=active" } else { "" },
                href,
                label
            )
        })
        .collect::<String>();
    format!(
        "<div class=crumb><a href=\"/\">repositories</a> / {}</div><header class=headline><div><h1>{}</h1><p>{}</p></div><div>owned by {}</div></header><nav class=tabs aria-label=Repository>{}</nav>",
        esc(&repo.meta.name),
        esc(&repo.meta.name),
        esc(&repo.meta.description),
        esc(&repo.meta.owner),
        nav
    )
}
fn page(title: &str, content: &str) -> String {
    format!(
        "<!doctype html><html lang=en><head><meta charset=utf-8><meta name=viewport content=\"width=device-width,initial-scale=1\"><meta name=color-scheme content=\"light dark\"><title>{} · Luigit</title><link rel=stylesheet href=/assets/style.css><script>let t=localStorage.getItem('luigit-theme');if(t)document.documentElement.dataset.theme=t</script></head><body><a class=skip href=#content>Skip to content</a><header class=top><a class=brand href=/>Luigit</a><form action=/ method=get><input class=search name=q aria-label=\"Global repository search\" placeholder=\"Search repositories…\"></form><button class=theme aria-label=\"Change theme\" onclick=\"let r=document.documentElement,t=r.dataset.theme==='dark'?'light':r.dataset.theme==='light'?'':'dark';if(t)r.dataset.theme=t;else delete r.dataset.theme;localStorage.setItem('luigit-theme',t)\">◐</button></header><main id=content class=page>{}</main><script>document.querySelectorAll('[data-copy]').forEach(b=>b.onclick=()=>navigator.clipboard.writeText(b.dataset.copy))</script></body></html>",
        esc(title),
        content
    )
}
fn esc(s: &str) -> String {
    html_escape::encode_text(s).into_owned()
}
fn esc_xml(s: &str) -> String {
    esc(s)
}
fn url(s: &str) -> String {
    urlencoding::encode(s).into_owned()
}
fn url_path(s: &str) -> String {
    s.split('/').map(url).collect::<Vec<_>>().join("/")
}
fn short(s: &str) -> &str {
    &s[..s.len().min(12)]
}
fn actor(c: &gix::Commit<'_>) -> String {
    c.author()
        .map(|a| a.name.to_str_lossy().to_string())
        .unwrap_or_else(|_| "unknown".into())
}
fn commit_title(c: &gix::Commit<'_>) -> String {
    c.message()
        .map(|m| m.title.to_str_lossy().to_string())
        .unwrap_or_else(|_| "unreadable commit".into())
}
fn format_time(seconds: i64) -> String {
    DateTime::from_timestamp(seconds, 0)
        .unwrap_or_default()
        .format("%Y-%m-%d %H:%M UTC")
        .to_string()
}
fn is_markdown(path: &str) -> bool {
    path.rsplit('.')
        .next()
        .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "md" | "markdown"))
}
fn is_text(data: &[u8]) -> bool {
    data.len() <= MAX_OBJECT_BYTES && !data.contains(&0) && std::str::from_utf8(data).is_ok()
}
fn not_found() -> HttpError {
    HttpError(
        StatusCode::NOT_FOUND,
        "Object is not reachable from a public ref.".into(),
    )
}

fn export_manifest(db: &Path, repos: &Path, output: &Path) -> Result<()> {
    let conn = Connection::open_with_flags(
        db,
        OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
    )
    .context("open Soft Serve database read-only")?;
    let required = [
        ("repos", "name"),
        ("repos", "description"),
        ("repos", "private"),
        ("repos", "hidden"),
        ("repos", "user_id"),
        ("users", "id"),
        ("users", "username"),
    ];
    for (table, column) in required {
        let mut stmt = conn.prepare(&format!("pragma table_info({table})"))?;
        let found = stmt
            .query_map([], |row| row.get::<_, String>(1))?
            .filter_map(Result::ok)
            .any(|c| c == column);
        if !found {
            bail!("unsupported Soft Serve schema: missing {table}.{column}")
        }
    }
    let mut stmt=conn.prepare("select repos.name, coalesce(repos.description,''), users.username, repos.hidden, repos.private from repos join users on users.id = repos.user_id order by repos.name")?;
    let rows = stmt.query_map([], |row| {
        Ok((
            row.get::<_, String>(0)?,
            row.get::<_, String>(1)?,
            row.get::<_, String>(2)?,
            row.get::<_, bool>(3)?,
            row.get::<_, bool>(4)?,
        ))
    })?;
    let mut repositories = Vec::new();
    let mut seen = HashSet::new();
    for row in rows {
        let (name, description, owner, hidden, private) = row?;
        validate_repo_name(&name)?;
        if !seen.insert(name.clone()) {
            bail!("duplicate repository name")
        };
        let path = repos.join(format!("{name}.git"));
        let marker = path.join("git-daemon-export-ok").is_file();
        if private == marker {
            bail!("visibility conflict for {name}")
        }
        if !private {
            if !path.is_dir() {
                bail!("public repository missing: {name}")
            }
            repositories.push(ManifestRepo {
                name,
                owner,
                description,
                hidden,
            });
        }
    }
    let now = Utc::now();
    let generation = SystemTime::now()
        .duration_since(SystemTime::UNIX_EPOCH)?
        .as_millis()
        .try_into()?;
    let manifest = Manifest {
        version: 1,
        source_version: "soft-serve-schema-v1".into(),
        schema_fingerprint: "repos(name,description,private,hidden,user_id);users(id,username)"
            .into(),
        generation,
        created_at: now,
        expires_at: now + chrono::Duration::seconds(20),
        repositories,
    };
    let bytes = serde_json::to_vec_pretty(&manifest)?;
    let parent = output
        .parent()
        .ok_or_else(|| anyhow!("manifest has no parent"))?;
    fs::create_dir_all(parent)?;
    let tmp = parent.join(format!(
        ".{}.tmp",
        output
            .file_name()
            .and_then(|s| s.to_str())
            .unwrap_or("manifest")
    ));
    {
        let mut file = fs::OpenOptions::new()
            .write(true)
            .create(true)
            .truncate(true)
            .open(&tmp)?;
        file.write_all(&bytes)?;
        file.write_all(b"\n")?;
        file.sync_all()?;
    }
    fs::rename(&tmp, output)?;
    fs::File::open(parent)?.sync_all()?;
    info!(
        repositories = manifest.repositories.len(),
        generation, "public manifest exported"
    );
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn router_constructs() {
        let state = Arc::new(AppState {
            repos_root: PathBuf::from("/nonexistent"),
            manifest: PathBuf::from("/nonexistent"),
            clone_host: "example.test".into(),
            last_generation: AtomicU64::new(0),
            expensive_requests: Arc::new(Semaphore::new(MAX_EXPENSIVE_REQUESTS)),
        });
        drop(build_router(state));
    }
    #[test]
    fn download_suffixes_are_exact() {
        assert_eq!(required_suffix("HEAD.patch", ".patch").unwrap(), "HEAD");
        assert!(required_suffix("HEAD", ".patch").is_err());
        assert!(required_suffix(".tar", ".tar").is_err());
    }
    #[test]
    fn manifest_rejects_expiry_unknown_versions_and_generation_regression() {
        let temp = tempfile::tempdir().unwrap();
        let path = temp.path().join("public.json");
        let state = AppState {
            repos_root: temp.path().into(),
            manifest: path.clone(),
            clone_host: "example.test".into(),
            last_generation: AtomicU64::new(0),
            expensive_requests: Arc::new(Semaphore::new(MAX_EXPENSIVE_REQUESTS)),
        };
        let now = Utc::now();
        let mut manifest = Manifest {
            version: 1,
            source_version: "fixture".into(),
            schema_fingerprint: "fixture".into(),
            generation: 2,
            created_at: now,
            expires_at: now + chrono::Duration::minutes(1),
            repositories: Vec::new(),
        };
        fs::write(&path, serde_json::to_vec(&manifest).unwrap()).unwrap();
        assert!(load_manifest(&state).is_ok());
        manifest.generation = 1;
        fs::write(&path, serde_json::to_vec(&manifest).unwrap()).unwrap();
        assert!(load_manifest(&state).is_err());
        manifest.generation = 3;
        manifest.version = 2;
        fs::write(&path, serde_json::to_vec(&manifest).unwrap()).unwrap();
        assert!(load_manifest(&state).is_err());
        manifest.version = 1;
        manifest.expires_at = now;
        fs::write(&path, serde_json::to_vec(&manifest).unwrap()).unwrap();
        assert!(load_manifest(&state).is_err());
    }
    #[test]
    fn exporter_fails_closed_on_visibility_conflict() {
        let temp = tempfile::tempdir().unwrap();
        let db = temp.path().join("soft-serve.db");
        let repos = temp.path().join("repos");
        let repo = repos.join("conflict.git");
        fs::create_dir_all(&repo).unwrap();
        fs::write(repo.join("git-daemon-export-ok"), b"").unwrap();
        let connection = Connection::open(&db).unwrap();
        connection.execute_batch("CREATE TABLE users(id INTEGER PRIMARY KEY, username TEXT); CREATE TABLE repos(name TEXT, description TEXT, private BOOL, hidden BOOL, user_id INTEGER); INSERT INTO users VALUES(1, 'owner'); INSERT INTO repos VALUES('conflict', '', 1, 0, 1);").unwrap();
        drop(connection);
        assert!(export_manifest(&db, &repos, &temp.path().join("public.json")).is_err());
    }
    #[test]
    fn rejects_traversal_names() {
        for n in ["", "../x", "x/../y", "x.git", "/x", "x//y"] {
            assert!(validate_repo_name(n).is_err(), "{n}")
        }
    }
    #[test]
    fn markdown_removes_active_content() {
        let html = render_markdown(
            b"<script>x()</script>\n\n<details onclick=x><summary>ok</summary>x</details>\n\n[bad](javascript:alert(1))\n\n<a href=\"javascript:alert(2)\">also bad</a>\n\n[safe](https://example.com)\n\n`javascript:literal`",
            "r",
            "abc",
            "README.md",
        );
        assert!(!html.contains("<script"));
        assert!(!html.contains("onclick"));
        assert!(!html.contains("href=\"javascript:"));
        assert!(html.contains("href=\"https://example.com\""));
        assert!(html.contains("<details>"));
        assert!(html.contains("javascript:literal"));
    }
    #[test]
    fn textual_diff_is_escaped() {
        let html = render_diff("<old>\n", "<new>\n", None, &mut WorkBudget::default()).unwrap();
        assert!(!html.contains("<old>"));
        assert!(html.contains("&lt;old&gt;"));
    }
    #[test]
    fn aggregate_diff_and_patch_limits_many_moderate_files() {
        let mut budget = WorkBudget::default();
        for _ in 0..512 {
            budget.file().unwrap();
            budget.input(64 * 1024).unwrap();
        }
        assert!(budget.input(1).is_err());
        let mut patch_budget = WorkBudget::default();
        for _ in 0..512 {
            patch_budget.output(64 * 1024).unwrap();
        }
        assert!(patch_budget.output(1).is_err());
    }
    #[test]
    fn archive_preflight_rejects_oversized_files_before_materialization() {
        let err = require_blob_within(
            MAX_ARCHIVE_BYTES + 1,
            MAX_ARCHIVE_BYTES,
            "Archive contains a file exceeding the archive limit.",
        )
        .unwrap_err();
        assert_eq!(err.0, StatusCode::PAYLOAD_TOO_LARGE);
        let mut bytes = 1024usize;
        for _ in 0..1016 {
            bytes += archive_record_bytes(64 * 1024).unwrap();
        }
        assert!(bytes <= MAX_ARCHIVE_BYTES);
        assert!(bytes + archive_record_bytes(64 * 1024).unwrap() > MAX_ARCHIVE_BYTES);
    }
    #[test]
    fn diff_preflight_rejects_oversized_blobs_before_materialization() {
        let err = require_blob_within(
            MAX_DIFF_BYTES + 1,
            MAX_DIFF_BYTES,
            "Diff blob exceeds the request limit.",
        )
        .unwrap_err();
        assert_eq!(err.0, StatusCode::PAYLOAD_TOO_LARGE);
    }
    #[test]
    fn paths_and_ref_route_selectors_are_constrained() {
        assert!(
            WorkBudget::default()
                .entry(&"x".repeat(MAX_PATH_BYTES + 1), MAX_DIFF_ENTRIES)
                .is_err()
        );
        for name in ["refs/heads/main", "refs/tags/v1", "refs/notes/review"] {
            assert!(is_public_ref(name), "{name}");
            assert!(matches_public_selector(name, name));
        }
        for name in [
            "refs/remotes/origin/main",
            "refs/hidden/secret",
            "refs/replace/x",
        ] {
            assert!(!is_public_ref(name), "{name}");
            assert!(!matches_public_selector(name, "secret"), "{name}");
        }
    }
    #[test]
    fn public_ref_cap_does_not_starve_main_behind_hidden_refs() {
        let refs = public_ref_names(["refs/hidden/a", "refs/hidden/b", "refs/heads/main"], 1);
        assert_eq!(refs, ["refs/heads/main"]);
    }
    #[test]
    fn diff_line_guard_rejects_adversarial_short_lines() {
        let text = "x\n".repeat(MAX_DIFF_LINES + 1);
        assert!(guard_diff_lines(&text, "").is_err());
    }
    #[tokio::test]
    async fn expensive_work_keeps_its_permit_after_request_cancellation() {
        let state = Arc::new(AppState {
            repos_root: PathBuf::from("/nonexistent"),
            manifest: PathBuf::from("/nonexistent"),
            clone_host: "example.test".into(),
            last_generation: AtomicU64::new(0),
            expensive_requests: Arc::new(Semaphore::new(1)),
        });
        let (started_tx, started_rx) = std::sync::mpsc::channel();
        let (finish_tx, finish_rx) = std::sync::mpsc::channel();
        let worker_state = state.clone();
        let request = tokio::spawn(async move {
            run_expensive(&worker_state, move || {
                started_tx.send(()).unwrap();
                finish_rx.recv().unwrap();
                Ok(())
            })
            .await
        });
        tokio::task::yield_now().await;
        started_rx.recv().unwrap();
        request.abort();
        assert!(
            state
                .expensive_requests
                .clone()
                .try_acquire_owned()
                .is_err()
        );
        assert_eq!(health().await, "ok\n");
        finish_tx.send(()).unwrap();
    }
    #[tokio::test]
    async fn third_expensive_request_gets_429() {
        let state = Arc::new(AppState {
            repos_root: PathBuf::from("/nonexistent"),
            manifest: PathBuf::from("/nonexistent"),
            clone_host: "example.test".into(),
            last_generation: AtomicU64::new(0),
            expensive_requests: Arc::new(Semaphore::new(2)),
        });
        let first = state
            .expensive_requests
            .clone()
            .try_acquire_owned()
            .unwrap();
        let second = state
            .expensive_requests
            .clone()
            .try_acquire_owned()
            .unwrap();
        let err = run_expensive(&state, || Ok(())).await.unwrap_err();
        assert_eq!(err.0, StatusCode::TOO_MANY_REQUESTS);
        drop((first, second));
    }
}