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};
#[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";
fn object_path(root: &Path, repo_name: &str, oid: &str) -> PathBuf {
root.join(repo_name)
.join("lfs")
.join("objects")
.join(&oid[..2])
.join(&oid[2..4])
.join(oid)
}
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('/');
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)
}
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 {
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())
}
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();
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())
}
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())
}