use std::cell::RefCell;
use std::io::{self, BufRead, BufReader, Read, Write};
use std::rc::Rc;
use std::time::Duration;
const CONNECT_TIMEOUT: Duration = Duration::from_secs(20);
const REQUEST_TIMEOUT: Duration = Duration::from_secs(60 * 60);
type Pending = Rc<RefCell<Vec<u8>>>;
pub(crate) struct HttpWrite {
pending: Pending,
}
impl Write for HttpWrite {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.pending.borrow_mut().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
pub(crate) struct HttpRead {
agent: ureq::Agent,
post_url: String,
auth: Option<String>,
pending: Pending,
reading: Box<dyn BufRead>,
}
pub(crate) fn open(
base: &str,
auth: Option<String>,
) -> Result<(HttpRead, HttpWrite), String> {
open_through(base, auth, nordisk_pairing::egress::proxy())
}
pub(crate) fn open_through(
base: &str,
auth: Option<String>,
door: Option<&str>,
) -> Result<(HttpRead, HttpWrite), String> {
let base = base.trim_end_matches('/');
let agent = ureq::Agent::config_builder()
.redirect_auth_headers(ureq::config::RedirectAuthHeaders::Never)
.timeout_connect(Some(CONNECT_TIMEOUT))
.timeout_global(Some(REQUEST_TIMEOUT))
.proxy(door.and_then(|d| ureq::Proxy::new(d).ok()))
.max_redirects(5)
.build()
.new_agent();
let advertisement = format!("{base}/info/refs?service=git-upload-pack");
let mut request = agent
.get(&advertisement)
.header("Git-Protocol", "version=2")
.header("Accept", "*/*");
if let Some(value) = &auth {
request = request.header("Authorization", value);
}
let response = request.call().map_err(|err| refuse("the advertisement", &advertisement, err))?;
let reading = skip_service_announcement(Box::new(BufReader::new(
response.into_body().into_reader(),
)))
.map_err(|err| format!("reading the advertisement from {}: {err}", scrub(&advertisement)))?;
let pending: Pending = Rc::new(RefCell::new(Vec::new()));
Ok((
HttpRead {
agent,
post_url: format!("{base}/git-upload-pack"),
auth,
pending: Rc::clone(&pending),
reading,
},
HttpWrite { pending },
))
}
fn skip_service_announcement(mut body: Box<dyn BufRead>) -> io::Result<Box<dyn BufRead>> {
let mut length = [0u8; 4];
if let Err(err) = body.read_exact(&mut length) {
if err.kind() == io::ErrorKind::UnexpectedEof {
return Ok(Box::new(io::Cursor::new(Vec::new())));
}
return Err(err);
}
let size = std::str::from_utf8(&length)
.ok()
.and_then(|hex| usize::from_str_radix(hex, 16).ok());
let line = match size {
Some(size) if size > 4 => {
let mut line = vec![0u8; size - 4];
body.read_exact(&mut line)?;
line
}
_ => Vec::new(),
};
if line.starts_with(b"# service=") {
let mut flush = [0u8; 4];
body.read_exact(&mut flush)?;
if &flush != b"0000" {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"the service announcement was not followed by a flush-pkt",
));
}
return Ok(body);
}
let mut seen = length.to_vec();
seen.extend_from_slice(&line);
Ok(Box::new(BufReader::new(io::Cursor::new(seen).chain(body))))
}
impl HttpRead {
fn post(&mut self) -> io::Result<()> {
let body = std::mem::take(&mut *self.pending.borrow_mut());
let mut request = self
.agent
.post(&self.post_url)
.header("Content-Type", "application/x-git-upload-pack-request")
.header("Accept", "application/x-git-upload-pack-result")
.header("Git-Protocol", "version=2");
if let Some(value) = &self.auth {
request = request.header("Authorization", value);
}
let response = request
.send(&body[..])
.map_err(|err| io::Error::other(refuse("a fetch round", &self.post_url, err)))?;
self.reading = Box::new(BufReader::new(response.into_body().into_reader()));
Ok(())
}
}
impl BufRead for HttpRead {
fn fill_buf(&mut self) -> io::Result<&[u8]> {
let exhausted = self.reading.fill_buf()?.is_empty();
if exhausted && !self.pending.borrow().is_empty() {
self.post()?;
}
self.reading.fill_buf()
}
fn consume(&mut self, amount: usize) {
self.reading.consume(amount);
}
}
impl Read for HttpRead {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
let available = self.fill_buf()?;
let taken = available.len().min(buf.len());
buf[..taken].copy_from_slice(&available[..taken]);
self.consume(taken);
Ok(taken)
}
}
fn refuse(what: &str, url: &str, err: ureq::Error) -> String {
let url = scrub(url);
match err {
ureq::Error::StatusCode(401) => format!(
"This repository is private. Connect your GitHub or Codeberg account or add an \
access token, then try again. ({url} answered {what} with 401.)"
),
ureq::Error::StatusCode(403) => format!(
"The access token was accepted but may not read this repository. Use a token \
that can read it, then try again. ({url} answered {what} with 403.)"
),
ureq::Error::StatusCode(404) => format!(
"No repository was found at this address. Check the address; a private \
repository can also look like this until an access token is added. ({url} \
answered {what} with 404.)"
),
ureq::Error::StatusCode(code) => format!("{url} answered {what} with HTTP {code}"),
transport => {
let (kind, detail) = transport_detail(&transport);
format!("{url} could not be reached for {what}: {kind}{detail}")
}
}
}
fn transport_detail(err: &ureq::Error) -> (&'static str, String) {
use ureq::Error::*;
match err {
Io(e) if e.to_string().starts_with("failed to lookup address information") => ("Dns", format!(" ({e})")),
Io(e) => ("Io", format!(" ({e})")),
HostNotFound => ("Dns", " (the name resolved to no address)".to_string()),
Timeout(t) => ("Timeout", format!(" ({t})")),
ConnectionFailed => ("ConnectionFailed", String::new()),
TooManyRedirects => ("TooManyRedirects", String::new()),
RedirectFailed => ("RedirectFailed", String::new()),
BadUri(_) | Http(_) => ("InvalidUrl", String::new()),
RequireHttpsOnly(_) | TlsRequired => ("InsecureRequestHttpsOnly", String::new()),
Tls(reason) => ("Tls", format!(" ({reason})")),
Rustls(e) => ("Tls", format!(" ({e})")),
Pem(_) => ("Tls", String::new()),
Protocol(_) | LargeResponseHeader(..) => ("BadResponse", String::new()),
BodyExceedsLimit(_) | BodyStalled => ("Io", String::new()),
InvalidProxyUrl | ConnectProxyFailed(_) => ("ProxyConnect", String::new()),
_ => ("Other", String::new()),
}
}
pub(crate) use crate::mirror_fetch::scrub;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_service_announcement_is_skipped_and_nothing_else_is() {
let v2 = b"000eversion 2\n0013object-format=sha1\n0000".to_vec();
let mut announced = b"001e# service=git-upload-pack\n0000".to_vec();
announced.extend_from_slice(&v2);
for body in [announced, v2.clone()] {
let mut read = Vec::new();
skip_service_announcement(Box::new(io::Cursor::new(body)))
.expect("a well-formed advertisement")
.read_to_end(&mut read)
.expect("read the rest");
assert_eq!(read, v2);
}
let mut short = Vec::new();
skip_service_announcement(Box::new(io::Cursor::new(b"00".to_vec())))
.expect("too short is left to the protocol layer")
.read_to_end(&mut short)
.expect("read");
assert!(short.is_empty());
assert!(
skip_service_announcement(Box::new(io::Cursor::new(
b"001e# service=git-upload-pack\n000eversion 2\n".to_vec()
)))
.is_err(),
"an announcement must be followed by a flush"
);
}
#[test]
fn a_mirror_fetch_leaves_through_the_fed_door() {
use std::net::{SocketAddr, TcpListener};
static SEEN: std::sync::Mutex<Vec<(String, u16)>> = std::sync::Mutex::new(Vec::new());
fn record(host: &str, port: u16, resolved: &[SocketAddr]) -> Result<(), String> {
SEEN.lock().unwrap().push((host.to_string(), port));
if resolved.is_empty() { Err("unresolved".into()) } else { Ok(()) }
}
let remote = TcpListener::bind("127.0.0.1:0").unwrap();
let at = remote.local_addr().unwrap();
std::thread::spawn(move || {
for mut c in remote.incoming().flatten() {
let mut head = Vec::new();
let mut b = [0u8; 1];
while !head.ends_with(b"\r\n\r\n") && c.read(&mut b).map(|n| n == 1).unwrap_or(false) {
head.push(b[0]);
}
let body = b"001e# service=git-upload-pack\n0000000eversion 2\n0000";
let _ = write!(c, "HTTP/1.1 200 OK\r\nContent-Type: application/x-git-upload-pack-advertisement\r\nContent-Length: {}\r\n\r\n", body.len());
let _ = c.write_all(body);
}
});
let door = nordisk_pairing::egress::serve("127.0.0.1:0".parse().unwrap(), nordisk_pairing::egress::Policy { peer: |_| true, admits: record }).unwrap();
let door = format!("http://{door}");
let opened = open_through(&format!("http://127.0.0.1:{}/ns/repo.git", at.port()), None, Some(&door));
assert!(opened.is_ok(), "the advertisement through the door: {:?}", opened.err());
assert!(SEEN.lock().unwrap().iter().any(|(h, p)| h == "127.0.0.1" && *p == at.port()), "the door carried the fetch: {:?}", SEEN.lock().unwrap());
}
#[test]
fn a_flush_sends_nothing_and_keeps_the_request() {
let pending: Pending = Rc::new(RefCell::new(Vec::new()));
let mut writer = HttpWrite {
pending: Rc::clone(&pending),
};
writer.write_all(b"0014command=ls-refs\n").expect("buffered");
writer.flush().expect("a flush is inert");
assert_eq!(
&*pending.borrow(),
b"0014command=ls-refs\n",
"a flush must not consume the request; the read half sends it"
);
}
}