anvilsign in

collin/anvil

main / crates / anvil-web / src / git_http.rs
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::{
12 collections::HashMap,
13 path::PathBuf,
14};
15
16use anvil_core::{
17 App,
18 Repository,
19 access,
20 periodic,
21 repos,
22 users,
23};
24use anvil_git::smart_http::{
25 self,
26 Service,
27 UploadPack,
28};
29use axum::{
30 Router,
31 body::{
32 Body,
33 Bytes,
34 },
35 extract::{
36 Path,
37 Query,
38 State,
39 },
40 http::{
41 HeaderMap,
42 StatusCode,
43 header,
44 },
45 response::{
46 IntoResponse,
47 Response,
48 },
49 routing::{
50 get,
51 post,
52 },
53};
54use tokio_util::io::ReaderStream;
55
56/// Mount the smart-HTTP git routes onto `router`.
57pub fn routes(router: Router<App>) -> Router<App> {
58 router
59 .route("/{owner}/{repo}/info/refs", get(info_refs))
60 .route("/{owner}/{repo}/git-upload-pack", post(upload_pack))
61 .route("/{owner}/{repo}/git-receive-pack", post(receive_pack))
62}
63
64/// Resolve `<owner>/<repo>` (repo may carry a `.git` suffix) to its on-disk path
65/// and metadata row, rejecting traversal and missing repos.
66async fn load_repo(app: &App, owner: &str, repo: &str) -> Result<(PathBuf, Repository), Response> {
67 let name = repo.strip_suffix(".git").unwrap_or(repo);
68 let bad = |s: &str| s.is_empty() || s.contains('/') || s.contains('\\') || s.contains("..");
69 if bad(owner) || bad(name) {
70 return Err((StatusCode::BAD_REQUEST, "invalid repository path").into_response());
71 }
72 let not_found = || (StatusCode::NOT_FOUND, "repository not found").into_response();
73 let owner_user = users::find_by_username(&app.db, owner)
74 .await
75 .map_err(internal)?
76 .ok_or_else(not_found)?;
77 let meta = repos::find(&app.db, owner_user.id, name)
78 .await
79 .map_err(internal)?
80 .ok_or_else(not_found)?;
81 let path = anvil_core::storage::repo_path(&app.config.repositories_dir(), owner, name);
82 if !path.exists() {
83 return Err(not_found());
84 }
85 Ok((path, meta))
86}
87
88/// Resolve a repo for a *push*, creating it on the fly when it doesn't exist
89/// and the authenticated pusher owns the namespace (or is an admin) — see
90/// [`repos::create_on_push`]. An unauthenticated request for a missing repo
91/// gets the Basic challenge, so `git push` to a new name prompts for
92/// credentials instead of failing with 404.
93async fn load_repo_for_push(
94 app: &App,
95 headers: &HeaderMap,
96 owner: &str,
97 repo: &str,
98) -> Result<(PathBuf, Repository), Response> {
99 match load_repo(app, owner, repo).await {
100 Err(resp) if resp.status() == StatusCode::NOT_FOUND => {
101 let name = repo.strip_suffix(".git").unwrap_or(repo);
102 let Some(user) = basic_user(app, headers).await else {
103 return Err(auth_challenge());
104 };
105 match repos::create_on_push(&app.db, &app.config.repositories_dir(), owner, name, &user)
106 .await
107 {
108 Ok(Some(meta)) => {
109 let path =
110 anvil_core::storage::repo_path(&app.config.repositories_dir(), owner, name);
111 Ok((path, meta))
112 }
113 Ok(None) => Err((StatusCode::NOT_FOUND, "repository not found").into_response()),
114 Err(anvil_core::Error::Invalid(m)) => {
115 Err((StatusCode::BAD_REQUEST, m).into_response())
116 }
117 Err(e) => Err(internal(e)),
118 }
119 }
120 other => other,
121 }
122}
123
124/// The authenticated Basic user, if any.
125async fn basic_user(app: &App, headers: &HeaderMap) -> Option<anvil_core::User> {
126 let authorization = headers
127 .get(header::AUTHORIZATION)
128 .and_then(|v| v.to_str().ok());
129 crate::auth::basic_auth_user(app, authorization).await
130}
131
132/// `401` with a `WWW-Authenticate` challenge so the git client prompts.
133fn auth_challenge() -> Response {
134 Response::builder()
135 .status(StatusCode::UNAUTHORIZED)
136 .header(header::WWW_AUTHENTICATE, "Basic realm=\"anvil\"")
137 .body(Body::from("authentication required"))
138 .unwrap()
139}
140
141/// Enforce access for a git request: anonymous reads are allowed for public
142/// repos; private reads and all writes require valid Basic credentials. On
143/// failure, returns a `401` with a `WWW-Authenticate` challenge so the git
144/// client prompts for credentials.
145async fn authorize(
146 app: &App,
147 headers: &HeaderMap,
148 repo: &Repository,
149 need_write: bool,
150) -> Result<(), Response> {
151 let user = basic_user(app, headers).await;
152 let allowed = if need_write {
153 access::can_write(repo, user.as_ref())
154 } else {
155 access::can_read(repo, user.as_ref())
156 };
157 if allowed {
158 Ok(())
159 } else {
160 Err(auth_challenge())
161 }
162}
163
164/// True if the client requested git protocol v2 via the `Git-Protocol` header.
165fn wants_v2(headers: &HeaderMap) -> bool {
166 headers
167 .get("git-protocol")
168 .and_then(|v| v.to_str().ok())
169 .map(|v| v.split(':').any(|item| item.trim() == "version=2"))
170 .unwrap_or(false)
171}
172
173fn internal(err: impl std::fmt::Display) -> Response {
174 tracing::error!("git smart-http error: {err}");
175 (StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response()
176}
177
178fn rpc_response(content_type: String, body: Body) -> Response {
179 Response::builder()
180 .status(StatusCode::OK)
181 .header(header::CONTENT_TYPE, content_type)
182 .header(header::CACHE_CONTROL, "no-cache")
183 .body(body)
184 .unwrap()
185}
186
187/// `GET /{owner}/{repo}/info/refs?service=…` — ref advertisement.
188async fn info_refs(
189 State(app): State<App>,
190 Path((owner, repo)): Path<(String, String)>,
191 Query(query): Query<HashMap<String, String>>,
192 headers: HeaderMap,
193) -> Response {
194 let Some(service) = query.get("service").and_then(|s| Service::from_query(s)) else {
195 return (StatusCode::BAD_REQUEST, "missing or unsupported service").into_response();
196 };
197 let need_write = service == Service::ReceivePack;
198 // A push may target a repo that doesn't exist yet (push-to-create).
199 let loaded = if need_write {
200 load_repo_for_push(&app, &headers, &owner, &repo).await
201 } else {
202 load_repo(&app, &owner, &repo).await
203 };
204 let (path, meta) = match loaded {
205 Ok(v) => v,
206 Err(resp) => return resp,
207 };
208 if let Err(resp) = authorize(&app, &headers, &meta, need_write).await {
209 return resp;
210 }
211
212 let v2 = service == Service::UploadPack && wants_v2(&headers);
213 match smart_http::advertise(&path, service, v2) {
214 Ok(body) => Response::builder()
215 .status(StatusCode::OK)
216 .header(header::CONTENT_TYPE, service.advertisement_content_type())
217 .header(header::CACHE_CONTROL, "no-cache")
218 .body(Body::from(body))
219 .unwrap(),
220 Err(e) => internal(e),
221 }
222}
223
224/// `POST /{owner}/{repo}/git-upload-pack` — clone/fetch (read access).
225async fn upload_pack(
226 State(app): State<App>,
227 Path((owner, repo)): Path<(String, String)>,
228 headers: HeaderMap,
229 body: Bytes,
230) -> Response {
231 let (path, meta) = match load_repo(&app, &owner, &repo).await {
232 Ok(v) => v,
233 Err(resp) => return resp,
234 };
235 if let Err(resp) = authorize(&app, &headers, &meta, false).await {
236 return resp;
237 }
238 let content_type = Service::UploadPack.result_content_type();
239
240 if wants_v2(&headers) {
241 match smart_http::upload_pack_v2(&path, &body).await {
242 Ok(UploadPack::Buffered(b)) => rpc_response(content_type, Body::from(b)),
243 Ok(UploadPack::Pack(reader)) => {
244 rpc_response(content_type, Body::from_stream(ReaderStream::new(reader)))
245 }
246 Err(e) => internal(e),
247 }
248 } else {
249 match smart_http::upload_pack_v0(&path, &body).await {
250 Ok(reader) => rpc_response(content_type, Body::from_stream(ReaderStream::new(reader))),
251 Err(e) => internal(e),
252 }
253 }
254}
255
256/// `POST /{owner}/{repo}/git-receive-pack` — push (write access).
257async fn receive_pack(
258 State(app): State<App>,
259 Path((owner, repo)): Path<(String, String)>,
260 headers: HeaderMap,
261 body: Bytes,
262) -> Response {
263 let (path, meta) = match load_repo_for_push(&app, &headers, &owner, &repo).await {
264 Ok(v) => v,
265 Err(resp) => return resp,
266 };
267 if let Err(resp) = authorize(&app, &headers, &meta, true).await {
268 return resp;
269 }
270
271 // Snapshot branch tips before the push so we can detect what changed.
272 let before = anvil_git::trigger::snapshot_branches(&path);
273 let reader = std::io::Cursor::new(body.to_vec());
274 match smart_http::receive_pack(&path, reader).await {
275 Ok(b) => {
276 for run_id in
277 anvil_git::trigger::enqueue_ci_for_push(&app.db, meta.id, &path, &before).await
278 {
279 app.notify_ci(run_id);
280 }
281 if !meta.mirror_url.is_empty() {
282 anvil_git::mirror::spawn_push(path.clone(), meta.mirror_url.clone());
283 }
284 periodic::trigger_repo_indexing(app.clone(), meta.id, path);
285 rpc_response(Service::ReceivePack.result_content_type(), Body::from(b))
286 }
287 Err(e) => internal(e),
288 }
289}