#![cfg(feature = "http-ureq")]
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use grit_lib::config::ConfigSet;
use grit_lib::error::{AuthFailure, Error, Result as GritResult};
use grit_lib::objects::odb::Odb;
use grit_lib::objects::ObjectId;
use grit_lib::refs::resolve_ref;
use grit_lib::remote::credentials::{Credential, CredentialProvider, HelperCredentialProvider};
use grit_lib::remote::fetch::NoProgress;
use grit_lib::remote::push::push_http;
use grit_lib::remote::push_report::PushRefStatus;
use grit_lib::remote::transfer::{FetchOptions, PushOptions, PushRefSpec, TagMode};
use grit_lib::remote::transport::http::ureq_client::UreqHttpClient;
use grit_lib::remote::transport::http::{http_client_arc, http_fetch};
const USER: &str = "alice";
const PASS: &str = "s3cr3t";
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:?} failed: {}",
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 build_source(dir: &Path) {
git(dir, &["init", "-q", "-b", "main", "."]);
std::fs::write(dir.join("a.txt"), "one\n").unwrap();
git(dir, &["add", "a.txt"]);
git(dir, &["commit", "-q", "-m", "c1"]);
std::fs::write(dir.join("b.txt"), "two\n").unwrap();
git(dir, &["add", "b.txt"]);
git(dir, &["commit", "-q", "-m", "c2"]);
git(dir, &["tag", "-a", "v1", "-m", "release one"]);
git(dir, &["branch", "topic"]);
}
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 find_binary(name: &str) -> Option<PathBuf> {
let exe = std::env::current_exe().ok()?;
let deps = exe.parent()?; let profile = deps.parent()?; for cand in [profile.join(name), deps.join(name)] {
if cand.is_file() {
return Some(cand);
}
}
None
}
fn spawn_authed_server(server_bin: &Path, root: &Path, port: u16) -> Option<Child> {
Command::new(server_bin)
.arg("--root")
.arg(root)
.arg("--bind")
.arg(format!("127.0.0.1:{port}"))
.arg("--require-auth")
.arg(format!("{USER}:{PASS}"))
.env_remove("GRIT_BIN")
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.ok()
}
fn wait_ready(port: u16) -> bool {
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));
let deadline = Instant::now() + Duration::from_secs(10);
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
}
struct ServerGuard(Child);
impl Drop for ServerGuard {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
struct StaticCredentialProvider {
username: String,
password: String,
fills: AtomicUsize,
approves: AtomicUsize,
rejects: AtomicUsize,
}
impl StaticCredentialProvider {
fn new(username: &str, password: &str) -> Self {
Self {
username: username.to_owned(),
password: password.to_owned(),
fills: AtomicUsize::new(0),
approves: AtomicUsize::new(0),
rejects: AtomicUsize::new(0),
}
}
}
impl CredentialProvider for StaticCredentialProvider {
fn fill(&self, input: &Credential) -> GritResult<Credential> {
self.fills.fetch_add(1, Ordering::SeqCst);
let mut out = input.clone();
out.username = Some(self.username.clone());
out.password = Some(self.password.clone());
Ok(out)
}
fn approve(&self, _cred: &Credential) -> GritResult<()> {
self.approves.fetch_add(1, Ordering::SeqCst);
Ok(())
}
fn reject(&self, _cred: &Credential) -> GritResult<()> {
self.rejects.fetch_add(1, Ordering::SeqCst);
Ok(())
}
}
#[derive(Clone)]
struct SharedProvider(Arc<StaticCredentialProvider>);
impl CredentialProvider for SharedProvider {
fn fill(&self, input: &Credential) -> GritResult<Credential> {
self.0.fill(input)
}
fn approve(&self, cred: &Credential) -> GritResult<()> {
self.0.approve(cred)
}
fn reject(&self, cred: &Credential) -> GritResult<()> {
self.0.reject(cred)
}
}
fn mirror_bare(work: &Path, root: &Path, name: &str) -> PathBuf {
std::fs::create_dir_all(root).unwrap();
let bare = root.join(name);
git(
work,
&[
"clone",
"-q",
"--bare",
".",
bare.to_str().expect("utf8 path"),
],
);
git(&bare, &["symbolic-ref", "HEAD", "refs/heads/main"]);
bare
}
fn make_bare_target(root: &Path, name: &str) -> PathBuf {
let bare = root.join(name);
std::fs::create_dir_all(&bare).unwrap();
git(&bare, &["init", "-q", "--bare", "."]);
git(&bare, &["symbolic-ref", "HEAD", "refs/heads/main"]);
bare
}
fn start_authed_server(root: &Path) -> Option<(ServerGuard, u16)> {
let server_bin = find_binary("grit-http-server")?;
let port = free_port()?;
let child = spawn_authed_server(&server_bin, root, port)?;
let guard = ServerGuard(child);
if !wait_ready(port) {
return None;
}
Some((guard, port))
}
#[test]
fn fetch_over_authed_http_succeeds_with_right_credentials_and_fails_typed_otherwise() {
if find_binary("grit-http-server").is_none() {
eprintln!("SKIP: `grit-http-server` binary not found (build it first)");
return;
}
let tmp = tempfile::tempdir().expect("tempdir");
let work = tmp.path().join("work");
std::fs::create_dir_all(&work).unwrap();
build_source(&work);
let root = tmp.path().join("srv");
let source = mirror_bare(&work, &root, "repo.git");
let main_oid = rev_parse(&source, "refs/heads/main");
let topic_oid = rev_parse(&source, "refs/heads/topic");
let c1_oid = rev_parse(&work, "HEAD~1");
let tag_oid = rev_parse(&source, "refs/tags/v1");
let Some((_guard, port)) = start_authed_server(&root) else {
eprintln!("SKIP: could not start authed grit-http-server");
return;
};
let url = format!("http://127.0.0.1:{port}/repo.git");
let url_with_embedded_creds =
format!("http://{USER}:wrong-embedded-pass@127.0.0.1:{port}/repo.git");
let mut opts = FetchOptions {
refspecs: vec!["+refs/heads/*:refs/remotes/origin/*".to_owned()],
tags: TagMode::All,
..Default::default()
};
{
let local = tmp.path().join("noauth");
std::fs::create_dir_all(&local).unwrap();
git(&local, &["init", "-q", "-b", "main", "."]);
let local_git = local.join(".git");
let client = http_client_arc(UreqHttpClient::new().with_git_protocol("version=2"));
let err = http_fetch(client.clone(), &local_git, &url, &mut opts, &mut NoProgress)
.expect_err("fetch with no credentials against an authed server must fail");
assert!(
matches!(&err, Error::Auth { reason, .. } if !reason.is_rejected()),
"expected a typed Error::Auth without credentials, got: {err:?}"
);
}
{
let local = tmp.path().join("embedded-creds-url");
std::fs::create_dir_all(&local).unwrap();
git(&local, &["init", "-q", "-b", "main", "."]);
let local_git = local.join(".git");
let client = http_client_arc(UreqHttpClient::new().with_git_protocol("version=2"));
let err = http_fetch(
client,
&local_git,
&url_with_embedded_creds,
&mut opts,
&mut NoProgress,
)
.expect_err("fetch with embedded wrong creds in URL must fail");
assert!(matches!(err, Error::Auth { .. }));
let msg = err.to_string();
assert!(
!msg.contains("wrong-embedded-pass"),
"auth error must redact password embedded in remote URL: {msg}"
);
assert!(
!msg.contains(&format!("{USER}:wrong-embedded-pass")),
"auth error must redact userinfo embedded in remote URL: {msg}"
);
}
{
let local = tmp.path().join("wrong");
std::fs::create_dir_all(&local).unwrap();
git(&local, &["init", "-q", "-b", "main", "."]);
let local_git = local.join(".git");
let provider = SharedProvider(Arc::new(StaticCredentialProvider::new(USER, "wrong-pass")));
let client = http_client_arc(
UreqHttpClient::with_credentials(Box::new(provider.clone()))
.with_git_protocol("version=2"),
);
let err = http_fetch(client.clone(), &local_git, &url, &mut opts, &mut NoProgress)
.expect_err("fetch with wrong credentials must fail");
assert!(
matches!(
err,
Error::Auth {
reason: AuthFailure::Rejected,
..
}
),
"expected a typed Error::Auth for wrong credentials, got: {err:?}"
);
let msg = err.to_string();
assert!(
!msg.contains("wrong-pass"),
"auth error must not echo credentials: {msg}"
);
assert_eq!(
provider.0.fills.load(Ordering::SeqCst),
1,
"fill should run once"
);
assert_eq!(
provider.0.rejects.load(Ordering::SeqCst),
1,
"wrong credentials should be rejected"
);
assert_eq!(
provider.0.approves.load(Ordering::SeqCst),
0,
"wrong credentials must never be approved"
);
assert!(resolve_ref(&local_git, "refs/remotes/origin/main").is_err());
}
let local = tmp.path().join("ok");
std::fs::create_dir_all(&local).unwrap();
git(&local, &["init", "-q", "-b", "main", "."]);
let local_git = local.join(".git");
let provider = SharedProvider(Arc::new(StaticCredentialProvider::new(USER, PASS)));
let client = http_client_arc(
UreqHttpClient::with_credentials(Box::new(provider.clone())).with_git_protocol("version=2"),
);
let outcome = http_fetch(client.clone(), &local_git, &url, &mut opts, &mut NoProgress)
.expect("authed fetch with correct credentials must succeed");
assert!(
provider.0.fills.load(Ordering::SeqCst) >= 1,
"fill should run at least once"
);
assert!(
provider.0.approves.load(Ordering::SeqCst) >= 1,
"correct credentials should be approved"
);
assert_eq!(
provider.0.rejects.load(Ordering::SeqCst),
0,
"correct credentials must not be rejected"
);
assert_eq!(
resolve_ref(&local_git, "refs/remotes/origin/main").expect("origin/main"),
main_oid
);
assert_eq!(
resolve_ref(&local_git, "refs/remotes/origin/topic").expect("origin/topic"),
topic_oid
);
assert_eq!(
resolve_ref(&local_git, "refs/tags/v1").expect("tag v1"),
tag_oid
);
assert!(outcome
.updates
.iter()
.any(|u| u.remote_ref == "refs/heads/main" && u.new_oid == Some(main_oid)));
let local_odb = open_odb(&local_git);
for oid in [main_oid, topic_oid, c1_oid, tag_oid] {
assert!(
local_odb.exists(&oid),
"object {} missing after authed fetch",
oid.to_hex()
);
}
assert_eq!(
resolve_ref(&local_git, "refs/remotes/origin/main")
.unwrap()
.to_hex(),
git(&source, &["rev-parse", "refs/heads/main"]).trim()
);
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 authed fetch: {}",
String::from_utf8_lossy(&fsck.stderr)
);
}
#[test]
fn push_over_authed_http_succeeds_with_right_credentials_and_fails_typed_otherwise() {
if find_binary("grit-http-server").is_none() {
eprintln!("SKIP: `grit-http-server` binary not found (build it first)");
return;
}
let tmp = tempfile::tempdir().expect("tempdir");
let local = tmp.path().join("local");
std::fs::create_dir_all(&local).unwrap();
build_source(&local);
let local_git = local.join(".git");
let main_oid = rev_parse(&local, "refs/heads/main");
let c1_oid = rev_parse(&local, "HEAD~1");
let root = tmp.path().join("srv");
let bare = make_bare_target(&root, "push.git");
let Some((_guard, port)) = start_authed_server(&root) else {
eprintln!("SKIP: could not start authed grit-http-server");
return;
};
let url = format!("http://127.0.0.1:{port}/push.git");
let spec = PushRefSpec {
src: Some(main_oid),
src_ref: None,
dst: "refs/heads/main".to_owned(),
force: false,
delete: false,
expected_old: None,
expect_absent: false,
..Default::default()
};
{
let client = http_client_arc(UreqHttpClient::new());
let err = push_http(
client.clone(),
&local_git,
&url,
std::slice::from_ref(&spec),
&PushOptions::default(),
&mut NoProgress,
&grit_lib::progress::NeverCancel,
)
.expect_err("push with no credentials against an authed server must fail");
assert!(
matches!(&err, Error::Auth { reason, .. } if !reason.is_rejected()),
"expected a typed Error::Auth for an unauthenticated push, got: {err:?}"
);
assert!(
resolve_ref(&bare, "refs/heads/main").is_err(),
"rejected push must not create the remote ref"
);
}
let provider = SharedProvider(Arc::new(StaticCredentialProvider::new(USER, PASS)));
let client = http_client_arc(UreqHttpClient::with_credentials(Box::new(provider.clone())));
let outcome = push_http(
client.clone(),
&local_git,
&url,
std::slice::from_ref(&spec),
&PushOptions::default(),
&mut NoProgress,
&grit_lib::progress::NeverCancel,
)
.expect("authed push with correct credentials must succeed");
assert_eq!(outcome.results.len(), 1);
assert_eq!(
outcome.results[0].status,
PushRefStatus::Ok,
"authed push should be accepted, got {:?} ({:?})",
outcome.results[0].status,
outcome.results[0].message
);
assert!(provider.0.approves.load(Ordering::SeqCst) >= 1);
assert_eq!(provider.0.rejects.load(Ordering::SeqCst), 0);
let remote_main = resolve_ref(&bare, "refs/heads/main").expect("remote main written");
assert_eq!(remote_main, main_oid);
let remote_odb = open_odb(&bare);
for oid in [main_oid, c1_oid] {
assert!(
remote_odb.exists(&oid),
"object {} missing from remote odb after authed push",
oid.to_hex()
);
}
let fsck = Command::new("git")
.current_dir(&bare)
.args(["fsck", "--no-dangling"])
.output()
.expect("run git fsck");
assert!(
fsck.status.success(),
"git fsck failed after authed push: {}",
String::from_utf8_lossy(&fsck.stderr)
);
assert_eq!(
remote_main.to_hex(),
git(&bare, &["rev-parse", "refs/heads/main"]).trim()
);
}
fn system_credential_store() -> Option<PathBuf> {
let out = Command::new("git").arg("--exec-path").output().ok()?;
let store =
PathBuf::from(String::from_utf8_lossy(&out.stdout).trim()).join("git-credential-store");
store.is_file().then_some(store)
}
#[test]
fn credential_store_host_entry_authenticates_fetch_and_push() {
if find_binary("grit-http-server").is_none() {
eprintln!("SKIP: `grit-http-server` binary not found (build it first)");
return;
}
if system_credential_store().is_none() {
eprintln!("SKIP: system git-credential-store not available");
return;
}
let tmp = tempfile::tempdir().expect("tempdir");
let work = tmp.path().join("work");
std::fs::create_dir_all(&work).unwrap();
build_source(&work);
let root = tmp.path().join("srv");
let source = mirror_bare(&work, &root, "hw.git");
let push_target = make_bare_target(&root, "push.git");
let main_oid = rev_parse(&source, "refs/heads/main");
let Some((_guard, port)) = start_authed_server(&root) else {
eprintln!("SKIP: could not start authed grit-http-server");
return;
};
let host = format!("127.0.0.1:{port}");
let store_file = tmp.path().join("git-credentials");
let store_helper = format!("store --file {}", store_file.display());
let approve = Command::new("git")
.args(["-c", &format!("credential.helper={store_helper}")])
.args(["credential", "approve"])
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.stdin(Stdio::piped())
.spawn()
.and_then(|mut child| {
use std::io::Write as _;
child.stdin.take().expect("stdin").write_all(
format!("protocol=http\nhost={host}\nusername={USER}\npassword={PASS}\n\n")
.as_bytes(),
)?;
child.wait()
})
.expect("run git credential approve");
assert!(approve.success(), "git credential approve failed");
let stored = std::fs::read_to_string(&store_file).expect("store file");
assert_eq!(stored.lines().count(), 1, "one host-level entry: {stored}");
let helper_log = tmp.path().join("helper.log");
let mut config = ConfigSet::new();
config
.add_command_override(
"credential.helper",
&format!("!cat >>'{}'; :", helper_log.display()),
)
.unwrap();
config
.add_command_override("credential.helper", &store_helper)
.unwrap();
let client = || {
http_client_arc(
UreqHttpClient::from_config(&config)
.expect("client from config")
.with_credential_provider(Box::new(HelperCredentialProvider::new(config.clone()))),
)
};
let local = tmp.path().join("clone");
std::fs::create_dir_all(&local).unwrap();
git(&local, &["init", "-q", "-b", "main", "."]);
let local_git = local.join(".git");
let mut opts = FetchOptions {
refspecs: vec!["+refs/heads/*:refs/remotes/origin/*".to_owned()],
tags: TagMode::All,
..Default::default()
};
http_fetch(
client(),
&local_git,
&format!("http://{host}/hw.git"),
&mut opts,
&mut NoProgress,
)
.expect("fetch authenticated by the host-level store entry");
assert_eq!(
resolve_ref(&local_git, "refs/remotes/origin/main").expect("origin/main"),
main_oid
);
let spec = PushRefSpec {
src: Some(main_oid),
src_ref: None,
dst: "refs/heads/main".to_owned(),
force: false,
delete: false,
expected_old: None,
expect_absent: false,
..Default::default()
};
let outcome = push_http(
client(),
&local_git,
&format!("http://{host}/push.git"),
std::slice::from_ref(&spec),
&PushOptions::default(),
&mut NoProgress,
&grit_lib::progress::NeverCancel,
)
.expect("push authenticated by the host-level store entry");
assert_eq!(outcome.results[0].status, PushRefStatus::Ok);
assert_eq!(
resolve_ref(&push_target, "refs/heads/main").expect("pushed main"),
main_oid
);
let log = std::fs::read_to_string(&helper_log).expect("helper log");
let get_request = format!("protocol=http\nhost={host}\n\n");
assert!(
log.starts_with(&get_request),
"helpers must get only protocol and host, got:\n{log}"
);
assert!(!log.contains("url="), "helpers must never get url=:\n{log}");
assert!(
!log.contains("path="),
"path needs credential.useHttpPath:\n{log}"
);
assert_eq!(
std::fs::read_to_string(&store_file).expect("store file"),
stored
);
}
#[cfg(unix)]
fn write_askpass(dir: &Path, log: &Path) -> PathBuf {
use std::os::unix::fs::PermissionsExt as _;
let script = dir.join("askpass.sh");
std::fs::write(
&script,
format!(
"#!/bin/sh\nprintf '%s\\n' \"$1\" >>'{}'\ncase \"$1\" in Username*) echo {USER};; *) echo {PASS};; esac\n",
log.display()
),
)
.unwrap();
let mut perms = std::fs::metadata(&script).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&script, perms).unwrap();
script
}
fn shared_client(
factory: &grit_lib::remote::DefaultHttpClientFactory,
config: &ConfigSet,
) -> Arc<dyn grit_lib::remote::transport::http::HttpClient> {
use grit_lib::remote::HttpClientFactory as _;
Arc::from(factory.create(config).expect("client from factory"))
}
fn git_fsck(git_dir: &Path) {
let fsck = Command::new("git")
.arg("--git-dir")
.arg(git_dir)
.args(["fsck", "--no-dangling"])
.output()
.expect("run git fsck");
assert!(
fsck.status.success(),
"git fsck failed: {}",
String::from_utf8_lossy(&fsck.stderr)
);
}
#[cfg(unix)]
#[test]
fn git_askpass_authenticates_fetch_with_the_prompts_system_git_uses() {
if find_binary("grit-http-server").is_none() {
eprintln!("SKIP: `grit-http-server` binary not found (build it first)");
return;
}
if system_credential_store().is_none() {
eprintln!("SKIP: system git-credential-store not available");
return;
}
let tmp = tempfile::tempdir().expect("tempdir");
let work = tmp.path().join("work");
std::fs::create_dir_all(&work).unwrap();
build_source(&work);
let root = tmp.path().join("srv");
let source = mirror_bare(&work, &root, "hello.git");
let main_oid = rev_parse(&source, "refs/heads/main");
let Some((_guard, port)) = start_authed_server(&root) else {
eprintln!("SKIP: could not start authed grit-http-server");
return;
};
let url = format!("http://127.0.0.1:{port}/hello.git");
let git_log = tmp.path().join("git-askpass.log");
let git_askpass_dir = tmp.path().join("git-askpass");
std::fs::create_dir_all(&git_askpass_dir).unwrap();
let git_askpass = write_askpass(&git_askpass_dir, &git_log);
let git_clone = Command::new("git")
.args(["clone", "-q", &url])
.arg(tmp.path().join("git-clone"))
.env("GIT_ASKPASS", &git_askpass)
.env("GIT_TERMINAL_PROMPT", "0")
.env_remove("SSH_ASKPASS")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.output()
.expect("run git clone");
assert!(
git_clone.status.success(),
"system git clone with GIT_ASKPASS failed: {}",
String::from_utf8_lossy(&git_clone.stderr)
);
let grit_log = tmp.path().join("grit-askpass.log");
let askpass = write_askpass(tmp.path(), &grit_log);
let store_file = tmp.path().join("git-credentials");
let mut config = ConfigSet::new();
config
.add_command_override(
"credential.helper",
&format!("store --file {}", store_file.display()),
)
.unwrap();
let env = grit_lib::config::environment::Environment::from_vars(
[(
std::ffi::OsString::from("GIT_ASKPASS"),
askpass.clone().into_os_string(),
)],
tmp.path().to_path_buf(),
);
let factory = grit_lib::remote::DefaultHttpClientFactory::with_environment(env);
let local = tmp.path().join("grit-clone");
std::fs::create_dir_all(&local).unwrap();
git(&local, &["init", "-q", "-b", "main", "."]);
let local_git = local.join(".git");
let mut opts = FetchOptions {
refspecs: vec!["+refs/heads/*:refs/remotes/origin/*".to_owned()],
tags: TagMode::All,
..Default::default()
};
http_fetch(
shared_client(&factory, &config),
&local_git,
&url,
&mut opts,
&mut NoProgress,
)
.expect("fetch authenticated through GIT_ASKPASS");
assert_eq!(
resolve_ref(&local_git, "refs/remotes/origin/main").expect("origin/main"),
main_oid
);
git_fsck(&local_git);
assert_eq!(
std::fs::read_to_string(&grit_log).expect("grit askpass log"),
std::fs::read_to_string(&git_log).expect("git askpass log"),
"grit must ask askpass the same prompts as system git"
);
let filled = Command::new("git")
.args([
"-c",
&format!("credential.helper=store --file {}", store_file.display()),
])
.args(["credential", "fill"])
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.env("GIT_TERMINAL_PROMPT", "0")
.env_remove("GIT_ASKPASS")
.env_remove("SSH_ASKPASS")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.and_then(|mut child| {
use std::io::Write as _;
child
.stdin
.take()
.expect("stdin")
.write_all(format!("protocol=http\nhost=127.0.0.1:{port}\n\n").as_bytes())?;
child.wait_with_output()
})
.expect("run git credential fill");
let filled = String::from_utf8_lossy(&filled.stdout);
assert!(
filled.contains(&format!("username={USER}\n"))
&& filled.contains(&format!("password={PASS}\n")),
"system git must find the askpass credential grit stored: {filled}"
);
}
#[cfg(unix)]
#[test]
fn core_askpass_authenticates_push_through_the_default_factory() {
if find_binary("grit-http-server").is_none() {
eprintln!("SKIP: `grit-http-server` binary not found (build it first)");
return;
}
let tmp = tempfile::tempdir().expect("tempdir");
let work = tmp.path().join("work");
std::fs::create_dir_all(&work).unwrap();
build_source(&work);
let root = tmp.path().join("srv");
let push_target = make_bare_target(&root, "push.git");
let main_oid = rev_parse(&work, "refs/heads/main");
let Some((_guard, port)) = start_authed_server(&root) else {
eprintln!("SKIP: could not start authed grit-http-server");
return;
};
let log = tmp.path().join("askpass.log");
let askpass = write_askpass(tmp.path(), &log);
let mut config = ConfigSet::new();
config
.add_command_override("core.askPass", askpass.to_str().expect("utf8 path"))
.unwrap();
let factory = grit_lib::remote::DefaultHttpClientFactory::new();
let spec = PushRefSpec {
src: Some(main_oid),
dst: "refs/heads/main".to_owned(),
..Default::default()
};
let outcome = push_http(
shared_client(&factory, &config),
&work.join(".git"),
&format!("http://127.0.0.1:{port}/push.git"),
std::slice::from_ref(&spec),
&PushOptions::default(),
&mut NoProgress,
&grit_lib::progress::NeverCancel,
)
.expect("push authenticated through core.askPass");
assert_eq!(outcome.results[0].status, PushRefStatus::Ok);
assert_eq!(
git(&push_target, &["rev-parse", "refs/heads/main"]).trim(),
main_oid.to_hex()
);
git_fsck(&push_target);
assert_eq!(
std::fs::read_to_string(&log).expect("askpass log"),
format!(
"Username for 'http://127.0.0.1:{port}': \nPassword for 'http://{USER}@127.0.0.1:{port}': \n"
)
);
}
#[cfg(unix)]
#[test]
fn wrong_credentials_in_the_url_are_rejected_without_asking_askpass() {
if find_binary("grit-http-server").is_none() {
eprintln!("SKIP: `grit-http-server` binary not found (build it first)");
return;
}
let tmp = tempfile::tempdir().expect("tempdir");
let work = tmp.path().join("work");
std::fs::create_dir_all(&work).unwrap();
build_source(&work);
let root = tmp.path().join("srv");
mirror_bare(&work, &root, "hello.git");
let Some((_guard, port)) = start_authed_server(&root) else {
eprintln!("SKIP: could not start authed grit-http-server");
return;
};
let log = tmp.path().join("askpass.log");
let askpass = write_askpass(tmp.path(), &log);
let mut config = ConfigSet::new();
config
.add_command_override("core.askPass", askpass.to_str().expect("utf8 path"))
.unwrap();
let factory = grit_lib::remote::DefaultHttpClientFactory::new();
let local = tmp.path().join("clone");
std::fs::create_dir_all(&local).unwrap();
git(&local, &["init", "-q", "-b", "main", "."]);
let mut opts = FetchOptions {
refspecs: vec!["+refs/heads/*:refs/remotes/origin/*".to_owned()],
..Default::default()
};
let err = http_fetch(
shared_client(&factory, &config),
&local.join(".git"),
&format!("http://{USER}:wrong@127.0.0.1:{port}/hello.git"),
&mut opts,
&mut NoProgress,
)
.expect_err("wrong URL credentials must fail");
assert!(
matches!(
&err,
Error::Auth {
reason: AuthFailure::Rejected,
..
}
),
"expected a rejected-credentials error, got: {err:?}"
);
assert!(!err.to_string().contains("wrong"), "secret leaked: {err}");
assert!(
!log.exists(),
"askpass must not run for a complete URL credential"
);
}