lmjtfy.git / apps / lmjtfy / src / clone.rs
clone.rsannotatedclone.rssource669 lines · 27.7 KB · raw
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}