anvilsign in

collin/anvil

1//! Smart-HTTP git endpoints: `info/refs`, `git-upload-pack` (clone/fetch), and
2//! `git-receive-pack` (push). These adapt the transport-agnostic protocol layer
3//! in [`anvil_git::smart_http`] to axum.
4//!
5//! Routes are mounted under `/{owner}/{repo}/…` where `{repo}` is the URL form
6//! including the `.git` suffix (e.g. `/alice/hello.git/info/refs`).
7//!
8//! Access control: public repos may be cloned anonymously; private repos and all
9//! pushes require HTTP Basic auth, enforced via [`anvil_core::access`].
10
11use std::collections::HashMap;
12use std::path::PathBuf;
13
14use anvil_core::{App, Repository, access, repos, users};
15use anvil_git::smart_http::{self, Service, UploadPack};
16use axum::{
17 Router,
18 body::{Body, Bytes},
19 extract::{Path, Query, State},
20 http::{HeaderMap, StatusCode, header},
21 response::{IntoResponse, Response},
22 routing::{get, post},
23};
24use tokio_util::io::ReaderStream;
25
26/// Mount the smart-HTTP git routes onto `router`.
27pub fn routes(router: Router<App>) -> Router<App> {
28 router
29 .route("/{owner}/{repo}/info/refs", get(info_refs))
30 .route("/{owner}/{repo}/git-upload-pack", post(upload_pack))
31 .route("/{owner}/{repo}/git-receive-pack", post(receive_pack))
32}
33
34/// Resolve `<owner>/<repo>` (repo may carry a `.git` suffix) to its on-disk path
35/// and metadata row, rejecting traversal and missing repos.
36async fn load_repo(app: &App, owner: &str, repo: &str) -> Result<(PathBuf, Repository), Response> {
37 let name = repo.strip_suffix(".git").unwrap_or(repo);
38 let bad = |s: &str| s.is_empty() || s.contains('/') || s.contains('\\') || s.contains("..");
39 if bad(owner) || bad(name) {
40 return Err((StatusCode::BAD_REQUEST, "invalid repository path").into_response());
41 }
42 let not_found = || (StatusCode::NOT_FOUND, "repository not found").into_response();
43 let owner_user = users::find_by_username(&app.db, owner)
44 .await
45 .map_err(internal)?
46 .ok_or_else(not_found)?;
47 let meta = repos::find(&app.db, owner_user.id, name)
48 .await
49 .map_err(internal)?
50 .ok_or_else(not_found)?;
51 let path = anvil_core::storage::repo_path(&app.config.repositories_dir(), owner, name);
52 if !path.exists() {
53 return Err(not_found());
54 }
55 Ok((path, meta))
56}
57
58/// Enforce access for a git request: anonymous reads are allowed for public
59/// repos; private reads and all writes require valid Basic credentials. On
60/// failure, returns a `401` with a `WWW-Authenticate` challenge so the git
61/// client prompts for credentials.
62async fn authorize(
63 app: &App,
64 headers: &HeaderMap,
65 repo: &Repository,
66 need_write: bool,
67) -> Result<(), Response> {
68 let authorization = headers
69 .get(header::AUTHORIZATION)
70 .and_then(|v| v.to_str().ok());
71 let user = crate::auth::basic_auth_user(app, authorization).await;
72 let allowed = if need_write {
73 access::can_write(repo, user.as_ref())
74 } else {
75 access::can_read(repo, user.as_ref())
76 };
77 if allowed {
78 Ok(())
79 } else {
80 Err(Response::builder()
81 .status(StatusCode::UNAUTHORIZED)
82 .header(header::WWW_AUTHENTICATE, "Basic realm=\"anvil\"")
83 .body(Body::from("authentication required"))
84 .unwrap())
85 }
86}
87
88/// True if the client requested git protocol v2 via the `Git-Protocol` header.
89fn wants_v2(headers: &HeaderMap) -> bool {
90 headers
91 .get("git-protocol")
92 .and_then(|v| v.to_str().ok())
93 .map(|v| v.split(':').any(|item| item.trim() == "version=2"))
94 .unwrap_or(false)
95}
96
97fn internal(err: impl std::fmt::Display) -> Response {
98 tracing::error!("git smart-http error: {err}");
99 (StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response()
100}
101
102fn rpc_response(content_type: String, body: Body) -> Response {
103 Response::builder()
104 .status(StatusCode::OK)
105 .header(header::CONTENT_TYPE, content_type)
106 .header(header::CACHE_CONTROL, "no-cache")
107 .body(body)
108 .unwrap()
109}
110
111/// `GET /{owner}/{repo}/info/refs?service=…` — ref advertisement.
112async fn info_refs(
113 State(app): State<App>,
114 Path((owner, repo)): Path<(String, String)>,
115 Query(query): Query<HashMap<String, String>>,
116 headers: HeaderMap,
117) -> Response {
118 let (path, meta) = match load_repo(&app, &owner, &repo).await {
119 Ok(v) => v,
120 Err(resp) => return resp,
121 };
122 let Some(service) = query.get("service").and_then(|s| Service::from_query(s)) else {
123 return (StatusCode::BAD_REQUEST, "missing or unsupported service").into_response();
124 };
125 let need_write = service == Service::ReceivePack;
126 if let Err(resp) = authorize(&app, &headers, &meta, need_write).await {
127 return resp;
128 }
129
130 let v2 = service == Service::UploadPack && wants_v2(&headers);
131 match smart_http::advertise(&path, service, v2) {
132 Ok(body) => Response::builder()
133 .status(StatusCode::OK)
134 .header(header::CONTENT_TYPE, service.advertisement_content_type())
135 .header(header::CACHE_CONTROL, "no-cache")
136 .body(Body::from(body))
137 .unwrap(),
138 Err(e) => internal(e),
139 }
140}
141
142/// `POST /{owner}/{repo}/git-upload-pack` — clone/fetch (read access).
143async fn upload_pack(
144 State(app): State<App>,
145 Path((owner, repo)): Path<(String, String)>,
146 headers: HeaderMap,
147 body: Bytes,
148) -> Response {
149 let (path, meta) = match load_repo(&app, &owner, &repo).await {
150 Ok(v) => v,
151 Err(resp) => return resp,
152 };
153 if let Err(resp) = authorize(&app, &headers, &meta, false).await {
154 return resp;
155 }
156 let content_type = Service::UploadPack.result_content_type();
157
158 if wants_v2(&headers) {
159 match smart_http::upload_pack_v2(&path, &body).await {
160 Ok(UploadPack::Buffered(b)) => rpc_response(content_type, Body::from(b)),
161 Ok(UploadPack::Pack(reader)) => {
162 rpc_response(content_type, Body::from_stream(ReaderStream::new(reader)))
163 }
164 Err(e) => internal(e),
165 }
166 } else {
167 match smart_http::upload_pack_v0(&path, &body).await {
168 Ok(reader) => rpc_response(content_type, Body::from_stream(ReaderStream::new(reader))),
169 Err(e) => internal(e),
170 }
171 }
172}
173
174/// `POST /{owner}/{repo}/git-receive-pack` — push (write access).
175async fn receive_pack(
176 State(app): State<App>,
177 Path((owner, repo)): Path<(String, String)>,
178 headers: HeaderMap,
179 body: Bytes,
180) -> Response {
181 let (path, meta) = match load_repo(&app, &owner, &repo).await {
182 Ok(v) => v,
183 Err(resp) => return resp,
184 };
185 if let Err(resp) = authorize(&app, &headers, &meta, true).await {
186 return resp;
187 }
188
189 // Snapshot branch tips before the push so we can detect what changed.
190 let before = anvil_git::trigger::snapshot_branches(&path);
191 let reader = std::io::Cursor::new(body.to_vec());
192 match smart_http::receive_pack(&path, reader).await {
193 Ok(b) => {
194 for run_id in
195 anvil_git::trigger::enqueue_ci_for_push(&app.db, meta.id, &path, &before).await
196 {
197 app.notify_ci(run_id);
198 }
199 rpc_response(Service::ReceivePack.result_content_type(), Body::from(b))
200 }
201 Err(e) => internal(e),
202 }
203}