repositories / bugabinga.net
bugabinga.net
personal infrastructure for bugabinga!
owned by admin
services/luigit/src/main.rs
Rawuse 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>{} <<a href=\"mailto:{}\">{}</a>> · {}</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(§ion);
}
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("<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));
}
}