use std::io::Write;
use std::path::Path;
use std::process::Command;
use std::sync::Arc;
use std::time::Duration;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use git_cache_proxy::evict::CacheIndex;
use git_cache_proxy::git::{GitCache, GitConfig};
use git_cache_proxy::metrics::Metrics;
use git_cache_proxy::server::{AppState, router};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tower::ServiceExt;
fn git(cwd: &Path, args: &[&str]) -> String {
let out = Command::new("git")
.current_dir(cwd)
.args(args)
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_AUTHOR_NAME", "test")
.env("GIT_AUTHOR_EMAIL", "test@example.invalid")
.env("GIT_COMMITTER_NAME", "test")
.env("GIT_COMMITTER_EMAIL", "test@example.invalid")
.output()
.expect("spawn git");
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).into_owned()
}
fn pkt(s: &str) -> Vec<u8> {
let mut v = format!("{:04x}", s.len() + 4).into_bytes();
v.extend_from_slice(s.as_bytes());
v
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn upload_pack_decodes_gzip_encoded_request() {
let work = tempfile::tempdir().unwrap();
git(work.path(), &["init", "-q", "-b", "main", "."]);
std::fs::write(work.path().join("README.md"), "hello\n").unwrap();
git(work.path(), &["add", "."]);
git(work.path(), &["commit", "-q", "-m", "init"]);
let up = tempfile::tempdir().unwrap();
let upstream_repo = up.path().join("repo.git");
git(
work.path(),
&[
"clone",
"-q",
"--mirror",
".",
upstream_repo.to_str().unwrap(),
],
);
let cache = tempfile::tempdir().unwrap();
let metrics = Arc::new(Metrics::new());
let cfg = GitConfig {
git_binary: "git".into(),
upstream_auth_header: None,
fetch_ttl: Duration::from_secs(0),
};
let state = AppState {
cache: Arc::new(GitCache::new(cfg, metrics.clone(), None)),
upstream_base: format!("file://{}", up.path().display()),
cache_root: cache.path().to_path_buf(),
serve_token: None,
max_decoded_body: 512 * 1024 * 1024,
max_concurrent: 8,
metrics,
};
let mut req = Vec::new();
req.extend_from_slice(&pkt("command=ls-refs\n"));
req.extend_from_slice(&pkt("object-format=sha1\n"));
req.extend_from_slice(b"0001"); req.extend_from_slice(b"0000"); let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
enc.write_all(&req).unwrap();
let gz = enc.finish().unwrap();
let resp = router(state)
.oneshot(
Request::post("/repo.git/git-upload-pack")
.header("content-type", "application/x-git-upload-pack-request")
.header("content-encoding", "gzip")
.header("git-protocol", "version=2")
.body(Body::from(gz))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"gzip-encoded upload-pack POST should be accepted"
);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let text = String::from_utf8_lossy(&body);
assert!(
text.contains("refs/heads/main"),
"ls-refs response should list refs/heads/main; got: {text:?}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn clones_through_proxy_serves_all_refs_and_rejects_push() {
let work = tempfile::tempdir().unwrap();
git(work.path(), &["init", "-q", "-b", "main", "."]);
std::fs::write(work.path().join("README.md"), "hello\n").unwrap();
git(work.path(), &["add", "."]);
git(work.path(), &["commit", "-q", "-m", "init"]);
git(work.path(), &["branch", "feature"]);
git(work.path(), &["tag", "v1"]);
let up = tempfile::tempdir().unwrap();
let upstream_repo = up.path().join("repo.git");
git(
work.path(),
&[
"clone",
"-q",
"--mirror",
".",
upstream_repo.to_str().unwrap(),
],
);
let cache = tempfile::tempdir().unwrap();
let metrics = Arc::new(Metrics::new());
let cfg = GitConfig {
git_binary: "git".into(),
upstream_auth_header: None,
fetch_ttl: Duration::from_secs(0),
};
let idx = CacheIndex::new(cache.path().to_path_buf(), u64::MAX, metrics.clone());
let state = AppState {
cache: Arc::new(GitCache::new(cfg, metrics.clone(), Some(idx.clone()))),
upstream_base: format!("file://{}", up.path().display()),
cache_root: cache.path().to_path_buf(),
serve_token: None,
max_decoded_body: 512 * 1024 * 1024,
max_concurrent: 8,
metrics: metrics.clone(),
};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
axum::serve(listener, router(state)).await.unwrap();
});
let dest = tempfile::tempdir().unwrap();
let checkout = dest.path().join("checkout");
let out = tokio::process::Command::new("git")
.arg("clone")
.arg("-q")
.arg(format!("http://{addr}/repo.git"))
.arg(&checkout)
.env("GIT_TERMINAL_PROMPT", "0")
.output()
.await
.expect("spawn git clone");
assert!(
out.status.success(),
"clone through proxy failed: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(
std::fs::read_to_string(checkout.join("README.md")).unwrap(),
"hello\n"
);
let tags = git(&checkout, &["tag"]);
assert!(tags.contains("v1"), "expected tag v1, got: {tags:?}");
let remotes = git(&checkout, &["branch", "-r"]);
assert!(
remotes.contains("feature"),
"expected origin/feature, got: {remotes:?}"
);
let scraped = metrics.gather();
assert!(
scraped.contains(r#"op="clone""#) && scraped.contains(r#"repo="repo.git""#),
"missing per-repo clone metric:\n{scraped}"
);
assert!(
scraped.contains(
r#"gitcacheproxy_requests_total{kind="info_refs",repo="repo.git",result="ok"}"#
),
"missing per-repo info_refs request metric:\n{scraped}"
);
assert!(
scraped.contains(
r#"gitcacheproxy_requests_total{kind="upload_pack",repo="repo.git",result="ok"}"#
),
"missing per-repo upload_pack request metric:\n{scraped}"
);
let checkout2 = dest.path().join("checkout2");
let out = tokio::process::Command::new("git")
.arg("clone")
.arg("-q")
.arg(format!("http://{addr}/repo.git"))
.arg(&checkout2)
.env("GIT_TERMINAL_PROMPT", "0")
.output()
.await
.expect("spawn second git clone");
assert!(
out.status.success(),
"second clone failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let scraped = metrics.gather();
assert!(
scraped.contains(r#"op="fetch""#) && scraped.contains(r#"repo="repo.git""#),
"missing per-repo fetch metric after second clone:\n{scraped}"
);
assert_eq!(
idx.totals().1,
1,
"cache index should track the cloned repo"
);
let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
let req = format!(
"GET /repo.git/info/refs?service=git-receive-pack HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n"
);
stream.write_all(req.as_bytes()).await.unwrap();
let mut buf = Vec::new();
stream.read_to_end(&mut buf).await.unwrap();
let resp = String::from_utf8_lossy(&buf);
assert!(
resp.starts_with("HTTP/1.1 403"),
"expected 403 for receive-pack, got: {}",
&resp[..resp.len().min(64)]
);
server.abort();
}