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();
+}