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