#![cfg(feature = "http-ureq")]
#![cfg(unix)]
use std::io::{Cursor, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::mpsc;
use std::time::{Duration, Instant};
use grit_lib::error::{Error, Result as GritResult};
use grit_lib::fetch::{fetch_remote, NoProgress};
use grit_lib::objects::ObjectId;
use grit_lib::odb::Odb;
use grit_lib::pkt_line;
use grit_lib::push::{push_http, push_remote};
use grit_lib::push_report::PushRefStatus;
use grit_lib::refs::resolve_ref;
use grit_lib::transfer::{FetchOptions, PushOptions, PushRefSpec, TagMode};
use grit_lib::transport::http::HttpClient;
use grit_lib::transport::{
read_advertisement, Connection, ConnectOptions, GitDaemonTransport, Service, SshTransport,
Transport,
};
fn git(dir: &Path, args: &[&str]) -> String {
let out = Command::new("git")
.current_dir(dir)
.args(args)
.env("GIT_AUTHOR_NAME", "T")
.env("GIT_AUTHOR_EMAIL", "t@example.com")
.env("GIT_AUTHOR_DATE", "2005-04-07T22:13:13 +0200")
.env("GIT_COMMITTER_NAME", "T")
.env("GIT_COMMITTER_EMAIL", "t@example.com")
.env("GIT_COMMITTER_DATE", "2005-04-07T22:13:13 +0200")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.output()
.expect("run git");
assert!(
out.status.success(),
"git {args:?} in {} failed: {}",
dir.display(),
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8(out.stdout).expect("utf8 git output")
}
fn rev_parse(dir: &Path, rev: &str) -> ObjectId {
ObjectId::from_hex(git(dir, &["rev-parse", rev]).trim()).expect("valid oid")
}
fn open_odb(git_dir: &Path) -> Odb {
Odb::new(&git_dir.join("objects")).with_config_git_dir(git_dir.to_path_buf())
}
fn empty_local(root: &Path) -> PathBuf {
std::fs::create_dir_all(root).unwrap();
git(root, &["init", "-q", "-b", "main", "."]);
root.join(".git")
}
fn free_port() -> Option<u16> {
static USED: std::sync::Mutex<Vec<u16>> = std::sync::Mutex::new(Vec::new());
let mut used = USED.lock().unwrap_or_else(|e| e.into_inner());
for _ in 0..200 {
let l = TcpListener::bind(("127.0.0.1", 0)).ok()?;
let p = l.local_addr().ok()?.port();
drop(l);
if !used.contains(&p) {
used.push(p);
return Some(p);
}
}
None
}
fn wait_ready(port: u16) -> bool {
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
if TcpStream::connect_timeout(&addr, Duration::from_millis(200)).is_ok() {
return true;
}
std::thread::sleep(Duration::from_millis(50));
}
false
}
fn with_watchdog<T: Send + 'static>(secs: u64, what: &str, f: impl FnOnce() -> T + Send + 'static) -> T {
let (tx, rx) = mpsc::channel();
let handle = std::thread::spawn(move || {
let _ = tx.send(f());
});
match rx.recv_timeout(Duration::from_secs(secs)) {
Ok(v) => {
let _ = handle.join();
v
}
Err(_) => panic!("{what}: did not return within {secs}s (it hung instead of erroring typed)"),
}
}
struct ScriptedConn {
reader: Cursor<Vec<u8>>,
sink: Vec<u8>,
refs: Vec<(String, ObjectId)>,
caps: Vec<String>,
head_symref: Option<String>,
version: u8,
}
impl ScriptedConn {
fn new(server_script: Vec<u8>) -> Self {
ScriptedConn {
reader: Cursor::new(server_script),
sink: Vec::new(),
refs: Vec::new(),
caps: Vec::new(),
head_symref: None,
version: 0,
}
}
fn with_ref(mut self, name: &str, oid: ObjectId) -> Self {
self.refs.push((name.to_owned(), oid));
self
}
fn with_caps(mut self, caps: &[&str]) -> Self {
self.caps = caps.iter().map(|c| (*c).to_owned()).collect();
self
}
fn with_version(mut self, v: u8) -> Self {
self.version = v;
self
}
}
impl Connection for ScriptedConn {
fn reader(&mut self) -> &mut dyn Read {
&mut self.reader
}
fn writer(&mut self) -> &mut dyn Write {
&mut self.sink
}
fn advertised_refs(&self) -> &[(String, ObjectId)] {
&self.refs
}
fn capabilities(&self) -> &[String] {
&self.caps
}
fn head_symref(&self) -> Option<&str> {
self.head_symref.as_deref()
}
fn protocol_version(&self) -> u8 {
self.version
}
}
fn push_sideband(buf: &mut Vec<u8>, band: u8, data: &[u8]) {
let mut payload = Vec::with_capacity(data.len() + 1);
payload.push(band);
payload.extend_from_slice(data);
pkt_line::write_packet_raw(buf, &payload).unwrap();
}
fn absent_oid(local_git: &Path) -> ObjectId {
let width = open_odb(local_git).hash_algo().hex_len();
ObjectId::from_hex(&"b".repeat(width)).expect("valid synthetic oid")
}
#[test]
fn fetch_v0_sideband_band3_fatal_is_typed_error_not_hang() {
let tmp = tempfile::tempdir().unwrap();
let local_git = empty_local(&tmp.path().join("local"));
let want = absent_oid(&local_git);
let mut script = Vec::new();
pkt_line::write_line_to_vec(&mut script, "NAK").unwrap();
push_sideband(&mut script, 3, b"fatal: the requested object is corrupt");
let conn = ScriptedConn::new(script)
.with_ref("refs/heads/main", want)
.with_caps(&["side-band-64k", "multi_ack_detailed", "ofs-delta"])
.with_version(0);
let opts = FetchOptions {
refspecs: vec!["+refs/heads/*:refs/remotes/origin/*".to_owned()],
tags: TagMode::None,
..Default::default()
};
let local_git_for_thread = local_git.clone();
let err = with_watchdog(10, "fetch band-3", move || {
let mut conn = conn;
fetch_remote(&local_git_for_thread, &mut conn, &opts, &mut NoProgress)
.expect_err("a band-3 fatal must surface as an error")
});
let msg = format!("{err}");
assert!(
msg.contains("remote error") && msg.contains("corrupt"),
"band-3 fatal must be reported as a remote error carrying the server text, got: {msg}"
);
assert!(resolve_ref(&local_git, "refs/remotes/origin/main").is_err());
}
#[test]
fn fetch_v0_err_after_done_is_typed_remote_error() {
let tmp = tempfile::tempdir().unwrap();
let local_git = empty_local(&tmp.path().join("local"));
let want = absent_oid(&local_git);
let mut script = Vec::new();
pkt_line::write_line_to_vec(&mut script, "ERR upload-pack: not our ref deadbeef").unwrap();
let conn = ScriptedConn::new(script)
.with_ref("refs/heads/main", want)
.with_caps(&["side-band-64k", "multi_ack_detailed", "ofs-delta"])
.with_version(0);
let opts = FetchOptions {
refspecs: vec!["+refs/heads/*:refs/remotes/origin/*".to_owned()],
tags: TagMode::None,
..Default::default()
};
let local_git_for_thread = local_git.clone();
let err = with_watchdog(10, "fetch ERR-after-done", move || {
let mut conn = conn;
fetch_remote(&local_git_for_thread, &mut conn, &opts, &mut NoProgress)
.expect_err("an ERR after done must surface as an error")
});
let msg = format!("{err}");
assert!(
msg.contains("remote error") && msg.contains("not our ref"),
"ERR-after-done must surface as a remote error with the server text, got: {msg}"
);
}
#[test]
fn read_advertisement_v0_err_line_is_typed_error() {
let mut wire = Vec::new();
pkt_line::write_line_to_vec(&mut wire, "ERR access denied or repository not exported").unwrap();
wire.extend_from_slice(b"0000");
let mut cur = Cursor::new(wire);
let err = read_advertisement(&mut cur).expect_err("an ERR advertisement must be an error");
let msg = format!("{err}");
assert!(
msg.contains("remote error") && msg.contains("access denied"),
"v0 ERR advertisement must surface as a typed remote error, got: {msg}"
);
}
#[test]
fn read_advertisement_v2_err_in_capability_block_is_typed_error() {
let mut wire = Vec::new();
pkt_line::write_line_to_vec(&mut wire, "version 2").unwrap();
pkt_line::write_line_to_vec(&mut wire, "agent=git/2.99").unwrap();
pkt_line::write_line_to_vec(&mut wire, "ERR service not enabled").unwrap();
wire.extend_from_slice(b"0000");
let mut cur = Cursor::new(wire);
let err = read_advertisement(&mut cur).expect_err("a v2 ERR must be an error");
let msg = format!("{err}");
assert!(
msg.contains("remote error") && msg.contains("service not enabled"),
"v2 ERR in capability block must surface as a typed remote error, got: {msg}"
);
}
#[test]
fn push_remote_rejects_protocol_v2_typed_before_touching_refs() {
let tmp = tempfile::tempdir().unwrap();
let local = tmp.path().join("local");
std::fs::create_dir_all(&local).unwrap();
git(&local, &["init", "-q", "-b", "main", "."]);
std::fs::write(local.join("a.txt"), "one\n").unwrap();
git(&local, &["add", "a.txt"]);
git(&local, &["commit", "-q", "-m", "c1"]);
let local_git = local.join(".git");
let main_oid = rev_parse(&local, "HEAD");
let mut conn = ScriptedConn::new(Vec::new())
.with_caps(&["agent=git/2.99", "object-format=sha1"])
.with_version(2);
let spec = PushRefSpec {
src: Some(main_oid),
dst: "refs/heads/main".to_owned(),
force: false,
delete: false,
expected_old: None,
expect_absent: false,
};
let err = push_remote(
&local_git,
&mut conn,
&[spec],
&PushOptions::default(),
&mut NoProgress,
)
.expect_err("a v2 push must be rejected in this phase");
let msg = format!("{err}");
assert!(
msg.contains("v2") && msg.to_lowercase().contains("not supported"),
"v2 push must fail with a clear 'v2 not supported' message, got: {msg}"
);
let written = std::mem::take(&mut conn.sink);
assert!(
written.is_empty(),
"v2 push must reject before sending any bytes, but wrote {} bytes",
written.len()
);
}
#[test]
fn push_remote_unpack_failure_report_demotes_all_sent_refs() {
let tmp = tempfile::tempdir().unwrap();
let local = tmp.path().join("local");
std::fs::create_dir_all(&local).unwrap();
git(&local, &["init", "-q", "-b", "main", "."]);
std::fs::write(local.join("a.txt"), "one\n").unwrap();
git(&local, &["add", "a.txt"]);
git(&local, &["commit", "-q", "-m", "c1"]);
let local_git = local.join(".git");
let main_oid = rev_parse(&local, "HEAD");
let mut report = Vec::new();
pkt_line::write_line_to_vec(&mut report, "unpack index-pack abort: object corrupt").unwrap();
pkt_line::write_line_to_vec(&mut report, "ok refs/heads/main").unwrap();
report.extend_from_slice(b"0000");
let mut conn = ScriptedConn::new(report)
.with_caps(&["report-status", "ofs-delta", "delete-refs"])
.with_version(0);
let spec = PushRefSpec {
src: Some(main_oid),
dst: "refs/heads/main".to_owned(),
force: false,
delete: false,
expected_old: None,
expect_absent: false,
};
let outcome = push_remote(
&local_git,
&mut conn,
&[spec],
&PushOptions::default(),
&mut NoProgress,
)
.expect("push_remote completes and parses the report");
assert_eq!(outcome.results.len(), 1);
let r = &outcome.results[0];
assert_eq!(
r.status,
PushRefStatus::RemoteRejected,
"an unpack failure must demote the ref to RemoteRejected, got {:?}",
r.status
);
let reason = r.message.clone().unwrap_or_default();
assert!(
reason.contains("unpack failed") && reason.contains("index-pack abort"),
"the unpack-failure reason must carry the server text, got {reason:?}"
);
assert!(
!conn.sink.is_empty(),
"the client should have sent the command block and pack"
);
}
struct FakeHttpClient {
get_body: Vec<u8>,
get_err: Option<String>,
post_err: Option<String>,
}
impl HttpClient for FakeHttpClient {
fn get(&self, _url: &str, _git_protocol: Option<&str>) -> GritResult<Vec<u8>> {
if let Some(e) = &self.get_err {
return Err(Error::Message(e.clone()));
}
Ok(self.get_body.clone())
}
fn post(
&self,
_url: &str,
_content_type: &str,
_accept: &str,
_body: &[u8],
_git_protocol: Option<&str>,
) -> GritResult<Vec<u8>> {
if let Some(e) = &self.post_err {
return Err(Error::Message(e.clone()));
}
Ok(b"0000".to_vec())
}
}
fn v2_receive_pack_advertisement() -> Vec<u8> {
let mut body = Vec::new();
pkt_line::write_line_to_vec(&mut body, "# service=git-receive-pack").unwrap();
body.extend_from_slice(b"0000");
pkt_line::write_line_to_vec(&mut body, "version 2").unwrap();
pkt_line::write_line_to_vec(&mut body, "agent=git/2.99").unwrap();
body.extend_from_slice(b"0000");
body
}
#[test]
fn push_http_rejects_v2_receive_pack_advertisement_typed() {
let tmp = tempfile::tempdir().unwrap();
let local = tmp.path().join("local");
std::fs::create_dir_all(&local).unwrap();
git(&local, &["init", "-q", "-b", "main", "."]);
std::fs::write(local.join("a.txt"), "one\n").unwrap();
git(&local, &["add", "a.txt"]);
git(&local, &["commit", "-q", "-m", "c1"]);
let local_git = local.join(".git");
let main_oid = rev_parse(&local, "HEAD");
let client = FakeHttpClient {
get_body: v2_receive_pack_advertisement(),
get_err: None,
post_err: Some("POST must not be reached for a v2 advertisement".to_owned()),
};
let spec = PushRefSpec {
src: Some(main_oid),
dst: "refs/heads/main".to_owned(),
force: false,
delete: false,
expected_old: None,
expect_absent: false,
};
let err = push_http(
&client,
&local_git,
"http://example.invalid/repo.git",
&[spec],
&PushOptions::default(),
&mut NoProgress,
)
.expect_err("a v2 receive-pack advertisement must be rejected");
let msg = format!("{err}");
assert!(
msg.contains("v2") && msg.to_lowercase().contains("not supported"),
"push_http must reject a v2 advertisement with a clear message, got: {msg}"
);
}
#[test]
fn push_http_propagates_discovery_transport_error_typed() {
let tmp = tempfile::tempdir().unwrap();
let local = tmp.path().join("local");
std::fs::create_dir_all(&local).unwrap();
git(&local, &["init", "-q", "-b", "main", "."]);
std::fs::write(local.join("a.txt"), "one\n").unwrap();
git(&local, &["add", "a.txt"]);
git(&local, &["commit", "-q", "-m", "c1"]);
let local_git = local.join(".git");
let main_oid = rev_parse(&local, "HEAD");
let client = FakeHttpClient {
get_body: Vec::new(),
get_err: Some("connection refused (os error 61)".to_owned()),
post_err: None,
};
let spec = PushRefSpec {
src: Some(main_oid),
dst: "refs/heads/main".to_owned(),
force: false,
delete: false,
expected_old: None,
expect_absent: false,
};
let err = push_http(
&client,
&local_git,
"http://127.0.0.1:1/repo.git",
&[spec],
&PushOptions::default(),
&mut NoProgress,
)
.expect_err("a discovery transport failure must surface as an error");
let msg = format!("{err}");
assert!(
msg.contains("connection refused"),
"discovery transport error must propagate, got: {msg}"
);
}
#[test]
fn git_daemon_connect_refused_port_is_typed_error_not_hang() {
let Some(port) = free_port() else {
eprintln!("SKIP: could not allocate a free port");
return;
};
let url = format!("git://127.0.0.1:{port}/repo.git");
let err = with_watchdog(10, "git daemon connect-refused", move || {
let transport = GitDaemonTransport::new();
transport
.connect(&url, Service::UploadPack, &ConnectOptions::default())
.err()
});
assert!(
err.is_some(),
"connecting to a closed port must return an Err (got Ok)"
);
}
#[test]
fn streaming_transports_reject_malformed_urls_typed() {
let daemon = GitDaemonTransport::new();
assert!(
daemon
.connect("https://example.com/repo.git", Service::UploadPack, &ConnectOptions::default())
.is_err(),
"git daemon transport must reject a non-git:// URL"
);
assert!(
daemon
.connect("git://example.com", Service::UploadPack, &ConnectOptions::default())
.is_err(),
"git daemon transport must reject a git:// URL with no path"
);
let ssh = SshTransport::new();
assert!(
ssh.connect("host:", Service::UploadPack, &ConnectOptions::default())
.is_err(),
"ssh transport must reject a scp-style URL with an empty path"
);
}
fn build_tagged_source(dir: &Path) {
git(dir, &["init", "-q", "-b", "main", "."]);
std::fs::write(dir.join("a.txt"), "root\n").unwrap();
git(dir, &["add", "a.txt"]);
git(dir, &["commit", "-q", "-m", "root"]);
std::fs::write(dir.join("a.txt"), "mid\n").unwrap();
git(dir, &["add", "a.txt"]);
git(dir, &["commit", "-q", "-m", "mid"]);
std::fs::write(dir.join("a.txt"), "tip\n").unwrap();
git(dir, &["add", "a.txt"]);
git(dir, &["commit", "-q", "-m", "tip"]);
git(dir, &["tag", "-a", "v1", "-m", "release one"]);
}
struct DaemonGuard(Child);
impl Drop for DaemonGuard {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
#[test]
fn shallow_depth1_with_all_tags_over_git_daemon() {
let tmp = tempfile::tempdir().unwrap();
let work = tmp.path().join("work");
std::fs::create_dir_all(&work).unwrap();
build_tagged_source(&work);
let base = tmp.path().join("srv");
std::fs::create_dir_all(&base).unwrap();
let served = base.join("repo.git");
let st = Command::new("git")
.args(["clone", "-q", "--bare", work.to_str().unwrap(), served.to_str().unwrap()])
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.status()
.expect("git clone --bare");
if !st.success() {
eprintln!("SKIP: could not bare-clone the source");
return;
}
let _ = Command::new("git")
.current_dir(&served)
.args(["symbolic-ref", "HEAD", "refs/heads/main"])
.status();
let Some(port) = free_port() else {
eprintln!("SKIP: could not allocate a free port");
return;
};
let Ok(child) = Command::new("git")
.arg("daemon")
.arg("--listen=127.0.0.1")
.arg(format!("--port={port}"))
.arg("--reuseaddr")
.arg("--export-all")
.arg(format!("--base-path={}", base.display()))
.arg(&base)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
else {
eprintln!("SKIP: `git daemon` is unavailable");
return;
};
let _guard = DaemonGuard(child);
if !wait_ready(port) {
eprintln!("SKIP: git daemon did not become ready");
return;
}
let tip = rev_parse(&work, "refs/heads/main");
let root = rev_parse(&work, "main~2");
let tag = rev_parse(&work, "refs/tags/v1");
let local = tmp.path().join("local");
let local_git = empty_local(&local);
let url = format!("git://127.0.0.1:{port}/repo.git");
let transport = GitDaemonTransport::new();
let mut conn = match transport.connect(&url, Service::UploadPack, &ConnectOptions::default()) {
Ok(c) => c,
Err(e) => {
eprintln!("SKIP: could not connect to git daemon: {e}");
return;
}
};
let opts = FetchOptions {
refspecs: vec!["+refs/heads/*:refs/remotes/origin/*".to_owned()],
tags: TagMode::All,
depth: Some(1),
..Default::default()
};
let outcome = fetch_remote(&local_git, &mut *conn, &opts, &mut NoProgress)
.expect("shallow+tags fetch over git daemon");
drop(conn);
let landed_tip = resolve_ref(&local_git, "refs/remotes/origin/main").expect("origin/main");
assert_eq!(landed_tip, tip, "shallow fetch must land the remote tip");
assert_eq!(
landed_tip.to_hex(),
git(&work, &["rev-parse", "refs/heads/main"]).trim()
);
let landed_tag = resolve_ref(&local_git, "refs/tags/v1").expect("tag v1 must be fetched");
assert_eq!(landed_tag, tag, "the annotated tag must land with the shallow fetch");
let local_odb = open_odb(&local_git);
assert!(local_odb.exists(&tip), "tip object must be present");
assert!(local_odb.exists(&tag), "tag object must be present");
assert!(
!local_odb.exists(&root),
"a depth-1 fetch must NOT bring the deep ancestor {} into the local odb",
root.to_hex()
);
let on_disk = grit_lib::shallow::load_shallow_oids(&local_git).expect("load shallow");
assert!(
on_disk.contains(&tip),
"the shallow file must graft the tip {} (boundary), got {:?}",
tip.to_hex(),
on_disk.iter().map(ObjectId::to_hex).collect::<Vec<_>>()
);
assert!(
outcome.new_shallow.contains(&tip),
"the fetch outcome must report the new shallow boundary"
);
let fsck = Command::new("git")
.current_dir(&local)
.args(["fsck", "--no-dangling"])
.output()
.expect("run git fsck");
assert!(
fsck.status.success(),
"git fsck failed after shallow+tags fetch: {}",
String::from_utf8_lossy(&fsck.stderr)
);
}