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);
}