1067 lines · 31962 bytes
1 # SPEC.md — Public Git Hosting in Rust (Smart HTTP + Quotas)
2
3 This spec is meant to be **implementation-ready**: schema, routes, and code skeletons are filled in so an implementation agent can mostly copy/paste.
4
5 **Goal:** One Rust service (`mygitd`) running on a VPS that:
6 - provides signup/login
7 - lets users create/delete/list repos (all public)
8 - supports `git clone/fetch/push` over HTTPS via **Smart HTTP**
9 - enforces:
10 - **max repos per user** (default 25)
11 - **max push body size** (default 10 MiB)
12 - **max repo disk usage** (default 50 MiB)
13
14 **Key design choice:** Do **not** implement the Git wire protocol in Rust. Instead:
15 - Rust server handles auth/quotas/routing
16 - Git protocol is handled by the system `git-http-backend`
17 - Repo quota enforcement uses a **Rust hook executable** installed as `hooks/pre-receive`
18
19 ---
20
21 ## 0) Non-goals (v1)
22
23 - SSH transport (HTTPS only)
24 - repo visibility (everything is public)
25 - code browsing UI
26 - orgs/teams
27 - LFS
28 - PR/issues/etc.
29
30 ---
31
32 ## 1) Architecture
33
34 ### 1.1 Storage
35 Bare repos on disk:
36
37 ```
38 <REPOS_ROOT>/<username>/<repo>.git
39 ```
40
41 Default:
42 - `REPOS_ROOT=/srv/mygit/repos`
43
44 ### 1.2 Read vs write
45 - Read (clone/fetch): anonymous allowed
46 - Write (push): **requires bearer token** AND token owner must match repo owner
47
48 ### 1.3 Git Smart HTTP
49 All Git HTTP requests are handled by `mygitd`, which runs `git-http-backend` as a child process and streams stdin/stdout.
50
51 Supported Git endpoints:
52 - `GET /:user/:repo.git/info/refs?service=git-upload-pack`
53 - `POST /:user/:repo.git/git-upload-pack`
54 - `GET /:user/:repo.git/HEAD`
55 - `POST /:user/:repo.git/git-receive-pack` (**push**)
56
57 ---
58
59 ## 2) Quotas & Enforcement
60
61 Defaults:
62 - `MAX_REPOS_PER_USER = 25`
63 - `MAX_REPO_BYTES = 50 * 1024 * 1024` (50 MiB)
64 - `MAX_PUSH_BYTES = 10 * 1024 * 1024` (10 MiB)
65
66 Enforcement points:
67 1) **Repo creation**: DB count check. Reject creation if count >= MAX_REPOS_PER_USER.
68 2) **Push size**: HTTP body limit for `git-receive-pack` (and optionally reverse proxy).
69 3) **Repo size**: Rust `pre-receive` hook computes repo directory size; rejects if > MAX_REPO_BYTES.
70
71 Notes:
72 - The pre-receive hook runs during pushes, so refs won’t update if it rejects.
73 - Rejected pushes may still upload objects; optional GC/prune is v2.
74
75 ---
76
77 ## 3) Configuration
78
79 All config via environment variables.
80
81 | Env var | Default | Meaning |
82 |---|---:|---|
83 | `MYGIT_BIND` | `0.0.0.0:8080` | bind address |
84 | `MYGIT_REPOS_ROOT` | `/srv/mygit/repos` | repos root |
85 | `MYGIT_DB_URL` | `sqlite:/srv/mygit/mygit.db` | DB URL |
86 | `MYGIT_MAX_REPOS_PER_USER` | `25` | quota |
87 | `MYGIT_MAX_REPO_BYTES` | `52428800` | quota bytes |
88 | `MYGIT_MAX_PUSH_BYTES` | `10485760` | max HTTP request body for pushes |
89 | `MYGIT_HOOK_PATH` | `/usr/local/bin/git-quota-hook` | shared hook binary path |
90 | `MYGIT_GIT_HTTP_BACKEND` | `git-http-backend` | backend executable |
91
92 ---
93
94 ## 4) Database Schema (SQLite)
95
96 `migrations/0001_init.sql`
97
98 ```sql
99 PRAGMA foreign_keys = ON;
100
101 CREATE TABLE users (
102 id INTEGER PRIMARY KEY AUTOINCREMENT,
103 username TEXT NOT NULL UNIQUE,
104 pass_hash TEXT NOT NULL,
105 created_at TEXT NOT NULL
106 );
107
108 CREATE TABLE tokens (
109 token_hash TEXT PRIMARY KEY, -- SHA256 of token
110 user_id INTEGER NOT NULL,
111 created_at TEXT NOT NULL,
112 FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
113 );
114
115 CREATE TABLE repos (
116 id INTEGER PRIMARY KEY AUTOINCREMENT,
117 owner_id INTEGER NOT NULL,
118 name TEXT NOT NULL,
119 created_at TEXT NOT NULL,
120 UNIQUE(owner_id, name),
121 FOREIGN KEY(owner_id) REFERENCES users(id) ON DELETE CASCADE
122 );
123
124 CREATE INDEX idx_repos_owner_id ON repos(owner_id);
125 CREATE INDEX idx_tokens_user_id ON tokens(user_id);
126 ```
127
128 ---
129
130 ## 5) Naming & Validation Rules
131
132 ### Username
133 - regex: `^[a-z0-9][a-z0-9_-]{1,31}$`
134 - lowercase only
135 - length: 2..32
136
137 ### Repo name
138 - regex: `^[a-z0-9][a-z0-9._-]{1,63}$`
139 - must NOT end with `.git`
140 - length: 2..64
141
142 ### Password
143 - 10..200 chars
144 - stored with Argon2id
145
146 ---
147
148 ## 6) HTTP API
149
150 All endpoints are JSON. Errors use:
151
152 ```json
153 { "error": { "code": "CODE", "message": "..." } }
154 ```
155
156 Auth uses:
157 - `Authorization: Bearer <token>`
158
159 ### POST /api/signup
160 Request:
161 ```json
162 { "username": "alice", "password": "..." }
163 ```
164 Response 201:
165 ```json
166 { "user": { "id": 1, "username": "alice" } }
167 ```
168
169 ### POST /api/login
170 Request:
171 ```json
172 { "username": "alice", "password": "..." }
173 ```
174 Response 200:
175 ```json
176 { "token": "MYGIT_...", "user": { "id": 1, "username": "alice" } }
177 ```
178
179 ### POST /api/repos (auth)
180 Request:
181 ```json
182 { "name": "rfc-foo" }
183 ```
184 Response 201:
185 ```json
186 { "repo": { "owner": "alice", "name": "rfc-foo", "http_url": "https://host/alice/rfc-foo.git" } }
187 ```
188
189 ### GET /api/repos/:username
190 Response 200:
191 ```json
192 { "repos": [ { "owner": "alice", "name": "rfc-foo", "http_url": "https://host/alice/rfc-foo.git" } ] }
193 ```
194
195 ### DELETE /api/repos/:username/:repo (auth; owner only)
196 Response 204 empty.
197
198 ---
199
200 ## 7) Repo Creation Procedure
201
202 On `POST /api/repos`:
203 1) Validate repo name
204 2) `SELECT COUNT(*) FROM repos WHERE owner_id=?` and enforce `MAX_REPOS_PER_USER`
205 3) Insert repo row in a transaction
206 4) Create directories:
207 - `mkdir -p <REPOS_ROOT>/<username>`
208 - `mkdir <REPOS_ROOT>/<username>/<repo>.git`
209 5) `git init --bare <repo_path>`
210 6) Install hook (symlink strongly preferred):
211 - `<repo_path>/hooks/pre-receive -> MYGIT_HOOK_PATH`
212 7) Commit transaction
213
214 Rollback behavior:
215 - If filesystem or git init fails, rollback DB tx and delete any created repo dir.
216
217 ---
218
219 ## 8) Git Request Handling
220
221 ### 8.1 Routing
222 All non-`/api/*` routes are treated as potential Git routes.
223 Expected path format:
224
225 ```
226 /:user/:repo.git/<rest>
227 ```
228
229 If the second segment does not end with `.git`, return 404.
230
231 ### 8.2 Repo existence
232 Before forwarding to Git backend, ensure the repo exists in DB:
233 - `SELECT ... WHERE users.username=? AND repos.name=?`
234
235 ### 8.3 Auth for push
236 If `<rest>` ends with `git-receive-pack`:
237 - require Bearer token
238 - token must map to a user whose username equals `:user`
239
240 Otherwise allow anonymous.
241
242 ---
243
244 ## 9) Hook Program (Rust executable)
245
246 Hook file is an executable named `pre-receive` in the bare repo’s `hooks/` directory.
247 This hook is a **Rust binary** compiled separately and installed once at `MYGIT_HOOK_PATH`.
248 Each repo symlinks `hooks/pre-receive` to this shared binary.
249
250 ### Environment variables
251 - `MAX_REPO_BYTES` (passed by server to git-http-backend child env)
252
253 ### hook/Cargo.toml
254 ```toml
255 [package]
256 name = "git-quota-hook"
257 version = "0.1.0"
258 edition = "2021"
259
260 [dependencies]
261 walkdir = "2"
262 ```
263
264 ### hook/src/main.rs
265 ```rust
266 use std::{
267 env,
268 io::{self, Read},
269 process,
270 };
271 use walkdir::WalkDir;
272
273 fn dir_size_bytes(root: &str) -> io::Result<u64> {
274 let mut total: u64 = 0;
275 for entry in WalkDir::new(root).follow_links(false) {
276 let entry = entry.map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?;
277 if entry.file_type().is_file() {
278 let md = entry.metadata()?;
279 total = total.saturating_add(md.len());
280 }
281 }
282 Ok(total)
283 }
284
285 fn main() {
286 // Consume stdin (pre-receive provides ref updates on stdin)
287 let mut _stdin = String::new();
288 let _ = io::stdin().read_to_string(&mut _stdin);
289
290 let max_bytes: u64 = env::var("MAX_REPO_BYTES")
291 .ok()
292 .and_then(|v| v.parse::<u64>().ok())
293 .unwrap_or(50 * 1024 * 1024);
294
295 let size = match dir_size_bytes(".") {
296 Ok(s) => s,
297 Err(e) => {
298 eprintln!("quota hook error: failed to compute repo size: {e}");
299 process::exit(1);
300 }
301 };
302
303 if size > max_bytes {
304 eprintln!("Rejected: repo exceeds quota ({} bytes > {} bytes).", size, max_bytes);
305 process::exit(1);
306 }
307
308 process::exit(0);
309 }
310 ```
311
312 ---
313
314 ## 10) Rust Server Code Skeleton
315
316 ### 10.1 Server Cargo.toml
317 ```toml
318 [package]
319 name = "mygitd"
320 version = "0.1.0"
321 edition = "2021"
322
323 [dependencies]
324 axum = "0.7"
325 tokio = { version = "1", features = ["full"] }
326 tower = "0.5"
327 tower-http = { version = "0.6", features = ["trace", "timeout", "limit"] }
328 tokio-util = "0.7"
329 sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "macros"] }
330 argon2 = "0.5"
331 rand = "0.8"
332 hex = "0.4"
333 sha2 = "0.10"
334 tracing = "0.1"
335 tracing-subscriber = { version = "0.3", features = ["env-filter"] }
336 chrono = "0.4"
337 serde = { version = "1", features = ["derive"] }
338 serde_json = "1"
339 thiserror = "1"
340 anyhow = "1"
341 ```
342
343 ### 10.2 src/config.rs
344 ```rust
345 use std::env;
346
347 #[derive(Clone)]
348 pub struct Config {
349 pub bind: String,
350 pub repos_root: String,
351 pub db_url: String,
352 pub max_repos_per_user: i64,
353 pub max_repo_bytes: u64,
354 pub max_push_bytes: usize,
355 pub hook_path: String,
356 pub git_http_backend: String,
357 }
358
359 impl Config {
360 pub fn from_env() -> Self {
361 let bind = env::var("MYGIT_BIND").unwrap_or_else(|_| "0.0.0.0:8080".to_string());
362 let repos_root = env::var("MYGIT_REPOS_ROOT").unwrap_or_else(|_| "/srv/mygit/repos".to_string());
363 let db_url = env::var("MYGIT_DB_URL").unwrap_or_else(|_| "sqlite:/srv/mygit/mygit.db".to_string());
364
365 let max_repos_per_user = env::var("MYGIT_MAX_REPOS_PER_USER").ok().and_then(|v| v.parse().ok()).unwrap_or(25);
366 let max_repo_bytes = env::var("MYGIT_MAX_REPO_BYTES").ok().and_then(|v| v.parse().ok()).unwrap_or(50 * 1024 * 1024);
367 let max_push_bytes = env::var("MYGIT_MAX_PUSH_BYTES").ok().and_then(|v| v.parse().ok()).unwrap_or(10 * 1024 * 1024);
368
369 let hook_path = env::var("MYGIT_HOOK_PATH").unwrap_or_else(|_| "/usr/local/bin/git-quota-hook".to_string());
370 let git_http_backend = env::var("MYGIT_GIT_HTTP_BACKEND").unwrap_or_else(|_| "git-http-backend".to_string());
371
372 Self {
373 bind,
374 repos_root,
375 db_url,
376 max_repos_per_user,
377 max_repo_bytes,
378 max_push_bytes,
379 hook_path,
380 git_http_backend,
381 }
382 }
383 }
384 ```
385
386 ### 10.3 src/db.rs
387 ```rust
388 use sqlx::{sqlite::SqlitePoolOptions, SqlitePool};
389
390 pub async fn connect(db_url: &str) -> SqlitePool {
391 SqlitePoolOptions::new()
392 .max_connections(10)
393 .connect(db_url)
394 .await
395 .expect("DB connect failed")
396 }
397 ```
398
399 ### 10.4 src/validate.rs
400 ```rust
401 pub fn validate_username(u: &str) -> bool {
402 let bytes = u.as_bytes();
403 if u.len() < 2 || u.len() > 32 { return false; }
404 if !(bytes[0].is_ascii_lowercase() || bytes[0].is_ascii_digit()) { return false; }
405 u.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
406 }
407
408 pub fn validate_repo_name(r: &str) -> bool {
409 if r.len() < 2 || r.len() > 64 { return false; }
410 if r.ends_with(".git") { return false; }
411 let bytes = r.as_bytes();
412 if !(bytes[0].is_ascii_lowercase() || bytes[0].is_ascii_digit()) { return false; }
413 r.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '.' || c == '_' || c == '-')
414 }
415 ```
416
417 ### 10.5 src/auth.rs (password + token)
418 ```rust
419 use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
420 use chrono::Utc;
421 use rand::RngCore;
422 use serde::{Deserialize, Serialize};
423 use sha2::{Digest, Sha256};
424 use sqlx::SqlitePool;
425
426 #[derive(Serialize)]
427 pub struct PublicUser {
428 pub id: i64,
429 pub username: String,
430 }
431
432 #[derive(Deserialize)]
433 pub struct SignupReq { pub username: String, pub password: String }
434 #[derive(Deserialize)]
435 pub struct LoginReq { pub username: String, pub password: String }
436
437 pub fn hash_password(password: &str) -> anyhow::Result<String> {
438 let salt = rand::random::<[u8; 16]>();
439 let salt = argon2::password_hash::SaltString::encode_b64(&salt)?;
440 Ok(Argon2::default().hash_password(password.as_bytes(), &salt)?.to_string())
441 }
442
443 pub fn verify_password(hash: &str, password: &str) -> bool {
444 let parsed = PasswordHash::new(hash);
445 if parsed.is_err() { return false; }
446 Argon2::default().verify_password(password.as_bytes(), &parsed.unwrap()).is_ok()
447 }
448
449 pub fn new_token() -> String {
450 let mut b = [0u8; 32];
451 rand::thread_rng().fill_bytes(&mut b);
452 format!("MYGIT_{}", hex::encode(b))
453 }
454
455 pub fn token_hash(token: &str) -> String {
456 let mut h = Sha256::new();
457 h.update(token.as_bytes());
458 hex::encode(h.finalize())
459 }
460
461 pub async fn auth_user_by_token(pool: &SqlitePool, token: &str) -> Option<(i64, String)> {
462 let th = token_hash(token);
463 let row = sqlx::query!(
464 r#"SELECT users.id as "id!: i64", users.username as "username!: String"
465 FROM tokens
466 JOIN users ON users.id = tokens.user_id
467 WHERE tokens.token_hash = ?"#,
468 th
469 ).fetch_optional(pool).await.ok().flatten()?;
470 Some((row.id, row.username))
471 }
472
473 pub async fn create_user(pool: &SqlitePool, username: &str, password: &str) -> Result<PublicUser, String> {
474 let pass_hash = hash_password(password).map_err(|_| "hash_failed".to_string())?;
475 let now = Utc::now().to_rfc3339();
476
477 let res = sqlx::query!(
478 "INSERT INTO users(username, pass_hash, created_at) VALUES(?, ?, ?)",
479 username, pass_hash, now
480 ).execute(pool).await;
481
482 match res {
483 Ok(r) => Ok(PublicUser { id: r.last_insert_rowid(), username: username.to_string() }),
484 Err(_) => Err("username_taken".to_string()),
485 }
486 }
487
488 pub async fn login(pool: &SqlitePool, username: &str, password: &str) -> Result<(String, PublicUser), String> {
489 let row = sqlx::query!(
490 r#"SELECT id as "id!: i64", username as "username!: String", pass_hash as "pass_hash!: String"
491 FROM users WHERE username = ?"#,
492 username
493 ).fetch_optional(pool).await.map_err(|_| "db_error".to_string())?;
494
495 let row = row.ok_or_else(|| "invalid_credentials".to_string())?;
496 if !verify_password(&row.pass_hash, password) {
497 return Err("invalid_credentials".to_string());
498 }
499
500 let token = new_token();
501 let th = token_hash(&token);
502 let now = Utc::now().to_rfc3339();
503
504 sqlx::query!(
505 "INSERT INTO tokens(token_hash, user_id, created_at) VALUES(?, ?, ?)",
506 th, row.id, now
507 ).execute(pool).await.map_err(|_| "db_error".to_string())?;
508
509 Ok((token, PublicUser { id: row.id, username: row.username }))
510 }
511 ```
512
513 ### 10.6 src/repos.rs
514 ```rust
515 use chrono::Utc;
516 use sqlx::{SqlitePool, Transaction, Sqlite};
517 use std::{fs, process::Command};
518
519 use crate::validate::validate_repo_name;
520
521 pub async fn repo_count(pool: &SqlitePool, owner_id: i64) -> Result<i64, sqlx::Error> {
522 let r = sqlx::query!("SELECT COUNT(*) as \"c!: i64\" FROM repos WHERE owner_id = ?", owner_id)
523 .fetch_one(pool).await?;
524 Ok(r.c)
525 }
526
527 pub async fn repo_exists(pool: &SqlitePool, owner: &str, repo: &str) -> bool {
528 sqlx::query!(
529 r#"SELECT repos.id as \"id!: i64\"
530 FROM repos
531 JOIN users ON users.id = repos.owner_id
532 WHERE users.username = ? AND repos.name = ?"#,
533 owner, repo
534 ).fetch_optional(pool).await.ok().flatten().is_some()
535 }
536
537 pub async fn create_repo(
538 pool: &SqlitePool,
539 repos_root: &str,
540 hook_path: &str,
541 max_repos_per_user: i64,
542 owner_id: i64,
543 owner_username: &str,
544 repo_name: &str,
545 ) -> Result<(), String> {
546 if !validate_repo_name(repo_name) { return Err("invalid_repo_name".into()); }
547
548 let count = repo_count(pool, owner_id).await.map_err(|_| "db_error".to_string())?;
549 if count >= max_repos_per_user {
550 return Err("repo_quota_exceeded".into());
551 }
552
553 let mut tx: Transaction<'_, Sqlite> = pool.begin().await.map_err(|_| "db_error".to_string())?;
554 let now = Utc::now().to_rfc3339();
555
556 let ins = sqlx::query!(
557 "INSERT INTO repos(owner_id, name, created_at) VALUES(?, ?, ?)",
558 owner_id, repo_name, now
559 ).execute(&mut *tx).await;
560
561 if ins.is_err() {
562 tx.rollback().await.ok();
563 return Err("repo_exists".into());
564 }
565
566 let owner_dir = std::path::Path::new(repos_root).join(owner_username);
567 fs::create_dir_all(&owner_dir).map_err(|_| "fs_error".to_string())?;
568
569 let repo_dir = owner_dir.join(format!("{repo_name}.git"));
570 fs::create_dir(&repo_dir).map_err(|_| "fs_error".to_string())?;
571
572 let st = Command::new("git")
573 .arg("init").arg("--bare")
574 .arg(&repo_dir)
575 .status()
576 .map_err(|_| "git_init_failed".to_string())?;
577
578 if !st.success() {
579 tx.rollback().await.ok();
580 let _ = fs::remove_dir_all(&repo_dir);
581 return Err("git_init_failed".into());
582 }
583
584 let hooks_dir = repo_dir.join("hooks");
585 let _ = fs::create_dir_all(&hooks_dir);
586
587 let pre_receive = hooks_dir.join("pre-receive");
588 #[cfg(unix)]
589 {
590 use std::os::unix::fs::symlink;
591 let _ = fs::remove_file(&pre_receive);
592 symlink(hook_path, &pre_receive).map_err(|_| "hook_install_failed".to_string())?;
593 }
594 #[cfg(not(unix))]
595 {
596 fs::copy(hook_path, &pre_receive).map_err(|_| "hook_install_failed".to_string())?;
597 }
598
599 tx.commit().await.map_err(|_| "db_error".to_string())?;
600 Ok(())
601 }
602
603 pub async fn delete_repo(pool: &SqlitePool, repos_root: &str, owner: &str, repo: &str) -> Result<(), String> {
604 let res = sqlx::query!(
605 r#"DELETE FROM repos
606 WHERE id IN (
607 SELECT repos.id
608 FROM repos
609 JOIN users ON users.id = repos.owner_id
610 WHERE users.username = ? AND repos.name = ?
611 )"#,
612 owner, repo
613 ).execute(pool).await.map_err(|_| "db_error".to_string())?;
614
615 if res.rows_affected() == 0 { return Err("not_found".into()); }
616
617 let repo_dir = std::path::Path::new(repos_root).join(owner).join(format!("{repo}.git"));
618 std::fs::remove_dir_all(repo_dir).map_err(|_| "fs_error".to_string())?;
619 Ok(())
620 }
621 ```
622
623 ### 10.7 src/git_http.rs (run git-http-backend)
624 ```rust
625 use axum::{body::Body, http::{Request, Response, StatusCode, HeaderMap}};
626 use std::{process::Stdio, sync::Arc};
627 use tokio::{io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, process::Command};
628 use tokio_util::io::ReaderStream;
629
630 use crate::config::Config;
631
632 #[derive(Clone)]
633 pub struct GitRunner {
634 pub cfg: Arc<Config>,
635 }
636
637 impl GitRunner {
638 pub fn new(cfg: Arc<Config>) -> Self { Self { cfg } }
639
640 pub async fn run_cgi(
641 &self,
642 path_info: &str,
643 req: Request<Body>,
644 remote_user: Option<&str>,
645 ) -> Result<Response<Body>, StatusCode> {
646 let (parts, mut body) = req.into_parts();
647
648 let mut cmd = Command::new(&self.cfg.git_http_backend);
649 cmd.env("GIT_PROJECT_ROOT", &self.cfg.repos_root)
650 .env("GIT_HTTP_EXPORT_ALL", "1")
651 .env("REQUEST_METHOD", parts.method.as_str())
652 .env("PATH_INFO", path_info)
653 .env("QUERY_STRING", parts.uri.query().unwrap_or(""))
654 .env("CONTENT_TYPE", parts.headers.get("content-type").and_then(|v| v.to_str().ok()).unwrap_or(""))
655 .env("REMOTE_USER", remote_user.unwrap_or(""))
656 .env("MAX_REPO_BYTES", self.cfg.max_repo_bytes.to_string())
657 .stdin(Stdio::piped())
658 .stdout(Stdio::piped());
659
660 if let Some(cl) = parts.headers.get("content-length").and_then(|v| v.to_str().ok()) {
661 cmd.env("CONTENT_LENGTH", cl);
662 }
663
664 let mut child = cmd.spawn().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
665 let mut stdin = child.stdin.take().ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
666 let stdout = child.stdout.take().ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
667
668 tokio::spawn(async move {
669 while let Some(frame) = body.frame().await {
670 if let Ok(frame) = frame {
671 if let Ok(chunk) = frame.into_data() {
672 if stdin.write_all(&chunk).await.is_err() { break; }
673 }
674 } else {
675 break;
676 }
677 }
678 let _ = stdin.shutdown().await;
679 });
680
681 let mut reader = BufReader::new(stdout);
682 let (status, headers) = parse_cgi_headers(&mut reader).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
683
684 let stream = ReaderStream::new(reader.into_inner());
685 let body = Body::from_stream(stream);
686
687 let mut resp = Response::new(body);
688 *resp.status_mut() = status;
689 *resp.headers_mut() = headers;
690
691 let _ = child.wait().await;
692 Ok(resp)
693 }
694 }
695
696 async fn parse_cgi_headers<R: tokio::io::AsyncBufRead + Unpin>(
697 reader: &mut R,
698 ) -> Result<(StatusCode, HeaderMap), std::io::Error> {
699 let mut status = StatusCode::OK;
700 let mut headers = HeaderMap::new();
701
702 loop {
703 let mut line = String::new();
704 let n = reader.read_line(&mut line).await?;
705 if n == 0 { break; }
706
707 let line = line.trim_end_matches(&['\r','\n'][..]);
708 if line.is_empty() { break; }
709
710 if let Some(rest) = line.strip_prefix("Status:") {
711 if let Some(code_str) = rest.trim().split_whitespace().next() {
712 if let Ok(code) = code_str.parse::<u16>() {
713 status = StatusCode::from_u16(code).unwrap_or(StatusCode::OK);
714 }
715 }
716 continue;
717 }
718
719 if let Some((k, v)) = line.split_once(':') {
720 if let (Ok(hk), Ok(hv)) = (
721 axum::http::HeaderName::from_bytes(k.trim().as_bytes()),
722 axum::http::HeaderValue::from_str(v.trim()),
723 ) {
724 headers.insert(hk, hv);
725 }
726 }
727 }
728
729 Ok((status, headers))
730 }
731 ```
732
733 ### 10.8 src/api.rs
734 ```rust
735 use axum::{
736 extract::{Path, State},
737 http::{HeaderMap, StatusCode},
738 response::IntoResponse,
739 Json,
740 };
741 use serde::{Deserialize, Serialize};
742 use sqlx::SqlitePool;
743 use std::sync::Arc;
744
745 use crate::{auth, config::Config, repos, validate};
746
747 #[derive(Clone)]
748 pub struct ApiState {
749 pub cfg: Arc<Config>,
750 pub pool: SqlitePool,
751 }
752
753 fn bearer_token(headers: &HeaderMap) -> Option<&str> {
754 let v = headers.get("authorization")?.to_str().ok()?;
755 v.strip_prefix("Bearer ")
756 }
757
758 #[derive(Serialize)]
759 struct ErrObj { error: ErrInner }
760 #[derive(Serialize)]
761 struct ErrInner { code: String, message: String }
762
763 fn json_err(code: &str, message: &str, status: StatusCode) -> (StatusCode, Json<ErrObj>) {
764 (status, Json(ErrObj { error: ErrInner { code: code.into(), message: message.into() } }))
765 }
766
767 pub async fn signup(State(st): State<ApiState>, Json(req): Json<auth::SignupReq>) -> impl IntoResponse {
768 let u = req.username.trim();
769 let p = req.password.as_str();
770 if !validate::validate_username(u) || p.len() < 10 || p.len() > 200 {
771 return json_err("INVALID_INPUT", "Invalid username or password", StatusCode::BAD_REQUEST);
772 }
773 match auth::create_user(&st.pool, u, p).await {
774 Ok(user) => (StatusCode::CREATED, Json(serde_json::json!({"user": user}))).into_response(),
775 Err(_) => json_err("USERNAME_TAKEN", "Username already exists", StatusCode::CONFLICT).into_response(),
776 }
777 }
778
779 pub async fn login(State(st): State<ApiState>, Json(req): Json<auth::LoginReq>) -> impl IntoResponse {
780 let u = req.username.trim();
781 let p = req.password.as_str();
782 match auth::login(&st.pool, u, p).await {
783 Ok((token, user)) => (StatusCode::OK, Json(serde_json::json!({"token": token, "user": user}))).into_response(),
784 Err(_) => json_err("INVALID_CREDENTIALS", "Invalid credentials", StatusCode::UNAUTHORIZED).into_response(),
785 }
786 }
787
788 #[derive(Deserialize)]
789 pub struct CreateRepoReq { pub name: String }
790
791 pub async fn create_repo(State(st): State<ApiState>, headers: HeaderMap, Json(req): Json<CreateRepoReq>) -> impl IntoResponse {
792 let token = match bearer_token(&headers) {
793 Some(t) => t,
794 None => return json_err("UNAUTHORIZED", "Missing bearer token", StatusCode::FORBIDDEN).into_response(),
795 };
796 let (uid, username) = match auth::auth_user_by_token(&st.pool, token).await {
797 Some(x) => x,
798 None => return json_err("UNAUTHORIZED", "Invalid token", StatusCode::FORBIDDEN).into_response(),
799 };
800
801 let name = req.name.trim();
802 match repos::create_repo(
803 &st.pool,
804 &st.cfg.repos_root,
805 &st.cfg.hook_path,
806 st.cfg.max_repos_per_user,
807 uid,
808 &username,
809 name,
810 ).await {
811 Ok(()) => {
812 let http_url = format!("/{}/{}.git", username, name);
813 (StatusCode::CREATED, Json(serde_json::json!({"repo": {"owner": username, "name": name, "http_url": http_url}}))).into_response()
814 }
815 Err(e) if e == "repo_quota_exceeded" => json_err("REPO_QUOTA", "Repo quota exceeded", StatusCode::TOO_MANY_REQUESTS).into_response(),
816 Err(e) if e == "repo_exists" => json_err("REPO_EXISTS", "Repo already exists", StatusCode::CONFLICT).into_response(),
817 Err(e) if e == "invalid_repo_name" => json_err("INVALID_REPO", "Invalid repo name", StatusCode::BAD_REQUEST).into_response(),
818 Err(_) => json_err("ERROR", "Failed to create repo", StatusCode::INTERNAL_SERVER_ERROR).into_response(),
819 }
820 }
821
822 pub async fn list_repos(State(st): State<ApiState>, Path(username): Path<String>) -> impl IntoResponse {
823 let user = sqlx::query!(r#"SELECT id as "id!: i64", username as "username!: String" FROM users WHERE username = ?"#, username)
824 .fetch_optional(&st.pool)
825 .await;
826
827 let user = match user {
828 Ok(Some(u)) => u,
829 _ => return json_err("NOT_FOUND", "User not found", StatusCode::NOT_FOUND).into_response(),
830 };
831
832 let rows = sqlx::query!(r#"SELECT name as "name!: String" FROM repos WHERE owner_id = ? ORDER BY name"#, user.id)
833 .fetch_all(&st.pool)
834 .await;
835
836 let rows = match rows {
837 Ok(r) => r,
838 Err(_) => return json_err("ERROR", "DB error", StatusCode::INTERNAL_SERVER_ERROR).into_response(),
839 };
840
841 let repos_json: Vec<_> = rows
842 .into_iter()
843 .map(|r| {
844 let http_url = format!("/{}/{}.git", user.username, r.name);
845 serde_json::json!({"owner": user.username, "name": r.name, "http_url": http_url})
846 })
847 .collect();
848
849 (StatusCode::OK, Json(serde_json::json!({"repos": repos_json}))).into_response()
850 }
851
852 pub async fn delete_repo(
853 State(st): State<ApiState>,
854 headers: HeaderMap,
855 Path((owner, repo)): Path<(String, String)>,
856 ) -> impl IntoResponse {
857 let token = match bearer_token(&headers) {
858 Some(t) => t,
859 None => return json_err("UNAUTHORIZED", "Missing bearer token", StatusCode::FORBIDDEN).into_response(),
860 };
861 let (_uid, username) = match auth::auth_user_by_token(&st.pool, token).await {
862 Some(x) => x,
863 None => return json_err("UNAUTHORIZED", "Invalid token", StatusCode::FORBIDDEN).into_response(),
864 };
865 if username != owner {
866 return json_err("FORBIDDEN", "Only owner can delete", StatusCode::FORBIDDEN).into_response();
867 }
868
869 match repos::delete_repo(&st.pool, &st.cfg.repos_root, &owner, &repo).await {
870 Ok(()) => StatusCode::NO_CONTENT.into_response(),
871 Err(e) if e == "not_found" => json_err("NOT_FOUND", "Repo not found", StatusCode::NOT_FOUND).into_response(),
872 Err(_) => json_err("ERROR", "Delete failed", StatusCode::INTERNAL_SERVER_ERROR).into_response(),
873 }
874 }
875 ```
876
877 ### 10.9 src/main.rs
878 ```rust
879 use axum::{
880 extract::{Path, State},
881 http::{HeaderMap, Request, StatusCode},
882 routing::{any, delete, get, post},
883 Router,
884 };
885 use std::{sync::Arc, time::Duration};
886 use tower_http::{
887 limit::RequestBodyLimitLayer,
888 timeout::TimeoutLayer,
889 trace::TraceLayer,
890 };
891
892 mod api;
893 mod auth;
894 mod config;
895 mod db;
896 mod git_http;
897 mod repos;
898 mod validate;
899
900 use config::Config;
901 use git_http::GitRunner;
902
903 #[derive(Clone)]
904 struct GitState {
905 cfg: Arc<Config>,
906 pool: sqlx::SqlitePool,
907 git: GitRunner,
908 }
909
910 fn bearer(headers: &HeaderMap) -> Option<&str> {
911 let v = headers.get("authorization")?.to_str().ok()?;
912 v.strip_prefix("Bearer ")
913 }
914
915 #[tokio::main]
916 async fn main() {
917 tracing_subscriber::fmt()
918 .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
919 .init();
920
921 let cfg = Arc::new(Config::from_env());
922 let pool = db::connect(&cfg.db_url).await;
923 let git = GitRunner::new(cfg.clone());
924
925 let api_state = api::ApiState { cfg: cfg.clone(), pool: pool.clone() };
926 let git_state = GitState { cfg: cfg.clone(), pool: pool.clone(), git };
927
928 let api_router = Router::new()
929 .route("/api/signup", post(api::signup))
930 .route("/api/login", post(api::login))
931 .route("/api/repos", post(api::create_repo))
932 .route("/api/repos/:username", get(api::list_repos))
933 .route("/api/repos/:username/:repo", delete(api::delete_repo))
934 .with_state(api_state);
935
936 let git_router = Router::new()
937 .route("/*path", any(git_handler))
938 .with_state(git_state);
939
940 let app = api_router
941 .merge(git_router)
942 .layer(TraceLayer::new_for_http())
943 .layer(TimeoutLayer::new(Duration::from_secs(60)))
944 // v1 simplification: global limit. If you want bigger API bodies later,
945 // apply this only to receive-pack.
946 .layer(RequestBodyLimitLayer::new(cfg.max_push_bytes));
947
948 let listener = tokio::net::TcpListener::bind(&cfg.bind).await.unwrap();
949 axum::serve(listener, app).await.unwrap();
950 }
951
952 async fn git_handler(
953 State(st): State<GitState>,
954 Path(path): Path<String>,
955 headers: HeaderMap,
956 req: Request<axum::body::Body>,
957 ) -> Result<axum::response::Response, StatusCode> {
958 // Expected: "user/repo.git/rest..."
959 let mut it = path.splitn(3, '/');
960 let user = it.next().ok_or(StatusCode::NOT_FOUND)?;
961 let repo_git = it.next().ok_or(StatusCode::NOT_FOUND)?;
962 let rest = it.next().unwrap_or("");
963
964 if !repo_git.ends_with(".git") {
965 return Err(StatusCode::NOT_FOUND);
966 }
967 let repo = repo_git.trim_end_matches(".git");
968
969 // Must exist in DB
970 if !repos::repo_exists(&st.pool, user, repo).await {
971 return Err(StatusCode::NOT_FOUND);
972 }
973
974 let is_receive_pack = rest.ends_with("git-receive-pack");
975
976 let remote_user = if is_receive_pack {
977 let token = bearer(&headers).ok_or(StatusCode::FORBIDDEN)?;
978 let (_uid, uname) = auth::auth_user_by_token(&st.pool, token)
979 .await
980 .ok_or(StatusCode::FORBIDDEN)?;
981 if uname != user {
982 return Err(StatusCode::FORBIDDEN);
983 }
984 Some(uname)
985 } else {
986 None
987 };
988
989 let path_info = format!("/{}/{}/{}", user, format!("{}.git", repo), rest);
990 st.git.run_cgi(&path_info, req, remote_user.as_deref()).await
991 }
992 ```
993
994 ---
995
996 ## 11) Reverse Proxy (Optional but recommended)
997
998 Nginx snippet:
999
1000 ```nginx
1001 server {
1002 listen 443 ssl;
1003 server_name example.com;
1004
1005 client_max_body_size 10m;
1006
1007 location / {
1008 proxy_pass http://127.0.0.1:8080;
1009 proxy_set_header Host $host;
1010 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
1011 proxy_set_header X-Forwarded-Proto $scheme;
1012 }
1013 }
1014 ```
1015
1016 ---
1017
1018 ## 12) Deployment Steps
1019
1020 Build:
1021
1022 ```bash
1023 cargo build --release
1024 cd hook && cargo build --release && cd ..
1025 sudo cp hook/target/release/git-quota-hook /usr/local/bin/git-quota-hook
1026 sudo chmod 0755 /usr/local/bin/git-quota-hook
1027 ```
1028
1029 Prepare directories:
1030
1031 ```bash
1032 sudo mkdir -p /srv/mygit/repos
1033 sudo chown -R mygit:mygit /srv/mygit
1034 ```
1035
1036 Run migration:
1037 - Execute `migrations/0001_init.sql` against `/srv/mygit/mygit.db`
1038
1039 Run server:
1040
1041 ```bash
1042 export MYGIT_BIND=0.0.0.0:8080
1043 export MYGIT_REPOS_ROOT=/srv/mygit/repos
1044 export MYGIT_DB_URL=sqlite:/srv/mygit/mygit.db
1045 export MYGIT_HOOK_PATH=/usr/local/bin/git-quota-hook
1046 ./target/release/mygitd
1047 ```
1048
1049 ---
1050
1051 ## 13) Acceptance Tests
1052
1053 API:
1054 - signup creates user
1055 - login returns token
1056 - create repo increments count
1057 - creating 26th repo returns 429
1058 - delete repo removes DB row and filesystem dir
1059
1060 Git:
1061 - anonymous `git clone https://host/alice/rfc-foo.git` works
1062 - anonymous fetch works
1063 - push with missing token fails
1064 - push with token for non-owner fails
1065 - push exceeding max push bytes fails
1066 - push that makes repo exceed 50 MiB fails (hook rejection message visible)
1067