From ec5475de85772087eb3cc7eb262e5134d31f8836 Mon Sep 17 00:00:00 2001 From: Ali Efruz YILDIRIR Date: Wed, 12 Aug 2026 14:24:13 +0300 Subject: [PATCH 1/4] In progress refactoring to a generic git server. --- Cargo.lock | 170 +++++++++++++++++++- Cargo.toml | 6 +- src/database.rs | 18 +++ src/main.rs | 44 +++-- src/{repo_router.rs => repo_base_router.rs} | 100 ++++++++++-- src/repository.rs | 1 + src/routers/mod.rs | 1 + src/routers/repository.rs | 11 ++ src/routers/repository/info.rs | 1 + src/routers/repository/info/lfs.rs | 0 10 files changed, 329 insertions(+), 23 deletions(-) create mode 100644 src/database.rs rename src/{repo_router.rs => repo_base_router.rs} (77%) create mode 100644 src/repository.rs create mode 100644 src/routers/mod.rs create mode 100644 src/routers/repository.rs create mode 100644 src/routers/repository/info.rs create mode 100644 src/routers/repository/info/lfs.rs diff --git a/Cargo.lock b/Cargo.lock index 22bb8a4..d3b6063 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -157,6 +157,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "axum-server" version = "0.8.0" @@ -191,6 +202,12 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "bytes" version = "1.12.1" @@ -311,15 +328,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] -name = "edvar-lfs" +name = "edvar-git-server" version = "0.1.0" dependencies = [ "axum", + "axum-macros", "axum-server", "clap", "env_logger", "faccess", "log", + "rusqlite", "serde", "serde_json", "tokio", @@ -383,6 +402,18 @@ dependencies = [ "winapi", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "find-msvc-tools" version = "0.1.10" @@ -395,6 +426,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -500,11 +537,32 @@ dependencies = [ "tracing", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248" +dependencies = [ + "hashbrown 0.17.1", +] [[package]] name = "heck" @@ -600,7 +658,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", ] [[package]] @@ -661,12 +719,32 @@ dependencies = [ "libc", ] +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + [[package]] name = "libc" version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libsqlite3-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +dependencies = [ + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -861,6 +939,31 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror", +] + +[[package]] +name = "rusqlite" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +dependencies = [ + "bitflags 2.13.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1031,6 +1134,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "strsim" version = "0.11.1" @@ -1242,12 +1357,63 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index c96c3c1..e7e0424 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "edvar-lfs" +name = "edvar-git-server" version = "0.1.0" edition = "2024" @@ -10,8 +10,12 @@ clap = { version = "4.6.6", features = ["derive", "env", "unicode", "wrap_help"] env_logger = "0.11.11" faccess = "0.2.4" log = "0.4.33" +rusqlite = "0.40.2" serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" tokio = { version = "1.53.1", features = ["full"] } tokio-stream = "0.1.19" tokio-util = { version = "0.7.19", features = ["io"] } + +[dev-dependencies] +axum-macros = "0.5.1" diff --git a/src/database.rs b/src/database.rs new file mode 100644 index 0000000..37c3263 --- /dev/null +++ b/src/database.rs @@ -0,0 +1,18 @@ +use crate::get_default_data_dir; + +struct AppDatabase { + connection: rusqlite::Connection, +} + +impl Default for AppDatabase { + fn default() -> Self { + let data_dir = get_default_data_dir(); + let db_path = std::path::Path::new(&data_dir).join("main_data.db"); + let conn = rusqlite::Connection::open(db_path).expect("Failed to open database"); + AppDatabase { connection: conn } + } + + + + +} diff --git a/src/main.rs b/src/main.rs index 7224e64..c3a501a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,5 @@ /* -edvar-lfs - A simple LFS server +edvar-git-server - A simple git server. Copyright (C) 2026 Edvar Studio - connect@edvarstudio.com This program is free software: you can redistribute it and/or modify @@ -21,14 +21,17 @@ use std::{ path::Path, }; +use axum::http::Uri; use faccess::PathExt; use axum_server::tls_rustls::RustlsConfig; use clap::Parser; +use log::info; -mod repo_router; +mod repository; +mod database; -/// Edvar LFS Server - A simple LFS server +/// Edvar Git Server - A simple Git server #[derive(Debug, Parser)] #[command(version, about, long_about = None, author = "Edvar Studio ")] struct Args { @@ -47,14 +50,23 @@ struct Args { key_file: String, #[arg(short, long, default_value_t = get_default_data_dir(), help="The directory to store LFS data")] data_dir: String, - #[arg(short, long, required = true, help = "The base URL for the LFS server")] - base_url: String, } #[derive(Clone)] struct AppState { data_dir: String, - base_url: String, + is_secure: bool, + base_addr: Option, +} + +impl Default for AppState { + fn default() -> Self { + AppState { + data_dir: get_default_data_dir(), + is_secure: false, + base_addr: None, + } + } } #[cfg(windows)] @@ -105,9 +117,21 @@ async fn main() { Some(_) => "https", }; + let base_addr: Uri = { + let as_str = format!("{}://{}:{}", scheme, args.address, args.port); + as_str.parse::().expect( + format!( + "Expected a valid base addr url ( {} ) to be constructed from {}, {}, {}", + as_str, scheme, args.address, args.port + ) + .as_str(), + ) + }; + let shared_data = AppState { data_dir: args.data_dir.clone(), - base_url: args.base_url.clone(), + is_secure: tls_config.is_some(), + base_addr: Some(base_addr), }; let addr = SocketAddr::new( @@ -116,9 +140,11 @@ async fn main() { ); let app = axum::Router::::new() - .nest("/{repo_owner}/{repo_id}", repo_router::create_repo_router()) + .nest("/{repo_owner}/{repo_id}", repo_base_router::create_repo_router()) .with_state(shared_data); - println!("Listening on {}://{}/", scheme, addr); + + info!("Listening on {}://{}/", scheme, addr); + let _server = if cert_file.is_empty() || key_file.is_empty() { axum_server::bind(addr) .serve(app.into_make_service()) diff --git a/src/repo_router.rs b/src/repo_base_router.rs similarity index 77% rename from src/repo_router.rs rename to src/repo_base_router.rs index f95b258..abd30e3 100644 --- a/src/repo_router.rs +++ b/src/repo_base_router.rs @@ -1,5 +1,5 @@ /* -edvar-lfs - A simple LFS server +edvar-git-server - A simple Git server Copyright (C) 2026 Edvar Studio - connect@edvarstudio.com This program is free software: you can redistribute it and/or modify @@ -22,10 +22,10 @@ use crate::AppState; use axum::{ Router, body::Body, - extract::{DefaultBodyLimit, Json, Path, State}, + extract::{DefaultBodyLimit, Json, Path, Query, State}, http::{ HeaderMap, - header::{ACCEPT, CONTENT_TYPE}, + header::{ACCEPT, CACHE_CONTROL, CONTENT_TYPE}, }, response::{IntoResponse, Response}, routing::{post, put}, @@ -37,7 +37,7 @@ use tokio_util::io::ReaderStream; pub fn create_repo_router() -> Router { Router::::new() - .route("/objects/batch", post(handle_batch)) + .route("info/lfs/objects/batch", post(handle_batch)) .route( "/_basic/{oid}", put(handle_upload_objects_basic) @@ -46,11 +46,20 @@ pub fn create_repo_router() -> Router { .layer(DefaultBodyLimit::disable()) .post(handle_verify_objects_basic), ) + .route("/info/refs", post(handle_git_advertisement_request)) } const LFS_ACCEPT_TYPE: &str = "application/vnd.git-lfs+json"; const LFS_CONTENT_TYPE: &str = "application/vnd.git-lfs+json"; + +fn format_lfs_basic_transfer_url(base_addr: &axum::http::Uri, repo_owner: &str, repo_id: &str, oid: &str) -> String { + format!( + "{}/{}/{}/_basic/{}", + base_addr, repo_owner, repo_id, oid + ) +} + #[derive(Deserialize, Serialize, Debug)] struct BatchRequestRef { name: String, @@ -195,10 +204,7 @@ async fn handle_batch( transfer: transfer, objects: Vec::from_iter(batch_request.objects.iter().map(|object| { let obj_act = BatchResponseObjectAction { - href: format!( - "{}/{}/{}/_basic/{}", // TODO: Make sure to parse a valid url. This causes problems here and there - state.base_url, repo_owner, repo_id, object.oid - ), + href: format_lfs_basic_transfer_url(&state.base_addr, repo_owner, repo_id, &object.oid).into(), header: None, expires_in: Some(3600), expires_at: None, @@ -209,9 +215,10 @@ async fn handle_batch( size: object.size, authenticated: true, actions: match batch_request.operation.as_str() { - "upload" => { - HashMap::from([("upload".into(), obj_act.clone()), ("verify".into(), obj_act)]) - } + "upload" => HashMap::from([ + ("upload".into(), obj_act.clone()), + ("verify".into(), obj_act), + ]), "download" => HashMap::from([("download".into(), obj_act)]), _ => HashMap::default(), }, @@ -366,3 +373,74 @@ async fn handle_verify_objects_basic( .body("Verification successful".into()) .unwrap(); } + +#[derive(Deserialize, Debug)] +struct SmartGitServerParams { + pub service: String, +} + +async fn handle_git_advertisement_request( + State(state): State, + Path((repo_owner, repo_id)): Path<(String, String)>, + Query(params): Query, +) -> Response { + if params.service == "git-upload-pack" || params.service == "git-receive-pack" { + // Handle git-upload-pack by requesting from the actual git executable. + // Exec should be like this: `git upload-pack|receive-pack --stateless-rpc --advertise-refs ` + let exec = std::process::Command::new("git") + .env( + "GIT_DIR", + std::path::Path::new(&state.data_dir) + .join(&repo_owner) + .join(&repo_id), + ) + .env("GIT_HTTP_EXPORT_ALL", "1") + .arg( + params + .service + .clone() + .strip_prefix("git-") + .expect("cutting the git- prefix failed."), + ) + .arg("--stateless-rpc") + .arg("--advertise-refs") + .arg( + std::path::Path::new(&state.data_dir) + .join(&repo_owner) + .join(&repo_id), + ) + .output(); + let output = match exec { + Ok(output) => output, + Err(e) => { + eprintln!("Failed to execute git upload-pack: {}", e); + return Response::builder() + .status(axum::http::StatusCode::INTERNAL_SERVER_ERROR) + .body("Failed to execute git upload-pack".into()) + .unwrap(); + } + }; + let mut response_bytes = Vec::new(); + let service_line = format!("# service={}\n", params.service); + let pkt_len = service_line.len() + 4; // 4 for the length prefix + response_bytes.extend_from_slice(format!("{:04x}", pkt_len).as_bytes()); + response_bytes.extend_from_slice(service_line.as_bytes()); + response_bytes.extend_from_slice(b"0000"); + + response_bytes.extend_from_slice(&output.stdout); + + return Response::builder() + .status(axum::http::StatusCode::OK) + .header( + CONTENT_TYPE, + format!("application/x-{}-advertisement", params.service), + ) + .header(CACHE_CONTROL, "no-cache") + .body(Body::from(response_bytes)) + .unwrap(); + } + return Response::builder() + .status(axum::http::StatusCode::FORBIDDEN) + .body("Unsupported service".into()) + .unwrap(); +} diff --git a/src/repository.rs b/src/repository.rs new file mode 100644 index 0000000..55cc091 --- /dev/null +++ b/src/repository.rs @@ -0,0 +1 @@ +pub mod info; \ No newline at end of file diff --git a/src/routers/mod.rs b/src/routers/mod.rs new file mode 100644 index 0000000..4e69383 --- /dev/null +++ b/src/routers/mod.rs @@ -0,0 +1 @@ +pub mod repository; \ No newline at end of file diff --git a/src/routers/repository.rs b/src/routers/repository.rs new file mode 100644 index 0000000..f7aad17 --- /dev/null +++ b/src/routers/repository.rs @@ -0,0 +1,11 @@ +pub mod info; + + + +struct Repository { + pub owner_identifier: u64, + pub repository_identifier: u64, + + pub repository_name: String, + +} \ No newline at end of file diff --git a/src/routers/repository/info.rs b/src/routers/repository/info.rs new file mode 100644 index 0000000..b9457c8 --- /dev/null +++ b/src/routers/repository/info.rs @@ -0,0 +1 @@ +pub mod lfs; \ No newline at end of file diff --git a/src/routers/repository/info/lfs.rs b/src/routers/repository/info/lfs.rs new file mode 100644 index 0000000..e69de29 From 95ef911828f9d3d8305f11a817c7027c36dae326 Mon Sep 17 00:00:00 2001 From: Ali Efruz YILDIRIR Date: Fri, 14 Aug 2026 13:29:59 +0300 Subject: [PATCH 2/4] A basic git server is available right now. --- Cargo.lock | 55 +++- Cargo.toml | 2 + src/database.rs | 22 ++ src/main.rs | 82 +++-- src/repo_base_router.rs | 446 ---------------------------- src/repository.rs | 1 - src/routers/mod.rs | 27 +- src/routers/repository.rs | 288 +++++++++++++++++- src/routers/repository/info.rs | 28 +- src/routers/repository/info/lfs.rs | 401 +++++++++++++++++++++++++ src/routers/repository/info/refs.rs | 87 ++++++ 11 files changed, 951 insertions(+), 488 deletions(-) delete mode 100644 src/repo_base_router.rs delete mode 100644 src/repository.rs create mode 100644 src/routers/repository/info/refs.rs diff --git a/Cargo.lock b/Cargo.lock index d3b6063..e460a04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -208,6 +208,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.12.1" @@ -337,6 +343,7 @@ dependencies = [ "clap", "env_logger", "faccess", + "futures-util", "log", "rusqlite", "serde", @@ -344,6 +351,7 @@ dependencies = [ "tokio", "tokio-stream", "tokio-util", + "tower", ] [[package]] @@ -468,9 +476,20 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-macro" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] [[package]] name = "futures-sink" @@ -480,17 +499,18 @@ checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", + "futures-macro", "futures-task", "pin-project-lite", "slab", @@ -564,6 +584,16 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "hdrhistogram" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f49d1053f4708f0af3cf9fc5bffc7e68a914a3c45becb231c80068c9c3f78bea" +dependencies = [ + "byteorder", + "num-traits", +] + [[package]] name = "heck" version = "0.5.0" @@ -795,6 +825,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1287,9 +1326,13 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", + "hdrhistogram", + "indexmap", "pin-project-lite", + "slab", "sync_wrapper", "tokio", + "tokio-util", "tower-layer", "tower-service", "tracing", diff --git a/Cargo.toml b/Cargo.toml index e7e0424..eff01a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ axum-server = { version = "0.8.0", features = ["tls-rustls"] } clap = { version = "4.6.6", features = ["derive", "env", "unicode", "wrap_help"] } env_logger = "0.11.11" faccess = "0.2.4" +futures-util = "0.3.34" log = "0.4.33" rusqlite = "0.40.2" serde = { version = "1.0.229", features = ["derive"] } @@ -16,6 +17,7 @@ serde_json = "1.0.151" tokio = { version = "1.53.1", features = ["full"] } tokio-stream = "0.1.19" tokio-util = { version = "0.7.19", features = ["io"] } +tower = { version = "0.5.3", features = ["full", "tokio-stream"] } [dev-dependencies] axum-macros = "0.5.1" diff --git a/src/database.rs b/src/database.rs index 37c3263..8174398 100644 --- a/src/database.rs +++ b/src/database.rs @@ -1,3 +1,25 @@ +/* +edvar-git-server - A simple git server. +Copyright (C) 2026 Edvar Studio - connect@edvarstudio.com + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +/** + * The server should have a database to store information, but + * the implementation will be done later and this is not intended to be used as of now. + */ use crate::get_default_data_dir; struct AppDatabase { diff --git a/src/main.rs b/src/main.rs index c3a501a..13a5283 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,10 +26,12 @@ use faccess::PathExt; use axum_server::tls_rustls::RustlsConfig; use clap::Parser; -use log::info; +use log::{error, info}; -mod repository; -mod database; +use crate::routers::create_main_router; + +// mod database; +mod routers; /// Edvar Git Server - A simple Git server #[derive(Debug, Parser)] @@ -48,33 +50,35 @@ struct Args { cert_file: String, #[arg(short, long, default_value_t = "".into(), help="The path to the private key file")] key_file: String, - #[arg(short, long, default_value_t = get_default_data_dir(), help="The directory to store LFS data")] - data_dir: String, + #[arg(short, long, default_value_t = get_default_repositories_dir(), help="The directory to store repository data")] + repositories_dir: String, + #[arg(short, long, default_value_t = get_default_lfs_dir(), help="The directory to store LFS data")] + lfs_dir: String, } #[derive(Clone)] struct AppState { - data_dir: String, - is_secure: bool, - base_addr: Option, + repositories_dir: String, + lfs_dir: String, + base_addr: axum::http::Uri, } impl Default for AppState { fn default() -> Self { AppState { - data_dir: get_default_data_dir(), - is_secure: false, - base_addr: None, + repositories_dir: get_default_repositories_dir(), + lfs_dir: get_default_lfs_dir(), + base_addr: axum::http::Uri::from_static("http://localhost:8080"), } } } #[cfg(windows)] -fn get_default_data_dir() -> String { +fn get_default_repositories_dir() -> String { let appdata = std::env::var("APPDATA").unwrap_or_else(|_| ".".to_string()); std::path::Path::new(&appdata) - .join("edvar-lfs") - .join("data") + .join("edvar-git-server") + .join("repositories") .to_str() .unwrap() .to_string() @@ -82,7 +86,23 @@ fn get_default_data_dir() -> String { #[cfg(unix)] fn get_default_data_dir() -> String { - "/var/lib/edvar-lfs/data".into(); + "/var/lib/edvar-git-server/repositories".into() +} + +#[cfg(windows)] +fn get_default_lfs_dir() -> String { + let appdata = std::env::var("APPDATA").unwrap_or_else(|_| ".".to_string()); + std::path::Path::new(&appdata) + .join("edvar-git-server") + .join("lfs-storage") + .to_str() + .unwrap() + .to_string() +} + +#[cfg(unix)] +fn get_default_lfs_dir() -> String { + "/var/lib/edvar-git-server/lfs-storage".into() } #[tokio::main] @@ -90,13 +110,23 @@ async fn main() { env_logger::init(); let args = Args::parse(); // Check permissions for writing to the data_dir - let data_dir = Path::new(&args.data_dir); - std::fs::create_dir_all(&data_dir).expect("Failed to create data directory"); - if !data_dir.writable() || !data_dir.readable() || !data_dir.is_dir() || !data_dir.executable() + let repositories_dir = Path::new(&args.repositories_dir); + std::fs::create_dir_all(&repositories_dir).expect("Failed to create repositories directory"); + if !repositories_dir.writable() || !repositories_dir.readable() || !repositories_dir.is_dir() || !repositories_dir.executable() { - eprintln!( - "Error: The data directory '{}' is not writable, readable, executable or directory. Please check the permissions and if it is a directory.", - args.data_dir + error!( + "Error: The repositories directory '{}' is not writable, readable, executable or directory. Please check the permissions and if it is a directory.", + args.repositories_dir + ); + std::process::exit(1); + } + + let lfs_dir = Path::new(&args.lfs_dir); + std::fs::create_dir_all(&lfs_dir).expect("Failed to create LFS directory"); + if !lfs_dir.writable() || !lfs_dir.readable() || !lfs_dir.is_dir() || !lfs_dir.executable() { + error!( + "Error: The LFS directory '{}' is not writable, readable, executable or directory. Please check the permissions and if it is a directory.", + args.lfs_dir ); std::process::exit(1); } @@ -129,9 +159,9 @@ async fn main() { }; let shared_data = AppState { - data_dir: args.data_dir.clone(), - is_secure: tls_config.is_some(), - base_addr: Some(base_addr), + repositories_dir: args.repositories_dir.clone(), + lfs_dir: args.lfs_dir.clone(), + base_addr: base_addr, }; let addr = SocketAddr::new( @@ -139,9 +169,7 @@ async fn main() { args.port, ); - let app = axum::Router::::new() - .nest("/{repo_owner}/{repo_id}", repo_base_router::create_repo_router()) - .with_state(shared_data); + let app = create_main_router(shared_data.clone()).with_state(shared_data); info!("Listening on {}://{}/", scheme, addr); diff --git a/src/repo_base_router.rs b/src/repo_base_router.rs deleted file mode 100644 index abd30e3..0000000 --- a/src/repo_base_router.rs +++ /dev/null @@ -1,446 +0,0 @@ -/* -edvar-git-server - A simple Git server -Copyright (C) 2026 Edvar Studio - connect@edvarstudio.com - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . -*/ - -use std::collections::HashMap; - -use crate::AppState; -use axum::{ - Router, - body::Body, - extract::{DefaultBodyLimit, Json, Path, Query, State}, - http::{ - HeaderMap, - header::{ACCEPT, CACHE_CONTROL, CONTENT_TYPE}, - }, - response::{IntoResponse, Response}, - routing::{post, put}, -}; -use log::debug; -use serde::{Deserialize, Serialize}; -use tokio_stream::StreamExt; -use tokio_util::io::ReaderStream; - -pub fn create_repo_router() -> Router { - Router::::new() - .route("info/lfs/objects/batch", post(handle_batch)) - .route( - "/_basic/{oid}", - put(handle_upload_objects_basic) - .layer(DefaultBodyLimit::disable()) - .get(handle_download_objects_basic) - .layer(DefaultBodyLimit::disable()) - .post(handle_verify_objects_basic), - ) - .route("/info/refs", post(handle_git_advertisement_request)) -} - -const LFS_ACCEPT_TYPE: &str = "application/vnd.git-lfs+json"; -const LFS_CONTENT_TYPE: &str = "application/vnd.git-lfs+json"; - - -fn format_lfs_basic_transfer_url(base_addr: &axum::http::Uri, repo_owner: &str, repo_id: &str, oid: &str) -> String { - format!( - "{}/{}/{}/_basic/{}", - base_addr, repo_owner, repo_id, oid - ) -} - -#[derive(Deserialize, Serialize, Debug)] -struct BatchRequestRef { - name: String, -} - -#[derive(Deserialize, Serialize, Debug)] -struct BatchRequestObjects { - oid: String, - size: u64, -} - -#[derive(Deserialize, Serialize, Debug)] -struct BatchRequest { - operation: String, - transfers: Option>, - #[serde(rename = "ref")] - ref_: Option, - objects: Vec, - hash_algo: Option, -} - -#[derive(Deserialize, Serialize, Debug, Clone)] -struct BatchResponseObjectAction { - href: String, - header: Option>, - expires_in: Option, - expires_at: Option, -} - -#[derive(Deserialize, Serialize, Debug)] -struct BatchResponseObject { - oid: String, - size: u64, - authenticated: bool, - actions: HashMap, -} - -#[derive(Deserialize, Serialize)] -struct BatchResponse { - transfer: String, - objects: Vec, - hash_algo: Option, -} - -async fn handle_batch( - State(state): State, - Path((repo_owner, repo_id)): Path<(String, String)>, - headers: HeaderMap, - Json(batch_request): Json, -) -> Response { - debug!( - "Received batch request for repo {}/{}: {:?}", - repo_owner, repo_id, batch_request - ); - match headers.get(ACCEPT) { - Some(accept_header) if accept_header == LFS_ACCEPT_TYPE => {} - _ => { - return ( - axum::http::StatusCode::BAD_REQUEST, - "Missing or invalid LFS Accept header", - ) - .into_response(); - } - } - let mut _charset = "utf-8"; - let mut _boundary_type: Option<&str> = None; - match headers.get(CONTENT_TYPE) { - Some(content_type_header) => { - let content_type_str = content_type_header.to_str().unwrap_or("").trim(); - // Parse the Content-Type header. ; = - let parts: Vec<&str> = content_type_str.split(';').map(|s| s.trim()).collect(); - if parts.is_empty() || parts[0] != LFS_CONTENT_TYPE { - return ( - axum::http::StatusCode::BAD_REQUEST, - "Missing or invalid LFS Content-Type header", - ) - .into_response(); - } - _charset = parts - .iter() - .find_map(|part| { - if part.starts_with("charset=") { - Some(part.trim_start_matches("charset=").trim()) - } else { - None - } - }) - .unwrap_or("utf-8"); - _boundary_type = parts.iter().find_map(|part| { - if part.starts_with("boundary=") { - Some(part.trim_start_matches("boundary=").trim()) - } else { - None - } - }); - } - _ => { - return ( - axum::http::StatusCode::BAD_REQUEST, - "Missing or invalid LFS Content-Type header", - ) - .into_response(); - } - } - - if batch_request.operation != "download" && batch_request.operation != "upload" { - return ( - axum::http::StatusCode::BAD_REQUEST, - "Invalid operation in batch request", - ) - .into_response(); - } - - let transfer = match batch_request.transfers { - Some(ref transfers) => { - if !transfers.contains(&"basic".to_string()) { - return ( - axum::http::StatusCode::BAD_REQUEST, - "Unsupported transfer type in batch request", - ) - .into_response(); - } - "basic".to_string() - } - None => { - // Assume "basic" transfer if no transfers are specified. - "basic".to_string() - } - }; - - // Process batch requests. - println!( - "Received batch request for repo {}/{} with operation: {}. Selected transfer: {}", - repo_owner, repo_id, batch_request.operation, transfer - ); - - let response = BatchResponse { - hash_algo: match batch_request.hash_algo { - Some(algo) => Some(algo), - None => Some("sha256".to_string()), - }, - transfer: transfer, - objects: Vec::from_iter(batch_request.objects.iter().map(|object| { - let obj_act = BatchResponseObjectAction { - href: format_lfs_basic_transfer_url(&state.base_addr, repo_owner, repo_id, &object.oid).into(), - header: None, - expires_in: Some(3600), - expires_at: None, - }; - - BatchResponseObject { - oid: object.oid.clone(), - size: object.size, - authenticated: true, - actions: match batch_request.operation.as_str() { - "upload" => HashMap::from([ - ("upload".into(), obj_act.clone()), - ("verify".into(), obj_act), - ]), - "download" => HashMap::from([("download".into(), obj_act)]), - _ => HashMap::default(), - }, - } - })), - }; - - return Response::builder() - .status(axum::http::StatusCode::OK) - .header(CONTENT_TYPE, LFS_CONTENT_TYPE) - .body(serde_json::to_string(&response).unwrap().into()) - .unwrap(); -} - -async fn handle_upload_objects_basic( - State(state): State, - Path((repo_owner, repo_id, oid)): Path<(String, String, String)>, - body: Body, -) -> Response { - println!( - "Received basic upload request for repo {}/{} with oid: {}", - repo_owner, repo_id, oid - ); - - // Write the data to the appropriate location in the data directory. - let mut stream = body.into_data_stream(); - - let p = std::path::Path::new(&state.data_dir) - .join(&repo_owner) - .join(&repo_id) - .join(&oid); - let parent_dir = p.parent().unwrap(); - match std::fs::create_dir_all(parent_dir) { - Ok(_) => {} - Err(e) => { - eprintln!("Failed to create directories: {}", e); - return Response::builder() - .status(axum::http::StatusCode::INTERNAL_SERVER_ERROR) - .body("Failed to create appropriate directories".into()) - .unwrap(); - } - } - match std::fs::File::create(p) { - Ok(mut file) => { - while let Some(chunk) = stream.next().await { - match chunk { - Ok(bytes) => { - if let Err(e) = std::io::Write::write_all(&mut file, &bytes) { - eprintln!("Failed to write to file: {}", e); - return Response::builder() - .status(axum::http::StatusCode::INTERNAL_SERVER_ERROR) - .body("Failed to write to file".into()) - .unwrap(); - } - } - Err(e) => { - eprintln!("Failed to read chunk from body: {}", e); - return Response::builder() - .status(axum::http::StatusCode::INTERNAL_SERVER_ERROR) - .body("Failed to read chunk from body".into()) - .unwrap(); - } - } - } - } - Err(e) => { - eprintln!("Failed to create file: {}", e); - return Response::builder() - .status(axum::http::StatusCode::INTERNAL_SERVER_ERROR) - .body("Failed to create file".into()) - .unwrap(); - } - } - - return Response::builder() - .status(axum::http::StatusCode::OK) - .body("".into()) - .unwrap(); -} - -async fn handle_download_objects_basic( - State(state): State, - Path((repo_owner, repo_id, oid)): Path<(String, String, String)>, -) -> Response { - println!( - "Received download request for repo {}/{} with oid: {}", - repo_owner, repo_id, oid - ); - - // Check if the object exists in the data directory. - let obj_path = std::path::Path::new(&state.data_dir) - .join(&repo_owner) - .join(&repo_id) - .join(&oid); - - let exists_res = std::fs::exists(&obj_path); - if exists_res.is_err() { - return Response::builder() - .status(axum::http::StatusCode::INTERNAL_SERVER_ERROR) - .body("Failed to check if object exists".into()) - .unwrap(); - } else if !exists_res.unwrap() { - return Response::builder() - .status(axum::http::StatusCode::NOT_FOUND) - .body("Object not found".into()) - .unwrap(); - } - - let file_stream = match tokio::fs::File::open(&obj_path).await { - Ok(f) => ReaderStream::new(f), - Err(e) => { - eprintln!("Failed to open file: {}", e); - return Response::builder() - .status(axum::http::StatusCode::INTERNAL_SERVER_ERROR) - .body("Failed to open file".into()) - .unwrap(); - } - }; - - // Upload the file as a stream in the response body. - return Response::builder() - .status(200) - .header(CONTENT_TYPE, "application/octet-stream") - .body(Body::from_stream(file_stream)) - .unwrap(); -} - -async fn handle_verify_objects_basic( - State(state): State, - Path((repo_owner, repo_id, oid)): Path<(String, String, String)>, -) -> Response { - // Check if the object exists in the data directory. - let obj_path = std::path::Path::new(&state.data_dir) - .join(&repo_owner) - .join(&repo_id) - .join(&oid); - - let exists_res = std::fs::exists(&obj_path); - if exists_res.is_err() { - return Response::builder() - .status(axum::http::StatusCode::INTERNAL_SERVER_ERROR) - .body("Failed to check if object exists".into()) - .unwrap(); - } else if !exists_res.unwrap() { - return Response::builder() - .status(axum::http::StatusCode::NOT_FOUND) - .body("Object not found".into()) - .unwrap(); - } - return Response::builder() - .status(axum::http::StatusCode::OK) - .body("Verification successful".into()) - .unwrap(); -} - -#[derive(Deserialize, Debug)] -struct SmartGitServerParams { - pub service: String, -} - -async fn handle_git_advertisement_request( - State(state): State, - Path((repo_owner, repo_id)): Path<(String, String)>, - Query(params): Query, -) -> Response { - if params.service == "git-upload-pack" || params.service == "git-receive-pack" { - // Handle git-upload-pack by requesting from the actual git executable. - // Exec should be like this: `git upload-pack|receive-pack --stateless-rpc --advertise-refs ` - let exec = std::process::Command::new("git") - .env( - "GIT_DIR", - std::path::Path::new(&state.data_dir) - .join(&repo_owner) - .join(&repo_id), - ) - .env("GIT_HTTP_EXPORT_ALL", "1") - .arg( - params - .service - .clone() - .strip_prefix("git-") - .expect("cutting the git- prefix failed."), - ) - .arg("--stateless-rpc") - .arg("--advertise-refs") - .arg( - std::path::Path::new(&state.data_dir) - .join(&repo_owner) - .join(&repo_id), - ) - .output(); - let output = match exec { - Ok(output) => output, - Err(e) => { - eprintln!("Failed to execute git upload-pack: {}", e); - return Response::builder() - .status(axum::http::StatusCode::INTERNAL_SERVER_ERROR) - .body("Failed to execute git upload-pack".into()) - .unwrap(); - } - }; - let mut response_bytes = Vec::new(); - let service_line = format!("# service={}\n", params.service); - let pkt_len = service_line.len() + 4; // 4 for the length prefix - response_bytes.extend_from_slice(format!("{:04x}", pkt_len).as_bytes()); - response_bytes.extend_from_slice(service_line.as_bytes()); - response_bytes.extend_from_slice(b"0000"); - - response_bytes.extend_from_slice(&output.stdout); - - return Response::builder() - .status(axum::http::StatusCode::OK) - .header( - CONTENT_TYPE, - format!("application/x-{}-advertisement", params.service), - ) - .header(CACHE_CONTROL, "no-cache") - .body(Body::from(response_bytes)) - .unwrap(); - } - return Response::builder() - .status(axum::http::StatusCode::FORBIDDEN) - .body("Unsupported service".into()) - .unwrap(); -} diff --git a/src/repository.rs b/src/repository.rs deleted file mode 100644 index 55cc091..0000000 --- a/src/repository.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod info; \ No newline at end of file diff --git a/src/routers/mod.rs b/src/routers/mod.rs index 4e69383..26893c5 100644 --- a/src/routers/mod.rs +++ b/src/routers/mod.rs @@ -1 +1,26 @@ -pub mod repository; \ No newline at end of file +/* +edvar-git-server - A simple git server. +Copyright (C) 2026 Edvar Studio - connect@edvarstudio.com + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +use crate::AppState; + +pub mod repository; + +pub fn create_main_router(state: AppState) -> axum::Router { + // This is the router for /. + axum::Router::::new().nest("/{repo_owner}/{repo_name}", repository::create_repository_router(state)) +} diff --git a/src/routers/repository.rs b/src/routers/repository.rs index f7aad17..743e902 100644 --- a/src/routers/repository.rs +++ b/src/routers/repository.rs @@ -1,11 +1,287 @@ +/* +edvar-git-server - A simple git server. +Copyright (C) 2026 Edvar Studio - connect@edvarstudio.com + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + + +use axum::{ + body::Body, + extract::{DefaultBodyLimit, Path, Request, State}, + http::{HeaderMap, HeaderValue}, + middleware::Next, + response::Response, + routing::post, +}; +use futures_util::stream::TryStreamExt; +use log::{debug, error}; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, process::Command, +}; +use tokio_util::io::{ReaderStream, StreamReader}; +use tower::ServiceBuilder; + +use crate::AppState; + pub mod info; +pub fn create_repository_router(state: AppState) -> axum::Router { + let dot_git_router = axum::Router::::new() + .nest("/info", info::create_info_router()) + .route("/git-upload-pack", post(handle_git_upload_pack_request)) + .route("/git-receive-pack", post(handle_git_receive_pack_request)) + .route_layer( + ServiceBuilder::new() + .layer(axum::middleware::from_fn_with_state( + state.clone(), + check_repository_is_dot_git, + )) + .layer(axum::middleware::from_fn_with_state( + state.clone(), + check_repository_exists, + )), + ) + .layer(DefaultBodyLimit::disable()); + // TODO: add the normal router, which will be used by web clients. + axum::Router::::new().merge(dot_git_router) +} + +async fn check_repository_is_dot_git( + Path((_repo_owner, repo_id)): Path<(String, String)>, + req: Request, + next: Next, +) -> Response { + if !repo_id.ends_with(".git") { + return axum::response::Response::builder() + .status(axum::http::StatusCode::NOT_FOUND) + .body("Repository not found".into()) + .unwrap(); + } + return next.run(req).await; +} + +async fn check_repository_exists( + State(state): State, + Path((repo_owner, repo_id)): Path<(String, String)>, + req: Request, + next: Next, +) -> Response { + let repo_path = std::path::Path::new(&state.repositories_dir) + .join(&repo_owner) + .join(format!("{}", repo_id)); + if !repo_path.exists() || !repo_path.is_dir() { + return axum::response::Response::builder() + .status(axum::http::StatusCode::NOT_FOUND) + .body("Repository not found".into()) + .unwrap(); + } + log::info!( + "Repository exists: {}/{} at path: {}", + repo_owner, + repo_id, + repo_path.display() + ); + return next.run(req).await; +} + +async fn exec_git_rpc( + action: &'static str, + repo_owner: &str, + repo_id: &str, + state: &AppState, + headers: &HeaderMap, + body: Body, +) -> Response { + log::info!( + "Handling git {} request for repo: {}/{}", + action, + repo_owner, + repo_id + ); + let expected_content_type = format!("application/x-git-{}-request", action) + .parse::() + .unwrap(); + match headers.get(axum::http::header::CONTENT_TYPE) { + Some(val) if val == &expected_content_type => {} + _ => { + return axum::response::Response::builder() + .status(axum::http::StatusCode::BAD_REQUEST) + .body( + format!( + "Invalid or missing Content-Type for git-{} request.", + action + ) + .into(), + ) + .unwrap(); + } + } + + let repo_path = std::path::Path::new(&state.repositories_dir) + .join(&repo_owner) + .join(&repo_id); + + let mut cmd: Command = Command::new("git"); + cmd.arg(action) + .arg("--stateless-rpc") + .arg(&repo_path) + .env("GIT_HTTP_EXPORT_ALL", "1") + .env("GIT_DIR", &repo_path) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true); + + if let Some(protocol) = headers.get("Git-Protocol") { + if let Ok(val) = protocol.to_str() { + cmd.env("GIT_PROTOCOL", val); + } + } + + let mut child = match cmd.spawn() { + Ok(c) => c, + Err(e) => { + error!("Failed to spawn git upload-pack: {}", e); + return axum::response::Response::builder() + .status(axum::http::StatusCode::INTERNAL_SERVER_ERROR) + .body("Internal server error.".into()) + .unwrap(); + } + }; + let mut stdin = match child.stdin.take() { + Some(stdin) => stdin, + None => { + error!("Failed to open stdin for git upload-pack"); + return axum::response::Response::builder() + .status(axum::http::StatusCode::INTERNAL_SERVER_ERROR) + .body("Internal server error.".into()) + .unwrap(); + } + }; + let stdout = match child.stdout.take() { + Some(stdout) => stdout, + None => { + error!("Failed to open stdout for git upload-pack"); + return axum::response::Response::builder() + .status(axum::http::StatusCode::INTERNAL_SERVER_ERROR) + .body("Internal server error.".into()) + .unwrap(); + } + }; + let stderr = match child.stderr.take() { + Some(stderr) => stderr, + None => { + error!("Failed to open stderr for git upload-pack"); + return axum::response::Response::builder() + .status(axum::http::StatusCode::INTERNAL_SERVER_ERROR) + .body("Internal server error.".into()) + .unwrap(); + } + }; + + tokio::spawn(async move { + let body_stream = body.into_data_stream().map_err(|e| { + error!("Failed to read request body: {}", e); + std::io::Error::new(std::io::ErrorKind::Other, e) + }); + let mut async_body_reader = StreamReader::new(body_stream); + match tokio::io::copy(&mut async_body_reader, &mut stdin).await { + Ok(bytes_written) => { + if let Err(e) = stdin.flush().await { + debug!("Failed to flush git {} stdin: {}", action, e); + } + debug!("Wrote {} bytes to git {} stdin", bytes_written, action); + } + Err(e) => { + error!("Failed to write to git {} stdin: {}", action, e); + } + } + drop(stdin); + }); + + tokio::spawn(async move { + let mut reader = BufReader::new(stderr); + let mut line = String::new(); + loop { + line.clear(); + match reader.read_line(&mut line).await { + Ok(0) => break, // EOF reached (git child process exited) + Ok(_) => { + let trimmed = line.trim_end(); + if !trimmed.is_empty() { + log::error!("[git {} stderr] {}", action, trimmed); + } + } + Err(e) => { + log::error!("Error reading git {} stderr: {}", action, e); + break; + } + } + } + }); + + tokio::spawn(async move { + match child.wait().await { + Ok(status) => { + if !status.success() { + log::error!( + "git {} process exited with status: {}", + action, + status.code().unwrap_or(-1) + ); + } + } + Err(e) => { + log::error!("Failed to wait for git {} process: {}", action, e); + } + } + }); + + let stdout_stream = ReaderStream::new(BufReader::new(stdout)); + let response_body = Body::from_stream(stdout_stream); + let content_type = format!("application/x-git-{}-result", action); + Response::builder() + .status(axum::http::StatusCode::OK) + .header(axum::http::header::CONTENT_TYPE, content_type) + .body(response_body) + .unwrap() +} -struct Repository { - pub owner_identifier: u64, - pub repository_identifier: u64, +async fn handle_git_upload_pack_request( + State(state): State, + headers: HeaderMap, + Path((repo_owner, repo_id)): Path<(String, String)>, + body: Body, +) -> axum::response::Response { + exec_git_rpc("upload-pack", &repo_owner, &repo_id, &state, &headers, body).await +} - pub repository_name: String, - -} \ No newline at end of file +async fn handle_git_receive_pack_request( + State(state): State, + headers: HeaderMap, + Path((repo_owner, repo_id)): Path<(String, String)>, + body: Body, +) -> axum::response::Response { + exec_git_rpc( + "receive-pack", + &repo_owner, + &repo_id, + &state, + &headers, + body, + ) + .await +} diff --git a/src/routers/repository/info.rs b/src/routers/repository/info.rs index b9457c8..e0cc922 100644 --- a/src/routers/repository/info.rs +++ b/src/routers/repository/info.rs @@ -1 +1,27 @@ -pub mod lfs; \ No newline at end of file +use crate::AppState; + +/* +edvar-git-server - A simple git server. +Copyright (C) 2026 Edvar Studio - connect@edvarstudio.com + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ +pub mod lfs; +pub mod refs; + +pub fn create_info_router() -> axum::Router { + axum::Router::::new() + .nest("/lfs", lfs::create_lfs_router()) + .nest("/refs", refs::create_refs_router()) +} diff --git a/src/routers/repository/info/lfs.rs b/src/routers/repository/info/lfs.rs index e69de29..2d7ef05 100644 --- a/src/routers/repository/info/lfs.rs +++ b/src/routers/repository/info/lfs.rs @@ -0,0 +1,401 @@ +/* +edvar-git-server - A simple git server. +Copyright (C) 2026 Edvar Studio - connect@edvarstudio.com + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +use axum::{body::Body, extract::DefaultBodyLimit, http::Uri, routing::post}; +use std::collections::HashMap; +use tokio_stream::StreamExt; +use tokio_util::io::ReaderStream; + +use axum::{ + Json, + extract::{Path, State}, + http::{ + HeaderMap, + header::{ACCEPT, CONTENT_TYPE}, + }, + response::{IntoResponse, Response}, +}; +use log::{debug, error}; +use serde::{Deserialize, Serialize}; + +use crate::AppState; + +pub fn create_lfs_router() -> axum::Router { + axum::Router::::new() + .route("/objects/batch", post(handle_batch)) + .route( + "/transfer/_basic/{oid}", + axum::routing::get(handle_download_objects_basic) + .layer(DefaultBodyLimit::disable()) + .post(handle_verify_objects_basic) + .layer(DefaultBodyLimit::disable()) + .put(handle_upload_objects_basic) + .layer(DefaultBodyLimit::disable()), + ) +} + +const LFS_JSON_MIME_TYPE: &str = "application/vnd.git-lfs+json"; + +fn format_lfs_basic_transfer_url( + base_addr: &axum::http::Uri, + repo_owner: &str, + repo_id: &str, + oid: &str, +) -> Uri { + Uri::builder() + .scheme(base_addr.scheme_str().unwrap_or("http")) + .authority(base_addr.authority().map(|a| a.as_str()).unwrap_or("")) + .path_and_query(format!( + "/{}/{}/info/lfs/transfer/_basic/{}", + repo_owner, repo_id, oid + )) + .build() + .unwrap_or_else(|_| { + error!( + "Failed to build LFS basic transfer URL for repo {}/{} with oid: {}", + repo_owner, repo_id, oid + ); + format!( + "{}/{}/{}/info/lfs/transfer/_basic/{}", + base_addr, repo_owner, repo_id, oid + ) + .parse::() + .unwrap_or(Uri::default()) + }) +} + +#[derive(Deserialize, Serialize, Debug)] +struct BatchRequestRef { + name: String, +} + +#[derive(Deserialize, Serialize, Debug)] +struct BatchRequestObjects { + oid: String, + size: u64, +} + +#[derive(Deserialize, Serialize, Debug)] +struct BatchRequest { + operation: String, + transfers: Option>, + #[serde(rename = "ref")] + ref_: Option, + objects: Vec, + hash_algo: Option, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +struct BatchResponseObjectAction { + href: String, + header: Option>, + expires_in: Option, + expires_at: Option, +} + +#[derive(Deserialize, Serialize, Debug)] +struct BatchResponseObject { + oid: String, + size: u64, + authenticated: bool, + actions: HashMap, +} + +#[derive(Deserialize, Serialize)] +struct BatchResponse { + transfer: String, + objects: Vec, + hash_algo: Option, +} + +async fn handle_batch( + State(state): State, + Path((repo_owner, repo_id)): Path<(String, String)>, + headers: HeaderMap, + Json(batch_request): Json, +) -> Response { + debug!( + "Received batch request for repo {}/{}: {:?}", + repo_owner, repo_id, batch_request + ); + match headers.get(ACCEPT) { + Some(accept_header) if accept_header == LFS_JSON_MIME_TYPE => {} + _ => { + return ( + axum::http::StatusCode::BAD_REQUEST, + "Missing or invalid LFS Accept header", + ) + .into_response(); + } + } + let mut _charset = "utf-8"; + let mut _boundary_type: Option<&str> = None; + match headers.get(CONTENT_TYPE) { + Some(content_type_header) => { + let content_type_str = content_type_header.to_str().unwrap_or("").trim(); + // Parse the Content-Type header. ; = + let parts: Vec<&str> = content_type_str.split(';').map(|s| s.trim()).collect(); + if parts.is_empty() || parts[0] != LFS_JSON_MIME_TYPE { + return ( + axum::http::StatusCode::BAD_REQUEST, + "Missing or invalid LFS Content-Type header", + ) + .into_response(); + } + _charset = parts + .iter() + .find_map(|part| { + if part.starts_with("charset=") { + Some(part.trim_start_matches("charset=").trim()) + } else { + None + } + }) + .unwrap_or("utf-8"); + _boundary_type = parts.iter().find_map(|part| { + if part.starts_with("boundary=") { + Some(part.trim_start_matches("boundary=").trim()) + } else { + None + } + }); + } + _ => { + return ( + axum::http::StatusCode::BAD_REQUEST, + "Missing or invalid LFS Content-Type header", + ) + .into_response(); + } + } + + if batch_request.operation != "download" && batch_request.operation != "upload" { + return ( + axum::http::StatusCode::BAD_REQUEST, + "Invalid operation in batch request", + ) + .into_response(); + } + + let transfer = match batch_request.transfers { + Some(ref transfers) => { + if !transfers.contains(&"basic".to_string()) { + return ( + axum::http::StatusCode::BAD_REQUEST, + "Unsupported transfer type in batch request", + ) + .into_response(); + } + "basic".to_string() + } + None => { + // Assume "basic" transfer if no transfers are specified. + "basic".to_string() + } + }; + + // Process batch requests. + println!( + "Received batch request for repo {}/{} with operation: {}. Selected transfer: {}", + repo_owner, repo_id, batch_request.operation, transfer + ); + + let response = BatchResponse { + hash_algo: match batch_request.hash_algo { + Some(algo) => Some(algo), + None => Some("sha256".to_string()), + }, + transfer: transfer, + objects: Vec::from_iter(batch_request.objects.iter().map(|object| { + let obj_act = BatchResponseObjectAction { + href: format_lfs_basic_transfer_url( + &state.base_addr, + repo_owner.as_str(), + repo_id.as_str(), + &object.oid, + ) + .to_string(), + header: None, + expires_in: Some(3600), + expires_at: None, + }; + + BatchResponseObject { + oid: object.oid.clone(), + size: object.size, + authenticated: true, + actions: match batch_request.operation.as_str() { + "upload" => HashMap::from([ + ("upload".into(), obj_act.clone()), + ("verify".into(), obj_act), + ]), + "download" => HashMap::from([("download".into(), obj_act)]), + _ => HashMap::default(), + }, + } + })), + }; + + return Response::builder() + .status(axum::http::StatusCode::OK) + .header(CONTENT_TYPE, LFS_JSON_MIME_TYPE) + .body(serde_json::to_string(&response).unwrap().into()) + .unwrap(); +} + +async fn handle_upload_objects_basic( + State(state): State, + Path((repo_owner, repo_id, oid)): Path<(String, String, String)>, + body: Body, +) -> Response { + println!( + "Received basic upload request for repo {}/{} with oid: {}", + repo_owner, repo_id, oid + ); + + // Write the data to the appropriate location in the data directory. + let mut stream = body.into_data_stream(); + + let p = std::path::Path::new(&state.lfs_dir) + .join(&repo_owner) + .join(&repo_id) + .join(&oid); + let parent_dir = p.parent().unwrap(); + match std::fs::create_dir_all(parent_dir) { + Ok(_) => {} + Err(e) => { + eprintln!("Failed to create directories: {}", e); + return Response::builder() + .status(axum::http::StatusCode::INTERNAL_SERVER_ERROR) + .body("Failed to create appropriate directories".into()) + .unwrap(); + } + } + match std::fs::File::create(p) { + Ok(mut file) => { + while let Some(chunk) = stream.next().await { + match chunk { + Ok(bytes) => { + if let Err(e) = std::io::Write::write_all(&mut file, &bytes) { + eprintln!("Failed to write to file: {}", e); + return Response::builder() + .status(axum::http::StatusCode::INTERNAL_SERVER_ERROR) + .body("Failed to write to file".into()) + .unwrap(); + } + } + Err(e) => { + eprintln!("Failed to read chunk from body: {}", e); + return Response::builder() + .status(axum::http::StatusCode::INTERNAL_SERVER_ERROR) + .body("Failed to read chunk from body".into()) + .unwrap(); + } + } + } + } + Err(e) => { + eprintln!("Failed to create file: {}", e); + return Response::builder() + .status(axum::http::StatusCode::INTERNAL_SERVER_ERROR) + .body("Failed to create file".into()) + .unwrap(); + } + } + + return Response::builder() + .status(axum::http::StatusCode::OK) + .body("".into()) + .unwrap(); +} + +async fn handle_download_objects_basic( + State(state): State, + Path((repo_owner, repo_id, oid)): Path<(String, String, String)>, +) -> Response { + println!( + "Received download request for repo {}/{} with oid: {}", + repo_owner, repo_id, oid + ); + + // Check if the object exists in the data directory. + let obj_path = std::path::Path::new(&state.lfs_dir) + .join(&repo_owner) + .join(&repo_id) + .join("") + .join(&oid); + + let exists_res = std::fs::exists(&obj_path); + if exists_res.is_err() { + return Response::builder() + .status(axum::http::StatusCode::INTERNAL_SERVER_ERROR) + .body("Failed to check if object exists".into()) + .unwrap(); + } else if !exists_res.unwrap() { + return Response::builder() + .status(axum::http::StatusCode::NOT_FOUND) + .body("Object not found".into()) + .unwrap(); + } + + let file_stream = match tokio::fs::File::open(&obj_path).await { + Ok(f) => ReaderStream::new(f), + Err(e) => { + eprintln!("Failed to open file: {}", e); + return Response::builder() + .status(axum::http::StatusCode::INTERNAL_SERVER_ERROR) + .body("Failed to open file".into()) + .unwrap(); + } + }; + + // Upload the file as a stream in the response body. + return Response::builder() + .status(200) + .header(CONTENT_TYPE, "application/octet-stream") + .body(Body::from_stream(file_stream)) + .unwrap(); +} + +async fn handle_verify_objects_basic( + State(state): State, + Path((repo_owner, repo_id, oid)): Path<(String, String, String)>, +) -> Response { + // Check if the object exists in the data directory. + let obj_path = std::path::Path::new(&state.lfs_dir) + .join(&repo_owner) + .join(&repo_id) + .join(&oid); + + let exists_res = std::fs::exists(&obj_path); + if exists_res.is_err() { + return Response::builder() + .status(axum::http::StatusCode::INTERNAL_SERVER_ERROR) + .body("Failed to check if object exists".into()) + .unwrap(); + } else if !exists_res.unwrap() { + return Response::builder() + .status(axum::http::StatusCode::NOT_FOUND) + .body("Object not found".into()) + .unwrap(); + } + return Response::builder() + .status(axum::http::StatusCode::OK) + .body("Verification successful".into()) + .unwrap(); +} diff --git a/src/routers/repository/info/refs.rs b/src/routers/repository/info/refs.rs new file mode 100644 index 0000000..a1c181c --- /dev/null +++ b/src/routers/repository/info/refs.rs @@ -0,0 +1,87 @@ +use axum::{ + body::Body, + extract::{Path, Query, State}, + http::header::{CACHE_CONTROL, CONTENT_TYPE}, + response::Response, +}; +use log::info; +use serde::Deserialize; + +use crate::AppState; + +pub fn create_refs_router() -> axum::Router { + axum::Router::::new().route( + "/", + axum::routing::get(handle_git_advertisement_request), + ) +} + +#[derive(Deserialize, Debug)] +struct SmartGitServerParams { + pub service: String, +} + +async fn handle_git_advertisement_request( + State(state): State, + Path((repo_owner, repo_id)): Path<(String, String)>, + Query(params): Query, +) -> Response { + info!( + "Handling git advertisement request for repo: {}/{} with service: {}", + repo_owner, repo_id, params.service + ); + if params.service == "git-upload-pack" || params.service == "git-receive-pack" { + // Handle git-upload-pack by requesting from the actual git executable. + // The service is filtered so there is no danger for using the params.service directly. + // Exec should be like this: `git upload-pack|receive-pack --stateless-rpc --advertise-refs ` + let exec = std::process::Command::new("git") + .arg( + params + .service + .clone() + .strip_prefix("git-") + .expect("cutting the git- prefix failed."), + ) + .arg("--stateless-rpc") + .arg("--advertise-refs") + .arg( + std::path::Path::new(&state.repositories_dir) + .join(&repo_owner) + .join(&repo_id), + ) + .output(); + // TODO: This could block the server. This should be a tokio command. + let output = match exec { + Ok(output) => output, + Err(e) => { + eprintln!("Failed to execute git upload-pack: {}", e); + return Response::builder() + .status(axum::http::StatusCode::INTERNAL_SERVER_ERROR) + .body("Failed to execute git upload-pack".into()) + .unwrap(); + } + }; + let mut response_bytes = Vec::new(); + let service_line = format!("# service={}\n", params.service); + let pkt_len = service_line.len() + 4; // 4 for the length prefix + response_bytes.extend_from_slice(format!("{:04x}", pkt_len).as_bytes()); + response_bytes.extend_from_slice(service_line.as_bytes()); + response_bytes.extend_from_slice(b"0000"); + + response_bytes.extend_from_slice(&output.stdout); + + return Response::builder() + .status(axum::http::StatusCode::OK) + .header( + CONTENT_TYPE, + format!("application/x-{}-advertisement", params.service), + ) + .header(CACHE_CONTROL, "no-cache") + .body(Body::from(response_bytes)) + .unwrap(); + } + return Response::builder() + .status(axum::http::StatusCode::FORBIDDEN) + .body("Unsupported service".into()) + .unwrap(); +} From 26dff92d89a42364cae4c5fe99e27b8bedd3c62b Mon Sep 17 00:00:00 2001 From: Ali Efruz YILDIRIR Date: Fri, 14 Aug 2026 15:07:10 +0300 Subject: [PATCH 3/4] Some fixes and imrpovement on lfs. Lfs now does path partitions to optimize. --- src/routers/repository.rs | 6 - src/routers/repository/info/lfs.rs | 176 ++++++++++++++++------------- 2 files changed, 97 insertions(+), 85 deletions(-) diff --git a/src/routers/repository.rs b/src/routers/repository.rs index 743e902..4e3825d 100644 --- a/src/routers/repository.rs +++ b/src/routers/repository.rs @@ -87,12 +87,6 @@ async fn check_repository_exists( .body("Repository not found".into()) .unwrap(); } - log::info!( - "Repository exists: {}/{} at path: {}", - repo_owner, - repo_id, - repo_path.display() - ); return next.run(req).await; } diff --git a/src/routers/repository/info/lfs.rs b/src/routers/repository/info/lfs.rs index 2d7ef05..dc841d4 100644 --- a/src/routers/repository/info/lfs.rs +++ b/src/routers/repository/info/lfs.rs @@ -16,7 +16,13 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ -use axum::{body::Body, extract::DefaultBodyLimit, http::Uri, routing::post}; +use axum::{ + body::Body, + extract::DefaultBodyLimit, + http::{self, Uri}, + routing::post, +}; +use log::error; use std::collections::HashMap; use tokio_stream::StreamExt; use tokio_util::io::ReaderStream; @@ -30,7 +36,6 @@ use axum::{ }, response::{IntoResponse, Response}, }; -use log::{debug, error}; use serde::{Deserialize, Serialize}; use crate::AppState; @@ -39,7 +44,7 @@ pub fn create_lfs_router() -> axum::Router { axum::Router::::new() .route("/objects/batch", post(handle_batch)) .route( - "/transfer/_basic/{oid}", + "/transfer/_basic/{oid_0_2}/{oid_2_4}/{oid}", axum::routing::get(handle_download_objects_basic) .layer(DefaultBodyLimit::disable()) .post(handle_verify_objects_basic) @@ -56,7 +61,7 @@ fn format_lfs_basic_transfer_url( repo_owner: &str, repo_id: &str, oid: &str, -) -> Uri { +) -> Result { Uri::builder() .scheme(base_addr.scheme_str().unwrap_or("http")) .authority(base_addr.authority().map(|a| a.as_str()).unwrap_or("")) @@ -65,18 +70,6 @@ fn format_lfs_basic_transfer_url( repo_owner, repo_id, oid )) .build() - .unwrap_or_else(|_| { - error!( - "Failed to build LFS basic transfer URL for repo {}/{} with oid: {}", - repo_owner, repo_id, oid - ); - format!( - "{}/{}/{}/info/lfs/transfer/_basic/{}", - base_addr, repo_owner, repo_id, oid - ) - .parse::() - .unwrap_or(Uri::default()) - }) } #[derive(Deserialize, Serialize, Debug)] @@ -108,12 +101,19 @@ struct BatchResponseObjectAction { expires_at: Option, } +#[derive(Deserialize, Serialize, Debug)] +struct BatchResponseObjectError { + code: u16, + message: String, +} + #[derive(Deserialize, Serialize, Debug)] struct BatchResponseObject { oid: String, size: u64, authenticated: bool, - actions: HashMap, + actions: Option>, + error: Option, } #[derive(Deserialize, Serialize)] @@ -129,15 +129,11 @@ async fn handle_batch( headers: HeaderMap, Json(batch_request): Json, ) -> Response { - debug!( - "Received batch request for repo {}/{}: {:?}", - repo_owner, repo_id, batch_request - ); match headers.get(ACCEPT) { Some(accept_header) if accept_header == LFS_JSON_MIME_TYPE => {} _ => { return ( - axum::http::StatusCode::BAD_REQUEST, + axum::http::StatusCode::NOT_ACCEPTABLE, "Missing or invalid LFS Accept header", ) .into_response(); @@ -208,13 +204,7 @@ async fn handle_batch( "basic".to_string() } }; - // Process batch requests. - println!( - "Received batch request for repo {}/{} with operation: {}. Selected transfer: {}", - repo_owner, repo_id, batch_request.operation, transfer - ); - let response = BatchResponse { hash_algo: match batch_request.hash_algo { Some(algo) => Some(algo), @@ -222,14 +212,30 @@ async fn handle_batch( }, transfer: transfer, objects: Vec::from_iter(batch_request.objects.iter().map(|object| { + let exists = get_lfs_object_path_from_oid(&state, &object.oid).exists(); + let obj_url = match format_lfs_basic_transfer_url( + &state.base_addr, + repo_owner.as_str(), + repo_id.as_str(), + &object.oid, + ) { + Ok(uri) => uri.to_string(), + Err(e) => { + eprintln!("Failed to format LFS basic transfer URL: {}", e); + return BatchResponseObject { + oid: object.oid.clone(), + size: object.size, + authenticated: false, + actions: None, + error: Some(BatchResponseObjectError { + code: 500, + message: "Failed to format LFS basic transfer URL".to_string(), + }), + }; + } + }; let obj_act = BatchResponseObjectAction { - href: format_lfs_basic_transfer_url( - &state.base_addr, - repo_owner.as_str(), - repo_id.as_str(), - &object.oid, - ) - .to_string(), + href: obj_url, header: None, expires_in: Some(3600), expires_at: None, @@ -239,13 +245,30 @@ async fn handle_batch( oid: object.oid.clone(), size: object.size, authenticated: true, + error: match batch_request.operation.as_str() { + "upload" => None, + "download" => match exists { + true => None, + false => Some(BatchResponseObjectError { + code: 404, + message: "Object not found".to_string(), + }), + }, + _ => None, + }, actions: match batch_request.operation.as_str() { - "upload" => HashMap::from([ - ("upload".into(), obj_act.clone()), - ("verify".into(), obj_act), - ]), - "download" => HashMap::from([("download".into(), obj_act)]), - _ => HashMap::default(), + "upload" => match exists { + true => None, + false => Some(HashMap::from([ + ("upload".into(), obj_act.clone()), + ("verify".into(), obj_act), + ])), + }, + "download" => match exists { + true => Some(HashMap::from([("download".into(), obj_act)])), + false => None, + }, + _ => None, }, } })), @@ -258,31 +281,38 @@ async fn handle_batch( .unwrap(); } +fn get_lfs_object_path_from_oid(state: &AppState, oid: &str) -> std::path::PathBuf { + std::path::Path::new(&state.lfs_dir) + .join(oid.get(0..2).unwrap_or("")) + .join(oid.get(2..4).unwrap_or("")) + .join(oid.get(4..).unwrap_or("")) +} + +// TODO: Later check if the repo has a real request for the upload and add a authentication step for these. async fn handle_upload_objects_basic( State(state): State, - Path((repo_owner, repo_id, oid)): Path<(String, String, String)>, + Path((_repo_owner, _repo_id, oid)): Path<(String, String, String)>, body: Body, ) -> Response { - println!( - "Received basic upload request for repo {}/{} with oid: {}", - repo_owner, repo_id, oid - ); - + // If already existing, return 200 OK. + let p = get_lfs_object_path_from_oid(&state, &oid); + if p.exists() { + return Response::builder() + .status(axum::http::StatusCode::OK) + .body("Object already exists".into()) + .unwrap(); + } // Write the data to the appropriate location in the data directory. let mut stream = body.into_data_stream(); - let p = std::path::Path::new(&state.lfs_dir) - .join(&repo_owner) - .join(&repo_id) - .join(&oid); let parent_dir = p.parent().unwrap(); match std::fs::create_dir_all(parent_dir) { Ok(_) => {} Err(e) => { - eprintln!("Failed to create directories: {}", e); + error!("Failed to create directories: {}", e); return Response::builder() .status(axum::http::StatusCode::INTERNAL_SERVER_ERROR) - .body("Failed to create appropriate directories".into()) + .body("Internal server error.".into()) .unwrap(); } } @@ -292,28 +322,28 @@ async fn handle_upload_objects_basic( match chunk { Ok(bytes) => { if let Err(e) = std::io::Write::write_all(&mut file, &bytes) { - eprintln!("Failed to write to file: {}", e); + error!("Failed to write to file: {}", e); return Response::builder() .status(axum::http::StatusCode::INTERNAL_SERVER_ERROR) - .body("Failed to write to file".into()) + .body("Internal server error.".into()) .unwrap(); } } Err(e) => { - eprintln!("Failed to read chunk from body: {}", e); + error!("Failed to read chunk from body: {}", e); return Response::builder() .status(axum::http::StatusCode::INTERNAL_SERVER_ERROR) - .body("Failed to read chunk from body".into()) + .body("Internal server error.".into()) .unwrap(); } } } } Err(e) => { - eprintln!("Failed to create file: {}", e); + error!("Failed to create file: {}", e); return Response::builder() .status(axum::http::StatusCode::INTERNAL_SERVER_ERROR) - .body("Failed to create file".into()) + .body("Internal server error.".into()) .unwrap(); } } @@ -326,25 +356,16 @@ async fn handle_upload_objects_basic( async fn handle_download_objects_basic( State(state): State, - Path((repo_owner, repo_id, oid)): Path<(String, String, String)>, + Path((_repo_owner, _repo_id, oid)): Path<(String, String, String)>, ) -> Response { - println!( - "Received download request for repo {}/{} with oid: {}", - repo_owner, repo_id, oid - ); - // Check if the object exists in the data directory. - let obj_path = std::path::Path::new(&state.lfs_dir) - .join(&repo_owner) - .join(&repo_id) - .join("") - .join(&oid); + let obj_path = get_lfs_object_path_from_oid(&state, &oid); let exists_res = std::fs::exists(&obj_path); if exists_res.is_err() { return Response::builder() .status(axum::http::StatusCode::INTERNAL_SERVER_ERROR) - .body("Failed to check if object exists".into()) + .body("Internal server error.".into()) .unwrap(); } else if !exists_res.unwrap() { return Response::builder() @@ -356,10 +377,10 @@ async fn handle_download_objects_basic( let file_stream = match tokio::fs::File::open(&obj_path).await { Ok(f) => ReaderStream::new(f), Err(e) => { - eprintln!("Failed to open file: {}", e); + error!("Failed to open file: {}", e); return Response::builder() .status(axum::http::StatusCode::INTERNAL_SERVER_ERROR) - .body("Failed to open file".into()) + .body("Internal server error.".into()) .unwrap(); } }; @@ -374,19 +395,16 @@ async fn handle_download_objects_basic( async fn handle_verify_objects_basic( State(state): State, - Path((repo_owner, repo_id, oid)): Path<(String, String, String)>, + Path((_repo_owner, _repo_id, oid)): Path<(String, String, String)>, ) -> Response { // Check if the object exists in the data directory. - let obj_path = std::path::Path::new(&state.lfs_dir) - .join(&repo_owner) - .join(&repo_id) - .join(&oid); + let obj_path = get_lfs_object_path_from_oid(&state, &oid); let exists_res = std::fs::exists(&obj_path); if exists_res.is_err() { return Response::builder() .status(axum::http::StatusCode::INTERNAL_SERVER_ERROR) - .body("Failed to check if object exists".into()) + .body("Internal server error.".into()) .unwrap(); } else if !exists_res.unwrap() { return Response::builder() From b2b3b146efed3e47ab4cdf76fc7f8bc3f7bd18af Mon Sep 17 00:00:00 2001 From: Ali Efruz YILDIRIR Date: Fri, 14 Aug 2026 15:14:22 +0300 Subject: [PATCH 4/4] Change get_default_data_dir to get_default_repositories_dir in unix --- src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 13a5283..b90268b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -85,7 +85,7 @@ fn get_default_repositories_dir() -> String { } #[cfg(unix)] -fn get_default_data_dir() -> String { +fn get_default_repositories_dir() -> String { "/var/lib/edvar-git-server/repositories".into() }