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