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::{Method, Request, Response, StatusCode};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};

// ---------------------------------------------------------------------------
// LFS types
// ---------------------------------------------------------------------------

#[derive(Deserialize)]
pub struct BatchRequest {
    pub operation: String,
    pub objects: Vec<LfsObject>,
}

#[derive(Serialize, Deserialize, Clone)]
pub struct LfsObject {
    pub oid: String,
    pub size: u64,
}

#[derive(Serialize)]
struct BatchResponse {
    transfer: String,
    objects: Vec<ObjectResponse>,
}

#[derive(Serialize)]
struct ObjectResponse {
    oid: String,
    size: u64,
    #[serde(skip_serializing_if = "Option::is_none")]
    actions: Option<Actions>,
    #[serde(skip_serializing_if = "Option::is_none")]
    error: Option<LfsError>,
}

#[derive(Serialize)]
struct Actions {
    #[serde(skip_serializing_if = "Option::is_none")]
    upload: Option<Action>,
    #[serde(skip_serializing_if = "Option::is_none")]
    download: Option<Action>,
}

#[derive(Serialize)]
struct Action {
    href: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    header: Option<std::collections::HashMap<String, String>>,
}

#[derive(Serialize)]
struct LfsError {
    code: u16,
    message: String,
}

const LFS_CONTENT_TYPE: &str = "application/vnd.git-lfs+json";

// ---------------------------------------------------------------------------
// Storage helpers
// ---------------------------------------------------------------------------

fn object_path(root: &Path, repo_name: &str, oid: &str) -> PathBuf {
    // Store as <root>/<repo>/lfs/objects/<oid[0:2]>/<oid[2:4]>/<oid>
    root.join(repo_name)
        .join("lfs")
        .join("objects")
        .join(&oid[..2])
        .join(&oid[2..4])
        .join(oid)
}

// ---------------------------------------------------------------------------
// Router
// ---------------------------------------------------------------------------

/// Handle an LFS request. Returns None if the path isn't an LFS path.
pub async fn handle_lfs(
    req: Request<Incoming>,
    root: &Path,
    base_url: &str,
) -> Option<Result<Response<Full<Bytes>>, String>> {
    let path = req.uri().path().to_string();
    let trimmed = path.trim_start_matches('/');

    // Find ".git/info/lfs/" or "/info/lfs/" in the path
    let lfs_marker = if let Some(idx) = trimmed.find(".git/info/lfs/") {
        let repo = &trimmed[..idx];
        let rest = &trimmed[idx + ".git/info/lfs/".len()..];
        Some((repo, rest))
    } else if let Some(idx) = trimmed.find("/info/lfs/") {
        let repo = &trimmed[..idx];
        let rest = &trimmed[idx + "/info/lfs/".len()..];
        Some((repo, rest))
    } else {
        None
    };

    let (repo_name, rest) = lfs_marker?;

    let result = match (req.method().clone(), rest) {
        (Method::POST, "objects/batch") => {
            handle_batch(req, root, repo_name, base_url).await
        }
        _ if rest.starts_with("objects/") => {
            let oid = &rest["objects/".len()..];
            if oid.is_empty() || oid.contains('/') {
                return Some(Err("Invalid OID".into()));
            }
            match req.method() {
                &Method::PUT => handle_upload(req, root, repo_name, oid).await,
                &Method::GET => handle_download(root, repo_name, oid).await,
                _ => return Some(Err("Method not allowed".into())),
            }
        }
        _ => return None,
    };

    Some(result)
}

// ---------------------------------------------------------------------------
// Batch API
// ---------------------------------------------------------------------------

