gitrub 1.1.13

A local git server — push, pull, clone over HTTP and SSH with LFS, hooks, and more
Documentation
use bytes::Bytes;
use http_body_util::{BodyExt, Full};
use hyper::body::Incoming;
use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper::{Request, Response, StatusCode};
use hyper_util::rt::TokioIo;
use std::path::Path;
use std::sync::Arc;
use tokio::net::TcpListener;

use crate::auth::check_auth;
use crate::git;
use crate::lfs;
use crate::Config;

/// Start the HTTP server.
pub async fn serve(listener: TcpListener, config: Arc<Config>) {
    loop {
        let (stream, _) = listener.accept().await.unwrap();
        let config = config.clone();
        tokio::spawn(async move {
            let svc = service_fn(|req| {
                let config = config.clone();
                async move { handle_request(req, &config).await }
            });
            http1::Builder::new()
                .serve_connection(TokioIo::new(stream), svc)
                .await
                .ok();
        });
    }
}

pub async fn handle_request(
    req: Request<Incoming>,
    config: &Config,
) -> Result<Response<Full<Bytes>>, hyper::Error> {
    if let Err(resp) = check_auth(&req, config) {
        return Ok(resp);
    }

    match route(req, config).await {
        Ok(r) => Ok(r),
        Err(msg) => Ok(Response::builder()
            .status(StatusCode::NOT_FOUND)
            .body(Full::new(Bytes::from(msg)))
            .unwrap()),
    }
}

/// Parse a request path into (repo_path, endpoint).
pub fn parse_path(path: &str) -> Option<(&str, &str)> {
    let path = path.trim_start_matches('/');
    for endpoint in ["info/refs", "git-upload-pack", "git-receive-pack"] {
        if let Some(repo) = path.strip_suffix(&format!("/{}", endpoint)) {
            let repo = repo.strip_suffix(".git").unwrap_or(repo);
            if !repo.is_empty() {
                return Some((repo, endpoint));
            }
        }
    }
    None
}

/// Parse archive path: /<repo>.git/archive/<ref>.<format>
fn parse_archive_path(path: &str) -> Option<(&str, &str, &str)> {
    let path = path.trim_start_matches('/');

    // Find .git/archive/ or /archive/
    let (repo, rest) = if let Some(idx) = path.find(".git/archive/") {
        (&path[..idx], &path[idx + ".git/archive/".len()..])
    } else {
        return None;
    };

    if repo.is_empty() || rest.is_empty() {
        return None;
    }

    // rest is like "main.tar.gz" or "v1.0.zip"
    let (tree, format) = if let Some(base) = rest.strip_suffix(".tar.gz") {
        (base, "tar.gz")
    } else if let Some(base) = rest.strip_suffix(".zip") {
        (base, "zip")
    } else if let Some(base) = rest.strip_suffix(".tar") {
        (base, "tar")
    } else {
        return None;
    };

    Some((repo, tree, format))
}

