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

471 lines16,457 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
211fn pkt_line(payload: &[u8]) -> Vec<u8> {
212 let mut line = format!("{:04x}", payload.len() + 4).into_bytes();
213 line.extend_from_slice(payload);
214 line
215}
216
217/// What git is told when a push would change a protected branch: every ref
218/// in it is declined, with the reason against the protected one, so that
219/// git prints it beside the branch. `None` if the push leaves the branch
220/// alone, or creates it in a repository that does not have it yet.
221fn refusal(body: &[u8], protected: &str) -> Option<Vec<u8>> {
222 let (commands, capabilities) = commands(body);
223 let reference = format!("{HEADS}{protected}");
224 if !commands
225 .iter()
226 .any(|command| command.name == reference && command.old != ZERO_ID)
227 {
228 return None;
229 }
230 let mut report = pkt_line(b"unpack ok\n");
231 for command in &commands {
232 let reason = if command.name == reference {
233 format!("{protected} is protected: push a branch and open a pull request")
234 } else {
235 format!("not pushed, because the same push would change {protected}")
236 };
237 report.extend(pkt_line(
238 format!("ng {} {reason}\n", command.name).as_bytes(),
239 ));
240 }
241 report.extend_from_slice(b"0000");
242 // With side-band the report travels inside channel 1.
243 let sideband = capabilities
244 .split(' ')
245 .any(|capability| capability.starts_with("side-band"));
246 Some(if sideband {
247 let mut framed = vec![1u8];
248 framed.extend(report);
249 let mut body = pkt_line(&framed);
250 body.extend_from_slice(b"0000");
251 body
252 } else {
253 report
254 })
255}
256
257/// A branch or tag a push asks to move.
258#[derive(Debug, PartialEq, Eq)]
259pub struct Pushed {
260 /// The full ref: `refs/heads/main`, `refs/tags/v1`.
261 pub git_ref: String,
262 /// Where it pointed before; `None` for a new ref.
263 pub before: Option<String>,
264 pub after: String,
265}
266
267impl Pushed {
268 pub fn branch(&self) -> Option<&str> {
269 self.git_ref.strip_prefix(HEADS)
270 }
271}
272
273/// The branches and tags a push asks to move, read from the commands at the
274/// start of a receive-pack request. Deletions and other refs are left out.
275fn pushed_branches(body: &[u8]) -> Vec<Pushed> {
276 commands(body)
277 .0
278 .into_iter()
279 .filter(|command| command.new != ZERO_ID)
280 .filter(|command| command.name.starts_with(HEADS) || command.name.starts_with(TAGS))
281 .map(|Command { old, new, name }| Pushed {
282 git_ref: name,
283 before: (old != ZERO_ID).then_some(old),
284 after: new,
285 })
286 .collect()
287}
288
289/// The git store's answer, and what the request asked it to change.
290pub struct Forwarded {
291 pub response: Response,
292 /// For a push: the branches and tags it asks to move, and the commits
293 /// to move them to. Whether each moved is for the caller to confirm.
294 pub pushed: Vec<Pushed>,
295}
296
297/// What became of a git request.
298pub enum Push {
299 Forwarded(Forwarded),
300 /// A push to a protected branch, answered here without reaching the store.
301 Refused(Response),
302}
303
304/// Sends the request on to the git store and returns its response as is,
305/// unless it is a push that would change the `protected` branch.
306pub async fn forward(
307 mut request: Request,
308 git: &GitRequest,
309 access: &GitAccess,
310 protected: Option<&str>,
311) -> Result<Push> {
312 let headers = Headers::new();
313 headers.set("authorization", &format!("Bearer {}", access.token))?;
314 for name in FORWARDED_HEADERS {
315 if let Some(value) = request.headers().get(name)? {
316 headers.set(name, &value)?;
317 }
318 }
319 let query = request
320 .url()?
321 .query()
322 .map(|query| format!("?{query}"))
323 .unwrap_or_default();
324 let mut init = RequestInit::new();
325 init.with_method(request.method()).with_headers(headers);
326 let mut pushed = Vec::new();
327 if request.method() == Method::Post {
328 // Pushes are capped at 100 MB by the platform, so buffering is safe.
329 let body = request.bytes().await?;
330 if git.endpoint == "git-receive-pack" {
331 if let Some(report) = protected.and_then(|branch| refusal(&body, branch)) {
332 let headers = Headers::new();
333 headers.set("content-type", "application/x-git-receive-pack-result")?;
334 headers.set("cache-control", "no-cache")?;
335 return Ok(Push::Refused(
336 Response::from_bytes(report)?.with_headers(headers),
337 ));
338 }
339 pushed = pushed_branches(&body);
340 }
341 init.with_body(Some(Uint8Array::from(body.as_slice()).into()));
342 }
343 let upstream =
344 Request::new_with_init(&format!("{}/{}{query}", access.remote, git.endpoint), &init)?;
345 Ok(Push::Forwarded(Forwarded {
346 response: Fetch::Request(upstream).send().await?,
347 pushed,
348 }))
349}
350
351#[cfg(test)]
352mod tests {
353 use super::{Pushed, ZERO_ID, pushed_branches, refusal, with_namespace};
354
355 #[test]
356 fn a_renamed_workspace_keeps_the_rest_of_the_address() {
357 let url = worker::Url::parse(
358 "https://g1t.sh/acme/rocket.git/info/refs?service=git-upload-pack",
359 )
360 .unwrap();
361 assert_eq!(
362 with_namespace(&url, "acme-inc").as_deref(),
363 Some("https://g1t.sh/acme-inc/rocket.git/info/refs?service=git-upload-pack")
364 );
365 let bare = worker::Url::parse("https://g1t.sh/acme").unwrap();
366 assert_eq!(with_namespace(&bare, "acme-inc"), None);
367 }
368
369 fn pkt(payload: &str) -> Vec<u8> {
370 format!("{:04x}{payload}", payload.len() + 4).into_bytes()
371 }
372
373 #[test]
374 fn pushed_branches_are_read_from_the_commands() {
375 let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
376 let new = "4807077b296e6edbf410d55e72749d3e1170c291";
377 let body = [
378 pkt(&format!(
379 "{old} {new} refs/heads/main\0 report-status side-band-64k\n"
380 )),
381 pkt(&format!("{ZERO_ID} {new} refs/heads/feature/x\n")),
382 pkt(&format!("{old} {ZERO_ID} refs/heads/gone\n")),
383 pkt(&format!("{ZERO_ID} {new} refs/tags/v1\n")),
384 b"0000".to_vec(),
385 b"PACK\0\0\0\x02\0\0\0\0".to_vec(),
386 ]
387 .concat();
388 assert_eq!(
389 pushed_branches(&body),
390 [
391 Pushed {
392 git_ref: "refs/heads/main".to_owned(),
393 before: Some(old.to_owned()),
394 after: new.to_owned()
395 },
396 Pushed {
397 git_ref: "refs/heads/feature/x".to_owned(),
398 before: None,
399 after: new.to_owned()
400 },
401 Pushed {
402 git_ref: "refs/tags/v1".to_owned(),
403 before: None,
404 after: new.to_owned()
405 },
406 ]
407 );
408 }
409
410 #[test]
411 fn a_push_to_a_protected_branch_is_declined_with_the_reason() {
412 let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
413 let new = "4807077b296e6edbf410d55e72749d3e1170c291";
414 let body = [
415 pkt(&format!("{old} {new} refs/heads/main\0 report-status\n")),
416 pkt(&format!("{ZERO_ID} {new} refs/heads/feature\n")),
417 b"0000".to_vec(),
418 ]
419 .concat();
420 let report = String::from_utf8(refusal(&body, "main").unwrap()).unwrap();
421 assert!(report.starts_with("000eunpack ok\n"));
422 assert!(report.contains("ng refs/heads/main main is protected"));
423 assert!(report.contains("ng refs/heads/feature not pushed"));
424 assert!(report.ends_with("0000"));
425 }
426
427 #[test]
428 fn the_report_is_framed_for_a_client_that_asked_for_side_band() {
429 let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
430 let body = [
431 pkt(&format!(
432 "{old} {ZERO_ID} refs/heads/main\0 report-status side-band-64k\n"
433 )),
434 b"0000".to_vec(),
435 ]
436 .concat();
437 let report = refusal(&body, "main").unwrap();
438 // A length, then channel 1, then the report itself.
439 assert_eq!(report[4], 1);
440 assert_eq!(&report[5..18], b"000eunpack ok");
441 assert!(report.ends_with(b"00000000"));
442 }
443
444 #[test]
445 fn other_branches_and_a_first_push_are_let_through() {
446 let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
447 let new = "4807077b296e6edbf410d55e72749d3e1170c291";
448 let feature = [
449 pkt(&format!("{old} {new} refs/heads/feature\0 report-status\n")),
450 b"0000".to_vec(),
451 ]
452 .concat();
453 assert!(refusal(&feature, "main").is_none());
454 // An empty repository has to be able to receive its first commits.
455 let first = [
456 pkt(&format!(
457 "{ZERO_ID} {new} refs/heads/main\0 report-status\n"
458 )),
459 b"0000".to_vec(),
460 ]
461 .concat();
462 assert!(refusal(&first, "main").is_none());
463 }
464
465 #[test]
466 fn a_fetch_request_names_no_branches() {
467 assert!(
468 pushed_branches(b"0032want c71546fcd893ef8b0f57388b65e620d759705dda\n0000").is_empty()
469 );
470 }
471}