use crate::io;
use std::io::{Read, Cursor};
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ClientError {
#[error("invalid server status")]
InvalidServerStatus,
#[error("invalid response content type, expected {0}, got {1}")]
InvalidContentType(&'static str, String),
#[error("invalid ref hash")]
InvalidRefHash,
#[error(transparent)]
RequestError(#[from] ureq::Error),
#[error(transparent)]
IOError(#[from] std::io::Error),
#[error(transparent)]
Utf8Error(#[from] std::string::FromUtf8Error),
}
pub struct Client {
url: String,
client: ureq::Agent,
}
impl Client {
pub fn new(url: &str) -> Self {
Self {
url: url.to_owned(),
client: ureq::AgentBuilder::new()
.user_agent("anni-fetch 0.2.0")
.build(),
}
}
pub fn handshake(&mut self) -> Result<PktIter, ClientError> {
let reader = self.client
.get(&format!("{}/info/refs?service=git-upload-pack", &self.url))
.set("Git-Protocol", "version=2")
.call()?
.into_reader();
Ok(PktIter::new(reader))
}
#[deprecated]
pub fn command(&self, command: &str, capabilities: Option<&[(&str, Option<&[&str]>)]>, arguments: &[&str]) -> Result<impl Read + Send, ClientError> {
let out = Vec::new();
let mut cursor = std::io::Cursor::new(out);
io::write_pktline(&mut cursor, &format!("command={}", command))?;
io::write_pktline(&mut cursor, "object-format=sha1")?;
io::write_pktline(&mut cursor, "agent=git/2.28.0")?;
if let Some(capabilities) = capabilities {
for (k, v) in capabilities {
if let Some(v) = v {
io::write_pktline(&mut cursor, &format!("{}={}", k, v.join(" ")))?;
} else {
io::write_pktline(&mut cursor, k)?;
}
}
}
io::write_packet(&mut cursor, 1)?;
for arg in arguments.iter() {
io::write_pktline(&mut cursor, arg)?;
}
io::write_packet(&mut cursor, 0)?;
Ok(self.client
.post(&format!("{}/git-upload-pack", &self.url))
.set("Git-Protocol", "version=2")
.set("Content-Type", "application/x-git-upload-pack-request")
.set("Accept", "application/x-git-upload-pack-result")
.send_bytes(&cursor.into_inner())?
.into_reader())
}
pub fn request(&self, body: Vec<u8>) -> Result<PktIter, ClientError> {
let response = self.client
.post(&format!("{}/git-upload-pack", &self.url))
.set("Git-Protocol", "version=2")
.set("Content-Type", "application/x-git-upload-pack-request")
.set("Accept", "application/x-git-upload-pack-result")
.send_bytes(&body)?;
if response.status() != 200 {
return Err(ClientError::InvalidServerStatus);
} else if response.content_type() != "application/x-git-upload-pack-result" {
return Err(ClientError::InvalidContentType("application/x-git-upload-pack-result", response.content_type().to_owned()));
}
let reader = response.into_reader();
Ok(PktIter::new(reader))
}
pub fn ls_ref(&self, prefix: &str) -> Result<String, ClientError> {
let iter = self.request(
RequestBuilder::new(true)
.command("ls-refs")
.argument("peel")
.argument("symrefs")
.argument(&format!("ref-prefix {}", prefix))
.build()
)?;
for msg in iter {
match msg {
Message::Normal(mut n) => {
n.truncate(40);
return Ok(String::from_utf8(n)?);
}
_ => {}
}
}
Err(ClientError::InvalidRefHash)
}
#[deprecated]
pub fn want_ref(&self, prefix: &str) -> Result<String, ClientError> {
Ok(format!("want {}", self.ls_ref(prefix)?))
}
}
pub struct RequestBuilder {
inner: Cursor<Vec<u8>>,
delimeter_written: bool,
flush_written: bool,
}
impl RequestBuilder {
pub fn new(auto_packet: bool) -> Self {
let mut inner = Default::default();
io::write_pktline(&mut inner, "object-format=sha1").unwrap();
io::write_pktline(&mut inner, "agent=git/2.28.0").unwrap();
Self {
inner,
delimeter_written: !auto_packet,
flush_written: !auto_packet,
}
}
pub fn command(mut self, command: &str) -> Self {
io::write_pktline(&mut self.inner, &format!("command={}", command)).unwrap();
self
}
pub fn capability(mut self, name: &str, value: &[&str]) -> Self {
if value.len() != 0 {
io::write_pktline(&mut self.inner, &format!("{}={}", name, value.join(" "))).unwrap();
} else {
io::write_pktline(&mut self.inner, name).unwrap();
}
self
}
pub fn argument(mut self, arg: &str) -> Self {
if !self.delimeter_written {
self = self.packet(Message::Delimeter);
self.delimeter_written = true;
}
io::write_pktline(&mut self.inner, arg).unwrap();
self
}
pub fn packet(mut self, packet: Message) -> Self {
match packet {
Message::Flush => io::write_packet(&mut self.inner, 0).unwrap(),
Message::Delimeter => io::write_packet(&mut self.inner, 1).unwrap(),
_ => panic!("invalid request packet type")
}
self
}
pub fn want(self, hash: &str) -> Self {
self.argument(&format!("want {}", hash))
}
pub fn have(self, hash: &str) -> Self {
self.argument(&format!("have {}", hash))
}
pub fn build(mut self) -> Vec<u8> {
if !self.flush_written {
self = self.packet(Message::Flush);
self.flush_written = true;
}
self.inner.into_inner()
}
}
#[derive(Debug, PartialEq)]
pub enum Message {
Normal(Vec<u8>),
Flush,
Delimeter,
ResponseEnd,
PackStart,
PackData(Vec<u8>),
PackProgress(String),
PackError(String),
}
pub struct PktIter {
inner: Box<dyn Read + Send>,
is_data: bool,
}
impl PktIter {
pub fn new(reader: impl Read + Send + 'static) -> Self {
Self {
inner: Box::new(reader),
is_data: false,
}
}
}
impl Iterator for PktIter {
type Item = Message;
fn next(&mut self) -> Option<Self::Item> {
let (mut data, len) = io::read_pktline(&mut self.inner).unwrap();
if len == 0 && data.len() == 0 {
None
} else if len > 0 && self.is_data {
match data[0] {
1 => {
data.remove(0);
Some(Message::PackData(data))
}
2 => {
Some(Message::PackProgress(String::from_utf8_lossy(&data[1..]).trim().to_owned()))
}
3 => {
Some(Message::PackError(String::from_utf8_lossy(&data[1..]).trim().to_owned()))
}
_ => unreachable!(),
}
} else if data == b"packfile\n" {
self.is_data = true;
Some(Message::PackStart)
} else {
Some(match len {
0 => Message::Flush,
1 => Message::Delimeter,
2 => Message::ResponseEnd,
_ => Message::Normal(data),
})
}
}
}
#[cfg(test)]
mod tests {
use crate::{Client, Pack};
use crate::client::Message::*;
use std::io::Cursor;
use crate::client::RequestBuilder;
#[test]
fn test_handshake() {
let v: Vec<_> = Client::new("https://github.com/project-anni/repo.git").handshake().unwrap().collect();
let (l, r) = v.split_at(3);
let (agent, r) = r.split_at(1);
assert_eq!(l, vec![
Normal(b"# service=git-upload-pack\n".to_vec()),
Flush,
Normal(b"version 2\n".to_vec()),
]);
match &agent[0] {
Normal(n) => assert!(n.starts_with(b"agent=git/github-")),
_ => panic!("invalid agent message type"),
}
assert_eq!(r, vec![
Normal(b"ls-refs\n".to_vec()),
Normal(b"fetch=shallow filter\n".to_vec()),
Normal(b"server-option\n".to_vec()),
Normal(b"object-format=sha1\n".to_vec()),
Flush,
]);
}
#[test]
fn test_ls_ref() {
let hash = Client::new("https://github.com/project-anni/anni-fetch.git")
.ls_ref("refs/tags")
.unwrap();
assert_eq!(hash, "9192b5e5f2941fd76aa5a08043dc8aa6a31831a2");
}
#[test]
fn test_fetch_iter() {
let client = Client::new("https://github.com/project-anni/repo.git");
let iter = client.request(
RequestBuilder::new(true)
.command("fetch")
.argument("thin-pack")
.argument("ofs-delta")
.argument("deepen 1")
.want(&client.ls_ref("HEAD").expect("failed to get sha1 of HEAD"))
.argument("done")
.build()
).unwrap();
let mut pack = Vec::new();
for msg in iter {
match msg {
PackData(mut d) => pack.append(&mut d),
_ => {}
}
}
let mut cursor = Cursor::new(pack);
Pack::from_reader(&mut cursor).expect("invalid pack file");
}
}