Menu
AkurAI-Build
publicLatest change cef82a0db1ab211acbc32248c8374bd3be2bfe7d - ci: deploy-production runs unattended, no approval or promotion by Ólafur Búi Ólafsson
use std::{
io::Write,
net::SocketAddr,
path::{Path as FsPath, PathBuf},
process::{Command, Stdio},
sync::Arc,
time::Duration,
};
use anyhow::{Context, Result, ensure};
use axum::{
Json, Router,
body::{Body, Bytes, to_bytes},
extract::{DefaultBodyLimit, Path, Query, State},
http::{HeaderMap, HeaderName, HeaderValue, Request, StatusCode, Uri, header},
middleware::{self, Next},
response::{Html, IntoResponse, Redirect, Response},
routing::{any, get, post},
};
use hmac::{Hmac, Mac};
use minijinja::{AutoEscape, Environment, context};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::Sha256;
use subtle::ConstantTimeEq;
use tokio::io::AsyncWriteExt;
use tokio_util::io::ReaderStream;
use tower::limit::GlobalConcurrencyLimitLayer;
use tower_http::{compression::CompressionLayer, timeout::TimeoutLayer, trace::TraceLayer};
use tracing::{info, warn};
use crate::oidc;
use crate::{
config,
db::{Database, Session},
git_process, mcp,
runner::Runner,
tree::RepoTree,
};
const MAX_BODY: usize = 1024 * 1024;
const MAX_GIT_BODY: usize = 512 * 1024 * 1024;
const MAX_CONNECTIONS: usize = 1024;
const MAX_BLOCKING_GIT: usize = 4;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
const LANDING: &str = include_str!("../app/pages/landing.html");
const DOCS: &str = include_str!("../app/pages/docs/index.html");
const DASHBOARD: &str = include_str!("../app/pages/index.html");
const REPOSITORIES: &str = include_str!("../app/pages/repositories.html");
const REPOSITORY_APP: &str = include_str!("../app/pages/repository-app.html");
const RUNS: &str = include_str!("../app/pages/runs.html");
const WORKERS: &str = include_str!("../app/pages/workers.html");
const CODE: &str = include_str!("../app/pages/repository-code.html");
const RUN: &str = include_str!("../app/pages/run.html");
const BASE: &str = include_str!("../app/templates/layouts/base.html");
const DOCS_LAYOUT: &str = include_str!("../app/templates/layouts/docs.html");
const DOCS_NAV: &str = include_str!("../app/templates/partials/docs-nav.html");
const PUBLIC_WORDMARK: &str = include_str!("../app/templates/partials/public-wordmark.html");
const PUBLIC_HEADER: &str = include_str!("../app/templates/partials/public-header.html");
const PUBLIC_FOOTER: &str = include_str!("../app/templates/partials/public-footer.html");
const CODE_CONTENT: &str = include_str!("../app/templates/partials/code-content.html");
const APP_ACCOUNT: &str = include_str!("../app/templates/partials/app-account.html");
const NOT_FOUND: &str = include_str!("../app/pages/_404.html");
const CSS: &str = include_str!("../public/app.css");
const JS: &str = include_str!("../public/app.js");
type HmacSha256 = Hmac<Sha256>;
#[derive(Clone)]
pub struct ServerOptions {
pub address: SocketAddr,
pub token: String,
pub worker_count: usize,
pub webhook_secret: String,
pub public_url: String,
pub data_root: PathBuf,
pub allow_native: bool,
}
#[derive(Clone)]
struct AppState {
database: Database,
runner: Runner,
tree: RepoTree,
hosted_root: PathBuf,
allow_native: bool,
blocking_git: BlockingGit,
token: Arc<str>,
webhook_secret: Arc<str>,
public_url: Arc<str>,
templates: Arc<Environment<'static>>,
}
#[derive(Serialize)]
struct Envelope<T: Serialize> {
ok: bool,
data: T,
}
#[derive(Serialize)]
struct ErrorEnvelope {
ok: bool,
error: ErrorMessage,
}
#[derive(Serialize)]
struct ErrorMessage {
code: &'static str,
message: String,
/// Where an unauthenticated browser or client should go to sign in.
#[serde(skip_serializing_if = "Option::is_none")]
login: Option<&'static str>,
}
struct ApiError {
status: StatusCode,
code: &'static str,
message: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct TriggerRequest {
git_ref: Option<String>,
commit: Option<String>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RepositoryRequest {
name: String,
url: String,
branch: String,
}
#[derive(Serialize)]
struct RunProgress {
run_id: i64,
repository: String,
git_ref: String,
commit_sha: Option<String>,
status: String,
stage: String,
progress: u8,
jobs: Vec<ProgressJob>,
deployments: Vec<ProgressDeployment>,
error: Option<String>,
}
#[derive(Serialize)]
struct ProgressJob {
id: i64,
name: String,
status: String,
environment: Option<String>,
approval_required: bool,
}
#[derive(Serialize)]
struct ProgressDeployment {
id: i64,
environment: String,
status: String,
}
#[derive(Deserialize, Default)]
struct StateQuery {
repo: Option<String>,
status: Option<String>,
git_ref: Option<String>,
trigger: Option<String>,
q: Option<String>,
before: Option<i64>,
limit: Option<usize>,
offset: Option<usize>,
}
#[derive(Deserialize, Default)]
struct DeliveryMetricsQuery {
repo: Option<String>,
environment: Option<String>,
window: Option<i64>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct VisibilityRequest {
visibility: String,
}
#[derive(Serialize)]
struct RepositoryView {
id: i64,
name: String,
url: String,
default_branch: String,
visibility: String,
created_at: i64,
}
fn repository_client_url(
repository_name: &str,
repository_url: &str,
data_root: &FsPath,
public_url: &str,
) -> String {
let repository_path = FsPath::new(repository_url);
let hosted_root = data_root.join("hosted");
if repository_path.starts_with(&hosted_root)
&& repository_path
.parent()
.and_then(FsPath::file_name)
.is_some_and(|name| name == "hosted")
&& repository_path
.extension()
.is_some_and(|extension| extension == "git")
{
return format!(
"{}/git/{repository_name}.git",
public_url.trim_end_matches('/')
);
}
repository_url.to_owned()
}
fn repository_view(
repository: crate::db::Repository,
data_root: &FsPath,
public_url: &str,
) -> RepositoryView {
let url = repository_client_url(&repository.name, &repository.url, data_root, public_url);
RepositoryView {
id: repository.id,
name: repository.name,
url,
default_branch: repository.default_branch,
visibility: repository.visibility,
created_at: repository.created_at,
}
}
#[derive(Serialize)]
struct StateResponse {
repositories: Vec<RepositoryView>,
runs: Vec<crate::db::Run>,
workers: Vec<crate::db::Worker>,
queued_runs: i64,
run_cursor: Option<i64>,
run_total: i64,
}
#[derive(Serialize)]
struct CodeEntryView {
name: String,
kind: String,
size: u64,
commit_message: Option<String>,
href: String,
}
#[derive(Serialize)]
struct CodeBreadcrumb {
label: String,
href: String,
}
#[derive(Serialize)]
struct CommitView {
sha: String,
short_sha: String,
message: String,
author: String,
date: String,
href: String,
}
#[derive(Serialize)]
struct ContributorView {
name: String,
initial: String,
commit_count: u64,
}
#[derive(Serialize)]
struct LanguageView {
name: String,
color: String,
percentage: f64,
percentage_label: String,
offset: f64,
}
#[derive(Serialize)]
struct CodePage {
repository: RepositoryView,
reference: String,
path: String,
entries: Vec<CodeEntryView>,
breadcrumbs: Vec<CodeBreadcrumb>,
source: Option<String>,
is_file: bool,
parent_href: String,
latest_commit: Option<crate::tree::LatestCommit>,
commit_href: Option<String>,
public: bool,
description: Option<String>,
counts: Option<crate::tree::RepoCounts>,
readme_html: Option<String>,
readme_name: Option<String>,
recent_commits: Vec<CommitView>,
contributors: Vec<ContributorView>,
languages: Vec<LanguageView>,
}
#[derive(Serialize)]
struct IdResponse {
id: i64,
}
#[derive(Serialize)]
struct LandingRepository {
name: String,
default_branch: String,
owner: String,
run_count: i64,
successful_runs: i64,
}
#[derive(Serialize)]
struct PublicBuilder {
name: String,
initial: String,
repository_count: usize,
}
#[derive(Deserialize)]
struct CallbackQuery {
code: Option<String>,
state: Option<String>,
error: Option<String>,
}
#[derive(Deserialize)]
struct LoginQuery {
return_to: Option<String>,
}
impl ApiError {
fn unauthorized() -> Self {
Self {
status: StatusCode::UNAUTHORIZED,
code: "unauthenticated",
message: "sign in with AkurAI ID or present a valid bearer token".into(),
}
}
fn bad(error: impl std::fmt::Display) -> Self {
Self {
status: StatusCode::BAD_REQUEST,
code: "invalid_request",
message: error.to_string(),
}
}
fn not_found(message: impl Into<String>) -> Self {
Self {
status: StatusCode::NOT_FOUND,
code: "not_found",
message: message.into(),
}
}
fn internal(error: impl std::fmt::Display) -> Self {
tracing::error!(error = %error, "request failed");
Self {
status: StatusCode::INTERNAL_SERVER_ERROR,
code: "internal",
message: "request failed".into(),
}
}
}
#[derive(Clone)]
struct BlockingGit {
permits: Arc<tokio::sync::Semaphore>,
}
impl BlockingGit {
fn new(limit: usize) -> Self {
Self {
permits: Arc::new(tokio::sync::Semaphore::new(limit)),
}
}
async fn run<T, F>(&self, operation: F) -> Result<T, ApiError>
where
T: Send + 'static,
F: FnOnce() -> Result<T, ApiError> + Send + 'static,
{
let permit = self
.permits
.clone()
.acquire_owned()
.await
.map_err(ApiError::internal)?;
tokio::task::spawn_blocking(move || {
let _permit = permit;
operation()
})
.await
.map_err(ApiError::internal)?
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
(
self.status,
Json(ErrorEnvelope {
ok: false,
error: ErrorMessage {
login: (self.status == StatusCode::UNAUTHORIZED).then_some(oidc::LOGIN_PATH),
code: self.code,
message: self.message,
},
}),
)
.into_response()
}
}
fn render_markdown(source: &str, urls: Option<&ReadmeUrls>) -> String {
let mut options = pulldown_cmark::Options::empty();
options.insert(pulldown_cmark::Options::ENABLE_TABLES);
options.insert(pulldown_cmark::Options::ENABLE_STRIKETHROUGH);
options.insert(pulldown_cmark::Options::ENABLE_TASKLISTS);
options.insert(pulldown_cmark::Options::ENABLE_FOOTNOTES);
let parser = pulldown_cmark::Parser::new_ext(source, options);
let mut html = String::with_capacity(source.len() * 2);
pulldown_cmark::html::push_html(&mut html, parser);
ammonia_clean(&html, urls)
}
/// Conservative allowlist sanitizer for rendered README HTML. Keeps the
/// output safe to inject with `|safe` without pulling in a full sanitizer
/// dependency: every tag not on the allowlist is escaped, attributes are
/// dropped except href/src on a/img with http(s), relative, or anchor URLs.
fn ammonia_clean(html: &str, urls: Option<&ReadmeUrls>) -> String {
const ALLOWED: &[&str] = &[
"a",
"abbr",
"b",
"blockquote",
"br",
"code",
"dd",
"del",
"details",
"div",
"dl",
"dt",
"em",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"hr",
"i",
"img",
"input",
"kbd",
"li",
"ol",
"p",
"pre",
"s",
"small",
"span",
"strong",
"sub",
"summary",
"sup",
"table",
"tbody",
"td",
"tfoot",
"th",
"thead",
"tr",
"ul",
];
let mut output = String::with_capacity(html.len());
let mut rest = html;
while let Some(start) = rest.find('<') {
output.push_str(&rest[..start]);
let tail = &rest[start..];
let Some(end) = tail.find('>') else {
output.push_str("<");
rest = &tail[1..];
continue;
};
let tag_body = &tail[1..end];
let closing = tag_body.starts_with('/');
let name_part = tag_body.trim_start_matches('/');
let name: String = name_part
.chars()
.take_while(|character| character.is_ascii_alphanumeric())
.collect::<String>()
.to_ascii_lowercase();
if ALLOWED.contains(&name.as_str()) {
if closing {
output.push_str(&format!("</{name}>"));
} else {
let self_closing = tag_body.trim_end().ends_with('/');
let mut kept = String::new();
for attribute in [
"href", "src", "alt", "title", "align", "checked", "disabled", "type", "open",
"start",
] {
if let Some(value) = extract_attribute(tag_body, attribute) {
if value.contains('"') {
continue;
}
let emit = if matches!(attribute, "href" | "src") {
match classify_url(&value) {
UrlKind::Keep => Some(value),
UrlKind::Drop => None,
UrlKind::Relative => match urls {
Some(base) => base.resolve(attribute, &value),
None => Some(value),
},
}
} else {
Some(value)
};
if let Some(value) = emit {
kept.push_str(&format!(" {attribute}=\"{value}\""));
}
}
}
output.push_str(&format!(
"<{name}{kept}{}>",
if self_closing { " /" } else { "" }
));
}
} else {
output.push_str("<");
output.push_str(&html_escape(tag_body));
output.push_str(">");
}
rest = &tail[end + 1..];
}
output.push_str(rest);
output
}
/// Base URLs for resolving relative links inside a rendered README. Relative
/// `img`/media `src` resolve to the raw-blob endpoint (so images load), while
/// relative `a` `href` resolve to the code browser (so links open the file
/// page, not raw bytes). Both resolve against the README's own directory.
struct ReadmeUrls {
/// Directory containing the README, repo-relative (may be empty for root).
dir: String,
/// Encoded raw-blob prefix, e.g. `/repos/<repo>/raw/<ref>`.
raw_prefix: String,
/// Encoded code-browser prefix, e.g. `/repos/<repo>/tree/<ref>`.
tree_prefix: String,
}
impl ReadmeUrls {
/// Rewrite a relative URL to an absolute app URL, or drop it (`None`) when
/// it escapes the repository root.
fn resolve(&self, attribute: &str, value: &str) -> Option<String> {
let (path_part, suffix) = split_url_suffix(value);
let resolved = resolve_relative(&self.dir, path_part)?;
let encoded = resolved
.split('/')
.filter(|segment| !segment.is_empty())
.map(encode_code_segment)
.collect::<Vec<_>>()
.join("/");
let prefix = if attribute == "src" {
&self.raw_prefix
} else {
&self.tree_prefix
};
Some(format!("{prefix}/{encoded}{suffix}"))
}
}
enum UrlKind {
/// Absolute, root-relative, or in-page anchor: keep verbatim.
Keep,
/// Protocol-relative or a foreign scheme: drop.
Drop,
/// Repository-relative: rewrite against the README location.
Relative,
}
fn classify_url(value: &str) -> UrlKind {
if value.starts_with("https://") || value.starts_with("http://") || value.starts_with('#') {
UrlKind::Keep
} else if value.starts_with("//") {
UrlKind::Drop
} else if value.starts_with('/') {
UrlKind::Keep
} else if value.contains(':') {
UrlKind::Drop
} else {
UrlKind::Relative
}
}
/// Split a relative URL into its path and a trailing `?query`/`#fragment`.
fn split_url_suffix(value: &str) -> (&str, &str) {
match value.find(['?', '#']) {
Some(index) => value.split_at(index),
None => (value, ""),
}
}
/// Resolve a repository-relative reference against `dir`, normalising `.` and
/// `..`. Returns `None` if the path escapes the repository root or is empty.
fn resolve_relative(dir: &str, rel: &str) -> Option<String> {
let mut segments: Vec<&str> = dir.split('/').filter(|s| !s.is_empty()).collect();
for part in rel.split('/') {
match part {
"" | "." => {}
".." => {
segments.pop()?;
}
other => segments.push(other),
}
}
if segments.is_empty() {
return None;
}
Some(segments.join("/"))
}
fn html_escape(value: &str) -> String {
value
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
fn extract_attribute(tag_body: &str, attribute: &str) -> Option<String> {
let lower = tag_body.to_ascii_lowercase();
let needle = format!("{attribute}=\"");
let start = lower.find(&needle)? + needle.len();
let end = tag_body[start..].find('"')? + start;
Some(tag_body[start..end].to_string())
}
/// Format a Unix timestamp as `YYYY-MM-DD` (proleptic Gregorian, UTC).
fn format_epoch_date(timestamp: i64) -> String {
let days = timestamp.div_euclid(86_400);
// Howard Hinnant's civil_from_days algorithm.
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let year = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = if month <= 2 { year + 1 } else { year };
format!("{year:04}-{month:02}-{day:02}")
}
fn contributor_initial(name: &str) -> String {
name.split_whitespace()
.take(2)
.filter_map(|part| part.chars().next())
.collect::<String>()
.to_uppercase()
}
fn template_environment() -> Result<Environment<'static>> {
let mut templates = Environment::new();
templates.set_auto_escape_callback(|name| {
if name.ends_with(".html") {
AutoEscape::Html
} else {
AutoEscape::None
}
});
templates.add_template("layouts/base.html", BASE)?;
templates.add_template("layouts/docs.html", DOCS_LAYOUT)?;
templates.add_template("partials/docs-nav.html", DOCS_NAV)?;
templates.add_template("partials/public-wordmark.html", PUBLIC_WORDMARK)?;
templates.add_template("partials/public-header.html", PUBLIC_HEADER)?;
templates.add_template("partials/public-footer.html", PUBLIC_FOOTER)?;
templates.add_template("partials/code-content.html", CODE_CONTENT)?;
templates.add_template("partials/app-account.html", APP_ACCOUNT)?;
templates.add_template("landing.html", LANDING)?;
templates.add_template("docs.html", DOCS)?;
templates.add_template("dashboard.html", DASHBOARD)?;
templates.add_template("repositories.html", REPOSITORIES)?;
templates.add_template("repository-app.html", REPOSITORY_APP)?;
templates.add_template("runs.html", RUNS)?;
templates.add_template("workers.html", WORKERS)?;
templates.add_template("run.html", RUN)?;
templates.add_template("repository-code.html", CODE)?;
templates.add_template("not-found.html", NOT_FOUND)?;
Ok(templates)
}
fn request_prefers_html(headers: &HeaderMap) -> bool {
let Some(accept) = headers
.get(header::ACCEPT)
.and_then(|value| value.to_str().ok())
else {
return false;
};
let media_types = accept
.split(',')
.map(|value| value.split(';').next().unwrap_or_default().trim());
let mut html = false;
for media_type in media_types {
if media_type == "application/json" || media_type.ends_with("+json") {
return false;
}
html |= media_type == "text/html" || media_type == "application/xhtml+xml";
}
html
}
fn public_error_response(
templates: &Environment<'_>,
headers: &HeaderMap,
error: ApiError,
title: &str,
heading: &str,
description: &str,
) -> Result<Response, ApiError> {
if !request_prefers_html(headers) {
return Ok(error.into_response());
}
let status = error.status;
let page = templates
.get_template("not-found.html")
.map_err(ApiError::internal)?
.render(context! {
title => title,
heading => heading,
description => description
})
.map_err(ApiError::internal)?;
Ok((status, Html(page)).into_response())
}
async fn public_not_found(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Response, ApiError> {
public_error_response(
&state.templates,
&headers,
ApiError::not_found("route not found"),
"Page not found",
"This page could not be found.",
"The address may be outdated or mistyped. Return home or open the documentation.",
)
}
pub async fn serve(database: Database, options: ServerOptions) -> Result<()> {
ensure!(!options.token.is_empty(), "API token must not be empty");
ensure!(
!options.webhook_secret.is_empty(),
"webhook secret must not be empty"
);
ensure!(
options.worker_count <= 16,
"worker count must be 0..=16 (0 = serve-only, no execution)"
);
database.validate_schema()?;
if options.worker_count > 0 {
// Recovery belongs to the executor role: only the worker instance may
// mark orphaned runs interrupted and reset worker rows. A serve-only
// restart must never clobber a live worker's in-flight run.
let recovered = database.recover_interrupted()?;
if recovered > 0 {
warn!(recovered, "marked interrupted work after restart");
}
database.reset_workers()?;
let configured: Vec<String> = (0..options.worker_count)
.map(|index| format!("titan-{}", index + 1))
.collect();
let pruned = database.prune_workers_except(&configured)?;
if pruned > 0 {
warn!(
pruned,
"removed stale worker rows beyond the configured count"
);
}
}
let templates = template_environment()?;
let hosted_root = options.data_root.clone();
let tree = RepoTree::new(&options.data_root)?;
let runner = Runner::new(database.clone(), options.data_root, options.allow_native)?;
let state = AppState {
database: database.clone(),
runner: runner.clone(),
tree,
blocking_git: BlockingGit::new(MAX_BLOCKING_GIT),
hosted_root,
allow_native: options.allow_native,
token: options.token.into(),
webhook_secret: options.webhook_secret.into(),
public_url: options.public_url.into(),
templates: Arc::new(templates),
};
for index in 0..options.worker_count {
let worker_id = format!("titan-{}", index + 1);
database.register_worker(&worker_id, "titan", "docker,native", 1)?;
tokio::spawn(worker(database.clone(), runner.clone(), worker_id));
}
let app = Router::new()
.route("/", get(index))
.route("/docs", get(documentation))
.route("/docs/", get(documentation))
.route("/repos/{repository}", get(public_repository))
.route(
"/repos/{repository}/tree/{reference}",
get(public_code_root),
)
.route(
"/repos/{repository}/tree/{reference}/{*path}",
get(public_code_path),
)
.route(
"/repos/{repository}/raw/{reference}/{*path}",
get(public_raw_content),
)
.route(
"/downloads/{repository}/{*path}",
get(public_release_download),
)
.route(
"/app/repositories/{repository}/tree/{reference}",
get(app_code_root),
)
.route(
"/app/repositories/{repository}/tree/{reference}/{*path}",
get(app_code_path),
)
.route(
"/app/repositories/{repository}/raw/{reference}/{*path}",
get(app_raw_content),
)
.route("/app", get(dashboard))
.route("/app/repositories", get(repositories_page))
.route("/app/repositories/{repository}", get(repository_dashboard))
.route("/app/run/{id}", get(run_page))
.route("/app/runs", get(runs_page))
.route("/app/workers", get(workers_page))
.route("/auth/login", get(auth_login))
.route("/auth/callback", get(auth_callback))
.route("/auth/logout", get(auth_logout).post(auth_logout))
.route("/assets/app.css", get(css))
.route("/assets/app.js", get(js))
.route("/git/{*path}", any(git_http))
.route("/api/health", get(health))
.route("/mcp", post(mcp_endpoint))
.route("/api/state", get(api_state))
.route("/api/repos", post(create_repository))
.route("/api/runs/{id}", get(run_detail))
.route("/api/metrics/delivery", get(delivery_metrics))
.route("/api/runs/{id}/events", get(run_events))
.route("/api/runs/{id}/retry", post(retry))
.route("/api/workers", get(api_workers))
.route("/api/runs/{id}/promote/{environment}", post(promote))
.route("/api/repos/{repository}/runs", post(trigger))
.route(
"/api/repos/{repository}/visibility",
post(change_repository_visibility),
)
.route("/api/hooks/{repository}", post(webhook))
.route("/api/artifacts/{id}", get(artifact))
.route("/api/repos/{repository}/branches", get(branches))
.route("/api/repos/{repository}/tree", get(repo_tree))
.fallback(public_not_found)
.with_state(state)
.layer(DefaultBodyLimit::max(MAX_BODY))
.layer(CompressionLayer::new())
.layer(TimeoutLayer::with_status_code(
StatusCode::REQUEST_TIMEOUT,
REQUEST_TIMEOUT,
))
.layer(TraceLayer::new_for_http())
.layer(middleware::from_fn(security_headers))
.layer(GlobalConcurrencyLimitLayer::new(MAX_CONNECTIONS));
let listener = tokio::net::TcpListener::bind(options.address).await?;
serve_bounded(listener, app).await
}
async fn worker(database: Database, runner: Runner, worker_id: String) {
if let Err(error) = database.heartbeat_worker(&worker_id, "idle", None, false) {
warn!(%worker_id, ?error, "worker registration heartbeat failed");
}
let mut last_prune = std::time::Instant::now() - Duration::from_secs(3600);
loop {
match database.claim_next_queued_run() {
Ok(Some(id)) => {
if let Err(error) =
database.heartbeat_worker(&worker_id, "running", Some(id), false)
{
warn!(%worker_id, ?error, "worker heartbeat failed");
}
let heartbeat_database = database.clone();
let heartbeat_worker_id = worker_id.clone();
let heartbeat = tokio::spawn(async move {
let mut ticker = tokio::time::interval(Duration::from_secs(5));
loop {
ticker.tick().await;
if let Err(error) = heartbeat_database.heartbeat_worker(
&heartbeat_worker_id,
"running",
Some(id),
false,
) {
warn!(worker_id = %heartbeat_worker_id, ?error, "worker heartbeat failed");
}
}
});
let process_runner = runner.clone();
let run =
tokio::task::spawn_blocking(move || process_runner.process_claimed_run(id))
.await;
heartbeat.abort();
let completed = match run {
Ok(Ok(detail)) => matches!(
detail.run.status.as_str(),
"waiting" | "succeeded" | "failed" | "canceled" | "interrupted"
),
Ok(Err(error)) => {
tracing::error!(run_id = id, ?error, "run failed");
false
}
Err(error) => {
tracing::error!(run_id = id, ?error, "run worker panicked");
false
}
};
if let Err(error) = database.heartbeat_worker(&worker_id, "idle", None, completed) {
warn!(%worker_id, ?error, "worker idle heartbeat failed");
}
}
Ok(None) => {
if let Err(error) = database.heartbeat_worker(&worker_id, "idle", None, false) {
warn!(%worker_id, ?error, "worker heartbeat failed");
}
if last_prune.elapsed() >= Duration::from_secs(3600) {
last_prune = std::time::Instant::now();
match runner.prune_stale_workspaces() {
Ok(0) => {}
Ok(removed) => tracing::info!(removed, "pruned stale workspaces"),
Err(error) => warn!(?error, "workspace prune failed"),
}
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
Err(error) => {
tracing::error!(?error, "queue poll failed");
tokio::time::sleep(Duration::from_secs(2)).await;
}
}
}
}
async fn index(State(state): State<AppState>) -> Result<Html<String>, ApiError> {
let repositories = state
.database
.public_repositories()
.map_err(ApiError::internal)?
.into_iter()
.map(|repository| LandingRepository {
owner: repository_owner(&repository.url),
name: repository.name,
default_branch: repository.default_branch,
run_count: repository.run_count,
successful_runs: repository.successful_runs,
})
.collect::<Vec<_>>();
let mut builder_counts = std::collections::BTreeMap::new();
for repository in &repositories {
*builder_counts.entry(repository.owner.clone()).or_insert(0) += 1;
}
let mut builders = builder_counts
.into_iter()
.map(|(name, repository_count)| PublicBuilder {
initial: name.chars().next().unwrap_or('A').to_uppercase().collect(),
name,
repository_count,
})
.collect::<Vec<_>>();
builders.sort_by(|left, right| {
right
.repository_count
.cmp(&left.repository_count)
.then_with(|| left.name.cmp(&right.name))
});
let page = state
.templates
.get_template("landing.html")
.map_err(ApiError::internal)?
.render(context! {
version => env!("CARGO_PKG_VERSION"),
repositories => repositories,
builders => builders
})
.map_err(ApiError::internal)?;
Ok(Html(page))
}
fn encode_code_segment(value: &str) -> String {
value
.bytes()
.map(|byte| {
if byte.is_ascii_alphanumeric() || b"-._~".contains(&byte) {
(byte as char).to_string()
} else {
format!("%{byte:02X}")
}
})
.collect()
}
fn code_base(public: bool, repository: &str, reference: &str) -> String {
let prefix = if public {
"/repos"
} else {
"/app/repositories"
};
format!(
"{prefix}/{}/tree/{}",
encode_code_segment(repository),
encode_code_segment(reference),
)
}
fn raw_base(public: bool, repository: &str, reference: &str) -> String {
let prefix = if public {
"/repos"
} else {
"/app/repositories"
};
format!(
"{prefix}/{}/raw/{}",
encode_code_segment(repository),
encode_code_segment(reference),
)
}
fn code_path_href(base: &str, path: &str) -> String {
if path.is_empty() {
return base.to_owned();
}
format!(
"{base}/{}",
path.split('/')
.filter(|segment| !segment.is_empty())
.map(encode_code_segment)
.collect::<Vec<_>>()
.join("/")
)
}
fn code_breadcrumbs(
public: bool,
repository: &str,
reference: &str,
path: &str,
) -> Vec<CodeBreadcrumb> {
let base = code_base(public, repository, reference);
let mut breadcrumbs = vec![CodeBreadcrumb {
label: repository.to_owned(),
href: base.clone(),
}];
let mut current = String::new();
for segment in path.split('/').filter(|segment| !segment.is_empty()) {
if !current.is_empty() {
current.push('/');
}
current.push_str(segment);
breadcrumbs.push(CodeBreadcrumb {
label: segment.to_owned(),
href: code_path_href(&base, ¤t),
});
}
breadcrumbs
}
async fn render_code(
state: &AppState,
repository_name: String,
reference: String,
path: String,
public: bool,
email: Option<String>,
) -> Result<Html<String>, ApiError> {
let repository = state
.database
.repository(&repository_name)
.map_err(|_| ApiError::not_found(format!("unknown repository {repository_name}")))?;
if public && repository.visibility != "public" {
return Err(ApiError::not_found("repository not found"));
}
let view = repository_view(repository.clone(), &state.hosted_root, &state.public_url);
let base = code_base(public, &repository_name, &reference);
let readme_urls = ReadmeUrls {
dir: path.clone(),
raw_prefix: raw_base(public, &repository_name, &reference),
tree_prefix: base.clone(),
};
enum Lookup {
Tree(crate::tree::TreeResponse),
Blob(crate::tree::BlobResponse),
}
let tree = state.tree.clone();
let name = repository.name.clone();
let url = repository.url.clone();
let lookup_reference = reference.clone();
let lookup_path = path.clone();
let lookup = state
.blocking_git
.run(
move || match tree.tree(&name, &url, Some(&lookup_reference), &lookup_path) {
Ok(response) => Ok(Lookup::Tree(response)),
Err(_) => tree
.blob(&name, &url, &lookup_reference, &lookup_path)
.map(Lookup::Blob)
.map_err(|_| ApiError::not_found("repository ref or path not found")),
},
)
.await?;
struct TreeExtras {
description: Option<String>,
counts: Option<crate::tree::RepoCounts>,
readme_html: Option<String>,
readme_name: Option<String>,
recent_commits: Vec<CommitView>,
contributors: Vec<ContributorView>,
languages: Vec<LanguageView>,
}
let (entries, source, is_file, latest_commit, extras) = match lookup {
Lookup::Tree(tree) => {
let mut offset = 0.0f64;
let extras = TreeExtras {
description: tree.description,
counts: Some(tree.counts),
readme_html: tree
.readme
.as_deref()
.map(|source| render_markdown(source, Some(&readme_urls))),
readme_name: tree.readme_name,
recent_commits: tree
.recent_commits
.into_iter()
.map(|commit| CommitView {
href: code_base(public, &repository_name, &commit.sha),
short_sha: commit.sha.chars().take(7).collect(),
sha: commit.sha,
message: commit.message,
author: commit.author,
date: format_epoch_date(commit.committed_at),
})
.collect(),
contributors: tree
.contributors
.into_iter()
.map(|contributor| ContributorView {
initial: contributor_initial(&contributor.name),
name: contributor.name,
commit_count: contributor.commit_count,
})
.collect(),
languages: tree
.languages
.into_iter()
.filter(|language| language.percentage > 0.0)
.map(|language| {
let view = LanguageView {
name: language.name,
color: language.color,
percentage: language.percentage,
percentage_label: format!("{:.1}", language.percentage),
offset,
};
offset += language.percentage;
view
})
.collect(),
};
(
tree.entries
.into_iter()
.map(|entry| CodeEntryView {
href: code_path_href(
&base,
&if path.is_empty() {
entry.name.clone()
} else {
format!("{path}/{}", entry.name)
},
),
name: entry.name,
kind: entry.kind,
size: entry.size,
commit_message: entry.commit_message,
})
.collect(),
None,
false,
tree.latest_commit,
extras,
)
}
Lookup::Blob(blob) => (
Vec::new(),
Some(blob.content),
true,
blob.latest_commit,
TreeExtras {
description: None,
counts: None,
readme_html: None,
readme_name: None,
recent_commits: Vec::new(),
contributors: Vec::new(),
languages: Vec::new(),
},
),
};
let commit_href = latest_commit
.as_ref()
.map(|commit| code_base(public, &repository_name, &commit.sha));
let parent_href = if path.is_empty() {
if public {
format!("/repos/{}", encode_code_segment(&repository_name))
} else {
format!(
"/app/repositories/{}",
encode_code_segment(&repository_name)
)
}
} else {
let parent = path.rsplit_once('/').map_or("", |(parent, _)| parent);
code_path_href(&base, parent)
};
let page = CodePage {
repository: view,
reference: reference.clone(),
path: path.clone(),
entries,
breadcrumbs: code_breadcrumbs(public, &repository_name, &reference, &path),
source,
is_file,
parent_href,
latest_commit,
commit_href,
public,
description: extras.description,
counts: extras.counts,
readme_html: extras.readme_html,
readme_name: extras.readme_name,
recent_commits: extras.recent_commits,
contributors: extras.contributors,
languages: extras.languages,
};
let rendered = state
.templates
.get_template("repository-code.html")
.map_err(ApiError::internal)?
.render(context! { page => page, email => email })
.map_err(ApiError::internal)?;
Ok(Html(rendered))
}
async fn public_code_root(
State(state): State<AppState>,
headers: HeaderMap,
Path((repository, reference)): Path<(String, String)>,
) -> Result<Response, ApiError> {
match render_code(&state, repository, reference, String::new(), true, None).await {
Ok(page) => Ok(page.into_response()),
Err(error) if error.status == StatusCode::NOT_FOUND => public_error_response(
&state.templates,
&headers,
error,
"Repository content not found",
"Repository content could not be found.",
"The repository, ref, or path may have moved. Return to the site or open the documentation.",
),
Err(error) => Err(error),
}
}
async fn public_code_path(
State(state): State<AppState>,
headers: HeaderMap,
Path((repository, reference, path)): Path<(String, String, String)>,
) -> Result<Response, ApiError> {
match render_code(&state, repository, reference, path, true, None).await {
Ok(page) => Ok(page.into_response()),
Err(error) if error.status == StatusCode::NOT_FOUND => public_error_response(
&state.templates,
&headers,
error,
"Repository content not found",
"Repository content could not be found.",
"The repository, ref, or path may have moved. Return to the site or open the documentation.",
),
Err(error) => Err(error),
}
}
async fn app_code_root(
State(state): State<AppState>,
uri: Uri,
headers: HeaderMap,
Path((repository, reference)): Path<(String, String)>,
) -> Result<Response, ApiError> {
let Some(session) = browser_session(&headers, &state) else {
return Ok(login_redirect(&uri));
};
match render_code(
&state,
repository,
reference,
String::new(),
false,
Some(session.email),
)
.await
{
Ok(page) => Ok(page.into_response()),
Err(error) if error.status == StatusCode::NOT_FOUND => public_error_response(
&state.templates,
&headers,
error,
"Repository content not found",
"Repository content could not be found.",
"The repository, ref, or path may have moved. Return to the site or open the documentation.",
),
Err(error) => Err(error),
}
}
async fn app_code_path(
State(state): State<AppState>,
uri: Uri,
headers: HeaderMap,
Path((repository, reference, path)): Path<(String, String, String)>,
) -> Result<Response, ApiError> {
let Some(session) = browser_session(&headers, &state) else {
return Ok(login_redirect(&uri));
};
match render_code(
&state,
repository,
reference,
path,
false,
Some(session.email),
)
.await
{
Ok(page) => Ok(page.into_response()),
Err(error) if error.status == StatusCode::NOT_FOUND => public_error_response(
&state.templates,
&headers,
error,
"Repository content not found",
"Repository content could not be found.",
"The repository, ref, or path may have moved. Return to the site or open the documentation.",
),
Err(error) => Err(error),
}
}
async fn public_raw_content(
State(state): State<AppState>,
Path((repository, reference, path)): Path<(String, String, String)>,
) -> Result<Response, ApiError> {
let repo = state
.database
.repository(&repository)
.map_err(|_| ApiError::not_found(format!("unknown repository {repository}")))?;
if repo.visibility != "public" {
return Err(ApiError::not_found("repository not found"));
}
raw_blob_response(&state, repo, reference, path).await
}
async fn app_raw_content(
State(state): State<AppState>,
uri: Uri,
headers: HeaderMap,
Path((repository, reference, path)): Path<(String, String, String)>,
) -> Result<Response, ApiError> {
if browser_session(&headers, &state).is_none() {
return Ok(login_redirect(&uri));
}
let repo = state
.database
.repository(&repository)
.map_err(|_| ApiError::not_found(format!("unknown repository {repository}")))?;
raw_blob_response(&state, repo, reference, path).await
}
/// Serve a repository blob's raw bytes inline, so README-referenced images
/// resolve. The content type is inferred from the path extension and is
/// restricted to a static, non-executable allowlist; anything else is served
/// as an octet stream so the browser never treats repo content as active.
async fn raw_blob_response(
state: &AppState,
repository: crate::db::Repository,
reference: String,
path: String,
) -> Result<Response, ApiError> {
let tree = state.tree.clone();
let name = repository.name.clone();
let url = repository.url.clone();
let content_type = content_type_for(&path);
let lookup_reference = reference.clone();
let lookup_path = path.clone();
let bytes = state
.blocking_git
.run(move || {
tree.blob_bytes(&name, &url, &lookup_reference, &lookup_path)
.map_err(|_| ApiError::not_found("file not found"))
})
.await?;
let length = bytes.len();
let mut response = Response::new(Body::from(bytes));
response
.headers_mut()
.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
response.headers_mut().insert(
header::CONTENT_LENGTH,
HeaderValue::from_str(&length.to_string()).map_err(ApiError::internal)?,
);
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static("public, max-age=300"),
);
response.headers_mut().insert(
HeaderName::from_static("x-content-type-options"),
HeaderValue::from_static("nosniff"),
);
Ok(response)
}
/// Map a file extension to a safe, static content type for inline serving.
/// Only image/font/media types a README embeds are recognised; everything
/// else falls back to a downloadable octet stream.
fn content_type_for(path: &str) -> &'static str {
let ext = path
.rsplit('.')
.next()
.map(str::to_ascii_lowercase)
.unwrap_or_default();
match ext.as_str() {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"svg" => "image/svg+xml",
"ico" => "image/x-icon",
"bmp" => "image/bmp",
"avif" => "image/avif",
"apng" => "image/apng",
"mp4" => "video/mp4",
"webm" => "video/webm",
"woff" => "font/woff",
"woff2" => "font/woff2",
_ => "application/octet-stream",
}
}
async fn documentation(State(state): State<AppState>) -> Result<Html<String>, ApiError> {
let page = state
.templates
.get_template("docs.html")
.map_err(ApiError::internal)?
.render(context! { version => env!("CARGO_PKG_VERSION") })
.map_err(ApiError::internal)?;
Ok(Html(page))
}
fn repository_owner(url: &str) -> String {
let (host, path) = if let Some((_, remote)) = url.split_once("://") {
remote
.split_once('/')
.map(|(host, path)| (host.rsplit('@').next().unwrap_or(host), path))
.unwrap_or((remote, ""))
} else if let Some((_, remote)) = url.split_once('@') {
remote.split_once(':').unwrap_or((remote, ""))
} else {
return "AkurAI Build".into();
};
let mut segments = path.split('/').filter(|segment| !segment.is_empty());
match (segments.next(), segments.next()) {
(Some("git"), Some(_)) => "AkurAI Build".into(),
(Some(owner), Some(_)) => owner.into(),
_ => host.into(),
}
}
async fn public_repository(
State(state): State<AppState>,
headers: HeaderMap,
Path(repository_name): Path<String>,
) -> Result<Response, ApiError> {
let repository = match state.database.repository(&repository_name) {
Ok(repository) if repository.visibility == "public" => repository,
_ => {
return public_error_response(
&state.templates,
&headers,
ApiError::not_found(format!("unknown repository {repository_name}")),
"Repository not found",
"This repository could not be found.",
"The repository may be private, renamed, or unavailable. Explore public work or open the documentation.",
);
}
};
let default_branch = repository.default_branch.clone();
match render_code(
&state,
repository.name.clone(),
default_branch,
String::new(),
true,
None,
)
.await
{
Ok(page) => Ok(page.into_response()),
Err(error) if error.status == StatusCode::NOT_FOUND => public_error_response(
&state.templates,
&headers,
error,
"Repository content not found",
"Repository content could not be found.",
"The repository may still be empty. Explore public work or open the documentation.",
),
Err(error) => Err(error),
}
}
async fn git_http(
State(state): State<AppState>,
Path(path): Path<String>,
request: Request<Body>,
) -> Result<Response, ApiError> {
authorize(request.headers(), &state)?;
validate_git_path(&path).map_err(ApiError::bad)?;
let is_push =
request.method() == axum::http::Method::POST && path.ends_with("/git-receive-pack");
let (parts, body) = request.into_parts();
let body = to_bytes(body, MAX_GIT_BODY).await.map_err(ApiError::bad)?;
let root = state.hosted_root.join("hosted");
let git_path = path.clone();
let response = tokio::task::spawn_blocking(move || run_git_http(root, git_path, parts, body))
.await
.map_err(ApiError::internal)?
.map_err(ApiError::internal)?;
// CI autonomy: a completed push to a hosted repository queues a pipeline
// run for its default branch, exactly like an external webhook would.
// Repositories without an .akurai.yml are skipped instead of producing
// guaranteed-failed runs.
if is_push
&& response.status().is_success()
&& let Some(repository) = path
.split('/')
.next()
.and_then(|segment| segment.strip_suffix(".git"))
{
let bare = state
.hosted_root
.join("hosted")
.join(format!("{repository}.git"));
let has_pipeline =
crate::git_process::output(&bare, ["cat-file", "-e", "HEAD:.akurai.yml"]).is_ok();
if has_pipeline {
match crate::git_process::output(&bare, ["rev-parse", "HEAD"]) {
Ok(commit) => {
match state
.runner
.queue(repository, None, Some(&commit), "webhook")
{
Ok(queued) => info!(
repository,
run_id = queued.id,
deduplicated = queued.deduplicated,
"queued run for pushed commit"
),
Err(error) => warn!(repository, ?error, "failed to queue run after push"),
}
}
Err(error) => warn!(repository, ?error, "failed to resolve pushed commit"),
}
} else {
info!(
repository,
"push accepted; no pipeline config, run not queued"
);
}
}
Ok(response)
}
fn validate_git_path(path: &str) -> Result<()> {
ensure!(!path.is_empty() && path.len() <= 512, "invalid Git path");
let repository = path.split('/').next().unwrap_or_default();
ensure!(repository.ends_with(".git"), "invalid Git repository path");
ensure!(
path.split('/').all(|component| {
!component.is_empty()
&& component
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte))
}),
"invalid Git path"
);
Ok(())
}
fn run_git_http(
root: PathBuf,
path: String,
parts: axum::http::request::Parts,
body: Bytes,
) -> Result<Response> {
let mut command = Command::new("git");
command
.arg("http-backend")
.env("GIT_PROJECT_ROOT", root)
.env("GIT_HTTP_EXPORT_ALL", "1")
.env("PATH_INFO", format!("/{path}"))
.env("REQUEST_METHOD", parts.method.as_str())
.env("QUERY_STRING", parts.uri.query().unwrap_or_default())
.env("CONTENT_LENGTH", body.len().to_string())
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(value) = parts.headers.get(header::CONTENT_TYPE) {
command.env("CONTENT_TYPE", value.to_str()?);
}
if let Some(value) = parts.headers.get(header::CONTENT_ENCODING) {
command.env("HTTP_CONTENT_ENCODING", value.to_str()?);
}
if let Some(value) = parts.headers.get("git-protocol") {
command.env("HTTP_GIT_PROTOCOL", value.to_str()?);
}
let mut child = command
.spawn()
.context("failed to start git http-backend")?;
child
.stdin
.take()
.context("git http-backend stdin is unavailable")?
.write_all(&body)?;
let output = child.wait_with_output()?;
ensure!(
output.status.success(),
"git http-backend failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
git_cgi_response(&output.stdout)
}
fn git_cgi_response(output: &[u8]) -> Result<Response> {
let (header_bytes, body) = output
.windows(4)
.position(|window| window == b"\r\n\r\n")
.map(|index| (&output[..index], &output[index + 4..]))
.or_else(|| {
output
.windows(2)
.position(|window| window == b"\n\n")
.map(|index| (&output[..index], &output[index + 2..]))
})
.context("git http-backend returned malformed CGI output")?;
let mut status = StatusCode::OK;
let mut response = Response::builder();
for line in String::from_utf8_lossy(header_bytes).lines() {
let (name, value) = line.split_once(':').context("invalid Git CGI header")?;
if name.eq_ignore_ascii_case("status") {
let code = value
.split_whitespace()
.next()
.context("missing Git CGI status")?
.parse::<u16>()?;
status = StatusCode::from_u16(code)?;
} else {
response = response.header(
HeaderName::from_bytes(name.trim().as_bytes())?,
HeaderValue::from_str(value.trim())?,
);
}
}
Ok(response.status(status).body(Body::from(body.to_vec()))?)
}
async fn dashboard(
State(state): State<AppState>,
uri: Uri,
headers: HeaderMap,
) -> Result<Response, ApiError> {
app_page(&state, &uri, &headers, "dashboard.html", context! {})
}
async fn repositories_page(
State(state): State<AppState>,
uri: Uri,
headers: HeaderMap,
) -> Result<Response, ApiError> {
app_page(&state, &uri, &headers, "repositories.html", context! {})
}
async fn repository_dashboard(
State(state): State<AppState>,
uri: Uri,
headers: HeaderMap,
Path(repository): Path<String>,
) -> Result<Response, ApiError> {
let repository = state
.database
.repository(&repository)
.map_err(ApiError::bad)?;
let repository = repository_view(repository, &state.hosted_root, &state.public_url);
app_page(
&state,
&uri,
&headers,
"repository-app.html",
context! { repository => repository },
)
}
async fn runs_page(
State(state): State<AppState>,
uri: Uri,
headers: HeaderMap,
) -> Result<Response, ApiError> {
app_page(&state, &uri, &headers, "runs.html", context! {})
}
async fn run_page(
State(state): State<AppState>,
uri: Uri,
headers: HeaderMap,
Path(id): Path<i64>,
) -> Result<Response, ApiError> {
app_page(
&state,
&uri,
&headers,
"run.html",
context! { run_id => id },
)
}
async fn workers_page(
State(state): State<AppState>,
uri: Uri,
headers: HeaderMap,
) -> Result<Response, ApiError> {
app_page(&state, &uri, &headers, "workers.html", context! {})
}
/// Render an authenticated shell page, or bounce an anonymous browser to
/// `/auth/login` with a same-origin `return_to`.
fn app_page(
state: &AppState,
uri: &Uri,
headers: &HeaderMap,
template: &str,
extra: minijinja::Value,
) -> Result<Response, ApiError> {
let Some(session) = browser_session(headers, state) else {
return Ok(login_redirect(uri));
};
let page = state
.templates
.get_template(template)
.map_err(ApiError::internal)?
.render(context! {
version => env!("CARGO_PKG_VERSION"),
email => session.email,
..extra
})
.map_err(ApiError::internal)?;
Ok(Html(page).into_response())
}
/// `GET /auth/login` — start the AkurAI ID authorization-code flow.
async fn auth_login(
State(state): State<AppState>,
Query(query): Query<LoginQuery>,
) -> Result<Response, ApiError> {
let config = idp_config(&state)?;
let redirect = oidc::login_redirect(&state.database, &config, query.return_to.as_deref())
.map_err(oidc_error)?;
let mut response = Redirect::to(&redirect.location).into_response();
response.headers_mut().insert(
header::SET_COOKIE,
HeaderValue::from_str(&redirect.set_cookie).map_err(ApiError::internal)?,
);
Ok(response)
}
/// `GET /auth/callback` — finish the flow: state, token, ID token, introspect,
/// userinfo, authorization, session. Any miss fails closed.
async fn auth_callback(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<CallbackQuery>,
) -> Result<Response, ApiError> {
let config = idp_config(&state)?;
let database = state.database.clone();
let state_cookie = request_cookie(&headers, oidc::STATE_COOKIE).map(str::to_owned);
let outcome = tokio::task::spawn_blocking(move || {
oidc::handle_callback(
&database,
&config,
&oidc::CallbackParams {
code: query.code.as_deref(),
state: query.state.as_deref(),
error: query.error.as_deref(),
state_cookie: state_cookie.as_deref(),
},
)
})
.await
.map_err(ApiError::internal)?
.map_err(oidc_error)?;
let mut response = Redirect::to(&outcome.location).into_response();
for cookie in [&outcome.session_cookie, &outcome.clear_state_cookie] {
response.headers_mut().append(
header::SET_COOKIE,
HeaderValue::from_str(cookie).map_err(ApiError::internal)?,
);
}
Ok(response)
}
/// `GET|POST /auth/logout` — drop the session, then RP-initiated IDP logout.
async fn auth_logout(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Response, ApiError> {
let config = idp_config(&state)?;
let redirect = oidc::logout_redirect(
&state.database,
&config,
request_cookie(&headers, oidc::SESSION_COOKIE),
);
let mut response = Redirect::to(&redirect.location).into_response();
for cookie in [&redirect.clear_session_cookie, &redirect.clear_state_cookie] {
response.headers_mut().append(
header::SET_COOKIE,
HeaderValue::from_str(cookie).map_err(ApiError::internal)?,
);
}
Ok(response)
}
fn idp_config(state: &AppState) -> Result<oidc::Config, ApiError> {
oidc::config(&state.public_url).map_err(oidc_error)
}
fn oidc_error(error: oidc::Error) -> ApiError {
match error {
oidc::Error::Invalid(message) => ApiError::bad(message),
oidc::Error::Forbidden(message) => ApiError {
status: StatusCode::FORBIDDEN,
code: "forbidden",
message,
},
oidc::Error::Upstream(message) => {
warn!(error = %message, "AkurAI ID back-channel call failed");
ApiError {
status: StatusCode::BAD_GATEWAY,
code: "identity_provider_unavailable",
message: "the identity provider could not be reached".into(),
}
}
oidc::Error::Configuration(message) => {
warn!(error = %message, "AkurAI ID is not configured; browser sign-in is unavailable");
ApiError::internal(message)
}
oidc::Error::Internal(message) => ApiError::internal(message),
}
}
async fn css() -> Response {
asset(CSS, "text/css; charset=utf-8")
}
async fn js() -> Response {
asset(JS, "text/javascript; charset=utf-8")
}
fn asset(body: &'static str, content_type: &'static str) -> Response {
let mut response = Response::new(Body::from(body));
response
.headers_mut()
.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static("public, max-age=300"),
);
response
}
async fn health(State(state): State<AppState>) -> Result<Json<Envelope<Value>>, ApiError> {
state.database.check_ready().map_err(ApiError::internal)?;
let workers = state.database.workers().map_err(ApiError::internal)?;
let active_workers = workers
.iter()
.filter(|worker| worker.status == "running")
.count();
let queued_runs = state.database.queued_runs().map_err(ApiError::internal)?;
Ok(ok(serde_json::json!({
"service": "akurai-build",
"status": "ok",
"version": env!("CARGO_PKG_VERSION"),
"workers": workers.len(),
"active_workers": active_workers,
"queued_runs": queued_runs,
})))
}
/// `POST /mcp` — the full AkurAI Build MCP tool set over HTTP JSON-RPC,
/// authenticated the same way as every other `/api/*` route (bearer token
/// or browser session). Dispatches through `mcp::handle_request`, the exact
/// same tool implementations the stdio `akurai-build mcp` transport uses —
/// no duplicated logic between the two transports.
async fn mcp_endpoint(
State(state): State<AppState>,
headers: HeaderMap,
Json(request): Json<Value>,
) -> Result<Json<Value>, ApiError> {
authorize(&headers, &state)?;
let response = mcp::handle_request(
state.database.clone(),
state.hosted_root.clone(),
state.allow_native,
state.tree.clone(),
&request,
);
Ok(Json(response.unwrap_or(Value::Null)))
}
async fn api_state(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<StateQuery>,
) -> Result<Json<Envelope<StateResponse>>, ApiError> {
authorize(&headers, &state)?;
let admin = superuser_session(&headers, &state) || valid_bearer(&headers, &state);
let all_repositories = state.database.repositories().map_err(ApiError::internal)?;
let visible_repositories = all_repositories
.iter()
.filter(|repository| admin || repository.visibility == "public")
.collect::<Vec<_>>();
let repository_filter = if let Some(requested) = query.repo.as_deref() {
let requested = requested
.split(',')
.map(str::trim)
.filter(|name| !name.is_empty())
.collect::<Vec<_>>();
if requested.is_empty() {
return Err(ApiError::bad("repository filter is empty"));
}
if !requested.iter().all(|name| {
visible_repositories
.iter()
.any(|repository| repository.name == *name)
}) {
return Err(ApiError::not_found("repository not found"));
}
Some(requested.join(","))
} else {
None
};
let repositories = visible_repositories
.into_iter()
.cloned()
.map(|repository| repository_view(repository, &state.hosted_root, &state.public_url))
.collect();
let mut run_query = crate::db::RunQuery {
repository: repository_filter,
statuses: list_filter(query.status),
git_ref: query.git_ref,
triggers: list_filter(query.trigger),
search: query.q,
before_id: query.before,
limit: query.limit.unwrap_or(50),
offset: query.offset.unwrap_or(0),
public_only: !admin,
};
let run_cursor = match run_query.before_id {
Some(cursor) => Some(cursor),
None => state
.database
.max_run_id(&run_query)
.map_err(ApiError::bad)?,
};
run_query.before_id = run_cursor;
let run_total = state
.database
.count_runs(&run_query)
.map_err(ApiError::bad)?;
let runs = state
.database
.query_runs(&run_query)
.map_err(ApiError::bad)?;
let workers = state.database.workers().map_err(ApiError::internal)?;
let queued_runs = state.database.queued_runs().map_err(ApiError::internal)?;
Ok(ok(StateResponse {
repositories,
runs,
workers,
queued_runs,
run_total,
run_cursor,
}))
}
async fn delivery_metrics(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<DeliveryMetricsQuery>,
) -> Result<Json<Envelope<crate::db::DeliveryMetrics>>, ApiError> {
authorize(&headers, &state)?;
let repository_name = query
.repo
.as_deref()
.ok_or_else(|| ApiError::bad("repo is required for repository-scoped metrics"))?;
let repository = state
.database
.repository(repository_name)
.map_err(|_| ApiError::not_found("repository not found"))?;
if repository.visibility != "public"
&& !superuser_session(&headers, &state)
&& !valid_bearer(&headers, &state)
{
return Err(ApiError::not_found("repository not found"));
}
let window = query.window.unwrap_or(7 * 86_400);
Ok(ok(state
.database
.delivery_metrics(Some(repository_name), query.environment.as_deref(), window)
.map_err(ApiError::bad)?))
}
async fn api_workers(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Envelope<Vec<crate::db::Worker>>>, ApiError> {
authorize(&headers, &state)?;
Ok(ok(state.database.workers().map_err(ApiError::internal)?))
}
fn visible_run_detail(
state: &AppState,
headers: &HeaderMap,
id: i64,
) -> Result<crate::db::RunDetail, ApiError> {
let mut detail = state.database.detail(id).map_err(ApiError::bad)?;
let repository = state
.database
.repository(&detail.run.repository)
.map_err(ApiError::bad)?;
let privileged = superuser_session(headers, state) || valid_bearer(headers, state);
if repository.visibility != "public" && !privileged {
return Err(ApiError::not_found("run not found"));
}
if !privileged {
for deployment in &mut detail.deployments {
deployment.organization_id = None;
deployment.workspace_id = None;
deployment.installation_id = None;
deployment.app_id = None;
deployment.installation_revision = None;
deployment.commit_sha = None;
deployment.artifact_digest = None;
deployment.result = None;
deployment.rollback_of_deployment_id = None;
deployment.recovery_of_deployment_id = None;
}
}
Ok(detail)
}
async fn run_detail(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<i64>,
) -> Result<Json<Envelope<crate::db::RunDetail>>, ApiError> {
authorize(&headers, &state)?;
Ok(ok(visible_run_detail(&state, &headers, id)?))
}
async fn run_events(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<i64>,
) -> Result<Response, ApiError> {
authorize(&headers, &state)?;
visible_run_detail(&state, &headers, id)?;
let database = state.database.clone();
let (reader, mut writer) = tokio::io::duplex(64 * 1024);
tokio::spawn(async move {
let mut previous = String::new();
let mut ticker = tokio::time::interval(Duration::from_millis(500));
loop {
ticker.tick().await;
let detail = match database.detail(id) {
Ok(detail) => detail,
Err(error) => {
let payload = serde_json::json!({"run_id": id, "status": "failed", "error": error.to_string()});
let _ = writer
.write_all(format!("event: error\ndata: {payload}\n\n").as_bytes())
.await;
break;
}
};
let terminal = is_terminal_run(&detail.run.status);
let payload = match serde_json::to_string(&run_progress(detail)) {
Ok(payload) => payload,
Err(_) => break,
};
if payload != previous {
let frame = format!("event: progress\ndata: {payload}\n\n");
if writer.write_all(frame.as_bytes()).await.is_err() {
break;
}
previous = payload;
} else if writer.write_all(b": keepalive\n\n").await.is_err() {
break;
}
if terminal {
break;
}
}
});
let mut response = Response::new(Body::from_stream(ReaderStream::new(reader)));
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/event-stream"),
);
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static("no-cache, no-transform"),
);
response.headers_mut().insert(
header::CONTENT_ENCODING,
HeaderValue::from_static("identity"),
);
response.headers_mut().insert(
HeaderName::from_static("x-accel-buffering"),
HeaderValue::from_static("no"),
);
Ok(response)
}
fn run_progress(detail: crate::db::RunDetail) -> RunProgress {
let job_count = detail.jobs.len() as u32;
let job_units = detail
.jobs
.iter()
.map(|job| match job.status.as_str() {
"succeeded" | "failed" | "canceled" | "skipped" => 4,
"waiting" => 3,
"running" => 2,
_ => 0,
})
.sum::<u32>();
let progress = match detail.run.status.as_str() {
"succeeded" => 100,
"queued" => 2,
"waiting" => 85,
_ if job_count > 0 => (5 + (80 * job_units / (job_count * 4))).min(95) as u8,
_ => 5,
};
let stage = if detail.run.status == "succeeded" {
"succeeded".to_string()
} else if is_terminal_run(&detail.run.status) {
detail.run.status.clone()
} else if detail.run.status == "waiting"
|| detail
.jobs
.iter()
.any(|job| job.status == "waiting" && job.approval_required)
{
"waiting_for_approval".to_string()
} else if let Some(deployment) = detail
.deployments
.iter()
.rev()
.find(|deployment| deployment.status != "succeeded")
{
format!("deploying:{}", deployment.environment)
} else if let Some(job) = detail.jobs.iter().find(|job| job.status == "running") {
format!("running:{}", job.name)
} else {
detail.run.status.clone()
};
RunProgress {
run_id: detail.run.id,
repository: detail.run.repository,
git_ref: detail.run.git_ref,
commit_sha: detail.run.commit_sha,
status: detail.run.status,
stage,
progress,
jobs: detail
.jobs
.into_iter()
.map(|job| ProgressJob {
id: job.id,
name: job.name,
status: job.status,
environment: job.environment,
approval_required: job.approval_required,
})
.collect(),
deployments: detail
.deployments
.into_iter()
.map(|deployment| ProgressDeployment {
id: deployment.id,
environment: deployment.environment,
status: deployment.status,
})
.collect(),
error: detail.run.error,
}
}
fn is_terminal_run(status: &str) -> bool {
matches!(status, "succeeded" | "failed" | "canceled" | "interrupted")
}
async fn create_repository(
State(state): State<AppState>,
headers: HeaderMap,
Json(request): Json<RepositoryRequest>,
) -> Result<Json<Envelope<RepositoryView>>, ApiError> {
authorize_mutation(&headers, &state)?;
config::validate_repository(&request.name, &request.url, &request.branch)
.map_err(ApiError::bad)?;
let repository = state
.database
.add_repository(&request.name, &request.url, &request.branch)
.map_err(ApiError::bad)?;
Ok(ok(repository_view(
repository,
&state.hosted_root,
&state.public_url,
)))
}
async fn change_repository_visibility(
State(state): State<AppState>,
headers: HeaderMap,
Path(repository): Path<String>,
Json(request): Json<VisibilityRequest>,
) -> Result<Json<Envelope<RepositoryView>>, ApiError> {
authorize_mutation(&headers, &state)?;
let repository = state
.database
.set_repository_visibility(&repository, &request.visibility)
.map_err(ApiError::bad)?;
Ok(ok(repository_view(
repository,
&state.hosted_root,
&state.public_url,
)))
}
async fn trigger(
State(state): State<AppState>,
headers: HeaderMap,
Path(repository): Path<String>,
Json(request): Json<TriggerRequest>,
) -> Result<Json<Envelope<IdResponse>>, ApiError> {
authorize_mutation(&headers, &state)?;
let queued = state
.runner
.queue(
&repository,
request.git_ref.as_deref(),
request.commit.as_deref(),
"manual",
)
.map_err(ApiError::bad)?;
Ok(ok(IdResponse { id: queued.id }))
}
async fn branches(
State(state): State<AppState>,
headers: HeaderMap,
Path(repository): Path<String>,
) -> Result<Json<Envelope<Vec<git_process::RemoteBranch>>>, ApiError> {
authorize(&headers, &state)?;
let repo = state
.database
.repository(&repository)
.map_err(|_| ApiError::bad("repository not found"))?;
let default_branch = repo.default_branch;
let url = repo.url;
let branches = state
.blocking_git
.run(move || git_process::remote_branches(&url, &default_branch).map_err(ApiError::bad))
.await?;
Ok(ok(branches))
}
#[derive(Deserialize)]
struct TreeQuery {
#[serde(rename = "ref")]
reference: Option<String>,
path: Option<String>,
}
async fn repo_tree(
State(state): State<AppState>,
headers: HeaderMap,
Path(repository): Path<String>,
Query(query): Query<TreeQuery>,
) -> Result<Json<Envelope<crate::tree::TreeResponse>>, ApiError> {
authorize(&headers, &state)?;
let repo = state
.database
.repository(&repository)
.map_err(|_| ApiError::bad("repository not found"))?;
let sub_path = query.path.unwrap_or_default();
let tree = state.tree.clone();
let name = repo.name;
let url = repo.url;
let reference = query.reference;
let response = state
.blocking_git
.run(move || {
tree.tree(&name, &url, reference.as_deref(), &sub_path)
.map_err(ApiError::bad)
})
.await?;
Ok(ok(response))
}
async fn retry(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<i64>,
) -> Result<Json<Envelope<IdResponse>>, ApiError> {
authorize_mutation(&headers, &state)?;
let id = state.database.retry(id).map_err(ApiError::bad)?;
Ok(ok(IdResponse { id }))
}
async fn promote(
State(state): State<AppState>,
headers: HeaderMap,
Path((id, environment)): Path<(i64, String)>,
) -> Result<Json<Envelope<Value>>, ApiError> {
authorize_mutation(&headers, &state)?;
let jobs = state
.database
.approve_environment(id, &environment)
.map_err(ApiError::bad)?;
Ok(ok(
serde_json::json!({"run_id":id,"environment":environment,"jobs":jobs}),
))
}
async fn artifact(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<i64>,
) -> Result<Response, ApiError> {
authorize(&headers, &state)?;
let artifact = state.database.artifact(id).map_err(ApiError::bad)?;
let path = state
.runner
.artifact_path(&artifact)
.map_err(ApiError::internal)?;
let file = tokio::fs::File::open(path)
.await
.map_err(ApiError::internal)?;
let mut response = Response::new(Body::from_stream(ReaderStream::new(file)));
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("application/octet-stream"),
);
response.headers_mut().insert(
header::CONTENT_LENGTH,
HeaderValue::from_str(&artifact.bytes.to_string()).map_err(ApiError::internal)?,
);
let filename = artifact
.name
.rsplit('/')
.next()
.unwrap_or("artifact")
.replace(['"', '\r', '\n'], "_");
response.headers_mut().insert(
header::CONTENT_DISPOSITION,
HeaderValue::from_str(&format!("attachment; filename=\"{filename}\""))
.map_err(ApiError::internal)?,
);
Ok(response)
}
async fn public_release_download(
State(state): State<AppState>,
Path((repository_name, download_path)): Path<(String, String)>,
) -> Result<Response, ApiError> {
let download_path = download_path
.strip_prefix('/')
.unwrap_or(download_path.as_str());
let repository = state
.database
.repository(&repository_name)
.map_err(|_| ApiError::not_found(format!("unknown repository {repository_name}")))?;
if repository.visibility != "public" || !valid_download_path(download_path) {
return Err(ApiError::not_found("release download not found"));
}
let path = state
.hosted_root
.join("releases")
.join(&repository_name)
.join(download_path);
let metadata = tokio::fs::metadata(&path)
.await
.map_err(|_| ApiError::not_found("release download not found"))?;
if !metadata.is_file() {
return Err(ApiError::not_found("release download not found"));
}
let file = tokio::fs::File::open(path)
.await
.map_err(ApiError::internal)?;
let mut response = Response::new(Body::from_stream(ReaderStream::new(file)));
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("application/octet-stream"),
);
response.headers_mut().insert(
header::CONTENT_LENGTH,
HeaderValue::from_str(&metadata.len().to_string()).map_err(ApiError::internal)?,
);
let filename = download_path.rsplit('/').next().unwrap_or("download");
response.headers_mut().insert(
header::CONTENT_DISPOSITION,
HeaderValue::from_str(&format!("attachment; filename=\"{filename}\""))
.map_err(ApiError::internal)?,
);
Ok(response)
}
fn valid_download_path(path: &str) -> bool {
!path.is_empty()
&& path.split('/').all(|component| {
!component.is_empty()
&& component != "."
&& component != ".."
&& !component.starts_with('.')
&& component
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_'))
})
}
async fn webhook(
State(state): State<AppState>,
headers: HeaderMap,
Path(repository): Path<String>,
body: Bytes,
) -> Result<Json<Envelope<IdResponse>>, ApiError> {
authorize_webhook(&headers, &state, &body)?;
let payload: Value = serde_json::from_slice(&body).map_err(ApiError::bad)?;
let git_ref = payload
.get("ref")
.and_then(Value::as_str)
.context("webhook has no ref")
.map_err(ApiError::bad)?;
let commit = payload
.get("after")
.or_else(|| payload.get("checkout_sha"))
.and_then(Value::as_str)
.or_else(|| {
payload
.get("head_commit")
.and_then(|value| value.get("id"))
.and_then(Value::as_str)
});
let commit = commit.filter(|value| value.bytes().any(|byte| byte != b'0'));
let queued = state
.runner
.queue(
&repository,
Some(git_ref.strip_prefix("refs/heads/").unwrap_or(git_ref)),
commit,
"webhook",
)
.map_err(ApiError::bad)?;
Ok(ok(IdResponse { id: queued.id }))
}
fn list_filter(value: Option<String>) -> Vec<String> {
value
.into_iter()
.flat_map(|value| {
value
.split(',')
.map(str::trim)
.filter(|item| !item.is_empty())
.map(str::to_owned)
.collect::<Vec<_>>()
})
.collect()
}
fn request_cookie<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
headers
.get(header::COOKIE)?
.to_str()
.ok()?
.split(';')
.map(str::trim)
.find_map(|pair| pair.strip_prefix(name)?.strip_prefix('='))
}
fn valid_bearer(headers: &HeaderMap, state: &AppState) -> bool {
headers
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "))
.is_some_and(|supplied| constant_time_equal(supplied.as_bytes(), state.token.as_bytes()))
}
/// Machine clients present the static API/Git bearer token; humans present the
/// `akurai_session` cookie minted by AkurAI ID. Nothing else authenticates.
fn authorize(headers: &HeaderMap, state: &AppState) -> Result<(), ApiError> {
if let Some(supplied) = headers
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "))
{
return constant_time_equal(supplied.as_bytes(), state.token.as_bytes())
.then_some(())
.ok_or_else(ApiError::unauthorized);
}
browser_session(headers, state)
.map(|_| ())
.ok_or_else(ApiError::unauthorized)
}
/// Resolve the durable session behind the `akurai_session` cookie, if any.
fn browser_session(headers: &HeaderMap, state: &AppState) -> Option<Session> {
oidc::require_session(
&state.database,
request_cookie(headers, oidc::SESSION_COOKIE),
)
}
/// `owner` and `admin` from `/userinfo` are platform superusers.
fn superuser_session(headers: &HeaderMap, state: &AppState) -> bool {
browser_session(headers, state).is_some_and(|session| session.is_superuser())
}
/// Bounce an unauthenticated browser to the login route, preserving where it
/// was heading as a same-origin `return_to`.
fn login_redirect(uri: &Uri) -> Response {
let target = uri
.path_and_query()
.map(|value| value.as_str())
.unwrap_or("/");
match oidc::same_origin_path(Some(target)).filter(|path| path != "/") {
Some(path) => Redirect::to(&format!(
"{}?return_to={}",
oidc::LOGIN_PATH,
oidc::encode(&path)
)),
None => Redirect::to(oidc::LOGIN_PATH),
}
.into_response()
}
fn authorize_mutation(headers: &HeaderMap, state: &AppState) -> Result<(), ApiError> {
authorize(headers, state)?;
if let Some(origin) = headers
.get(header::ORIGIN)
.and_then(|value| value.to_str().ok())
&& origin != state.public_url.as_ref()
{
return Err(ApiError::bad("origin is not allowed"));
}
Ok(())
}
fn authorize_webhook(headers: &HeaderMap, state: &AppState, body: &[u8]) -> Result<(), ApiError> {
if let Some(token) = headers
.get("x-gitlab-token")
.and_then(|value| value.to_str().ok())
&& constant_time_equal(token.as_bytes(), state.webhook_secret.as_bytes())
{
return Ok(());
}
for name in ["x-hub-signature-256", "x-gitea-signature"] {
if let Some(signature) = headers.get(name).and_then(|value| value.to_str().ok()) {
let signature = signature.strip_prefix("sha256=").unwrap_or(signature);
let supplied = decode_hex(signature).map_err(ApiError::bad)?;
let mut mac = HmacSha256::new_from_slice(state.webhook_secret.as_bytes())
.map_err(ApiError::internal)?;
mac.update(body);
if constant_time_equal(&supplied, &mac.finalize().into_bytes()) {
return Ok(());
}
}
}
Err(ApiError::unauthorized())
}
fn decode_hex(value: &str) -> Result<Vec<u8>> {
ensure!(
value.len().is_multiple_of(2) && value.len() <= 128,
"invalid signature"
);
value
.as_bytes()
.chunks_exact(2)
.map(|pair| {
let text = std::str::from_utf8(pair)?;
Ok(u8::from_str_radix(text, 16)?)
})
.collect()
}
fn constant_time_equal(left: &[u8], right: &[u8]) -> bool {
left.len() == right.len() && left.ct_eq(right).into()
}
fn ok<T: Serialize>(data: T) -> Json<Envelope<T>> {
Json(Envelope { ok: true, data })
}
async fn security_headers(request: Request<Body>, next: Next) -> Response {
let mut response = next.run(request).await;
let is_json = response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.contains("json"));
let headers = response.headers_mut();
headers.insert(
header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
);
headers.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
headers.insert(
header::REFERRER_POLICY,
HeaderValue::from_static("no-referrer"),
);
headers.insert(header::CONTENT_SECURITY_POLICY, HeaderValue::from_static("default-src 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self'; connect-src 'self'"));
headers.insert(
HeaderName::from_static("permissions-policy"),
HeaderValue::from_static("camera=(), geolocation=(), microphone=()"),
);
headers.insert(
HeaderName::from_static("cross-origin-opener-policy"),
HeaderValue::from_static("same-origin"),
);
headers.insert(
HeaderName::from_static("cross-origin-resource-policy"),
HeaderValue::from_static("same-origin"),
);
if is_json {
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
}
response
}
async fn serve_bounded(listener: tokio::net::TcpListener, app: Router) -> Result<()> {
use hyper_util::{
rt::{TokioExecutor, TokioIo, TokioTimer},
server::{conn::auto::Builder, graceful::GracefulShutdown},
service::TowerToHyperService,
};
info!(address = %listener.local_addr()?, "AkurAI Build listening");
let connections = Arc::new(tokio::sync::Semaphore::new(MAX_CONNECTIONS));
let graceful = GracefulShutdown::new();
let mut shutdown = std::pin::pin!(shutdown_signal());
loop {
let permit = tokio::select! { () = &mut shutdown => break, permit = connections.clone().acquire_owned() => permit? };
let (socket, _) = tokio::select! { () = &mut shutdown => break, accepted = listener.accept() => accepted? };
let mut builder = Builder::new(TokioExecutor::new());
builder
.http1()
.timer(TokioTimer::new())
.header_read_timeout(REQUEST_TIMEOUT);
let connection = graceful.watch(
builder
.serve_connection_with_upgrades(
TokioIo::new(socket),
TowerToHyperService::new(app.clone()),
)
.into_owned(),
);
tokio::spawn(async move {
if let Err(error) = connection.await {
tracing::debug!(?error, "connection closed");
}
drop(permit);
});
}
drop(listener);
tokio::select! { () = graceful.shutdown() => {}, () = tokio::time::sleep(REQUEST_TIMEOUT) => warn!("graceful shutdown timed out") }
Ok(())
}
async fn shutdown_signal() {
let ctrl_c = async {
if let Err(error) = tokio::signal::ctrl_c().await {
warn!(?error, "failed to install Ctrl-C handler");
}
};
#[cfg(unix)]
let terminate = async {
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
Ok(mut signal) => {
signal.recv().await;
}
Err(error) => warn!(?error, "failed to install terminate handler"),
}
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! { () = ctrl_c => {}, () = terminate => {} }
}
#[cfg(test)]
mod tests {
use super::*;
use tower::Service;
#[test]
fn templates_compile_and_render_without_inline_code() -> Result<()> {
let environment = template_environment()?;
let page = environment.get_template("landing.html")?.render(context! {
version => "test",
repositories => vec![LandingRepository {
name: "AkurAI-Build".into(),
default_branch: "main".into(),
owner: "olibuijr".into(),
run_count: 4,
successful_runs: 3,
}],
builders => vec![PublicBuilder {
name: "olibuijr".into(),
initial: "O".into(),
repository_count: 1,
}]
})?;
let docs = environment
.get_template("docs.html")?
.render(context! { version => "test" })?;
assert!(docs.contains("AkurAI Build documentation"));
assert!(docs.contains("akurai_run_queue"));
assert!(!docs.contains("Bunfork"));
assert!(docs.contains("AkurAI <b>Build</b>"));
assert!(!docs.contains("AkurAI <b>/Build</b>"));
assert!(page.contains("olibuijr /</span> AkurAI-Build"));
assert!(page.contains("Trending repositories"));
assert!(page.contains("Public builders"));
assert!(!page.contains("<script>"));
Ok(())
}
#[tokio::test]
async fn public_errors_negotiate_branded_html_and_json() -> Result<()> {
let environment = template_environment()?;
let mut html_headers = HeaderMap::new();
html_headers.insert(
header::ACCEPT,
HeaderValue::from_static("text/html,application/xhtml+xml"),
);
let html = public_error_response(
&environment,
&html_headers,
ApiError::not_found("missing"),
"Page not found",
"This page could not be found.",
"Return home or open the documentation.",
)
.map_err(|error| anyhow::anyhow!(error.message))?;
assert_eq!(html.status(), StatusCode::NOT_FOUND);
assert_eq!(
html.headers().get(header::CONTENT_TYPE),
Some(&HeaderValue::from_static("text/html; charset=utf-8"))
);
let html_body = to_bytes(html.into_body(), MAX_BODY).await?;
let html_body = String::from_utf8(html_body.to_vec())?;
assert!(html_body.contains("AkurAI <b>Build</b>"));
assert!(html_body.contains("Go home"));
assert!(!html_body.contains("{\"ok\":false"));
let mut json_headers = HeaderMap::new();
json_headers.insert(header::ACCEPT, HeaderValue::from_static("application/json"));
let json = public_error_response(
&environment,
&json_headers,
ApiError::not_found("missing"),
"Page not found",
"This page could not be found.",
"Return home or open the documentation.",
)
.map_err(|error| anyhow::anyhow!(error.message))?;
assert_eq!(json.status(), StatusCode::NOT_FOUND);
assert_eq!(
json.headers().get(header::CONTENT_TYPE),
Some(&HeaderValue::from_static("application/json"))
);
let json_body = to_bytes(json.into_body(), MAX_BODY).await?;
let json_body: Value = serde_json::from_slice(&json_body)?;
assert_eq!(json_body["error"]["code"], "not_found");
assert_eq!(json_body["error"]["message"], "missing");
Ok(())
}
/// Insert a live session into the state's database and return its cookie.
fn authenticated_session_cookie(state: &AppState) -> Result<String> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_secs() as i64;
let session = Session {
id: "1".repeat(64),
sub: "test-user".into(),
email: "test@example.com".into(),
role: "member".into(),
organization_id: None,
tenant_id: None,
groups: Vec::new(),
id_token: String::new(),
created_at: now,
expires_at: now + oidc::SESSION_TTL,
};
state.database.create_session(&session)?;
Ok(format!("{}={}", oidc::SESSION_COOKIE, session.id))
}
#[tokio::test]
async fn not_found_handlers_negotiate_branded_html_and_json_end_to_end() -> Result<()> {
let state = test_state("api-token-123", "webhook-secret");
let session = authenticated_session_cookie(&state)?;
let cases = [
("/missing", "This page could not be found.", None),
(
"/repos/missing",
"This repository could not be found.",
None,
),
(
"/repos/missing/tree/main",
"Repository content could not be found.",
None,
),
(
"/repos/missing/tree/main/src/missing.rs",
"Repository content could not be found.",
None,
),
(
"/app/repositories/missing/tree/main",
"Repository content could not be found.",
Some(session.as_str()),
),
(
"/app/repositories/missing/tree/main/src/missing.rs",
"Repository content could not be found.",
Some(session.as_str()),
),
];
for (uri, heading, cookie) in cases {
let mut request = Request::builder()
.uri(uri)
.header(header::ACCEPT, "text/html,application/xhtml+xml");
if let Some(cookie) = cookie {
request = request.header(header::COOKIE, cookie);
}
let mut app = test_router(state.clone());
let response = app.call(request.body(Body::empty())?).await?;
assert_eq!(response.status(), StatusCode::NOT_FOUND, "{uri}");
assert_eq!(
response.headers().get(header::CONTENT_TYPE),
Some(&HeaderValue::from_static("text/html; charset=utf-8")),
"{uri}"
);
let body = to_bytes(response.into_body(), MAX_BODY).await?;
let body = String::from_utf8(body.to_vec())?;
assert!(body.contains("AkurAI <b>Build</b>"), "{uri}");
assert!(body.contains(heading), "{uri}");
assert!(!body.contains("{\"ok\":false"), "{uri}");
let mut request = Request::builder()
.uri(uri)
.header(header::ACCEPT, "application/json");
if let Some(cookie) = cookie {
request = request.header(header::COOKIE, cookie);
}
let mut app = test_router(state.clone());
let response = app.call(request.body(Body::empty())?).await?;
assert_eq!(response.status(), StatusCode::NOT_FOUND, "{uri}");
assert_eq!(
response.headers().get(header::CONTENT_TYPE),
Some(&HeaderValue::from_static("application/json")),
"{uri}"
);
let body = json_body(response).await;
assert_eq!(body["ok"], false, "{uri}");
assert_eq!(body["error"]["code"], "not_found", "{uri}");
}
Ok(())
}
#[test]
fn directory_sizes_render_as_not_applicable() -> Result<()> {
let environment = template_environment()?;
let entries = || {
vec![
CodeEntryView {
name: "src".into(),
kind: "tree".into(),
size: 0,
commit_message: None,
href: "/repos/AkurAI-Build/tree/main/src".into(),
},
CodeEntryView {
name: "Cargo.toml".into(),
kind: "blob".into(),
size: 42,
commit_message: None,
href: "/repos/AkurAI-Build/tree/main/Cargo.toml".into(),
},
]
};
let page = |public: bool| CodePage {
repository: RepositoryView {
id: 1,
name: "AkurAI-Build".into(),
url: "https://example.com/AkurAI-Build.git".into(),
default_branch: "main".into(),
visibility: "public".into(),
created_at: 0,
},
reference: "main".into(),
path: String::new(),
entries: entries(),
breadcrumbs: vec![CodeBreadcrumb {
label: "AkurAI-Build".into(),
href: "/repos/AkurAI-Build/tree/main".into(),
}],
source: None,
is_file: false,
parent_href: "/repos/AkurAI-Build".into(),
latest_commit: None,
commit_href: None,
public,
description: None,
counts: None,
readme_html: None,
readme_name: None,
recent_commits: Vec::new(),
contributors: Vec::new(),
languages: Vec::new(),
};
// Public branch: GitHub-style file rows; trees show a dash, blobs a size.
let rendered = environment
.get_template("repository-code.html")?
.render(context! { page => page(true) })?;
assert!(rendered.contains("<span class=\"pt-repo-file-age\">—</span>"));
assert!(rendered.contains("<span class=\"pt-repo-file-age\">42</span>"));
assert!(!rendered.contains("<span class=\"pt-repo-file-age\">0</span>"));
// App fallback branch keeps the attached-table markup.
let rendered = environment
.get_template("repository-code.html")?
.render(context! { page => page(false) })?;
assert!(rendered.contains(
"<td data-label=\"Size\"><span aria-label=\"Not applicable\">—</span></td>"
));
assert!(rendered.contains("<td data-label=\"Size\">42</td>"));
assert!(!rendered.contains("<td data-label=\"Size\">0</td>"));
Ok(())
}
#[test]
fn code_path_segments_encode_special_characters_and_separators() {
assert_eq!(encode_code_segment("!*'()"), "%21%2A%27%28%29");
assert_eq!(
code_path_href(
"/app/repositories/repo/tree/main",
"bang!/star*/quote'/left(/right)"
),
"/app/repositories/repo/tree/main/bang%21/star%2A/quote%27/left%28/right%29"
);
assert_eq!(encode_code_segment("../secret\\file"), "..%2Fsecret%5Cfile");
}
#[test]
fn public_repository_owner_uses_remote_namespace() {
assert_eq!(
repository_owner("https://github.com/olibuijr/AkurAI-Build.git"),
"olibuijr"
);
assert_eq!(
repository_owner("git@gitlab.example.com:platform/service.git"),
"platform"
);
assert_eq!(
repository_owner("https://akurai-build.example.com/git/service.git"),
"AkurAI Build"
);
}
#[test]
fn hosted_repository_url_uses_public_clone_route() {
let root = FsPath::new("/home/olibuijr/.local/share/akurai-build");
assert_eq!(
repository_client_url(
"AkurAI-Build",
"/home/olibuijr/.local/share/akurai-build/hosted/AkurAI-Build.git",
root,
"https://akurai-build.olibuijr.com",
),
"https://akurai-build.olibuijr.com/git/AkurAI-Build.git"
);
assert_eq!(
repository_client_url(
"AkurAI-Build",
"https://github.com/olibuijr/AkurAI-Build.git",
root,
"https://akurai-build.olibuijr.com",
),
"https://github.com/olibuijr/AkurAI-Build.git"
);
}
#[test]
fn authentication_helpers_fail_closed() {
assert!(constant_time_equal(b"same", b"same"));
assert!(!constant_time_equal(b"same", b"different"));
assert!(decode_hex("not-hex").is_err());
}
#[test]
fn durable_session_cookie_authorizes_without_a_bearer_token() -> Result<()> {
let state = test_state("api-token-123", "webhook-secret");
let cookie = authenticated_session_cookie(&state)?;
let mut headers = HeaderMap::new();
headers.insert(header::COOKIE, HeaderValue::from_str(&cookie)?);
assert!(authorize(&headers, &state).is_ok());
assert!(browser_session(&headers, &state).is_some());
// A member session is not a platform superuser.
assert!(!superuser_session(&headers, &state));
// An unknown cookie value is refused; nothing is derivable client-side.
let mut forged = HeaderMap::new();
forged.insert(
header::COOKIE,
HeaderValue::from_str(&format!("{}={}", oidc::SESSION_COOKIE, "9".repeat(64)))?,
);
assert!(authorize(&forged, &state).is_err());
Ok(())
}
#[test]
fn expired_session_cookies_are_rejected() -> Result<()> {
let state = test_state("api-token-123", "webhook-secret");
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_secs() as i64;
let session = Session {
id: "2".repeat(64),
sub: "test-user".into(),
email: "test@example.com".into(),
role: "owner".into(),
organization_id: None,
tenant_id: None,
groups: Vec::new(),
id_token: String::new(),
created_at: now - oidc::SESSION_TTL - 60,
expires_at: now - 1,
};
state.database.create_session(&session)?;
let mut headers = HeaderMap::new();
headers.insert(
header::COOKIE,
HeaderValue::from_str(&format!("{}={}", oidc::SESSION_COOKIE, session.id))?,
);
assert!(browser_session(&headers, &state).is_none());
assert!(!superuser_session(&headers, &state));
assert_eq!(
authorize(&headers, &state)
.expect_err("expired session must not authorize")
.status,
StatusCode::UNAUTHORIZED
);
Ok(())
}
#[test]
fn git_smart_http_paths_and_cgi_responses_are_bounded() -> Result<()> {
validate_git_path("tasks.git/info/refs")?;
assert!(validate_git_path("../tasks.git/info/refs").is_err());
assert!(validate_git_path("tasks/info/refs").is_err());
let response = git_cgi_response(
b"Status: 200 OK\r\nContent-Type: application/x-git-upload-pack-result\r\n\r\npack",
)?;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers().get(header::CONTENT_TYPE),
Some(&HeaderValue::from_static(
"application/x-git-upload-pack-result"
))
);
Ok(())
}
// ── constant_time_equal boundaries ──
#[test]
fn constant_time_equal_rejects_empty_input() {
assert!(!constant_time_equal(b"", b"token"));
assert!(!constant_time_equal(b"token", b""));
}
#[test]
fn release_download_path_rejects_traversal_and_hidden_files() {
assert!(valid_download_path("bifrost-0.2.2-x86_64.iso"));
assert!(valid_download_path(
"alpm/x86_64/bifrost-system-0.2.2-1-any.pkg.tar.zst"
));
assert!(!valid_download_path("../secret"));
assert!(!valid_download_path("nested/../file.iso"));
assert!(!valid_download_path("nested/.hidden"));
assert!(!valid_download_path(""));
}
#[test]
fn constant_time_equal_rejects_wrong_token() {
assert!(!constant_time_equal(b"correct", b"wrong-token"));
assert!(!constant_time_equal(b"xyz", b"abc"));
}
#[test]
fn constant_time_equal_rejects_too_short_token() {
assert!(!constant_time_equal(b"secret", b"sec"));
}
#[test]
fn constant_time_equal_rejects_correct_prefix_but_longer_token() {
// "secret" is a prefix of "secret-extended" — must be rejected
assert!(!constant_time_equal(b"secret", b"secret-extended"));
assert!(!constant_time_equal(b"secret-extended", b"secret"));
}
#[test]
fn constant_time_equal_accepts_exact_match_only() {
assert!(constant_time_equal(
b"exact-token-value",
b"exact-token-value"
));
assert!(constant_time_equal(b"a", b"a"));
}
// ── decode_hex boundaries ──
#[test]
fn decode_hex_rejects_odd_length() {
assert!(decode_hex("abc").is_err());
}
#[test]
fn decode_hex_rejects_non_hex_characters() {
assert!(decode_hex("zz").is_err());
assert!(decode_hex("gg12").is_err());
}
#[test]
fn decode_hex_accepts_valid_hex() -> Result<()> {
assert_eq!(decode_hex("48656c6c6f")?, b"Hello");
assert_eq!(decode_hex("00ff")?, vec![0x00, 0xff]);
assert_eq!(decode_hex("")?, Vec::<u8>::new());
Ok(())
}
// ── authorize header parsing ──
fn test_state(token: &str, webhook_secret: &str) -> AppState {
const KEY: &str = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
let temp = tempfile::tempdir().expect("tempdir");
let database = Database::memory(KEY).expect("database");
let runner = Runner::new(database.clone(), temp.path().to_owned(), false).expect("runner");
let tree = RepoTree::new(temp.path()).expect("tree");
AppState {
database,
runner,
tree,
blocking_git: BlockingGit::new(MAX_BLOCKING_GIT),
hosted_root: temp.path().to_owned(),
allow_native: false,
token: Arc::from(token),
webhook_secret: Arc::from(webhook_secret),
public_url: Arc::from("https://build.example"),
templates: Arc::new(template_environment().expect("templates")),
}
}
#[test]
fn authorize_rejects_missing_authorization_header() {
let state = test_state("api-token-123", "wh-secret");
let headers = HeaderMap::new();
let result = authorize(&headers, &state);
assert!(result.is_err());
assert_eq!(
result.expect_err("should be unauthorized").status,
StatusCode::UNAUTHORIZED
);
}
#[test]
fn authorize_rejects_malformed_scheme_not_bearer() {
let state = test_state("api-token-123", "wh-secret");
let mut headers = HeaderMap::new();
headers.insert(
header::AUTHORIZATION,
HeaderValue::from_static("Basic some-value"),
);
let result = authorize(&headers, &state);
assert!(result.is_err());
}
#[test]
fn authorize_rejects_bearer_with_wrong_token() {
let state = test_state("api-token-123", "wh-secret");
let mut headers = HeaderMap::new();
headers.insert(
header::AUTHORIZATION,
HeaderValue::from_static("Bearer wrong-value"),
);
let result = authorize(&headers, &state);
assert!(result.is_err());
}
#[test]
fn authorize_accepts_correct_bearer_token() {
let state = test_state("api-token-123", "wh-secret");
let mut headers = HeaderMap::new();
headers.insert(
header::AUTHORIZATION,
HeaderValue::from_static("Bearer api-token-123"),
);
assert!(authorize(&headers, &state).is_ok());
}
#[test]
fn authorize_webhook_rejects_missing_header() {
let state = test_state("api-token", "webhook-secret");
let headers = HeaderMap::new();
let result = authorize_webhook(&headers, &state, b"{}");
assert!(result.is_err());
assert_eq!(
result.expect_err("should be unauthorized").status,
StatusCode::UNAUTHORIZED
);
}
#[test]
fn authorize_webhook_rejects_wrong_token() {
let state = test_state("api-token", "webhook-secret");
let mut headers = HeaderMap::new();
headers.insert("x-gitlab-token", HeaderValue::from_static("wrong-token"));
let result = authorize_webhook(&headers, &state, b"{}");
assert!(result.is_err());
}
#[test]
fn authorize_webhook_accepts_correct_x_gitlab_token() {
let state = test_state("api-token", "webhook-secret");
let mut headers = HeaderMap::new();
headers.insert("x-gitlab-token", HeaderValue::from_static("webhook-secret"));
assert!(authorize_webhook(&headers, &state, b"{}").is_ok());
}
// ── ApiError envelope shape ──
#[tokio::test]
async fn unauthorized_error_response_has_correct_envelope_shape() {
let error = ApiError::unauthorized();
let response = error.into_response();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let bytes = axum::body::to_bytes(response.into_body(), 4096)
.await
.expect("read body");
let body = String::from_utf8(bytes.to_vec()).expect("utf-8");
let parsed: serde_json::Value = serde_json::from_str(&body).expect("json");
assert_eq!(parsed["ok"], false);
assert_eq!(parsed["error"]["code"], "unauthenticated");
assert_eq!(parsed["error"]["login"], "/auth/login");
assert!(
parsed["error"]["message"]
.as_str()
.is_some_and(|m| !m.is_empty())
);
}
#[test]
fn readme_rewrites_relative_urls_by_kind_and_leaves_others() {
let urls = ReadmeUrls {
dir: "docs".into(),
raw_prefix: "/repos/Demo/raw/main".into(),
tree_prefix: "/repos/Demo/tree/main".into(),
};
let source = "\
\n\n\
<img src=\"../logo.svg\" alt=\"logo\">\n\n\
[guide](guide.md) [site](https://example.com/x) [top](#intro)\n\n\
 \n";
let html = render_markdown(source, Some(&urls));
// Relative image src -> raw endpoint, resolved against the README dir.
assert!(html.contains("src=\"/repos/Demo/raw/main/docs/assets/shot.webp\""));
// `..` climbs out of docs/ to the repo root.
assert!(html.contains("src=\"/repos/Demo/raw/main/logo.svg\""));
// Relative anchor href -> code browser, not raw.
assert!(html.contains("href=\"/repos/Demo/tree/main/docs/guide.md\""));
// Absolute and anchor URLs are left untouched.
assert!(html.contains("href=\"https://example.com/x\""));
assert!(html.contains("href=\"#intro\""));
assert!(html.contains("src=\"/already/abs.png\""));
// A reference escaping the repo root is dropped, not emitted.
assert!(!html.contains("etc/passwd"));
}
// ── In-process app / router tests ──
fn test_router(state: AppState) -> Router {
Router::new()
.route("/repos/{repository}", get(public_repository))
.route(
"/repos/{repository}/tree/{reference}",
get(public_code_root),
)
.route(
"/repos/{repository}/tree/{reference}/{*path}",
get(public_code_path),
)
.route(
"/repos/{repository}/raw/{reference}/{*path}",
get(public_raw_content),
)
.route(
"/app/repositories/{repository}/raw/{reference}/{*path}",
get(app_raw_content),
)
.route(
"/app/repositories/{repository}/tree/{reference}",
get(app_code_root),
)
.route(
"/app/repositories/{repository}/tree/{reference}/{*path}",
get(app_code_path),
)
.route("/api/health", get(health))
.route("/api/state", get(api_state))
.route("/api/runs/{id}/promote/{environment}", post(promote))
.route("/api/hooks/{repository}", post(webhook))
.route("/api/repos", post(create_repository))
.fallback(public_not_found)
.with_state(state)
.layer(DefaultBodyLimit::max(MAX_BODY))
.layer(middleware::from_fn(security_headers))
}
async fn json_body(response: Response) -> serde_json::Value {
let bytes = axum::body::to_bytes(response.into_body(), 64 * 1024)
.await
.expect("read body")
.to_vec();
serde_json::from_slice(&bytes).expect("json")
}
#[tokio::test]
async fn app_unauthenticated_api_returns_401() {
let mut app = test_router(test_state("api-token-123", "webhook-secret"));
let request = Request::builder()
.uri("/api/state")
.body(Body::empty())
.expect("build request");
let response = app.call(request).await.expect("call");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let body = json_body(response).await;
assert_eq!(body["ok"], false);
assert_eq!(body["error"]["code"], "unauthenticated");
assert_eq!(body["error"]["login"], "/auth/login");
}
#[tokio::test]
async fn app_health_is_public_200() {
let mut app = test_router(test_state("api-token-123", "webhook-secret"));
let request = Request::builder()
.uri("/api/health")
.body(Body::empty())
.expect("build request");
let response = app.call(request).await.expect("call");
assert_eq!(response.status(), StatusCode::OK);
let body = json_body(response).await;
assert_eq!(body["ok"], true);
}
#[tokio::test]
async fn app_security_headers_are_present() {
let mut app = test_router(test_state("api-token-123", "webhook-secret"));
let request = Request::builder()
.uri("/api/health")
.body(Body::empty())
.expect("build request");
let response = app.call(request).await.expect("call");
let headers = response.headers();
assert_eq!(
headers
.get(header::X_CONTENT_TYPE_OPTIONS)
.and_then(|v| v.to_str().ok()),
Some("nosniff")
);
assert_eq!(
headers
.get(header::X_FRAME_OPTIONS)
.and_then(|v| v.to_str().ok()),
Some("DENY")
);
let csp = headers
.get(header::CONTENT_SECURITY_POLICY)
.and_then(|v| v.to_str().ok())
.expect("CSP header");
assert!(
csp.contains("script-src 'self'"),
"CSP must include script-src 'self': {csp}"
);
}
#[tokio::test]
async fn app_webhook_rejects_bad_token() {
let mut app = test_router(test_state("api-token-123", "webhook-secret"));
let request = Request::builder()
.uri("/api/hooks/my-repo")
.method("POST")
.header("x-gitlab-token", "wrong-value")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"ref":"refs/heads/main","after":"abc123"}"#))
.expect("build request");
let response = app.call(request).await.expect("call");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn app_promote_requires_authorization() {
let mut app = test_router(test_state("api-token-123", "webhook-secret"));
let request = Request::builder()
.uri("/api/runs/1/promote/staging")
.method("POST")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::empty())
.expect("build request");
let response = app.call(request).await.expect("call");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn app_body_limit_rejects_oversized_request() {
let state = test_state("api-token-123", "webhook-secret");
let mut app = Router::new()
.route("/api/repos", post(create_repository))
.with_state(state)
.layer(DefaultBodyLimit::max(8))
.layer(middleware::from_fn(security_headers));
let oversized = vec![b'a'; 64];
let request = Request::builder()
.uri("/api/repos")
.method("POST")
.header(header::CONTENT_TYPE, "application/json")
.header(
header::AUTHORIZATION,
HeaderValue::from_static("Bearer api-token-123"),
)
.body(Body::from(oversized))
.expect("build request");
let response = app.call(request).await.expect("call");
assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
}
#[tokio::test]
async fn app_unknown_route_returns_404() {
let mut app = test_router(test_state("api-token-123", "webhook-secret"));
let request = Request::builder()
.uri("/nonexistent")
.body(Body::empty())
.expect("build request");
let response = app.call(request).await.expect("call");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn app_wrong_method_returns_405() {
let mut app = test_router(test_state("api-token-123", "webhook-secret"));
// GET /api/repos is not defined, only POST
let request = Request::builder()
.uri("/api/repos")
.body(Body::empty())
.expect("build request");
let response = app.call(request).await.expect("call");
assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
}
#[tokio::test]
async fn app_authorized_api_state_accepts_valid_token() {
let mut app = test_router(test_state("api-token-123", "webhook-secret"));
let request = Request::builder()
.uri("/api/state")
.header(
header::AUTHORIZATION,
HeaderValue::from_static("Bearer api-token-123"),
)
.body(Body::empty())
.expect("build request");
let response = app.call(request).await.expect("call");
// Should be OK even though DB queries might fail on empty tables;
// the authorize gate passes so we either get 200 or a DB error,
// but never 401.
assert_ne!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn blocking_git_limits_concurrency_and_preserves_api_errors() {
use std::sync::atomic::{AtomicUsize, Ordering};
let gate = BlockingGit::new(1);
let active = Arc::new(AtomicUsize::new(0));
let maximum = Arc::new(AtomicUsize::new(0));
let operation = |active: Arc<AtomicUsize>, maximum: Arc<AtomicUsize>| {
move || {
let current = active.fetch_add(1, Ordering::SeqCst) + 1;
maximum.fetch_max(current, Ordering::SeqCst);
std::thread::sleep(Duration::from_millis(25));
active.fetch_sub(1, Ordering::SeqCst);
Ok(())
}
};
let first = gate.run(operation(active.clone(), maximum.clone()));
let second = gate.run(operation(active, maximum.clone()));
let (first, second) = tokio::join!(first, second);
assert!(first.is_ok());
assert!(second.is_ok());
assert_eq!(maximum.load(Ordering::SeqCst), 1);
let error = gate
.run(|| Err::<(), _>(ApiError::bad("specific git failure")))
.await
.expect_err("operation error");
assert_eq!(error.status, StatusCode::BAD_REQUEST);
assert_eq!(error.message, "specific git failure");
}
}