use alloc::string::{String, ToString};
use alloc::sync::Arc;
use alloc::vec::Vec;
use crate::error::{Error, Result};
use crate::oid::HashAlgo;
use crate::protocol::RefAdvertisement;
use crate::protocol::pktline::{self, Packet};
use super::dumb_http::{self, DumbConfig, DumbHttpOdb};
use super::{Service, Transport};
#[derive(Debug, Clone)]
pub struct AuthChallenge {
pub url: String,
pub scheme: String,
pub realm: Option<String>,
}
type CredentialFn = Arc<dyn Fn(&AuthChallenge) -> Option<(String, String)>>;
#[derive(Clone)]
pub struct HttpTransport {
base_url: String,
algo: HashAlgo,
auth: Option<String>,
credential: Option<CredentialFn>,
follow_redirects: bool,
max_redirects: u32,
effective_base: Option<String>,
dumb: bool,
v2: bool,
v2_packfile_uris: bool,
v2_fetch_features: Vec<String>,
interrupted: core::cell::Cell<bool>,
}
impl HttpTransport {
pub fn new(base_url: impl Into<String>, algo: HashAlgo) -> Self {
HttpTransport {
base_url: base_url.into(),
algo,
auth: None,
credential: None,
follow_redirects: true,
max_redirects: 10,
effective_base: None,
dumb: false,
v2: false,
v2_packfile_uris: false,
v2_fetch_features: Vec::new(),
interrupted: core::cell::Cell::new(false),
}
}
pub fn with_basic_auth(mut self, user: &str, password: &str) -> Self {
self.auth = Some(basic_auth_header(user, password));
self
}
pub fn with_authorization(mut self, value: impl Into<String>) -> Self {
self.auth = Some(value.into());
self
}
pub fn with_credential_callback<F>(mut self, f: F) -> Self
where
F: Fn(&AuthChallenge) -> Option<(String, String)> + 'static,
{
self.credential = Some(Arc::new(f));
self
}
pub fn with_redirects(mut self, follow: bool) -> Self {
self.follow_redirects = follow;
self
}
fn active_base(&self) -> &str {
self.effective_base.as_deref().unwrap_or(&self.base_url)
}
fn info_refs_url(&self, service: Service) -> String {
alloc::format!(
"{}/info/refs?service={}",
self.active_base().trim_end_matches('/'),
service.name()
)
}
fn service_url(&self, service: Service) -> String {
alloc::format!(
"{}/{}",
self.active_base().trim_end_matches('/'),
service.name()
)
}
fn send_once(
&self,
method: &str,
url: &str,
headers: &[(&str, &str)],
body: Option<&[u8]>,
) -> Result<rsurl::Response> {
let mut req = rsurl::Request::new(method, url)
.map_err(map_rsurl_err)?
.header("User-Agent", USER_AGENT)
.follow_redirects(self.follow_redirects)
.max_redirs(self.max_redirects);
for (name, value) in headers {
req = req.header(name, value);
}
if let Some(auth) = &self.auth {
req = req.header("Authorization", auth);
}
if let Some(body) = body {
req = req.body(body.to_vec());
}
let idempotent =
method.eq_ignore_ascii_case("GET") || url.ends_with(Service::UploadPack.name());
let mut attempts = 0;
loop {
match req.clone().send() {
Err(e) if idempotent && attempts < MAX_EINTR_RETRIES && is_interrupted(&e) => {
attempts += 1;
}
Err(e) => {
self.interrupted.set(is_interrupted(&e));
return Err(map_rsurl_err(e));
}
Ok(r) => {
self.interrupted.set(false);
return Ok(r);
}
}
}
}
fn request(
&mut self,
method: &str,
url: &str,
headers: &[(&str, &str)],
body: Option<&[u8]>,
) -> Result<rsurl::Response> {
let response = self.send_once(method, url, headers, body)?;
if response.status != 401 {
return Ok(response);
}
let Some(callback) = self.credential.clone() else {
return Ok(response);
};
let challenge = parse_auth_challenge(url, &response.headers);
let Some((user, password)) = callback(&challenge) else {
return Ok(response);
};
self.auth = Some(basic_auth_header(&user, &password));
self.send_once(method, url, headers, body)
}
}
impl core::fmt::Debug for HttpTransport {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("HttpTransport")
.field("base_url", &self.base_url)
.field("algo", &self.algo)
.field("authenticated", &self.auth.is_some())
.field("has_credential_callback", &self.credential.is_some())
.field("follow_redirects", &self.follow_redirects)
.finish()
}
}
impl Transport for HttpTransport {
fn discover(&mut self, service: Service) -> Result<RefAdvertisement> {
validate_base_url(&self.base_url)?;
let url = self.info_refs_url(service);
let headers: &[(&str, &str)] = match service {
Service::UploadPack => &[("Accept", "*/*"), ("Git-Protocol", "version=2")],
Service::ReceivePack => &[("Accept", "*/*")],
};
let response = self.request("GET", &url, headers, None)?;
if response.status != 200 {
return Err(Error::Protocol(alloc::format!(
"info/refs returned HTTP {}",
response.status
)));
}
self.rebase_after_redirect(&response.final_url);
if service == Service::UploadPack
&& let Ok(packets) = pktline::decode_all(&response.body)
&& let Ok(caps) = crate::protocol::v2::parse_capability_advertisement(&packets)
{
self.v2 = true;
self.dumb = false;
self.v2_packfile_uris = caps.iter().any(|c| {
c.strip_prefix("fetch=")
.is_some_and(|v| v.split(' ').any(|w| w == "packfile-uris"))
});
if let Some(algo) = caps
.iter()
.find_map(|c| c.strip_prefix("object-format="))
.and_then(|n| match n {
"sha1" => Some(HashAlgo::Sha1),
"sha256" => Some(HashAlgo::Sha256),
_ => None,
})
{
self.algo = algo;
}
self.v2_fetch_features = caps
.iter()
.find_map(|c| c.strip_prefix("fetch="))
.map(|v| v.split(' ').map(str::to_string).collect())
.unwrap_or_default();
return self.v2_ls_refs();
}
self.v2 = false;
match parse_smart_advertisement(self.algo, &response.body) {
Ok(advert) => {
self.dumb = false;
Ok(advert)
}
Err(_) => {
self.dumb = true;
dumb_http::discover(&self.dumb_config(), &response.body)
}
}
}
fn exchange(&mut self, service: Service, request: &[u8]) -> Result<Vec<u8>> {
validate_base_url(&self.base_url)?;
if self.dumb {
return self.dumb_exchange(service, request);
}
if self.v2 && service == Service::UploadPack {
return self.v2_fetch(request);
}
let url = self.service_url(service);
let content_type = alloc::format!("application/x-{}-request", service.name());
let accept = alloc::format!("application/x-{}-result", service.name());
let headers = [
("Content-Type", content_type.as_str()),
("Accept", accept.as_str()),
];
let response = match self.request("POST", &url, &headers, Some(request)) {
Err(e) if service == Service::ReceivePack && self.interrupted.get() => {
return self.recover_interrupted_push(request, &url, &headers, e);
}
other => other?,
};
if response.status != 200 {
return Err(Error::Protocol(alloc::format!(
"{} returned HTTP {}",
service.name(),
response.status
)));
}
Ok(response.body)
}
}
impl HttpTransport {
fn recover_interrupted_push(
&mut self,
request: &[u8],
url: &str,
headers: &[(&str, &str)],
original: Error,
) -> Result<Vec<u8>> {
use crate::protocol::{PushRequest, RefStatus, ReportStatus};
let mut off = 0;
let mut commands = Vec::new();
while let Some((packet, n)) = pktline::decode(&request[off..])? {
off += n;
let flush = packet == Packet::Flush;
commands.push(packet);
if flush || n == 0 {
break;
}
}
let push = PushRequest::parse(self.algo, &commands)?;
let advert = self.discover(Service::ReceivePack)?;
let current = |name: &str| advert.refs.iter().find(|r| r.name == name).map(|r| r.id);
let state = |id: &crate::oid::ObjectId| (!id.is_zero()).then_some(*id);
if push
.commands
.iter()
.all(|c| current(&c.name) == state(&c.new))
{
let report = ReportStatus {
unpack: Ok(()),
refs: push
.commands
.iter()
.map(|c| RefStatus::new(c.name.clone(), Ok(())))
.collect(),
};
return report.encode();
}
if push
.commands
.iter()
.all(|c| current(&c.name) == state(&c.old))
{
let response = self.request("POST", url, headers, Some(request))?;
if response.status != 200 {
return Err(Error::Protocol(alloc::format!(
"git-receive-pack returned HTTP {}",
response.status
)));
}
return Ok(response.body);
}
Err(original)
}
fn dumb_config(&self) -> DumbConfig {
DumbConfig {
base_url: self.active_base().trim_end_matches('/').to_string(),
algo: self.algo,
auth: self.auth.clone(),
follow_redirects: self.follow_redirects,
max_redirects: self.max_redirects,
}
}
fn dumb_exchange(&self, service: Service, request: &[u8]) -> Result<Vec<u8>> {
use crate::pack::PackWriter;
use crate::protocol::FetchRequest;
if service != Service::UploadPack {
return Err(Error::Unsupported(
"dumb http: server is read-only (push needs smart HTTP)".into(),
));
}
let packets = pktline::decode_all(request)?;
let req = FetchRequest::parse(self.algo, &packets)?;
if req.wants.is_empty() {
return Err(Error::Protocol("dumb http: fetch has no wants".into()));
}
let odb = DumbHttpOdb::new(self.dumb_config());
let to_send = crate::walk::objects_to_send(&odb, &req.wants, &req.haves)?;
let mut writer = PackWriter::new(self.algo);
for id in &to_send {
let (ty, payload) = crate::odb::ObjectDatabase::read(&odb, id)?;
writer.add(ty, &payload);
}
let pack = writer.finish()?;
let mut response = pktline::encode_data(b"NAK\n")?;
response.extend_from_slice(&pack.pack);
Ok(response)
}
fn v2_command_caps(&self) -> Vec<String> {
alloc::vec![alloc::format!("object-format={}", self.algo.name())]
}
fn v2_ls_refs(&mut self) -> Result<RefAdvertisement> {
use crate::protocol::v2;
let args = alloc::vec![
"peel".to_string(),
"symrefs".to_string(),
"unborn".to_string(),
"ref-prefix HEAD".to_string(),
"ref-prefix refs/".to_string(),
];
let body = v2::encode_command("ls-refs", &self.v2_command_caps(), &args)?;
let url = self.service_url(Service::UploadPack);
let response = self.request(
"POST",
&url,
&[
("Content-Type", "application/x-git-upload-pack-request"),
("Accept", "application/x-git-upload-pack-result"),
("Git-Protocol", "version=2"),
],
Some(&body),
)?;
if response.status != 200 {
return Err(Error::Protocol(alloc::format!(
"v2 ls-refs returned HTTP {}",
response.status
)));
}
let refs = v2::parse_ls_refs_response(self.algo, &pktline::decode_all(&response.body)?)?;
let mut capabilities = crate::protocol::Capabilities::new();
capabilities.add_flag("ofs-delta");
for feature in &self.v2_fetch_features {
match feature.as_str() {
"shallow" => {
for c in ["shallow", "deepen-since", "deepen-not", "deepen-relative"] {
capabilities.add_flag(c);
}
}
"filter" => capabilities.add_flag("filter"),
_ => {}
}
}
Ok(RefAdvertisement {
refs,
capabilities,
object_format: self.algo,
})
}
fn v2_fetch(&mut self, request: &[u8]) -> Result<Vec<u8>> {
use crate::protocol::{FetchRequest, v2};
let packets = pktline::decode_all(request)?;
let req = FetchRequest::parse(self.algo, &packets)?;
let mut args = Vec::new();
for w in &req.wants {
args.push(alloc::format!("want {}", w.to_hex()));
}
for h in &req.haves {
args.push(alloc::format!("have {}", h.to_hex()));
}
for s in &req.shallows {
args.push(alloc::format!("shallow {}", s.to_hex()));
}
if let Some(depth) = req.deepen {
args.push(alloc::format!("deepen {depth}"));
}
if let Some(since) = req.deepen_since {
args.push(alloc::format!("deepen-since {since}"));
}
for name in &req.deepen_not {
args.push(alloc::format!("deepen-not {name}"));
}
if req.deepen_relative {
args.push("deepen-relative".to_string());
}
if let Some(filter) = &req.filter {
args.push(alloc::format!("filter {filter}"));
}
if req.capabilities.has("thin-pack") {
args.push("thin-pack".to_string());
}
args.push("ofs-delta".to_string());
if self.v2_packfile_uris {
args.push("packfile-uris https".to_string());
}
if req.done {
args.push("done".to_string());
}
let body = v2::encode_command("fetch", &self.v2_command_caps(), &args)?;
let url = self.service_url(Service::UploadPack);
let response = self.request(
"POST",
&url,
&[
("Content-Type", "application/x-git-upload-pack-request"),
("Accept", "application/x-git-upload-pack-result"),
("Git-Protocol", "version=2"),
],
Some(&body),
)?;
if response.status != 200 {
return Err(Error::Protocol(alloc::format!(
"v2 fetch returned HTTP {}",
response.status
)));
}
if !req.done {
return v2_acks_as_v0(self.algo, &response.body);
}
let resp =
v2::parse_fetch_response_sections(self.algo, &pktline::decode_all(&response.body)?)?;
let (uris, pack) = (resp.packfile_uris, resp.pack);
let mut out = Vec::new();
for oid in &resp.shallow {
out.extend_from_slice(&pktline::encode_data(
alloc::format!("shallow {}\n", oid.to_hex()).as_bytes(),
)?);
}
for oid in &resp.unshallow {
out.extend_from_slice(&pktline::encode_data(
alloc::format!("unshallow {}\n", oid.to_hex()).as_bytes(),
)?);
}
out.extend_from_slice(&pktline::encode_data(b"NAK\n")?);
out.extend_from_slice(&pack);
for (hash, uri) in &uris {
out.extend_from_slice(&self.download_packfile_uri(hash, uri)?);
}
Ok(out)
}
fn download_packfile_uri(&mut self, hash: &str, uri: &str) -> Result<Vec<u8>> {
if !uri.starts_with("https://") {
return Err(Error::Protocol(alloc::format!(
"packfile-uris: refusing non-https uri {uri:?}"
)));
}
let response = self.request("GET", uri, &[("Accept", "*/*")], None)?;
if response.status != 200 {
return Err(Error::Protocol(alloc::format!(
"packfile-uris: {uri} returned HTTP {}",
response.status
)));
}
verify_downloaded_pack(self.algo, hash, response.body)
}
fn rebase_after_redirect(&mut self, final_url: &str) {
let Some(base) = base_from_info_refs(final_url) else {
return;
};
if validate_base_url(&base).is_err() {
return;
}
if url_host(&base) != url_host(&self.base_url) {
return; }
if base != self.base_url {
self.effective_base = Some(base);
}
}
}
fn v2_acks_as_v0(algo: HashAlgo, body: &[u8]) -> Result<Vec<u8>> {
let mut common = Vec::new();
let mut ready = false;
for packet in pktline::decode_all_bounded(body, pktline::MAX_PACKETS)? {
let line = match &packet {
Packet::Data(d) => d.strip_suffix(b"\n").unwrap_or(d),
_ => break,
};
if let Some(hex) = line.strip_prefix(b"ACK ") {
let hex = core::str::from_utf8(hex)
.map_err(|_| Error::Protocol("v2 acknowledgments: non-utf8".into()))?;
common.push(crate::oid::ObjectId::from_hex(algo, hex.trim())?);
} else if line == b"ready" {
ready = true;
}
}
let mut out = Vec::new();
for oid in &common {
out.extend_from_slice(&pktline::encode_data(
alloc::format!("ACK {} common\n", oid.to_hex()).as_bytes(),
)?);
}
if ready && let Some(last) = common.last() {
out.extend_from_slice(&pktline::encode_data(
alloc::format!("ACK {} ready\n", last.to_hex()).as_bytes(),
)?);
}
out.extend_from_slice(&pktline::encode_data(b"NAK\n")?);
Ok(out)
}
const USER_AGENT: &str = concat!("puregit/", env!("CARGO_PKG_VERSION"));
fn basic_auth_header(user: &str, password: &str) -> String {
let raw = alloc::format!("{user}:{password}");
alloc::format!("Basic {}", base64_encode(raw.as_bytes()))
}
fn parse_auth_challenge(url: &str, headers: &[(String, String)]) -> AuthChallenge {
let value = headers
.iter()
.find(|(name, _)| name.eq_ignore_ascii_case("www-authenticate"))
.map(|(_, v)| v.as_str())
.unwrap_or("");
let scheme = value
.split_whitespace()
.next()
.unwrap_or("")
.to_ascii_lowercase();
let realm = value.find("realm=").and_then(|i| {
let after = &value[i + "realm=".len()..];
let after = after.trim_start_matches('"');
after.split('"').next().map(|s| s.to_string())
});
AuthChallenge {
url: url.to_string(),
scheme,
realm,
}
}
fn base_from_info_refs(final_url: &str) -> Option<String> {
if final_url.is_empty() {
return None;
}
let path = final_url.split(['?', '#']).next().unwrap_or(final_url);
path.strip_suffix("/info/refs").map(|s| s.to_string())
}
fn url_host(url: &str) -> Option<&str> {
let rest = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))?;
Some(rest.split(['/', '?', '#']).next().unwrap_or(rest))
}
fn base64_encode(input: &[u8]) -> String {
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
for chunk in input.chunks(3) {
let b0 = chunk[0];
let b1 = chunk.get(1).copied().unwrap_or(0);
let b2 = chunk.get(2).copied().unwrap_or(0);
out.push(T[(b0 >> 2) as usize] as char);
out.push(T[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char);
out.push(if chunk.len() > 1 {
T[(((b1 & 0x0f) << 2) | (b2 >> 6)) as usize] as char
} else {
'='
});
out.push(if chunk.len() > 2 {
T[(b2 & 0x3f) as usize] as char
} else {
'='
});
}
out
}
fn parse_smart_advertisement(algo: HashAlgo, body: &[u8]) -> Result<RefAdvertisement> {
let packets = pktline::decode_all(body)?;
let is_service_banner = matches!(
packets.first(),
Some(Packet::Data(d)) if d.starts_with(b"# service=")
);
if !is_service_banner {
return Err(Error::Protocol(
"info/refs: not a smart-HTTP advertisement (dumb HTTP unsupported)".into(),
));
}
let mut start = 1;
if matches!(packets.get(start), Some(Packet::Flush)) {
start += 1;
}
RefAdvertisement::parse(algo, &packets[start..])
}
const MAX_EINTR_RETRIES: usize = 5;
fn is_interrupted(e: &rsurl::Error) -> bool {
matches!(e, rsurl::Error::Io(io) if io.kind() == std::io::ErrorKind::Interrupted)
}
fn map_rsurl_err(e: rsurl::Error) -> Error {
Error::Io(e.to_string())
}
fn verify_downloaded_pack(algo: HashAlgo, hash: &str, body: Vec<u8>) -> Result<Vec<u8>> {
let id_len = algo.raw_len();
if body.len() < 12 + id_len || &body[..4] != b"PACK" {
return Err(Error::Protocol(
"packfile-uris: downloaded object is not a packfile".into(),
));
}
let split = body.len() - id_len;
let trailer = to_hex(&crate::hash::hash_bytes(algo, &body[..split]));
let announced_trailer = to_hex(&body[split..]);
if trailer != announced_trailer {
return Err(Error::Protocol(
"packfile-uris: pack checksum is not self-consistent (corrupt download)".into(),
));
}
let whole = to_hex(&crate::hash::hash_bytes(algo, &body));
if !hash.eq_ignore_ascii_case(&trailer) && !hash.eq_ignore_ascii_case(&whole) {
return Err(Error::Protocol(alloc::format!(
"packfile-uris: announced hash {hash} matches neither the pack checksum nor its file digest"
)));
}
Ok(body)
}
fn to_hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push(char::from_digit((b >> 4) as u32, 16).unwrap());
s.push(char::from_digit((b & 0x0f) as u32, 16).unwrap());
}
s
}
fn validate_base_url(base_url: &str) -> Result<()> {
let is_http = base_url.starts_with("http://") || base_url.starts_with("https://");
if !is_http {
return Err(Error::Protocol(
"http url: scheme must be http:// or https://".into(),
));
}
if base_url.chars().any(|c| c.is_control()) {
return Err(Error::Protocol(
"http url: contains control characters (CR/LF/NUL)".into(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base64_matches_rfc4648_vectors() {
assert_eq!(base64_encode(b""), "");
assert_eq!(base64_encode(b"f"), "Zg==");
assert_eq!(base64_encode(b"fo"), "Zm8=");
assert_eq!(base64_encode(b"foo"), "Zm9v");
assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
assert_eq!(
base64_encode(b"Aladdin:open sesame"),
"QWxhZGRpbjpvcGVuIHNlc2FtZQ=="
);
}
#[test]
fn basic_auth_sets_authorization_header_value() {
let t = HttpTransport::new("https://example.com/r.git", HashAlgo::Sha1)
.with_basic_auth("user", "pass");
assert_eq!(t.auth.as_deref(), Some("Basic dXNlcjpwYXNz"));
}
#[test]
fn accepts_http_and_https() {
assert!(validate_base_url("https://example.com/r.git").is_ok());
assert!(validate_base_url("http://example.com/r.git").is_ok());
}
#[test]
fn rejects_non_http_scheme() {
assert!(validate_base_url("file:///etc/passwd").is_err());
assert!(validate_base_url("ftp://example.com/r.git").is_err());
assert!(validate_base_url("//example.com/r.git").is_err());
}
#[test]
fn rejects_control_characters() {
assert!(validate_base_url("https://example.com/r.git\r\nHost: evil").is_err());
assert!(validate_base_url("https://example.com/\nr.git").is_err());
assert!(validate_base_url("https://example.com/\0r.git").is_err());
}
#[test]
fn parses_www_authenticate_scheme_and_realm() {
let headers = alloc::vec![(
"WWW-Authenticate".to_string(),
"Basic realm=\"Git Access\"".to_string(),
)];
let ch = parse_auth_challenge("https://h/r.git/info/refs", &headers);
assert_eq!(ch.scheme, "basic");
assert_eq!(ch.realm.as_deref(), Some("Git Access"));
assert_eq!(ch.url, "https://h/r.git/info/refs");
let ch = parse_auth_challenge("https://h/r.git", &[]);
assert_eq!(ch.scheme, "");
assert!(ch.realm.is_none());
}
#[test]
fn derives_base_from_redirected_info_refs() {
assert_eq!(
base_from_info_refs("https://new/r.git/info/refs?service=git-upload-pack").as_deref(),
Some("https://new/r.git")
);
assert_eq!(base_from_info_refs(""), None);
assert_eq!(
base_from_info_refs("https://new/r.git/git-upload-pack"),
None
);
}
#[test]
fn same_host_check_ignores_scheme_and_path() {
assert_eq!(url_host("http://h:8080/a"), url_host("https://h:8080/b"));
assert_ne!(url_host("https://h/a"), url_host("https://evil/a"));
assert_eq!(url_host("ftp://h/a"), None);
}
#[test]
fn rebases_only_on_same_host_redirect() {
let mut t = HttpTransport::new("http://h/r.git", HashAlgo::Sha1);
t.rebase_after_redirect("https://h/r.git/info/refs?service=git-upload-pack");
assert_eq!(t.active_base(), "https://h/r.git");
let mut t = HttpTransport::new("https://h/r.git", HashAlgo::Sha1).with_basic_auth("u", "p");
t.rebase_after_redirect("https://evil/r.git/info/refs");
assert_eq!(t.active_base(), "https://h/r.git");
}
#[test]
fn debug_does_not_leak_credentials() {
let t =
HttpTransport::new("https://h/r.git", HashAlgo::Sha1).with_basic_auth("u", "sekret");
let shown = alloc::format!("{t:?}");
assert!(!shown.contains("sekret"));
assert!(!shown.contains("dXNlcg")); assert!(shown.contains("authenticated: true"));
}
#[test]
fn discover_rejects_bad_base_url() {
let mut t = HttpTransport::new("file:///etc/passwd", HashAlgo::Sha1);
assert!(matches!(
t.discover(Service::UploadPack),
Err(Error::Protocol(_))
));
}
#[test]
fn packfile_uri_download_refuses_non_https() {
let mut t = HttpTransport::new("https://example/r.git", HashAlgo::Sha1);
for uri in [
"http://cdn.example/p.pack",
"file:///etc/passwd",
"ftp://cdn.example/p.pack",
] {
assert!(
matches!(
t.download_packfile_uri("abcd", uri),
Err(Error::Protocol(_))
),
"must refuse {uri}"
);
}
}
#[test]
fn downloaded_pack_is_verified_against_the_announced_hash() {
let header = alloc::vec![b'P', b'A', b'C', b'K', 0, 0, 0, 2, 0, 0, 0, 0];
let checksum = crate::hash::hash_bytes(HashAlgo::Sha1, &header);
let mut pack = header.clone();
pack.extend_from_slice(&checksum);
let by_checksum = to_hex(&checksum); let by_file = to_hex(&crate::hash::hash_bytes(HashAlgo::Sha1, &pack));
assert_eq!(
verify_downloaded_pack(HashAlgo::Sha1, &by_checksum, pack.clone()).unwrap(),
pack
);
assert!(verify_downloaded_pack(HashAlgo::Sha1, &by_file, pack.clone()).is_ok());
assert!(verify_downloaded_pack(HashAlgo::Sha1, &"0".repeat(40), pack.clone()).is_err());
assert!(
verify_downloaded_pack(HashAlgo::Sha1, &by_checksum, b"not a pack".to_vec()).is_err()
);
let mut corrupt = header;
corrupt.extend_from_slice(&[0u8; 20]); let corrupt_file = to_hex(&crate::hash::hash_bytes(HashAlgo::Sha1, &corrupt));
assert!(verify_downloaded_pack(HashAlgo::Sha1, &corrupt_file, corrupt).is_err());
}
#[test]
fn v2_acknowledgments_translate_to_v0_negotiation_lines() {
let a = "a".repeat(40);
let mut body = Vec::new();
for line in ["acknowledgments\n", &alloc::format!("ACK {a}\n"), "ready\n"] {
body.extend_from_slice(&pktline::encode_data(line.as_bytes()).unwrap());
}
body.extend_from_slice(&pktline::encode_control(&Packet::Delim));
body.extend_from_slice(&pktline::encode_data(b"packfile\n").unwrap());
body.extend_from_slice(&pktline::encode_data(b"\x01PACK").unwrap());
body.extend_from_slice(&pktline::encode_control(&Packet::Flush));
let out = v2_acks_as_v0(HashAlgo::Sha1, &body).unwrap();
let text = String::from_utf8_lossy(&out);
assert!(text.contains(&alloc::format!("ACK {a} common")));
assert!(text.contains(&alloc::format!("ACK {a} ready")));
assert!(text.ends_with("NAK\n"));
assert!(
!text.contains("PACK"),
"the pack of a negotiation round is dropped"
);
let mut body = pktline::encode_data(b"acknowledgments\n").unwrap();
body.extend_from_slice(&pktline::encode_data(b"NAK\n").unwrap());
body.extend_from_slice(&pktline::encode_control(&Packet::Flush));
let out = v2_acks_as_v0(HashAlgo::Sha1, &body).unwrap();
assert_eq!(out, pktline::encode_data(b"NAK\n").unwrap());
}
#[test]
fn eintr_is_recognised_for_retry() {
let eintr = rsurl::Error::Io(std::io::ErrorKind::Interrupted.into());
assert!(is_interrupted(&eintr));
let other = rsurl::Error::Io(std::io::ErrorKind::ConnectionReset.into());
assert!(!is_interrupted(&other));
}
}