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, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct Manifest { version: u32, source_version: String, schema_fingerprint: String, generation: u64, created_at: DateTime, expires_at: DateTime, repositories: Vec, } #[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; 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!( "
{}

{}

", esc(title), esc(&self.1) ), )), ) .into_response() } } impl From 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, regex: Option, #[serde(rename = "type")] _type: Option, } #[derive(Deserialize, Default)] struct DiffQuery { view: Option, parent: Option, } #[derive(Clone)] struct FlatEntry { path: String, id: gix::ObjectId, kind: gix::object::tree::EntryKind, mode: gix::object::tree::EntryMode, } async fn run_expensive( state: &Arc, work: impl FnOnce() -> Result + Send + 'static, ) -> Result 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) -> 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, name: &str) -> Result { 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>) -> 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) -> 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 { 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 { 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::open_opts(&public.path, gix::open::Options::isolated()).context("open repository") } async fn catalog( State(state): State>, Query(query): Query, ) -> 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!("
  • {0}{1}{2}{3} · {4}
  • ", url(&meta.name), esc(&meta.description), esc(&meta.owner), esc(&branch), esc(&activity))); } let body = format!( "

    Repositories

    Public source, history, notes, and exact Git state.

      {}
    ", esc(&needle), rows ); Ok(Html(page("Repositories", &body)).into_response()) } async fn repository_landing( State(state): State>, AxumPath(name): AxumPath, ) -> 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(|| "
    No README found.
    ".into()); let tree = render_tree_rows(&repo, &commit, "", &public.meta.name, &id)?; format!( "{}
    {}

    Default branch source

      {}
    ", 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!( "{}
    Empty repository

    No commits exist yet.

    {}

    ", repo_header(&public, "README"), esc(&clone) ), }; Ok(Html(page(&public.meta.name, &body)).into_response()) } async fn tree_root( State(state): State>, AxumPath((name, rev)): AxumPath<(String, String)>, ) -> HttpResult { tree_response(&state, &name, &rev, "") } async fn tree_view( State(state): State>, 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!( "
  • ../
  • ", 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!( "
  • {}{}{}{}
  • ", 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!( "{}

    {}

    {}
      {}
    ", 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>, 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!( "
    Rendered preview
    {}
    {}", render_markdown(&data, &name, &commit.id().to_string(), &path), highlight(&path, &data) ) } else { highlight(&path, &data) } } else { format!( "
    Binary content, {} bytes. Download raw.
    ", data.len(), url(&name), commit.id(), url_path(&path) ) }; let body = format!( "{}

    {}

    Raw
    {}", 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>, 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>, AxumPath(name): AxumPath, ) -> 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!( "
  • {}{}{}
  • ", esc(&refname), short(&id.to_string()), target, label )); } let content = groups .into_iter() .map(|(g, r)| format!("

    {}

      {}
    ", g, r)) .collect::(); Ok(Html(page( &format!("{} refs", name), &format!("{}{}", repo_header(&public, "Refs"), content), )) .into_response()) } async fn log_view( State(state): State>, 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!("
  • {}{}{} · {} parent{}
  • ",url(&name),id,esc(&commit_title(&c)),esc(&actor(&c)),short(&id),parents,if parents==1{""}else{"s"})); } let body = format!( "{}

    History

    {}
      {}
    ", repo_header(&public, "History"), esc(&rev), rows ); Ok(Html(page(&format!("{} history", name), &body)).into_response()) } async fn commit_view( State(state): State>, AxumPath((name, oid)): AxumPath<(String, String)>, Query(query): Query, ) -> 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!( "{}

    {}

    {}

    {} <{}> · {}

    {} {} parent{}

    {}
    {}", 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>, AxumPath((name, spec)): AxumPath<(String, String)>, Query(query): Query, ) -> 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!( "{}

    Compare {} → {}

    Semantics: {}. Base {}, head {}.
    {}", 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>, AxumPath(name): AxumPath, Query(query): Query, ) -> 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!("
    object {} {}
    {}
    {} · current content
    ",short(&hex),if reachable{""}else{"target not publicly reachable"},if is_text(&data){render_markdown(&data,&name,&hex,"")}else{"

    Binary note content. Download unavailable in this view.

    ".into()},esc(&refname))); } namespaces.push_str(&format!( "
  • {}{} note{}
  • ", esc(&refname), count, if count == 1 { "" } else { "s" } )); } let body = format!( "{}

    Namespaces

      {}
    {}
    ", repo_header(&public, "Notes"), esc(&needle), namespaces, notes ); Ok(Html(page(&format!("{} notes", name), &body)).into_response()) } async fn search_view( State(state): State>, AxumPath(name): AxumPath, Query(query): Query, ) -> 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!("
  • {}{}{}
  • ",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!( "{}
    {}

    {} result{}, {} MiB verified. Coverage: advertised branch and tag snapshots.

      {}
    ", repo_header(&public, "Search"), esc(&q), if matcher.is_some() { "checked" } else { "" }, if partial { "
    Partial results: work or result budget exhausted.
    " } 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>, AxumPath(name): AxumPath, ) -> 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!("urn:git:{}{}{}",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!( "https://vcs.bugabinga.net/{0}/{1} commits{2}{3}", 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>, 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>, 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 { 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 { 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, 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, ) -> Result { 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> { 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, 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)>> { 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, 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> { flatten_commit_limited(commit, MAX_ENTRIES).map_err(|err| anyhow!(err.1)) } fn flatten_commit_limited( commit: &gix::Commit<'_>, limit: usize, ) -> Result, 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> { 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 { 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!("
  • {}{}{}{}
  • ",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, after: &BTreeMap, view: Option<&str>, ) -> Result { 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!("{} ", 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!( "
    {}. Complete patch represents binary entries by metadata only.
    ", state ), }; let section = format!( "

    {}

    {}{}
    ", idx, esc(path), state, rendered ); budget.output(section.len())?; files.push_str(§ion); } let rendered = format!( "

    Changed files ({})

    {}
    {}
    ", changed.len(), nav, files ); budget.output(rendered.len())?; Ok(rendered) } fn load_text( repo: &gix::Repository, e: Option<&FlatEntry>, budget: &mut WorkBudget, ) -> Result, 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 { 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("
    "); if view == Some("split") { html.push_str( "
    Side-by-side preference selected. Canonical unified line facts follow.
    ", ) } 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!("
    {}
    ", class, esc(line))); } html.push_str("
    "); budget.output(html.len())?; Ok(html) } fn text_patch( repo: &gix::Repository, before: &BTreeMap, after: &BTreeMap, ) -> Result { 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> { 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("
    ");
        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("
    "); 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!( "{}", if label == active { " class=active" } else { "" }, href, label ) }) .collect::(); format!( "

    {}

    {}

    owned by {}
    ", 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!( "{} · Luigit
    Luigit
    {}
    ", 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::>().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"\n\n
    okx
    \n\n[bad](javascript:alert(1))\n\nalso bad\n\n[safe](https://example.com)\n\n`javascript:literal`", "r", "abc", "README.md", ); assert!(!html.contains("")); assert!(html.contains("javascript:literal")); } #[test] fn textual_diff_is_escaped() { let html = render_diff("\n", "\n", None, &mut WorkBudget::default()).unwrap(); assert!(!html.contains("")); assert!(html.contains("<old>")); } #[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)); } }