pr_01m47d15m3e54sn21z27rpy5n9/services/repos/src/git_http.rs

423 lines14,581 bytesCodeBlame
1//! Git over HTTPS: the smart HTTP remote at `/<namespace>/<repo>.git`,
2//! proxied to the git store with a short-lived token.
3
4use g1t_contracts::identity::GitCredentialsArgs;
5use g1t_contracts::repos::{GitAccess, GitService, RepoPath};
6use g1t_contracts::{FailureCode, Outcome, Viewer};
7use worker::js_sys::Uint8Array;
8use worker::{Fetch, Fetcher, Headers, Method, Request, RequestInit, Response, Result, Url};
9
10const ENDPOINTS: [&str; 3] = ["info/refs", "git-upload-pack", "git-receive-pack"];
11const FORWARDED_HEADERS: [&str; 5] = [
12 "accept",
13 "content-encoding",
14 "content-type",
15 "git-protocol",
16 "user-agent",
17];
18
19/// A git request, parsed from its URL.
20pub struct GitRequest {
21 pub path: RepoPath,
22 pub endpoint: &'static str,
23 pub service: GitService,
24}
25
26/// Parses `/<namespace>/<name>[.git]/<endpoint>`, or returns `None` if the
27/// request is not git's.
28pub fn parse(url: &Url) -> Option<GitRequest> {
29 let path = url.path().strip_prefix('/')?;
30 let endpoint = ENDPOINTS
31 .into_iter()
32 .find(|endpoint| path.ends_with(&format!("/{endpoint}")))?;
33 let repo = &path[..path.len() - endpoint.len() - 1];
34 let (namespace, name) = repo.split_once('/')?;
35 let name = name.strip_suffix(".git").unwrap_or(name);
36 if namespace.is_empty() || name.is_empty() || name.contains('/') {
37 return None;
38 }
39 let service = if endpoint == "info/refs" {
40 url.query_pairs()
41 .find(|(key, _)| key == "service")
42 .map(|(_, value)| value.into_owned())?
43 } else {
44 endpoint.to_owned()
45 };
46 let service = match service.as_str() {
47 "git-upload-pack" => GitService::UploadPack,
48 "git-receive-pack" => GitService::ReceivePack,
49 _ => return None,
50 };
51 Some(GitRequest {
52 path: RepoPath {
53 namespace: namespace.to_owned(),
54 name: name.to_owned(),
55 },
56 endpoint,
57 service,
58 })
59}
60
61/// The user named by an HTTP Basic `Authorization` header, as git sends it.
62pub async fn viewer(request: &Request, identity: &Fetcher) -> Result<Viewer> {
63 let Some(header) = request.headers().get("authorization")? else {
64 return Ok(None);
65 };
66 let Some((scheme, encoded)) = header.split_once(' ') else {
67 return Ok(None);
68 };
69 if !scheme.eq_ignore_ascii_case("basic") {
70 return Ok(None);
71 }
72 let Some(decoded) = decode_base64(encoded.trim()) else {
73 return Ok(None);
74 };
75 let Some((username, secret)) = decoded.split_once(':') else {
76 return Ok(None);
77 };
78 g1t_kit::call(
79 identity,
80 "user_for_git_credentials",
81 &GitCredentialsArgs {
82 username: username.to_owned(),
83 secret: secret.to_owned(),
84 },
85 )
86 .await
87}
88
89/// Standard base64 to a UTF-8 string, or `None` if either step fails.
90fn decode_base64(input: &str) -> Option<String> {
91 let mut bytes = Vec::with_capacity(input.len() * 3 / 4);
92 let mut buffer = 0u32;
93 let mut bits = 0;
94 for byte in input.bytes().filter(|byte| *byte != b'=') {
95 let value = match byte {
96 b'A'..=b'Z' => byte - b'A',
97 b'a'..=b'z' => byte - b'a' + 26,
98 b'0'..=b'9' => byte - b'0' + 52,
99 b'+' => 62,
100 b'/' => 63,
101 _ => return None,
102 };
103 buffer = (buffer << 6) | u32::from(value);
104 bits += 6;
105 if bits >= 8 {
106 bits -= 8;
107 bytes.push((buffer >> bits) as u8);
108 }
109 }
110 String::from_utf8(bytes).ok()
111}
112
113/// The response for a refused git request. Anonymous callers are asked to
114/// authenticate, which is what makes git prompt for credentials.
115pub fn refuse<T>(outcome: Outcome<T>) -> Result<Response> {
116 let Outcome::Fail(failure) = outcome else {
117 return Response::error("Not found", 404);
118 };
119 let mut response = Response::error(failure.message, failure.code.http_status())?;
120 if failure.code == FailureCode::Unauthenticated {
121 response
122 .headers_mut()
123 .set("www-authenticate", "Basic realm=\"g1t\"")?;
124 }
125 Ok(response)
126}
127
128const ZERO_ID: &str = "0000000000000000000000000000000000000000";
129const HEADS: &str = "refs/heads/";
130const TAGS: &str = "refs/tags/";
131
132/// One ref a push asks to change.
133struct Command {
134 old: String,
135 new: String,
136 name: String,
137}
138
139/// The commands at the start of a receive-pack request, and the
140/// capabilities the client sent with the first of them.
141fn commands(body: &[u8]) -> (Vec<Command>, String) {
142 let mut commands = Vec::new();
143 let mut capabilities = String::new();
144 let mut position = 0;
145 // Commands are pkt-lines; a flush packet ends them and the pack follows.
146 while let Some(length) = body
147 .get(position..position + 4)
148 .and_then(|hex| std::str::from_utf8(hex).ok())
149 .and_then(|hex| usize::from_str_radix(hex, 16).ok())
150 {
151 if length < 4 || position + length > body.len() {
152 break;
153 }
154 let line = &body[position + 4..position + length];
155 position += length;
156 // `<old> <new> <ref>`, and on the first command a NUL then capabilities.
157 let mut halves = line.splitn(2, |byte| *byte == 0);
158 let command = halves.next().unwrap_or_default();
159 if let Some(rest) = halves.next() {
160 capabilities = String::from_utf8_lossy(rest).trim().to_owned();
161 }
162 let Ok(command) = std::str::from_utf8(command) else {
163 continue;
164 };
165 let mut parts = command.trim_end().splitn(3, ' ');
166 if let (Some(old), Some(new), Some(name)) = (parts.next(), parts.next(), parts.next()) {
167 commands.push(Command {
168 old: old.to_owned(),
169 new: new.to_owned(),
170 name: name.to_owned(),
171 });
172 }
173 }
174 (commands, capabilities)
175}
176
177fn pkt_line(payload: &[u8]) -> Vec<u8> {
178 let mut line = format!("{:04x}", payload.len() + 4).into_bytes();
179 line.extend_from_slice(payload);
180 line
181}
182
183/// What git is told when a push would change a protected branch: every ref
184/// in it is declined, with the reason against the protected one, so that
185/// git prints it beside the branch. `None` if the push leaves the branch
186/// alone, or creates it in a repository that does not have it yet.
187fn refusal(body: &[u8], protected: &str) -> Option<Vec<u8>> {
188 let (commands, capabilities) = commands(body);
189 let reference = format!("{HEADS}{protected}");
190 if !commands
191 .iter()
192 .any(|command| command.name == reference && command.old != ZERO_ID)
193 {
194 return None;
195 }
196 let mut report = pkt_line(b"unpack ok\n");
197 for command in &commands {
198 let reason = if command.name == reference {
199 format!("{protected} is protected: push a branch and open a pull request")
200 } else {
201 format!("not pushed, because the same push would change {protected}")
202 };
203 report.extend(pkt_line(
204 format!("ng {} {reason}\n", command.name).as_bytes(),
205 ));
206 }
207 report.extend_from_slice(b"0000");
208 // With side-band the report travels inside channel 1.
209 let sideband = capabilities
210 .split(' ')
211 .any(|capability| capability.starts_with("side-band"));
212 Some(if sideband {
213 let mut framed = vec![1u8];
214 framed.extend(report);
215 let mut body = pkt_line(&framed);
216 body.extend_from_slice(b"0000");
217 body
218 } else {
219 report
220 })
221}
222
223/// A branch or tag a push asks to move.
224#[derive(Debug, PartialEq, Eq)]
225pub struct Pushed {
226 /// The full ref: `refs/heads/main`, `refs/tags/v1`.
227 pub git_ref: String,
228 /// Where it pointed before; `None` for a new ref.
229 pub before: Option<String>,
230 pub after: String,
231}
232
233impl Pushed {
234 pub fn branch(&self) -> Option<&str> {
235 self.git_ref.strip_prefix(HEADS)
236 }
237}
238
239/// The branches and tags a push asks to move, read from the commands at the
240/// start of a receive-pack request. Deletions and other refs are left out.
241fn pushed_branches(body: &[u8]) -> Vec<Pushed> {
242 commands(body)
243 .0
244 .into_iter()
245 .filter(|command| command.new != ZERO_ID)
246 .filter(|command| command.name.starts_with(HEADS) || command.name.starts_with(TAGS))
247 .map(|Command { old, new, name }| Pushed {
248 git_ref: name,
249 before: (old != ZERO_ID).then_some(old),
250 after: new,
251 })
252 .collect()
253}
254
255/// The git store's answer, and what the request asked it to change.
256pub struct Forwarded {
257 pub response: Response,
258 /// For a push: the branches and tags it asks to move, and the commits
259 /// to move them to. Whether each moved is for the caller to confirm.
260 pub pushed: Vec<Pushed>,
261}
262
263/// What became of a git request.
264pub enum Push {
265 Forwarded(Forwarded),
266 /// A push to a protected branch, answered here without reaching the store.
267 Refused(Response),
268}
269
270/// Sends the request on to the git store and returns its response as is,
271/// unless it is a push that would change the `protected` branch.
272pub async fn forward(
273 mut request: Request,
274 git: &GitRequest,
275 access: &GitAccess,
276 protected: Option<&str>,
277) -> Result<Push> {
278 let headers = Headers::new();
279 headers.set("authorization", &format!("Bearer {}", access.token))?;
280 for name in FORWARDED_HEADERS {
281 if let Some(value) = request.headers().get(name)? {
282 headers.set(name, &value)?;
283 }
284 }
285 let query = request
286 .url()?
287 .query()
288 .map(|query| format!("?{query}"))
289 .unwrap_or_default();
290 let mut init = RequestInit::new();
291 init.with_method(request.method()).with_headers(headers);
292 let mut pushed = Vec::new();
293 if request.method() == Method::Post {
294 // Pushes are capped at 100 MB by the platform, so buffering is safe.
295 let body = request.bytes().await?;
296 if git.endpoint == "git-receive-pack" {
297 if let Some(report) = protected.and_then(|branch| refusal(&body, branch)) {
298 let headers = Headers::new();
299 headers.set("content-type", "application/x-git-receive-pack-result")?;
300 headers.set("cache-control", "no-cache")?;
301 return Ok(Push::Refused(
302 Response::from_bytes(report)?.with_headers(headers),
303 ));
304 }
305 pushed = pushed_branches(&body);
306 }
307 init.with_body(Some(Uint8Array::from(body.as_slice()).into()));
308 }
309 let upstream =
310 Request::new_with_init(&format!("{}/{}{query}", access.remote, git.endpoint), &init)?;
311 Ok(Push::Forwarded(Forwarded {
312 response: Fetch::Request(upstream).send().await?,
313 pushed,
314 }))
315}
316
317#[cfg(test)]
318mod tests {
319 use super::{Pushed, ZERO_ID, pushed_branches, refusal};
320
321 fn pkt(payload: &str) -> Vec<u8> {
322 format!("{:04x}{payload}", payload.len() + 4).into_bytes()
323 }
324
325 #[test]
326 fn pushed_branches_are_read_from_the_commands() {
327 let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
328 let new = "4807077b296e6edbf410d55e72749d3e1170c291";
329 let body = [
330 pkt(&format!(
331 "{old} {new} refs/heads/main\0 report-status side-band-64k\n"
332 )),
333 pkt(&format!("{ZERO_ID} {new} refs/heads/feature/x\n")),
334 pkt(&format!("{old} {ZERO_ID} refs/heads/gone\n")),
335 pkt(&format!("{ZERO_ID} {new} refs/tags/v1\n")),
336 b"0000".to_vec(),
337 b"PACK\0\0\0\x02\0\0\0\0".to_vec(),
338 ]
339 .concat();
340 assert_eq!(
341 pushed_branches(&body),
342 [
343 Pushed {
344 git_ref: "refs/heads/main".to_owned(),
345 before: Some(old.to_owned()),
346 after: new.to_owned()
347 },
348 Pushed {
349 git_ref: "refs/heads/feature/x".to_owned(),
350 before: None,
351 after: new.to_owned()
352 },
353 Pushed {
354 git_ref: "refs/tags/v1".to_owned(),
355 before: None,
356 after: new.to_owned()
357 },
358 ]
359 );
360 }
361
362 #[test]
363 fn a_push_to_a_protected_branch_is_declined_with_the_reason() {
364 let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
365 let new = "4807077b296e6edbf410d55e72749d3e1170c291";
366 let body = [
367 pkt(&format!("{old} {new} refs/heads/main\0 report-status\n")),
368 pkt(&format!("{ZERO_ID} {new} refs/heads/feature\n")),
369 b"0000".to_vec(),
370 ]
371 .concat();
372 let report = String::from_utf8(refusal(&body, "main").unwrap()).unwrap();
373 assert!(report.starts_with("000eunpack ok\n"));
374 assert!(report.contains("ng refs/heads/main main is protected"));
375 assert!(report.contains("ng refs/heads/feature not pushed"));
376 assert!(report.ends_with("0000"));
377 }
378
379 #[test]
380 fn the_report_is_framed_for_a_client_that_asked_for_side_band() {
381 let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
382 let body = [
383 pkt(&format!(
384 "{old} {ZERO_ID} refs/heads/main\0 report-status side-band-64k\n"
385 )),
386 b"0000".to_vec(),
387 ]
388 .concat();
389 let report = refusal(&body, "main").unwrap();
390 // A length, then channel 1, then the report itself.
391 assert_eq!(report[4], 1);
392 assert_eq!(&report[5..18], b"000eunpack ok");
393 assert!(report.ends_with(b"00000000"));
394 }
395
396 #[test]
397 fn other_branches_and_a_first_push_are_let_through() {
398 let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
399 let new = "4807077b296e6edbf410d55e72749d3e1170c291";
400 let feature = [
401 pkt(&format!("{old} {new} refs/heads/feature\0 report-status\n")),
402 b"0000".to_vec(),
403 ]
404 .concat();
405 assert!(refusal(&feature, "main").is_none());
406 // An empty repository has to be able to receive its first commits.
407 let first = [
408 pkt(&format!(
409 "{ZERO_ID} {new} refs/heads/main\0 report-status\n"
410 )),
411 b"0000".to_vec(),
412 ]
413 .concat();
414 assert!(refusal(&first, "main").is_none());
415 }
416
417 #[test]
418 fn a_fetch_request_names_no_branches() {
419 assert!(
420 pushed_branches(b"0032want c71546fcd893ef8b0f57388b65e620d759705dda\n0000").is_empty()
421 );
422 }
423}