flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/services/repos/src/git_http.rs

567 lines20,519 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
128/// `url` with its first path segment, the workspace, replaced by `slug`.
129pub fn with_namespace(url: &Url, slug: &str) -> Option<String> {
130 let rest = url.path().strip_prefix('/')?.split_once('/')?.1;
131 let mut moved = url.clone();
132 moved.set_path(&format!("/{slug}/{rest}"));
133 Some(moved.to_string())
134}
135
136/// Where a git request for a renamed workspace's old address should go
137/// now, if its first segment is an old slug that still redirects.
138pub async fn renamed(url: &Url, identity: &Fetcher) -> Result<Option<String>> {
139 let Some(old) = url.path().strip_prefix('/').and_then(|path| path.split('/').next()) else {
140 return Ok(None);
141 };
142 let current: Option<String> = g1t_kit::call(
143 identity,
144 "resolve_slug",
145 &g1t_contracts::identity::SlugArgs {
146 slug: old.to_owned(),
147 },
148 )
149 .await?;
150 Ok(current.and_then(|slug| with_namespace(url, &slug)))
151}
152
153/// A permanent redirect: 301 for git's first request for refs, which it
154/// follows and then uses the new address for the rest; 308 for the
155/// others, so a POST stays a POST.
156pub fn moved(location: &str, get: bool) -> Result<Response> {
157 let mut response = Response::empty()?.with_status(if get { 301 } else { 308 });
158 response.headers_mut().set("location", location)?;
159 Ok(response)
160}
161
162const ZERO_ID: &str = "0000000000000000000000000000000000000000";
163const HEADS: &str = "refs/heads/";
164const TAGS: &str = "refs/tags/";
165
166/// One ref a push asks to change.
167struct Command {
168 old: String,
169 new: String,
170 name: String,
171}
172
173/// The commands at the start of a receive-pack request, and the
174/// capabilities the client sent with the first of them.
175fn commands(body: &[u8]) -> (Vec<Command>, String) {
176 let mut commands = Vec::new();
177 let mut capabilities = String::new();
178 let mut position = 0;
179 // Commands are pkt-lines; a flush packet ends them and the pack follows.
180 while let Some(length) = body
181 .get(position..position + 4)
182 .and_then(|hex| std::str::from_utf8(hex).ok())
183 .and_then(|hex| usize::from_str_radix(hex, 16).ok())
184 {
185 if length < 4 || position + length > body.len() {
186 break;
187 }
188 let line = &body[position + 4..position + length];
189 position += length;
190 // `<old> <new> <ref>`, and on the first command a NUL then capabilities.
191 let mut halves = line.splitn(2, |byte| *byte == 0);
192 let command = halves.next().unwrap_or_default();
193 if let Some(rest) = halves.next() {
194 capabilities = String::from_utf8_lossy(rest).trim().to_owned();
195 }
196 let Ok(command) = std::str::from_utf8(command) else {
197 continue;
198 };
199 let mut parts = command.trim_end().splitn(3, ' ');
200 if let (Some(old), Some(new), Some(name)) = (parts.next(), parts.next(), parts.next()) {
201 commands.push(Command {
202 old: old.to_owned(),
203 new: new.to_owned(),
204 name: name.to_owned(),
205 });
206 }
207 }
208 (commands, capabilities)
209}
210
211/// The bytes of the pack a receive-pack request carries: everything after
212/// the flush packet that ends its commands. Zero for a push that only
213/// deletes refs.
214pub(crate) fn pack_bytes(body: &[u8]) -> u64 {
215 let mut position = 0;
216 while let Some(length) = body
217 .get(position..position + 4)
218 .and_then(|hex| std::str::from_utf8(hex).ok())
219 .and_then(|hex| usize::from_str_radix(hex, 16).ok())
220 {
221 if length == 0 {
222 return (body.len() - position - 4) as u64;
223 }
224 if length < 4 || position + length > body.len() {
225 break;
226 }
227 position += length;
228 }
229 0
230}
231
232fn pkt_line(payload: &[u8]) -> Vec<u8> {
233 let mut line = format!("{:04x}", payload.len() + 4).into_bytes();
234 line.extend_from_slice(payload);
235 line
236}
237
238/// What git is told when a push would change a protected branch: every ref
239/// in it is declined, with the reason against the protected one, so that
240/// git prints it beside the branch. `None` if the push leaves the branch
241/// alone, or creates it in a repository that does not have it yet.
242fn refusal(body: &[u8], protected: &str) -> Option<Vec<u8>> {
243 let (commands, capabilities) = commands(body);
244 let reference = format!("{HEADS}{protected}");
245 if !commands
246 .iter()
247 .any(|command| command.name == reference && command.old != ZERO_ID)
248 {
249 return None;
250 }
251 let mut report = pkt_line(b"unpack ok\n");
252 for command in &commands {
253 let reason = if command.name == reference {
254 format!("{protected} is protected: push a branch and open a pull request")
255 } else {
256 format!("not pushed, because the same push would change {protected}")
257 };
258 report.extend(pkt_line(
259 format!("ng {} {reason}\n", command.name).as_bytes(),
260 ));
261 }
262 report.extend_from_slice(b"0000");
263 Some(framed(report, &capabilities, &[]))
264}
265
266/// A report-status as git expects it: inside channel 1 when the client
267/// asked for side-band, after `messages` on channel 2, which git prints as
268/// `remote:` lines. Without side-band the messages cannot be shown.
269fn framed(report: Vec<u8>, capabilities: &str, messages: &[String]) -> Vec<u8> {
270 let sideband = capabilities
271 .split(' ')
272 .any(|capability| capability.starts_with("side-band"));
273 if !sideband {
274 return report;
275 }
276 let mut body = Vec::new();
277 for message in messages {
278 let mut packet = vec![2u8];
279 packet.extend_from_slice(message.as_bytes());
280 packet.push(b'\n');
281 body.extend(pkt_line(&packet));
282 }
283 // side-band (not -64k) packets carry at most 1000 bytes.
284 for chunk in report.chunks(990) {
285 let mut packet = vec![1u8];
286 packet.extend_from_slice(chunk);
287 body.extend(pkt_line(&packet));
288 }
289 body.extend_from_slice(b"0000");
290 body
291}
292
293/// Declines every ref in a push with `reason`, explaining why in
294/// `messages`: what push protection answers when a push adds a secret.
295pub fn declined(body: &[u8], reason: &str, messages: &[String]) -> Result<Response> {
296 let (commands, capabilities) = commands(body);
297 let mut report = pkt_line(b"unpack ok\n");
298 for command in &commands {
299 report.extend(pkt_line(format!("ng {} {reason}\n", command.name).as_bytes()));
300 }
301 report.extend_from_slice(b"0000");
302 let headers = Headers::new();
303 headers.set("content-type", "application/x-git-receive-pack-result")?;
304 headers.set("cache-control", "no-cache")?;
305 Ok(Response::from_bytes(framed(report, &capabilities, messages))?.with_headers(headers))
306}
307
308/// A branch or tag a push asks to move.
309#[derive(Debug, PartialEq, Eq)]
310pub struct Pushed {
311 /// The full ref: `refs/heads/main`, `refs/tags/v1`.
312 pub git_ref: String,
313 /// Where it pointed before; `None` for a new ref.
314 pub before: Option<String>,
315 pub after: String,
316}
317
318impl Pushed {
319 pub fn branch(&self) -> Option<&str> {
320 self.git_ref.strip_prefix(HEADS)
321 }
322}
323
324/// The branches and tags a push asks to move, read from the commands at the
325/// start of a receive-pack request. Deletions and other refs are left out.
326fn pushed_branches(body: &[u8]) -> Vec<Pushed> {
327 commands(body)
328 .0
329 .into_iter()
330 .filter(|command| command.new != ZERO_ID)
331 .filter(|command| command.name.starts_with(HEADS) || command.name.starts_with(TAGS))
332 .map(|Command { old, new, name }| Pushed {
333 git_ref: name,
334 before: (old != ZERO_ID).then_some(old),
335 after: new,
336 })
337 .collect()
338}
339
340/// The git store's answer, and what the request asked it to change.
341pub struct Forwarded {
342 pub response: Response,
343 /// For a push: the branches and tags it asks to move, and the commits
344 /// to move them to. Whether each moved is for the caller to confirm.
345 pub pushed: Vec<Pushed>,
346 /// For a push: the size of the pack it sent, for the storage meter.
347 pub pack_bytes: u64,
348}
349
350/// What became of a git request.
351pub enum Push {
352 Forwarded(Forwarded),
353 /// A push to a protected branch, answered here without reaching the store.
354 Refused(Response),
355 /// A push that adds a secret nobody allowed, answered the same way.
356 Blocked(Response),
357}
358
359/// Sends the request on to the git store and returns its response as is,
360/// unless it is a push that would change the `protected` branch, or one
361/// that `scan` (push protection) answers itself.
362pub async fn forward(
363 mut request: Request,
364 git: &GitRequest,
365 access: &GitAccess,
366 protected: Option<&str>,
367 scan: impl AsyncFnOnce(&[u8]) -> Result<Option<Response>>,
368) -> Result<Push> {
369 let headers = Headers::new();
370 headers.set("authorization", &format!("Bearer {}", access.token))?;
371 for name in FORWARDED_HEADERS {
372 if let Some(value) = request.headers().get(name)? {
373 headers.set(name, &value)?;
374 }
375 }
376 let query = request
377 .url()?
378 .query()
379 .map(|query| format!("?{query}"))
380 .unwrap_or_default();
381 let mut init = RequestInit::new();
382 init.with_method(request.method()).with_headers(headers);
383 let mut pushed = Vec::new();
384 let mut pack = 0;
385 if request.method() == Method::Post {
386 // Pushes are capped at 100 MB by the platform, so buffering is safe.
387 let body = request.bytes().await?;
388 if git.endpoint == "git-receive-pack" {
389 if let Some(report) = protected.and_then(|branch| refusal(&body, branch)) {
390 let headers = Headers::new();
391 headers.set("content-type", "application/x-git-receive-pack-result")?;
392 headers.set("cache-control", "no-cache")?;
393 return Ok(Push::Refused(
394 Response::from_bytes(report)?.with_headers(headers),
395 ));
396 }
397 if let Some(response) = scan(&body).await? {
398 return Ok(Push::Blocked(response));
399 }
400 pushed = pushed_branches(&body);
401 pack = pack_bytes(&body);
402 }
403 init.with_body(Some(Uint8Array::from(body.as_slice()).into()));
404 }
405 let upstream =
406 Request::new_with_init(&format!("{}/{}{query}", access.remote, git.endpoint), &init)?;
407 Ok(Push::Forwarded(Forwarded {
408 response: Fetch::Request(upstream).send().await?,
409 pushed,
410 pack_bytes: pack,
411 }))
412}
413
414#[cfg(test)]
415mod tests {
416 use super::{Pushed, ZERO_ID, framed, pack_bytes, pushed_branches, refusal, with_namespace};
417
418 #[test]
419 fn a_push_is_measured_by_the_pack_after_its_commands() {
420 let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
421 let new = "4807077b296e6edbf410d55e72749d3e1170c291";
422 let pack = b"PACK\0\0\0\x02\0\0\0\0rest-of-pack";
423 let body = [
424 pkt(&format!("{old} {new} refs/heads/main\0 report-status\n")),
425 b"0000".to_vec(),
426 pack.to_vec(),
427 ]
428 .concat();
429 assert_eq!(pack_bytes(&body), pack.len() as u64);
430 // Only deletions: no pack.
431 let body = [pkt(&format!("{old} {ZERO_ID} refs/heads/gone\n")), b"0000".to_vec()].concat();
432 assert_eq!(pack_bytes(&body), 0);
433 assert_eq!(pack_bytes(b"garbage"), 0);
434 }
435
436 #[test]
437 fn a_renamed_workspace_keeps_the_rest_of_the_address() {
438 let url = worker::Url::parse(
439 "https://g1t.sh/acme/rocket.git/info/refs?service=git-upload-pack",
440 )
441 .unwrap();
442 assert_eq!(
443 with_namespace(&url, "acme-inc").as_deref(),
444 Some("https://g1t.sh/acme-inc/rocket.git/info/refs?service=git-upload-pack")
445 );
446 let bare = worker::Url::parse("https://g1t.sh/acme").unwrap();
447 assert_eq!(with_namespace(&bare, "acme-inc"), None);
448 }
449
450 fn pkt(payload: &str) -> Vec<u8> {
451 format!("{:04x}{payload}", payload.len() + 4).into_bytes()
452 }
453
454 #[test]
455 fn pushed_branches_are_read_from_the_commands() {
456 let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
457 let new = "4807077b296e6edbf410d55e72749d3e1170c291";
458 let body = [
459 pkt(&format!(
460 "{old} {new} refs/heads/main\0 report-status side-band-64k\n"
461 )),
462 pkt(&format!("{ZERO_ID} {new} refs/heads/feature/x\n")),
463 pkt(&format!("{old} {ZERO_ID} refs/heads/gone\n")),
464 pkt(&format!("{ZERO_ID} {new} refs/tags/v1\n")),
465 b"0000".to_vec(),
466 b"PACK\0\0\0\x02\0\0\0\0".to_vec(),
467 ]
468 .concat();
469 assert_eq!(
470 pushed_branches(&body),
471 [
472 Pushed {
473 git_ref: "refs/heads/main".to_owned(),
474 before: Some(old.to_owned()),
475 after: new.to_owned()
476 },
477 Pushed {
478 git_ref: "refs/heads/feature/x".to_owned(),
479 before: None,
480 after: new.to_owned()
481 },
482 Pushed {
483 git_ref: "refs/tags/v1".to_owned(),
484 before: None,
485 after: new.to_owned()
486 },
487 ]
488 );
489 }
490
491 #[test]
492 fn a_push_to_a_protected_branch_is_declined_with_the_reason() {
493 let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
494 let new = "4807077b296e6edbf410d55e72749d3e1170c291";
495 let body = [
496 pkt(&format!("{old} {new} refs/heads/main\0 report-status\n")),
497 pkt(&format!("{ZERO_ID} {new} refs/heads/feature\n")),
498 b"0000".to_vec(),
499 ]
500 .concat();
501 let report = String::from_utf8(refusal(&body, "main").unwrap()).unwrap();
502 assert!(report.starts_with("000eunpack ok\n"));
503 assert!(report.contains("ng refs/heads/main main is protected"));
504 assert!(report.contains("ng refs/heads/feature not pushed"));
505 assert!(report.ends_with("0000"));
506 }
507
508 #[test]
509 fn the_report_is_framed_for_a_client_that_asked_for_side_band() {
510 let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
511 let body = [
512 pkt(&format!(
513 "{old} {ZERO_ID} refs/heads/main\0 report-status side-band-64k\n"
514 )),
515 b"0000".to_vec(),
516 ]
517 .concat();
518 let report = refusal(&body, "main").unwrap();
519 // A length, then channel 1, then the report itself.
520 assert_eq!(report[4], 1);
521 assert_eq!(&report[5..18], b"000eunpack ok");
522 assert!(report.ends_with(b"00000000"));
523 }
524
525 #[test]
526 fn other_branches_and_a_first_push_are_let_through() {
527 let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
528 let new = "4807077b296e6edbf410d55e72749d3e1170c291";
529 let feature = [
530 pkt(&format!("{old} {new} refs/heads/feature\0 report-status\n")),
531 b"0000".to_vec(),
532 ]
533 .concat();
534 assert!(refusal(&feature, "main").is_none());
535 // An empty repository has to be able to receive its first commits.
536 let first = [
537 pkt(&format!(
538 "{ZERO_ID} {new} refs/heads/main\0 report-status\n"
539 )),
540 b"0000".to_vec(),
541 ]
542 .concat();
543 assert!(refusal(&first, "main").is_none());
544 }
545
546 #[test]
547 fn a_blocked_push_explains_itself_on_the_progress_channel() {
548 let report = b"000eunpack ok\n0000".to_vec();
549 let messages = vec!["g1t found a secret in this push, so nothing was pushed.".to_owned()];
550 let body = framed(report.clone(), "report-status side-band-64k", &messages);
551 // Channel 2 first, which git prints as `remote:` lines.
552 assert_eq!(body[4], 2);
553 assert!(String::from_utf8_lossy(&body).contains("so nothing was pushed.\n"));
554 let at = body.windows(5).position(|w| w == b"000eu").unwrap();
555 assert_eq!(body[at - 1], 1);
556 assert!(body.ends_with(b"0000"));
557 // A client without side-band gets the bare report.
558 assert_eq!(framed(report.clone(), "report-status", &messages), report);
559 }
560
561 #[test]
562 fn a_fetch_request_names_no_branches() {
563 assert!(
564 pushed_branches(b"0032want c71546fcd893ef8b0f57388b65e620d759705dda\n0000").is_empty()
565 );
566 }
567}