#![cfg(feature = "http")]
#[allow(dead_code)]
#[path = "support/http_git.rs"]
mod http_git;
#[allow(dead_code)]
#[path = "support/pack_git.rs"]
mod pack_git;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex};
use girt::Repository;
use girt::transport::http::{CertificateCheck, CertificateDecision, ServerCertificate};
use girt::workflow::{Environment, NoCallbacks, TransferCallbacks};
use http_git::Server;
use pack_git::{Fixture, git};
use rstest::rstest;
fn openssl(root: &Path, args: &[&str]) {
let output = Command::new("openssl")
.current_dir(root)
.args(args)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
}
fn leaf(root: &Path, name: &str, ca: &str, usage: &str) {
std::fs::write(
root.join(format!("{name}.cnf")),
"[req]\ndistinguished_name=dn\nprompt=no\n[dn]\nCN=localhost\n",
)
.unwrap();
std::fs::write(
root.join(format!("{name}.ext")),
format!(
"subjectAltName=DNS:localhost\nbasicConstraints=critical,CA:FALSE\n\
keyUsage=critical,digitalSignature,keyEncipherment\nextendedKeyUsage={usage}\n"
),
)
.unwrap();
let [cnf, key, csr, ext, pem] =
["cnf", "key", "csr", "ext", "pem"].map(|e| format!("{name}.{e}"));
openssl(
root,
&[
"req", "-new", "-newkey", "rsa:2048", "-nodes", "-config", &cnf, "-keyout", &key,
"-out", &csr,
],
);
let [ca_pem, ca_key] = ["pem", "key"].map(|e| format!("{ca}.{e}"));
openssl(
root,
&[
"x509",
"-req",
"-in",
&csr,
"-CA",
&ca_pem,
"-CAkey",
&ca_key,
"-CAcreateserial",
"-days",
"1",
"-extfile",
&ext,
"-out",
&pem,
],
);
}
fn certificate_authority(root: &Path, name: &str) {
std::fs::write(
root.join(format!("{name}.cnf")),
format!(
"[req]\ndistinguished_name=dn\nx509_extensions=ext\nprompt=no\n[dn]\nCN=Girt {name}\n\
[ext]\nbasicConstraints=critical,CA:TRUE\nkeyUsage=critical,keyCertSign,cRLSign\n"
),
)
.unwrap();
let [cnf, key, pem] = ["cnf", "key", "pem"].map(|e| format!("{name}.{e}"));
openssl(
root,
&[
"req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1", "-config", &cnf,
"-keyout", &key, "-out", &pem,
],
);
}
fn certificates(root: &Path) {
certificate_authority(root, "ca");
leaf(root, "server", "ca", "serverAuth");
leaf(root, "client", "ca", "clientAuth");
let mut combined = std::fs::read(root.join("client.pem")).unwrap();
combined.extend(std::fs::read(root.join("client.key")).unwrap());
std::fs::write(root.join("combined.pem"), combined).unwrap();
}
#[rstest]
#[case::no_client_certificate(&["sslCAInfo={certs}/ca.pem"], &[], false)]
#[case::certificate_and_key(&["sslCAInfo={certs}/ca.pem", "sslCert={certs}/client.pem", "sslKey={certs}/client.key"], &[], true)]
#[case::combined_file(&["sslCAInfo={certs}/ca.pem", "sslCert={certs}/combined.pem"], &[], true)]
#[case::certificate_without_key(&["sslCAInfo={certs}/ca.pem", "sslCert={certs}/client.pem"], &[], false)]
#[case::environment_certificate(&["sslCAInfo={certs}/ca.pem"], &[("GIT_SSL_CERT", "{certs}/combined.pem")], true)]
#[case::environment_beats_setting(&["sslCAInfo={certs}/ca.pem", "sslCert={certs}/combined.pem"], &[("GIT_SSL_CERT", "{certs}/client.pem")], false)]
#[case::environment_replaces_empty_setting(&["sslCAInfo={certs}/ca.pem", "sslCert="], &[("GIT_SSL_CERT", "{certs}/combined.pem")], true)]
#[case::environment_key(&["sslCAInfo={certs}/ca.pem", "sslCert={certs}/client.pem"], &[("GIT_SSL_KEY", "{certs}/client.key")], true)]
fn tls_files_match_git(
#[case] settings: &[&str],
#[case] variables: &[(&str, &str)],
#[case] succeeds: bool,
) {
let certs = tempfile::tempdir().unwrap();
certificates(certs.path());
let source = Fixture::new(girt::ObjectFormat::Sha1, true, 2);
let server = Server::requiring_client_certificate(
source.root.path(),
(
&certs.path().join("server.pem"),
&certs.path().join("server.key"),
),
&certs.path().join("ca.pem"),
);
let url = server.url.replace("127.0.0.1", "localhost");
let expand = |value: &str| value.replace("{certs}", certs.path().to_str().unwrap());
let local = tempfile::tempdir().unwrap();
git(local.path(), &["init", "--quiet", "."], b"");
git(local.path(), &["remote", "add", "origin", &url], b"");
for setting in settings {
let (name, value) = setting.split_once('=').unwrap();
let value = &expand(value);
git(
local.path(),
&["config", &format!("http.{name}"), value],
b"",
);
}
let mut command = Command::new("git");
for (key, _) in std::env::vars_os() {
let key_text = key.to_string_lossy().to_ascii_lowercase();
if key_text.starts_with("git_") || key_text.ends_with("_proxy") {
command.env_remove(key);
}
}
command
.current_dir(local.path())
.env("HOME", local.path())
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", local.path().join("absent-config"))
.env("GIT_TERMINAL_PROMPT", "0")
.env_remove("SSH_AUTH_SOCK")
.args(["-c", "credential.helper=", "-c", "core.askPass="])
.args(["ls-remote", "origin"])
.stdin(Stdio::null());
let mut environment = Environment::default().with("HOME", local.path());
for (name, value) in variables {
command.env(name, expand(value));
environment = environment.with(*name, PathBuf::from(expand(value)));
}
let output = command.output().unwrap();
assert_eq!(
output.status.success(),
succeeds,
"git: {}",
String::from_utf8_lossy(&output.stderr)
);
let repository = Repository::open(local.path()).unwrap();
let result = girt::workflow::remote_head(&repository, "origin", &environment, &mut NoCallbacks);
assert_eq!(result.is_ok(), succeeds, "girt: {:?}", result.err());
}
#[test]
fn password_protected_certificates_are_refused() {
let config = girt::Config::parse(
b"[remote \"r\"]\nurl = https://localhost/repo\n[http]\nsslCertPasswordProtected = true\n",
)
.unwrap();
let destination = girt::remote::Remote::find(&config, b"r")
.unwrap()
.unwrap()
.fetch_destination(&config, &girt::remote::ProtocolEnvironment::default())
.unwrap();
let resolved = girt::transport::http::HttpSettings::resolve(
&config,
&destination,
&girt::transport::http::HttpEnvironment::default(),
);
assert!(matches!(
resolved,
Err(girt::transport::http::HttpError::Configuration(_))
));
}
struct Checking {
decision: fn(&ServerCertificate<'_>, &[u8]) -> CertificateDecision,
pinned: Vec<u8>,
seen: Arc<Mutex<Vec<(String, usize, bool)>>>,
}
impl TransferCallbacks for Checking {
fn certificate_check(&self) -> Option<Arc<dyn CertificateCheck>> {
let decision = self.decision;
let pinned = self.pinned.clone();
let seen = self.seen.clone();
Some(Arc::new(move |server: &ServerCertificate<'_>| {
seen.lock().unwrap().push((
server.host().to_owned(),
server.intermediates().len(),
server.verification().is_ok(),
));
decision(server, &pinned)
}))
}
}
fn accept_pinned(server: &ServerCertificate<'_>, pinned: &[u8]) -> CertificateDecision {
if server.end_entity() == pinned {
CertificateDecision::Accept
} else {
CertificateDecision::UseDefault
}
}
#[rstest]
#[case::pinned_untrusted_certificate(false, accept_pinned, true, true)]
#[case::pin_mismatch_keeps_failure(false, |_: &ServerCertificate<'_>, _: &[u8]| CertificateDecision::UseDefault, false, true)]
#[case::reject_trusted_certificate(true, |_: &ServerCertificate<'_>, _: &[u8]| CertificateDecision::Reject, false, true)]
#[case::default_keeps_trust(true, |_: &ServerCertificate<'_>, _: &[u8]| CertificateDecision::UseDefault, true, true)]
#[case::accept_overrides_unknown_issuer(false, |_: &ServerCertificate<'_>, _: &[u8]| CertificateDecision::Accept, true, true)]
fn certificate_check_decides_server_trust(
#[case] trusted: bool,
#[case] decision: fn(&ServerCertificate<'_>, &[u8]) -> CertificateDecision,
#[case] succeeds: bool,
#[case] checked: bool,
) {
let certs = tempfile::tempdir().unwrap();
certificates(certs.path());
openssl(
certs.path(),
&[
"x509",
"-in",
"server.pem",
"-outform",
"der",
"-out",
"server.der",
],
);
let pinned = if trusted {
Vec::new()
} else {
std::fs::read(certs.path().join("server.der")).unwrap()
};
let source = Fixture::new(girt::ObjectFormat::Sha1, true, 2);
let server = Server::new(
source.root.path(),
"",
"",
Some((
&certs.path().join("server.pem"),
&certs.path().join("server.key"),
)),
);
let local = tempfile::tempdir().unwrap();
git(local.path(), &["init", "--quiet", "."], b"");
let url = server.url.replace("127.0.0.1", "localhost");
git(local.path(), &["remote", "add", "origin", &url], b"");
if trusted {
let ca = certs.path().join("ca.pem");
git(
local.path(),
&["config", "http.sslCAInfo", ca.to_str().unwrap()],
b"",
);
}
let seen = Arc::new(Mutex::new(Vec::new()));
let mut callbacks = Checking {
decision,
pinned,
seen: seen.clone(),
};
let repository = Repository::open(local.path()).unwrap();
let environment = Environment::default().with("HOME", local.path());
let result = girt::workflow::remote_head(&repository, "origin", &environment, &mut callbacks);
assert_eq!(result.is_ok(), succeeds, "{:?}", result.err());
let seen = seen.lock().unwrap();
assert_eq!(!seen.is_empty(), checked);
for (host, intermediates, verified) in seen.iter() {
assert_eq!(host, "localhost");
assert_eq!(*intermediates, 0);
assert_eq!(*verified, trusted);
}
}
#[rstest]
#[case::client_certificate(true, false, |_: &ServerCertificate<'_>, _: &[u8]| CertificateDecision::UseDefault, true)]
#[case::verification_off(false, true, |_: &ServerCertificate<'_>, _: &[u8]| CertificateDecision::UseDefault, true)]
#[case::verification_off_rejected(false, true, |_: &ServerCertificate<'_>, _: &[u8]| CertificateDecision::Reject, false)]
fn certificate_check_keeps_tls_settings(
#[case] client_certificate: bool,
#[case] no_verify: bool,
#[case] decision: fn(&ServerCertificate<'_>, &[u8]) -> CertificateDecision,
#[case] succeeds: bool,
) {
let certs = tempfile::tempdir().unwrap();
certificates(certs.path());
let source = Fixture::new(girt::ObjectFormat::Sha1, true, 2);
let tls = (
&*certs.path().join("server.pem"),
&*certs.path().join("server.key"),
);
let ca = certs.path().join("ca.pem");
let server = if client_certificate {
Server::requiring_client_certificate(source.root.path(), tls, &ca)
} else {
Server::new(source.root.path(), "", "", Some(tls))
};
let local = tempfile::tempdir().unwrap();
git(local.path(), &["init", "--quiet", "."], b"");
let url = server.url.replace("127.0.0.1", "localhost");
git(local.path(), &["remote", "add", "origin", &url], b"");
let mut environment = Environment::default().with("HOME", local.path());
if client_certificate {
let combined = certs.path().join("combined.pem");
for (name, value) in [("http.sslCAInfo", &ca), ("http.sslCert", &combined)] {
git(
local.path(),
&["config", name, value.to_str().unwrap()],
b"",
);
}
}
if no_verify {
environment = environment.with("GIT_SSL_NO_VERIFY", "1");
}
let mut callbacks = Checking {
decision,
pinned: Vec::new(),
seen: Arc::new(Mutex::new(Vec::new())),
};
let repository = Repository::open(local.path()).unwrap();
let result = girt::workflow::remote_head(&repository, "origin", &environment, &mut callbacks);
assert_eq!(result.is_ok(), succeeds, "{:?}", result.err());
}
#[test]
fn certificate_check_runs_for_every_connection() {
let certs = tempfile::tempdir().unwrap();
certificates(certs.path());
let source = Fixture::new(girt::ObjectFormat::Sha1, true, 2);
let server = Server::new(
source.root.path(),
"",
"",
Some((
&certs.path().join("server.pem"),
&certs.path().join("server.key"),
)),
);
let local = tempfile::tempdir().unwrap();
git(local.path(), &["init", "--quiet", "."], b"");
let url = server.url.replace("127.0.0.1", "localhost");
git(local.path(), &["remote", "add", "origin", &url], b"");
let ca = certs.path().join("ca.pem");
git(
local.path(),
&["config", "http.sslCAInfo", ca.to_str().unwrap()],
b"",
);
let seen = Arc::new(Mutex::new(Vec::new()));
let mut callbacks = Checking {
decision: |_, _| CertificateDecision::UseDefault,
pinned: Vec::new(),
seen: seen.clone(),
};
let repository = Repository::open(local.path()).unwrap();
let environment = Environment::default().with("HOME", local.path());
girt::workflow::fetch(
&repository,
"origin",
&girt::workflow::FetchOptions::new(["refs/heads/*:refs/remotes/origin/*"]),
&environment,
&mut callbacks,
)
.unwrap();
let requests = server.requests().len();
assert!(requests >= 2, "{requests}");
assert_eq!(seen.lock().unwrap().len(), requests);
}