clone.rs raw

//! Cloning over HTTP, through the smart protocol endpoints and with a real
//! git client against a live server.

mod common;

use std::{process::Command, time::Duration};

use assert2::check;
use common::Fixture;
use poem::{
    Body, Server,
    http::StatusCode,
    listener::{Acceptor, Listener, TcpListener},
    test::{TestClient, TestResponse},
};
use tokio::io::AsyncWriteExt;

fn fixture() -> Fixture {
    let fx = Fixture::new();
    fx.repo(
        "widget",
        true,
        "Widgets",
        &[("README.md", "# Widget\n"), ("src.rs", "fn main() {}\n")],
    );
    fx.repo("secret", false, "Secret", &[("README.md", "# Secret\n")]);
    fx
}

#[tokio::test]
async fn advertises_refs_for_public_repositories_only() {
    let fx = fixture();
    let client = TestClient::new(fx.app.routes());

    let resp = client
        .get("/widget.git/info/refs")
        .query("service", &"git-upload-pack")
        .send()
        .await;
    resp.assert_status_is_ok();
    resp.assert_content_type("application/x-git-upload-pack-advertisement");
    let body = resp.0.into_body().into_string().await.unwrap();
    check!(body.starts_with("001e# service=git-upload-pack"));
    check!(body.contains("refs/heads/main"));

    for path in ["/secret.git/info/refs", "/missing.git/info/refs"] {
        let resp = client
            .get(path)
            .query("service", &"git-upload-pack")
            .send()
            .await;
        check!(resp.0.status() == StatusCode::NOT_FOUND, "{path}");
    }
}

#[tokio::test]
async fn protocol_v2_is_negotiated_when_the_client_asks() {
    let fx = fixture();
    let client = TestClient::new(fx.app.routes());
    let resp = client
        .get("/widget.git/info/refs")
        .query("service", &"git-upload-pack")
        .header("git-protocol", "version=2")
        .send()
        .await;
    resp.assert_status_is_ok();
    let body = resp.0.into_body().into_string().await.unwrap();
    check!(body.contains("version 2"));
    check!(body.contains("ls-refs"));
}

#[tokio::test]
async fn pushing_is_not_offered() {
    let fx = fixture();
    let client = TestClient::new(fx.app.routes());
    let resp = client
        .get("/widget.git/info/refs")
        .query("service", &"git-receive-pack")
        .send()
        .await;
    check!(resp.0.status() != StatusCode::OK);
    let resp = client.post("/widget.git/git-receive-pack").send().await;
    check!(resp.0.status() == StatusCode::NOT_FOUND);
}

#[tokio::test(flavor = "multi_thread")]
async fn git_can_clone_a_public_repository_but_not_a_private_one() {
    let fx = fixture();
    let acceptor = TcpListener::bind("127.0.0.1:0")
        .into_acceptor()
        .await
        .expect("bind");
    let addr = *acceptor.local_addr()[0]
        .as_socket_addr()
        .expect("socket addr");
    let routes = fx.app.routes();
    let server = mugit::serve::configure(Server::new_with_acceptor(acceptor), &fx.app.config);
    let server = tokio::spawn(async move { server.run(routes).await });

    let dest = tempfile::tempdir().expect("temp dir");
    let (public, private) = tokio::task::spawn_blocking(move || {
        let public = Command::new("git")
            .args(["clone", "--quiet", &format!("http://{addr}/widget.git")])
            .arg(dest.path().join("widget"))
            .env("GIT_TERMINAL_PROMPT", "0")
            .output()
            .expect("run git clone");
        let private = Command::new("git")
            .args(["clone", "--quiet", &format!("http://{addr}/secret.git")])
            .arg(dest.path().join("secret"))
            .env("GIT_TERMINAL_PROMPT", "0")
            .output()
            .expect("run git clone");
        let cloned_readme = std::fs::read_to_string(dest.path().join("widget/README.md")).ok();
        (
            (public.status.success(), cloned_readme),
            private.status.success(),
        )
    })
    .await
    .expect("clone task");

    check!(public.0, "public clone should succeed");
    check!(public.1.as_deref() == Some("# Widget\n"));
    check!(!private, "private clone should fail");
    server.abort();
}

// A fixture that serves one git process at a time and gives up on a
// stalled one after a second.
fn tightly_bounded_fixture() -> Fixture {
    let fx = Fixture::with_args(&["--git-processes", "1", "--git-idle-timeout", "1"]);
    fx.repo("widget", true, "Widgets", &[("README.md", "# Widget\n")]);
    fx
}

// Starts a fetch whose request body is whatever gets written to the
// returned end of a pipe, so the backend can be kept busy at will.
async fn fetch_with_open_body(
    client: &TestClient<impl poem::Endpoint>,
) -> (TestResponse, tokio::io::DuplexStream) {
    let (writer, reader) = tokio::io::duplex(1024);
    let resp = client
        .post("/widget.git/git-upload-pack")
        .content_type("application/x-git-upload-pack-request")
        .body(Body::from_async_read(reader))
        .send()
        .await;
    (resp, writer)
}

async fn refs_status(client: &TestClient<impl poem::Endpoint>) -> StatusCode {
    client
        .get("/widget.git/info/refs")
        .query("service", &"git-upload-pack")
        .send()
        .await
        .0
        .status()
}

#[tokio::test]
async fn a_request_beyond_the_process_bound_is_refused() {
    let fx = tightly_bounded_fixture();
    let client = TestClient::new(fx.app.routes());
    let (busy, mut writer) = fetch_with_open_body(&client).await;
    busy.assert_status_is_ok();
    // The client keeps its request trickling in, so the backend is at
    // work and stays in its slot.
    let trickle = tokio::spawn(async move {
        while writer.write_all(b"0000").await.is_ok() {
            tokio::time::sleep(Duration::from_millis(100)).await;
        }
    });

    let refused = client
        .get("/widget.git/info/refs")
        .query("service", &"git-upload-pack")
        .send()
        .await;
    check!(refused.0.status() == StatusCode::SERVICE_UNAVAILABLE);
    check!(refused.0.headers().contains_key("retry-after"));

    // Once the fetch is over, its slot is free again.
    trickle.abort();
    let _ = tokio::time::timeout(Duration::from_secs(10), busy.0.into_body().into_vec())
        .await
        .expect("the fetch should end once its request does");
    check!(refs_status(&client).await == StatusCode::OK);
}

#[tokio::test]
async fn a_stalled_client_is_dropped_and_its_slot_freed() {
    let fx = tightly_bounded_fixture();
    let client = TestClient::new(fx.app.routes());
    // A request whose body never arrives.
    let (stalled, writer) = fetch_with_open_body(&client).await;
    stalled.assert_status_is_ok();
    let _ = tokio::time::timeout(Duration::from_secs(10), stalled.0.into_body().into_vec())
        .await
        .expect("the stalled fetch should be dropped");
    check!(refs_status(&client).await == StatusCode::OK);
    drop(writer);
}