diff --git a/Cargo.lock b/Cargo.lock index 22bb8a4..e460a04 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,18 @@ 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 = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.12.1" @@ -311,20 +334,24 @@ 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", + "futures-util", "log", + "rusqlite", "serde", "serde_json", "tokio", "tokio-stream", "tokio-util", + "tower", ] [[package]] @@ -383,6 +410,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 +434,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" @@ -431,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" @@ -443,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", @@ -500,11 +557,42 @@ 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 = "hdrhistogram" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f49d1053f4708f0af3cf9fc5bffc7e68a914a3c45becb231c80068c9c3f78bea" +dependencies = [ + "byteorder", + "num-traits", +] [[package]] name = "heck" @@ -600,7 +688,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", ] [[package]] @@ -661,12 +749,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" @@ -717,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" @@ -861,6 +978,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 +1173,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" @@ -1172,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", @@ -1242,12 +1400,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..eff01a3 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" @@ -9,9 +9,15 @@ 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"] } 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 new file mode 100644 index 0000000..8174398 --- /dev/null +++ b/src/database.rs @@ -0,0 +1,40 @@ +/* +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 { + 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..b90268b 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,19 @@ use std::{ path::Path, }; +use axum::http::Uri; use faccess::PathExt; use axum_server::tls_rustls::RustlsConfig; use clap::Parser; +use log::{error, info}; -mod repo_router; +use crate::routers::create_main_router; -/// Edvar LFS Server - A simple LFS server +// mod database; +mod routers; + +/// Edvar Git Server - A simple Git server #[derive(Debug, Parser)] #[command(version, about, long_about = None, author = "Edvar Studio ")] struct Args { @@ -45,32 +50,59 @@ 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, required = true, help = "The base URL for the LFS server")] - base_url: 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, - base_url: String, + repositories_dir: String, + lfs_dir: String, + base_addr: axum::http::Uri, +} + +impl Default for AppState { + fn default() -> Self { + AppState { + 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() } #[cfg(unix)] -fn get_default_data_dir() -> String { - "/var/lib/edvar-lfs/data".into(); +fn get_default_repositories_dir() -> String { + "/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] @@ -78,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); } @@ -105,9 +147,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(), + repositories_dir: args.repositories_dir.clone(), + lfs_dir: args.lfs_dir.clone(), + base_addr: base_addr, }; let addr = SocketAddr::new( @@ -115,10 +169,10 @@ async fn main() { args.port, ); - let app = axum::Router::::new() - .nest("/{repo_owner}/{repo_id}", repo_router::create_repo_router()) - .with_state(shared_data); - println!("Listening on {}://{}/", scheme, addr); + let app = create_main_router(shared_data.clone()).with_state(shared_data); + + 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/routers/mod.rs b/src/routers/mod.rs new file mode 100644 index 0000000..26893c5 --- /dev/null +++ b/src/routers/mod.rs @@ -0,0 +1,26 @@ +/* +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 new file mode 100644 index 0000000..4e3825d --- /dev/null +++ b/src/routers/repository.rs @@ -0,0 +1,281 @@ +/* +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(); + } + 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() +} + +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 +} + +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 new file mode 100644 index 0000000..e0cc922 --- /dev/null +++ b/src/routers/repository/info.rs @@ -0,0 +1,27 @@ +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/repo_router.rs b/src/routers/repository/info/lfs.rs similarity index 63% rename from src/repo_router.rs rename to src/routers/repository/info/lfs.rs index f95b258..dc841d4 100644 --- a/src/repo_router.rs +++ b/src/routers/repository/info/lfs.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 @@ -16,40 +16,61 @@ 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::{self, Uri}, + routing::post, +}; +use log::error; use std::collections::HashMap; +use tokio_stream::StreamExt; +use tokio_util::io::ReaderStream; -use crate::AppState; use axum::{ - Router, - body::Body, - extract::{DefaultBodyLimit, Json, Path, State}, + Json, + extract::{Path, State}, http::{ HeaderMap, header::{ACCEPT, 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() +use crate::AppState; + +pub fn create_lfs_router() -> axum::Router { + axum::Router::::new() .route("/objects/batch", post(handle_batch)) .route( - "/_basic/{oid}", - put(handle_upload_objects_basic) + "/transfer/_basic/{oid_0_2}/{oid_2_4}/{oid}", + axum::routing::get(handle_download_objects_basic) .layer(DefaultBodyLimit::disable()) - .get(handle_download_objects_basic) + .post(handle_verify_objects_basic) .layer(DefaultBodyLimit::disable()) - .post(handle_verify_objects_basic), + .put(handle_upload_objects_basic) + .layer(DefaultBodyLimit::disable()), ) } -const LFS_ACCEPT_TYPE: &str = "application/vnd.git-lfs+json"; -const LFS_CONTENT_TYPE: &str = "application/vnd.git-lfs+json"; +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, +) -> Result { + 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() +} #[derive(Deserialize, Serialize, Debug)] struct BatchRequestRef { @@ -80,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)] @@ -101,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_ACCEPT_TYPE => {} + 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(); @@ -122,7 +146,7 @@ async fn handle_batch( 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 { + if parts.is_empty() || parts[0] != LFS_JSON_MIME_TYPE { return ( axum::http::StatusCode::BAD_REQUEST, "Missing or invalid LFS Content-Type header", @@ -180,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), @@ -194,11 +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!( - "{}/{}/{}/_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: obj_url, header: None, expires_in: Some(3600), expires_at: None, @@ -208,12 +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, }, } })), @@ -221,36 +276,43 @@ async fn handle_batch( return Response::builder() .status(axum::http::StatusCode::OK) - .header(CONTENT_TYPE, LFS_CONTENT_TYPE) + .header(CONTENT_TYPE, LFS_JSON_MIME_TYPE) .body(serde_json::to_string(&response).unwrap().into()) .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.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); + 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(); } } @@ -260,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(); } } @@ -294,24 +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.data_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() @@ -323,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(); } }; @@ -341,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.data_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() 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(); +}