1//! `git clone https://code.lmjtfy.fun/lmjtfy.git`: the code, for 2//! anyone, without a GitHub account and without publishing it on GitHub. 3//! 4//! Git's smart HTTP is two requests: `GET <repo>/info/refs?service= 5//! git-upload-pack` lists the refs, and `POST <repo>/git-upload-pack` sends 6//! the pack. Both are forwarded to the private repository on GitHub with a 7//! fine-grained token that can read these two repositories and do nothing 8//! else, so a push through here is refused by GitHub, not only by the routes. 9//! Nothing is kept and nothing is spent: a clone reaches neither Jev nor the 10//! LLM, and the budgets do not count it. 11//! 12//! The code host is `code.lmjtfy.fun`; the paths on `lmjtfy.fun` lead there 13//! (`host::gate`). Which repositories are served is `Repo::ALL`, and the code 14//! host's front page (`index`) lists exactly that. 15//! 16//! jevcrates is served beside lmjtfy because lmjtfy's `.gitmodules` names it 17//! by a relative URL (`../jevcrates.git`), which git resolves against 18//! wherever the clone came from: here, the Worker; from GitHub, GitHub. 19 20use axum::body::{Body, to_bytes}; 21use axum::extract::{Path, Query, State}; 22use axum::http::{HeaderMap, Method, Response, StatusCode, header}; 23use base64::Engine; 24use base64::engine::general_purpose::STANDARD; 25use serde::Deserialize; 26 27use std::cell::RefCell; 28 29use archive::Fetch; 30use card::Commit; 31use std::io::Read; 32 33use futures_util::StreamExt; 34 35use super::App; 36use crate::view::code::Meta; 37 38/// The secret's name: a GitHub fine-grained token with Contents: read on 39/// `deizel/lmjtfy` and `deizel/jevcrates`, and no other permission. 40pub const TOKEN: &str = "LMJTFY_GITHUB_TOKEN"; 41 42/// The one git service served. `git-receive-pack` is a push. 43const UPLOAD_PACK: &str = "git-upload-pack"; 44 45/// The largest request body forwarded. An upload-pack request is the refs a 46/// client wants and has, a few kilobytes even for a long history. 47const MAX_REQUEST_BYTES: usize = 1 << 20; 48 49/// The headers a git client sends that change GitHub's answer, forwarded as 50/// they came. `git-protocol` selects protocol v2, which modern git asks for; 51/// GitHub answers smart HTTP only to a `git/` user agent. 52const FORWARDED: [&str; 5] = ["accept", "content-type", "content-encoding", "git-protocol", "user-agent"]; 53 54/// The latest commit on lmjtfy's main, from GitHub's API. The token's 55/// Contents: read covers it. `per_page=1` makes the last page's number the 56/// count of commits. 57 58/// How long an isolate keeps the latest commit before asking again. Chats 59/// fetch a pasted link's page and picture together; this makes that one 60/// call, not two. 61const LATEST_MS: f64 = 60_000.0; 62 63thread_local! { 64 /// Each repository's latest commit and when it was read, in this isolate. 65 static LATEST: RefCell<Vec<(Repo, f64, Commit)>> = const { RefCell::new(Vec::new()) }; 66} 67 68thread_local! { 69 /// Each repository's metadata from GitHub and when it was read. 70 static META: RefCell<Vec<(Repo, f64, Meta)>> = const { RefCell::new(Vec::new()) }; 71} 72 73/// Declares `Repo` and `Repo::ALL` from one list, so a repository cannot be 74/// served without being listed, or listed without being served: the routes, 75/// the redirect from the old addresses (`host::gate`) and the code host's 76/// landing page all read `ALL`, and `ALL` is the variants written here. 77macro_rules! repositories { 78 ($($(#[$note:meta])* $name:ident),+ $(,)?) => { 79 /// The repositories that can be cloned from here. Anything else is 80 /// not found. 81 /// 82 /// To serve another: add its 83 /// name to the list under `repositories!` below, then answer the 84 /// compiler's `match` errors (`served`, `github`, 85 /// `submodules`). Nothing else changes: it is cloned at 86 /// `code.lmjtfy.fun/<served>`, listed on that host's front page and 87 /// redirected to from `lmjtfy.fun`. Its token needs Contents: read on 88 /// the repository too (README, "Credentials"). 89 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 90 pub enum Repo { 91 $($(#[$note])* $name),+ 92 } 93 94 impl Repo { 95 /// Every repository served, in the order the code pages list 96 /// them: this site, the client, then the other projects that use it. 97 pub const ALL: [Repo; [$(Repo::$name),+].len()] = [$(Repo::$name),+]; 98 } 99 }; 100} 101 102repositories! { 103 /// lmjtfy and the three other projects that ask Jev, jevcrates, the 104 /// client those four share, jevstrudel, which asks Jev from the 105 /// browser, and whiskers, which asks it to guard a child's chat. 106 Lmjtfy, 107 Jevcrates, 108 Postjevsql, 109 Jevsnes, 110 Jevhooks, 111 Jevstrudel, 112 /// Whiskers, a talking cat for a young child, which uses Jev as its guard. 113 Whiskers, 114} 115 116impl Repo { 117 /// The name its downloads are under (`/whiskers/latest/...`): `served` 118 /// without `.git`. 119 pub fn bare(self) -> &'static str { 120 self.served().strip_suffix(".git").expect("every served name ends in .git") 121 } 122 123 /// The path segment it is cloned by: `lmjtfy.git`. 124 pub fn served(self) -> &'static str { 125 match self { 126 Repo::Lmjtfy => "lmjtfy.git", 127 Repo::Jevcrates => "jevcrates.git", 128 Repo::Postjevsql => "postjevsql.git", 129 Repo::Jevsnes => "jevsnes.git", 130 Repo::Jevhooks => "jevhooks.git", 131 Repo::Jevstrudel => "jevstrudel.git", 132 Repo::Whiskers => "whiskers.git", 133 } 134 } 135 136 pub fn named(segment: &str) -> Option<Repo> { 137 Repo::ALL.into_iter().find(|repo| repo.served() == segment) 138 } 139 140 /// `owner/name` on GitHub. 141 pub fn github(self) -> &'static str { 142 match self { 143 Repo::Lmjtfy => "deizel/lmjtfy", 144 Repo::Jevcrates => "deizel/jevcrates", 145 Repo::Postjevsql => "deizel/postjevsql", 146 Repo::Jevsnes => "deizel/jevsnes", 147 Repo::Jevhooks => "deizel/jevhooks", 148 Repo::Jevstrudel => "deizel/jevstrudel", 149 Repo::Whiskers => "deizel/whiskers", 150 } 151 } 152 153 /// Its submodules: where each is mounted, and which repository it is. 154 /// The test below holds this to `.gitmodules`, where the checkout has it. 155 pub fn submodules(self) -> &'static [(&'static str, Repo)] { 156 match self { 157 Repo::Lmjtfy | Repo::Postjevsql | Repo::Jevhooks | Repo::Whiskers => &[("third-party/jevcrates", Repo::Jevcrates)], 158 Repo::Jevsnes => &[("third-party/rust/jevcrates", Repo::Jevcrates)], 159 Repo::Jevcrates | Repo::Jevstrudel => &[], 160 } 161 } 162 163 /// The branch the pages read: GitHub's default for it. 164 pub fn branch(self) -> &'static str { 165 match self { 166 Repo::Jevstrudel => "jevstrudel", 167 _ => "main", 168 } 169 } 170 171 fn upstream(self) -> String { 172 format!("https://github.com/{}.git", self.github()) 173 } 174 175 /// The command the page offers, for this site's origin. 176 pub fn command(self, origin: &str) -> String { 177 let recurse = if self.submodules().is_empty() { "" } else { "--recurse-submodules " }; 178 format!("git clone {recurse}{origin}/{}", self.served()) 179 } 180 181 /// What the code pages need to know about it. 182 pub fn project(self) -> crate::view::code::Project { 183 crate::view::code::Project { served: self.served(), branch: self.branch() } 184 } 185} 186 187/// `GET /` at the code host: every repository served, from `Repo::ALL`, with 188/// the command that clones it. 189#[worker::send] 190pub async fn index(State(app): State<App>, headers: HeaderMap) -> Response<Body> { 191 let origin = super::origin(&headers); 192 let metas = metas(&app.env).await; 193 let listings: Vec<_> = Repo::ALL 194 .iter() 195 .zip(metas) 196 .map(|(repo, meta)| crate::view::repos::Listing { project: repo.project(), command: repo.command(&origin), meta }) 197 .collect(); 198 Response::builder() 199 .header(header::CONTENT_TYPE, "text/html; charset=utf-8") 200 .header(header::CACHE_CONTROL, "no-cache") 201 .body(Body::from(crate::view::repos::page(&origin, &listings).into_string())) 202 .expect("static headers are valid") 203} 204 205/// What GitHub says about each repository, in the order of `Repo::ALL`, 206/// asked for together. 207pub async fn metas(env: &worker::Env) -> Vec<Option<Meta>> { 208 futures_util::future::join_all(Repo::ALL.map(|repo| meta(env, repo))).await 209} 210 211/// What GitHub says `repo` is (description, homepage, topics), kept a 212/// minute per isolate like `latest`. Only a good answer is kept: if GitHub 213/// cannot be read, the last good one however old, else `None`, and the 214/// next page asks again. 215pub async fn meta(env: &worker::Env, repo: Repo) -> Option<Meta> { 216 let now = js_sys::Date::now(); 217 let kept = META.with(|kept| kept.borrow().iter().find(|(seen, _, _)| *seen == repo).map(|(_, at, meta)| (*at, meta.clone()))); 218 if let Some((at, meta)) = &kept 219 && now - at < LATEST_MS 220 { 221 return Some(meta.clone()); 222 } 223 let url = format!("https://api.github.com/repos/{}", repo.github()); 224 let read = match github(env, &url).await { 225 Some((200, body, _)) => Meta::parse(&body), 226 _ => None, 227 }; 228 match read { 229 Some(meta) => { 230 META.with(|kept| { 231 let mut kept = kept.borrow_mut(); 232 kept.retain(|(seen, _, _)| *seen != repo); 233 kept.push((repo, now, meta.clone())); 234 }); 235 Some(meta) 236 } 237 None => kept.map(|(_, meta)| meta), 238 } 239} 240 241/// The command the home page offers: lmjtfy's. 242pub fn command(origin: &str) -> String { 243 Repo::Lmjtfy.command(origin) 244} 245 246/// `?view=agents` on the front page shows the root CLAUDE.md. 247#[derive(Deserialize)] 248pub struct Viewed { 249 view: Option<String>, 250} 251 252#[derive(Deserialize)] 253pub struct Service { 254 #[serde(default)] 255 service: String, 256} 257 258/// `GET /lmjtfy.git` (or any served repository) from a browser or a chat's 259/// link preview: its front page. Git never asks for this path, so the clone 260/// address is also the page. 261#[worker::send] 262pub async fn landing( 263 State(app): State<App>, 264 Path(segment): Path<String>, 265 Query(asked): Query<Viewed>, 266 headers: HeaderMap, 267) -> Response<Body> { 268 let Some(repo) = Repo::named(&segment) else { return refused(StatusCode::NOT_FOUND, "No such page.") }; 269 let latest = latest(&app.env, repo).await; 270 let view = crate::view::code::View::asked(asked.view.as_deref()); 271 let docs = crate::browse::root(&app.env, repo, view).await; 272 let whole = crate::browse::explorer(&app.env, repo).await; 273 let origin = super::origin(&headers); 274 let metas = metas(&app.env).await; 275 let frame = crate::browse::frame(&origin, super::host_of(&headers), super::git_redirect(&app.env), repo, "", &whole, latest.as_ref(), metas); 276 let page = crate::view::code::page(&frame, crate::view::code::Shown::Dir { docs }); 277 Response::builder() 278 .header(header::CONTENT_TYPE, "text/html; charset=utf-8") 279 .header(header::CACHE_CONTROL, "no-cache") 280 .body(Body::from(page.into_string())) 281 .expect("static headers are valid") 282} 283 284/// The latest commit on main, read at most once a minute per isolate. If 285/// GitHub cannot be read, the last one read, however old; `None` only if 286/// there has never been one. 287pub async fn latest(env: &worker::Env, repo: Repo) -> Option<Commit> { 288 let now = js_sys::Date::now(); 289 let kept = LATEST.with(|latest| latest.borrow().iter().find(|(seen, _, _)| *seen == repo).map(|(_, at, commit)| (*at, commit.clone()))); 290 if let Some((at, commit)) = &kept 291 && now - at < LATEST_MS 292 { 293 return Some(commit.clone()); 294 } 295 match read_latest(env, repo).await { 296 Some(commit) => { 297 LATEST.with(|latest| { 298 let mut latest = latest.borrow_mut(); 299 latest.retain(|(seen, _, _)| *seen != repo); 300 latest.push((repo, now, commit.clone())); 301 }); 302 Some(commit) 303 } 304 None => kept.map(|(_, commit)| commit), 305 } 306} 307 308async fn read_latest(env: &worker::Env, repo: Repo) -> Option<Commit> { 309 let url = format!("https://api.github.com/repos/{}/commits?sha={}&per_page=1", repo.github(), repo.branch()); 310 match github(env, &url).await? { 311 (200, body, link) => listed(&body, link.as_deref()), 312 _ => None, 313 } 314} 315 316/// A GET of GitHub's API with the read-only token: its status, body and 317/// `link` header. `None` if there is no token or GitHub did not answer. 318pub async fn github(env: &worker::Env, url: &str) -> Option<(u16, String, Option<String>)> { 319 let token = env.secret(TOKEN).ok()?.to_string(); 320 let headers = worker::Headers::new(); 321 headers.set("authorization", &format!("Bearer {token}")).ok()?; 322 headers.set("accept", "application/vnd.github+json").ok()?; 323 // GitHub refuses API requests without one. 324 headers.set("user-agent", "lmjtfy").ok()?; 325 let mut init = worker::RequestInit::new(); 326 init.with_headers(headers); 327 let request = worker::Request::new_with_init(url, &init).ok()?; 328 let mut response = worker::Fetch::Request(request).send().await.ok()?; 329 let link = response.headers().get("link").ok().flatten(); 330 Some((response.status_code(), response.text().await.ok()?, link)) 331} 332 333#[derive(Deserialize)] 334struct Listed { 335 sha: String, 336 commit: Inner, 337} 338 339#[derive(Deserialize)] 340struct Inner { 341 message: String, 342 committer: Option<Signed>, 343} 344 345#[derive(Deserialize)] 346struct Signed { 347 date: String, 348} 349 350/// The commit in GitHub's one-item list, and the count from its `link` 351/// header. 352fn listed(body: &str, link: Option<&str>) -> Option<Commit> { 353 let mut listed: Vec<Listed> = serde_json::from_str(body).ok()?; 354 let first = (!listed.is_empty()).then(|| listed.swap_remove(0))?; 355 Some(Commit { 356 subject: first.commit.message.lines().next().unwrap_or_default().to_owned(), 357 date: first.commit.committer.map(|signed| signed.date.chars().take(10).collect()).unwrap_or_default(), 358 count: link.and_then(last_page).or(Some(1)), 359 sha: first.sha, 360 }) 361} 362 363/// The page number of `rel="last"` in a `link` header. With no `link` there 364/// is one page, which the caller counts. 365fn last_page(link: &str) -> Option<u64> { 366 let last = link.split(',').find(|part| part.contains("rel=\"last\""))?; 367 let page = last.split(['?', '&', '>']).find_map(|part| part.strip_prefix("page="))?; 368 page.parse().ok() 369} 370 371/// `GET /<repo>/info/refs?service=git-upload-pack`. Without the service 372/// parameter this is git's old dumb protocol, which is not served. 373#[worker::send] 374pub async fn refs(State(app): State<App>, Path(segment): Path<String>, Query(asked): Query<Service>, headers: HeaderMap) -> Response<Body> { 375 let Some(repo) = Repo::named(&segment) else { return refused(StatusCode::NOT_FOUND, "No such repository.") }; 376 if asked.service != UPLOAD_PACK { 377 return refused(StatusCode::FORBIDDEN, "This copy can be cloned and fetched, and nothing else."); 378 } 379 let url = format!("{}/info/refs?service={UPLOAD_PACK}", repo.upstream()); 380 forward(&app, Method::GET, url, &headers, None).await 381} 382 383/// `POST /<repo>/git-upload-pack`: the pack. 384#[worker::send] 385pub async fn upload_pack( 386 State(app): State<App>, 387 Path(segment): Path<String>, 388 axum::extract::Extension(event): axum::extract::Extension<crate::events::Event>, 389 headers: HeaderMap, 390 body: Body, 391) -> Response<Body> { 392 let Some(repo) = Repo::named(&segment) else { return refused(StatusCode::NOT_FOUND, "No such repository.") }; 393 let Ok(body) = to_bytes(body, MAX_REQUEST_BYTES).await else { 394 return refused(StatusCode::PAYLOAD_TOO_LARGE, "That request is too large."); 395 }; 396 let fetch = fetched(&body, headers.get(header::CONTENT_ENCODING).and_then(|value| value.to_str().ok())); 397 let url = format!("{}/{UPLOAD_PACK}", repo.upstream()); 398 let response = forward(&app, Method::POST, url, &headers, Some(body.to_vec())).await; 399 let Some(fetch) = fetch.filter(|_| response.status() == StatusCode::OK) else { return response }; 400 // A round of negotiation that ends a fetch is the one GitHub answers 401 // with the pack. The start of the reply is read to see, and sent on 402 // with the rest. 403 let (parts, body) = response.into_parts(); 404 let mut rest = body.into_data_stream(); 405 let mut start = Vec::new(); 406 while start.len() < PEEK_BYTES && !packs(&start) { 407 match rest.next().await { 408 Some(Ok(chunk)) => start.extend_from_slice(&chunk), 409 _ => break, 410 } 411 } 412 if packs(&start) { 413 let which = match fetch { 414 Fetch::Clone => "clone", 415 Fetch::Pull => "pull", 416 }; 417 let mut event = event.named("fetch").with(which); 418 event.status = 200.0; 419 crate::events::record(&app.env, event).await; 420 crate::archive::fetched(&app.env, repo.served(), fetch).await; 421 } 422 let first = futures_util::stream::once(async move { Ok::<_, axum::Error>(bytes::Bytes::from(start)) }); 423 Response::from_parts(parts, Body::from_stream(first.chain(rest))) 424} 425 426/// How much of a reply is read before giving up on finding a pack: past the 427/// acknowledgments a long negotiation can send. 428const PEEK_BYTES: usize = 64 * 1024; 429 430/// Whether a reply has begun sending a pack: protocol v2's `packfile` 431/// section, or a v0 pack's `PACK` signature. 432fn packs(start: &[u8]) -> bool { 433 start.windows(9).any(|window| window == b"packfile\n") || start.windows(4).any(|window| window == b"PACK") 434} 435 436/// What a fetch is, from its upload-pack request: a clone if it names no 437/// commit the client has, a pull if it does. `None` for anything that is 438/// not a fetch, such as protocol v2's `ls-refs`, which every clone and pull 439/// (and a pull with nothing new) starts with. Every round of a negotiation 440/// looks like this; the reply says which round sent the pack (`packs`). 441pub fn fetched(body: &[u8], encoding: Option<&str>) -> Option<Fetch> { 442 let body = match encoding { 443 Some(encoding) if encoding.eq_ignore_ascii_case("gzip") => { 444 let mut unzipped = Vec::new(); 445 flate2::read::GzDecoder::new(body).take(MAX_REQUEST_BYTES as u64 * 8).read_to_end(&mut unzipped).ok()?; 446 unzipped 447 } 448 Some(_) => return None, 449 None => body.to_vec(), 450 }; 451 let lines = pkt_lines(&body)?; 452 let command = lines.iter().find_map(|line| line.strip_prefix(b"command=".as_slice())); 453 if command.is_some_and(|command| command != b"fetch") { 454 return None; 455 } 456 let wants = lines.iter().any(|line| line.starts_with(b"want ")); 457 let haves = lines.iter().any(|line| line.starts_with(b"have ")); 458 match (wants, haves) { 459 (true, false) => Some(Fetch::Clone), 460 (true, true) => Some(Fetch::Pull), 461 (false, _) => None, 462 } 463} 464 465/// Git's pkt-lines: four hex digits of length, then the payload, without its 466/// newline. Flush, delimiter and response-end packets (`0000`, `0001`, 467/// `0002`) carry nothing. `None` if the body is not pkt-lines. 468fn pkt_lines(body: &[u8]) -> Option<Vec<&[u8]>> { 469 let mut lines = Vec::new(); 470 let mut at = 0; 471 while at < body.len() { 472 let length = usize::from_str_radix(std::str::from_utf8(body.get(at..at + 4)?).ok()?, 16).ok()?; 473 if length < 4 { 474 at += 4; 475 continue; 476 } 477 let line = body.get(at + 4..at + length)?; 478 lines.push(line.strip_suffix(b"\n").unwrap_or(line)); 479 at += length; 480 } 481 Some(lines) 482} 483 484async fn forward(app: &App, method: Method, url: String, headers: &HeaderMap, body: Option<Vec<u8>>) -> Response<Body> { 485 let Ok(token) = app.env.secret(TOKEN).map(|secret| secret.to_string()) else { 486 return refused(StatusCode::SERVICE_UNAVAILABLE, "Cloning is not set up here yet."); 487 }; 488 let mut request = http::Request::builder().method(method).uri(url); 489 for (name, value) in forwarded(headers) { 490 request = request.header(name, value); 491 } 492 request = request.header(header::AUTHORIZATION, authorization(&token)); 493 let request = match request.body(Body::from(body.unwrap_or_default())) { 494 Ok(request) => request, 495 Err(_) => return refused(StatusCode::BAD_REQUEST, "That request could not be read."), 496 }; 497 let sent = match worker::Request::try_from(request) { 498 Ok(request) => worker::Fetch::Request(request).send().await, 499 Err(error) => Err(error), 500 }; 501 match sent { 502 // Streamed back as GitHub sends it: a pack can be megabytes. 503 Ok(response) => response.into(), 504 Err(_) => refused(StatusCode::BAD_GATEWAY, "GitHub did not answer."), 505 } 506} 507 508/// The client's headers that are passed on. Its own `authorization`, if it 509/// sent one, is not: the token is the only credential GitHub sees. 510fn forwarded(headers: &HeaderMap) -> impl Iterator<Item = (&'static str, &header::HeaderValue)> { 511 FORWARDED.into_iter().filter_map(|name| headers.get(name).map(|value| (name, value))) 512} 513 514/// GitHub takes a token over git's HTTP as the password of Basic auth. 515fn authorization(token: &str) -> String { 516 format!("Basic {}", STANDARD.encode(format!("x-access-token:{token}"))) 517} 518 519pub fn refused(status: StatusCode, why: &str) -> Response<Body> { 520 Response::builder() 521 .status(status) 522 .header(header::CONTENT_TYPE, "text/plain; charset=utf-8") 523 .body(Body::from(format!("{why}\n"))) 524 .expect("a static response") 525} 526 527#[cfg(test)] 528mod tests { 529 use super::*; 530 531 #[test] 532 fn only_the_listed_repositories_are_served() { 533 assert_eq!(Repo::named("lmjtfy.git"), Some(Repo::Lmjtfy)); 534 assert_eq!(Repo::named("jevcrates.git"), Some(Repo::Jevcrates)); 535 assert_eq!(Repo::named("postjevsql.git"), Some(Repo::Postjevsql)); 536 assert_eq!(Repo::named("jevhooks.git"), Some(Repo::Jevhooks)); 537 assert_eq!(Repo::named("jevsnes.git"), Some(Repo::Jevsnes)); 538 assert_eq!(Repo::named("lmjtfy"), None); 539 assert_eq!(Repo::named("nixos-config.git"), None); 540 assert_eq!(Repo::named("..%2Fnixos-config.git"), None); 541 } 542 543 #[test] 544 fn what_is_served_is_what_is_listed_once_each() { 545 // `Repo::ALL` is the variants written in `repositories!`, so these 546 // are the checks a hand-kept list would need: no name twice, every one 547 // a `.git` that `named` finds, and a GitHub repository of its own. 548 let served: std::collections::HashSet<_> = Repo::ALL.iter().map(|repo| repo.served()).collect(); 549 let github: std::collections::HashSet<_> = Repo::ALL.iter().map(|repo| repo.github()).collect(); 550 assert_eq!(served.len(), Repo::ALL.len()); 551 assert_eq!(github.len(), Repo::ALL.len()); 552 for repo in Repo::ALL { 553 assert!(repo.served().ends_with(".git") && !repo.served().contains('/'), "{repo:?}"); 554 assert_eq!(Repo::named(repo.served()), Some(repo)); 555 assert!(repo.github().starts_with("deizel/"), "{repo:?}"); 556 } 557 } 558 559 #[test] 560 fn the_submodule_resolves_to_a_served_repository() { 561 // `.gitmodules` names jevcrates relative to lmjtfy, so the path it 562 // resolves to here must be one that is served. 563 let gitmodules = include_str!("../../../.gitmodules"); 564 assert!(gitmodules.contains("url = ../jevcrates.git"), "{gitmodules}"); 565 assert_eq!(Repo::named("jevcrates.git"), Some(Repo::Jevcrates)); 566 for (mount, repo) in Repo::Lmjtfy.submodules() { 567 assert!(gitmodules.contains(&format!("path = {mount}")), "{mount}"); 568 assert!(gitmodules.contains(&format!("url = ../{}", repo.served())), "{mount}"); 569 } 570 } 571 572 #[test] 573 fn a_repository_with_submodules_is_cloned_with_them() { 574 assert_eq!(Repo::Lmjtfy.command("https://x"), "git clone --recurse-submodules https://x/lmjtfy.git"); 575 assert_eq!(Repo::Jevcrates.command("https://x"), "git clone https://x/jevcrates.git"); 576 // Every submodule is a served repository, so a clone from here can 577 // fetch it from here. 578 for repo in Repo::ALL { 579 for (_, inner) in repo.submodules() { 580 assert!(Repo::ALL.contains(inner), "{repo:?}"); 581 } 582 } 583 } 584 585 #[test] 586 fn the_clients_own_credentials_are_not_forwarded() { 587 let mut headers = HeaderMap::new(); 588 headers.insert("authorization", "Basic c29tZW9uZQ==".parse().unwrap()); 589 headers.insert("cookie", "a=b".parse().unwrap()); 590 headers.insert("git-protocol", "version=2".parse().unwrap()); 591 headers.insert("user-agent", "git/2.51.0".parse().unwrap()); 592 let names: Vec<_> = forwarded(&headers).map(|(name, _)| name).collect(); 593 assert_eq!(names, ["git-protocol", "user-agent"]); 594 } 595 596 #[test] 597 fn the_token_is_the_basic_password() { 598 let decoded = STANDARD.decode(authorization("t0k").trim_start_matches("Basic ")).unwrap(); 599 assert_eq!(decoded, b"x-access-token:t0k"); 600 } 601 602 #[test] 603 fn the_latest_commit_is_read_from_githubs_list() { 604 let body = r#"[{"sha":"4d93353abc","commit":{"message":"clone: the token\n\nmore","committer":{"date":"2026-10-02T23:59:31Z"}}}]"#; 605 let link = r#"<https://api.github.com/repositories/1/commits?sha=main&per_page=1&page=2>; rel="next", <https://api.github.com/repositories/1/commits?sha=main&per_page=1&page=140>; rel="last""#; 606 let commit = listed(body, Some(link)).unwrap(); 607 assert_eq!(commit.short(), "4d93353"); 608 assert_eq!(commit.subject, "clone: the token"); 609 assert_eq!(commit.date, "2026-10-02"); 610 assert_eq!(commit.count, Some(140)); 611 assert_eq!(listed(body, None).unwrap().count, Some(1)); 612 assert_eq!(listed("[]", None), None); 613 } 614 615 fn pkt(lines: &[&str]) -> Vec<u8> { 616 let mut out = Vec::new(); 617 for line in lines { 618 match *line { 619 "0000" | "0001" => out.extend_from_slice(line.as_bytes()), 620 line => out.extend_from_slice(format!("{:04x}{line}\n", line.len() + 5).as_bytes()), 621 } 622 } 623 out 624 } 625 626 #[test] 627 fn a_clone_and_a_pull_are_told_apart() { 628 let want = "want 4d93353131e2d6b5a1b1a3a3c5f2f6a7b8c9d0e1"; 629 let have = "have 886ece71e2d6b5a1b1a3a3c5f2f6a7b8c9d0e1f2"; 630 // Protocol v2, as git 2.x sends it. 631 let clone = pkt(&["command=fetch", "agent=git/2.51.0", "0001", "thin-pack", want, "done", "0000"]); 632 assert_eq!(fetched(&clone, None), Some(Fetch::Clone)); 633 let pull = pkt(&["command=fetch", "0001", want, have, "done", "0000"]); 634 assert_eq!(fetched(&pull, None), Some(Fetch::Pull)); 635 // Without `done`: the server may still send the pack, and the reply 636 // says whether it did. 637 let round = pkt(&["command=fetch", "0001", want, have, "0000"]); 638 assert_eq!(fetched(&round, None), Some(Fetch::Pull)); 639 let refs = pkt(&["command=ls-refs", "0001", "peel", "symrefs", "0000"]); 640 assert_eq!(fetched(&refs, None), None); 641 // Protocol v0, with capabilities on the first want. 642 let old = pkt(&[&format!("{want} multi_ack side-band-64k"), "0000", "done"]); 643 assert_eq!(fetched(&old, None), Some(Fetch::Clone)); 644 assert_eq!(fetched(b"not pkt lines", None), None); 645 } 646 647 #[test] 648 fn only_a_reply_that_sends_the_pack_counts() { 649 assert!(packs(&pkt(&["acknowledgments", "ACK 886ece7", "ready", "0001", "packfile", "0000"]))); 650 assert!(packs(b"0008NAK\n0031\x01PACK\x00\x00\x00\x02")); 651 assert!(!packs(&pkt(&["acknowledgments", "NAK", "0000"]))); 652 assert!(!packs(&pkt(&["886ece7 HEAD symref-target:refs/heads/main", "0000"]))); 653 } 654 655 #[test] 656 fn a_gzipped_request_is_read() { 657 use std::io::Write; 658 let pull = pkt(&["command=fetch", "0001", "want a", "have b", "done", "0000"]); 659 let mut gzip = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast()); 660 gzip.write_all(&pull).unwrap(); 661 assert_eq!(fetched(&gzip.finish().unwrap(), Some("gzip")), Some(Fetch::Pull)); 662 assert_eq!(fetched(&pull, Some("br")), None); 663 } 664 665 #[test] 666 fn the_command_clones_the_submodule_too() { 667 assert_eq!(command("https://code.lmjtfy.fun"), "git clone --recurse-submodules https://code.lmjtfy.fun/lmjtfy.git"); 668 } 669}