async fn route(
    req: Request<Incoming>,
    config: &Config,
) -> Result<Response<Full<Bytes>>, String> {
    let path = req.uri().path().to_string();

    // Extract Git-Protocol header for protocol v2
    let git_protocol: Option<String> = req
        .headers()
        .get("Git-Protocol")
        .and_then(|v| v.to_str().ok())
        .map(String::from);

    // LFS endpoints (checked before consuming req body)
    let base_url = format!("http://{}:{}", config.host, config.port);
    if path.contains("/info/lfs/") {
        if let Some(result) = lfs::handle_lfs(req, &config.root, &base_url).await {
            return result;
        }
        // If handle_lfs returned None, it wasn't an LFS path after all.
        // But req was consumed. This shouldn't happen since we check the marker.
        return Err("Invalid LFS request".into());
    }

    // Archive endpoint: /<repo>.git/archive/<ref>.<format>
    if path.contains("/archive/") {
        if let Some((repo_name, tree, format)) = parse_archive_path(&path) {
            if repo_name.contains("..") {
                return Err("Invalid repo path".into());
            }
            let repo_path = config.root.join(repo_name);
            if !git::is_git_repo(&repo_path) {
                return Err(format!("Repository '{}' not found", repo_name));
            }

            let data = git::archive(&repo_path, tree, format).await?;

            let content_type = match format {
                "tar.gz" => "application/gzip",
                "zip" => "application/zip",
                "tar" => "application/x-tar",
                _ => "application/octet-stream",
            };

            return Response::builder()
                .status(StatusCode::OK)
                .header("Content-Type", content_type)
                .header(
                    "Content-Disposition",
                    format!("attachment; filename=\"{}-{}.{}\"", repo_name, tree, format),
                )
                .body(Full::new(Bytes::from(data)))
                .map_err(|e| e.to_string());
        }
    }

    // Git smart protocol
    let (repo_name, endpoint) =
        parse_path(&path).ok_or("Usage: git clone http://host:port/<path>.git")?;

    if repo_name.contains("..") {
        return Err("Invalid repo path".into());
    }

    // Reject paths that would access local .git directories
    if repo_name.split('/').any(|seg| seg == ".git") || repo_name == ".git" {
        return Err("Invalid repo path".into());
    }

    let repo_path = config.root.join(repo_name);

    // Auto-init repo on first access
    if !git::is_git_repo(&repo_path) {
        if let Some(parent) = repo_path.parent() {
            tokio::fs::create_dir_all(parent).await.ok();
        }
        git::init_repo(&repo_path, config).await?;
        println!("+ Created repo: {}", repo_name);
    }

    match endpoint {
        "info/refs" => {
            let svc = req
                .uri()
                .query()
                .and_then(|q| q.strip_prefix("service="))
                .ok_or("Missing service param")?;

            let (content_type, body) =
                git::info_refs(&repo_path, svc, git_protocol.as_deref()).await?;

            Response::builder()
                .header("Content-Type", content_type)
                .header("Cache-Control", "no-cache")
                .body(Full::new(Bytes::from(body)))
                .map_err(|e| e.to_string())
        }
        "git-upload-pack" | "git-receive-pack" => {
            let body = req.collect().await.map_err(|e| e.to_string())?.to_bytes();
            let (content_type, data) =
                git::run_pack(&repo_path, endpoint, &body, git_protocol.as_deref()).await?;

            // Record activity
            let activity_kind = if endpoint == "git-receive-pack" {
                "push"
            } else {
                "pull"
            };
            let activity_path = repo_path.clone();
            tokio::spawn(async move {
                git::record_activity(&activity_path, activity_kind).await;
            });

            Response::builder()
                .header("Content-Type", content_type)
                .header("Cache-Control", "no-cache")
                .body(Full::new(Bytes::from(data)))
                .map_err(|e| e.to_string())
        }
        _ => Err(format!("Unknown endpoint: {}", endpoint)),
    }
}

/// List repos found under root.
pub async fn list_repos(root: &Path, host: &str, port: u16, recursive: bool) {
    let mut repos = Vec::new();
    find_repos_async(root, root, recursive, &mut repos).await;
    repos.sort();
    if repos.is_empty() {
        println!("No repos yet. Push to create one:");
        println!("  git remote add origin http://{}:{}/name.git", host, port);
        println!("  git push -u origin main");
    } else {
        println!("Repos:");
        for r in &repos {
            println!("  http://{}:{}/{}.git", host, port, r);
        }
    }
    println!();
}

/// Discover bare git repos under `dir`. When `recursive` is false, only
/// top-level entries in `base` are inspected.
async fn find_repos_async(
    base: &Path,
    dir: &Path,
    recursive: bool,
    out: &mut Vec<String>,
) {
    let Ok(mut entries) = tokio::fs::read_dir(dir).await else {
        return;
    };
    while let Ok(Some(entry)) = entries.next_entry().await {
        let path = entry.path();
        // Skip dotfiles/dirs (e.g. .git, .host_key)
        if path
            .file_name()
            .map_or(false, |n| n.to_string_lossy().starts_with('.'))
        {
            continue;
        }
        if !path.is_dir() {
            continue;
        }
        // Check if this directory is a git repo (bare or non-bare)
        if git::is_git_repo(&path) {
            if let Ok(rel) = path.strip_prefix(base) {
                out.push(rel.to_string_lossy().into_owned());
            }
        } else if recursive {
            Box::pin(find_repos_async(base, &path, true, out)).await;
        }
    }
}