async fn handle_batch(
    req: Request<Incoming>,
    root: &Path,
    repo_name: &str,
    base_url: &str,
) -> Result<Response<Full<Bytes>>, String> {
    let body = req
        .collect()
        .await
        .map_err(|e| e.to_string())?
        .to_bytes();

    let batch: BatchRequest =
        serde_json::from_slice(&body).map_err(|e| format!("Invalid LFS request: {}", e))?;

    let objects: Vec<ObjectResponse> = batch
        .objects
        .iter()
        .map(|obj| {
            let path = object_path(root, repo_name, &obj.oid);
            let exists = path.exists();

            match batch.operation.as_str() {
                "upload" => {
                    if exists {
                        // Already have it
                        ObjectResponse {
                            oid: obj.oid.clone(),
                            size: obj.size,
                            actions: None,
                            error: None,
                        }
                    } else {
                        ObjectResponse {
                            oid: obj.oid.clone(),
                            size: obj.size,
                            actions: Some(Actions {
                                upload: Some(Action {
                                    href: format!(
                                        "{}/{}.git/info/lfs/objects/{}",
                                        base_url, repo_name, obj.oid
                                    ),
                                    header: None,
                                }),
                                download: None,
                            }),
                            error: None,
                        }
                    }
                }
                "download" => {
                    if exists {
                        ObjectResponse {
                            oid: obj.oid.clone(),
                            size: obj.size,
                            actions: Some(Actions {
                                upload: None,
                                download: Some(Action {
                                    href: format!(
                                        "{}/{}.git/info/lfs/objects/{}",
                                        base_url, repo_name, obj.oid
                                    ),
                                    header: None,
                                }),
                            }),
                            error: None,
                        }
                    } else {
                        ObjectResponse {
                            oid: obj.oid.clone(),
                            size: obj.size,
                            actions: None,
                            error: Some(LfsError {
                                code: 404,
                                message: "Object not found".into(),
                            }),
                        }
                    }
                }
                _ => ObjectResponse {
                    oid: obj.oid.clone(),
                    size: obj.size,
                    actions: None,
                    error: Some(LfsError {
                        code: 400,
                        message: format!("Unknown operation: {}", batch.operation),
                    }),
                },
            }
        })
        .collect();

    let resp = BatchResponse {
        transfer: "basic".into(),
        objects,
    };

    let json = serde_json::to_vec(&resp).map_err(|e| e.to_string())?;

    Response::builder()
        .status(StatusCode::OK)
        .header("Content-Type", LFS_CONTENT_TYPE)
        .body(Full::new(Bytes::from(json)))
        .map_err(|e| e.to_string())
}

// ---------------------------------------------------------------------------
// Upload
// ---------------------------------------------------------------------------

async fn handle_upload(
    req: Request<Incoming>,
    root: &Path,
    repo_name: &str,
    oid: &str,
) -> Result<Response<Full<Bytes>>, String> {
    let body = req
        .collect()
        .await
        .map_err(|e| e.to_string())?
        .to_bytes();

    // Verify OID matches SHA-256 of content
    let mut hasher = Sha256::new();
    hasher.update(&body);
    let computed = hex::encode(hasher.finalize());

    if computed != oid {
        return Response::builder()
            .status(StatusCode::BAD_REQUEST)
            .body(Full::new(Bytes::from("OID mismatch")))
            .map_err(|e| e.to_string());
    }

    let path = object_path(root, repo_name, oid);
    if let Some(parent) = path.parent() {
        tokio::fs::create_dir_all(parent)
            .await
            .map_err(|e| e.to_string())?;
    }
    tokio::fs::write(&path, &body)
        .await
        .map_err(|e| e.to_string())?;

    Response::builder()
        .status(StatusCode::OK)
        .body(Full::new(Bytes::new()))
        .map_err(|e| e.to_string())
}

// ---------------------------------------------------------------------------
// Download
// ---------------------------------------------------------------------------

async fn handle_download(
    root: &Path,
    repo_name: &str,
    oid: &str,
) -> Result<Response<Full<Bytes>>, String> {
    let path = object_path(root, repo_name, oid);

    let data = tokio::fs::read(&path)
        .await
        .map_err(|_| format!("LFS object not found: {}", oid))?;

    Response::builder()
        .status(StatusCode::OK)
        .header("Content-Type", "application/octet-stream")
        .body(Full::new(Bytes::from(data)))
        .map_err(|e| e.to_string())
}