use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpStream;
use url::Url;
use crate::plus::{AttributeBlock, MalformedHeader, PlusHeader, parse_attributes, parse_header};
pub use crate::plus::PlusRequest;
pub const DEFAULT_PORT: u16 = 70;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ClientError {
BadUrl(String),
Connect(String),
Io(String),
BadPlusHeader(String),
PlusError(String),
}
impl From<MalformedHeader> for ClientError {
fn from(error: MalformedHeader) -> Self {
Self::BadPlusHeader(error.0)
}
}
impl std::fmt::Display for ClientError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::BadUrl(m) => write!(f, "bad url: {m}"),
Self::Connect(m) => write!(f, "connect: {m}"),
Self::Io(m) => write!(f, "io: {m}"),
Self::BadPlusHeader(m) => write!(f, "malformed gopher+ header: {m}"),
Self::PlusError(m) => write!(f, "gopher+ error: {m}"),
}
}
}
impl std::error::Error for ClientError {}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Response {
pub mime: String,
pub body: Vec<u8>,
}
pub async fn fetch(url: &str) -> Result<Response, ClientError> {
let url = Url::parse(url).map_err(|e| ClientError::BadUrl(e.to_string()))?;
let host = url
.host_str()
.ok_or_else(|| ClientError::BadUrl("gopher URL has no host".into()))?;
let port = url.port().unwrap_or(DEFAULT_PORT);
let (item_type, selector) = split_path(&url);
let mut request = selector;
if let Some(query) = url.query() {
request.push('\t');
request.push_str(query);
}
request.push_str("\r\n");
let body = exchange(host, port, request.as_bytes()).await?;
Ok(Response {
mime: mime_for_item_type(item_type).to_string(),
body,
})
}
async fn exchange(host: &str, port: u16, request: &[u8]) -> Result<Vec<u8>, ClientError> {
let mut stream = TcpStream::connect((host, port))
.await
.map_err(|e| ClientError::Connect(format!("tcp {host}:{port}: {e}")))?;
stream
.write_all(request)
.await
.map_err(|e| ClientError::Io(e.to_string()))?;
let mut buf = Vec::new();
stream
.read_to_end(&mut buf)
.await
.map_err(|e| ClientError::Io(e.to_string()))?;
Ok(buf)
}
fn split_path(url: &Url) -> (char, String) {
let path = url.path();
let trimmed = path.strip_prefix('/').unwrap_or(path);
let mut chars = trimmed.chars();
match chars.next() {
Some(item_type) => (item_type, chars.as_str().to_string()),
None => ('1', String::new()),
}
}
pub fn mime_for_item_type(item_type: char) -> &'static str {
match item_type {
'0' => "text/plain",
'1' | '7' => "application/gopher-menu",
'h' => "text/html",
'g' => "image/gif",
'I' | ':' => "image/*",
's' | '<' => "audio/*",
_ => "application/octet-stream",
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PlusReply {
pub header: PlusHeader,
pub body: Vec<u8>,
}
pub async fn fetch_plus(url: &str, request: PlusRequest) -> Result<PlusReply, ClientError> {
let url = Url::parse(url).map_err(|e| ClientError::BadUrl(e.to_string()))?;
let host = url
.host_str()
.ok_or_else(|| ClientError::BadUrl("gopher URL has no host".into()))?;
let port = url.port().unwrap_or(DEFAULT_PORT);
let (_, selector) = split_path(&url);
let search = url.query().unwrap_or("");
let line = format!("{selector}\t{search}\t{}\r\n", request.token());
let (header, body) = plus_exchange(host, port, line.as_bytes()).await?;
if header == PlusHeader::Error {
return Err(ClientError::PlusError(
String::from_utf8_lossy(&body).trim().to_string(),
));
}
Ok(PlusReply { header, body })
}
pub async fn fetch_attributes(url: &str) -> Result<Vec<AttributeBlock>, ClientError> {
let reply = fetch_plus(url, PlusRequest::Attributes).await?;
Ok(parse_attributes(&String::from_utf8_lossy(&reply.body)))
}
pub async fn fetch_directory_attributes(url: &str) -> Result<Vec<AttributeBlock>, ClientError> {
let reply = fetch_plus(url, PlusRequest::DirectoryAttributes).await?;
Ok(parse_attributes(&String::from_utf8_lossy(&reply.body)))
}
async fn plus_exchange(
host: &str,
port: u16,
request: &[u8],
) -> Result<(PlusHeader, Vec<u8>), ClientError> {
let mut stream = TcpStream::connect((host, port))
.await
.map_err(|e| ClientError::Connect(format!("tcp {host}:{port}: {e}")))?;
stream
.write_all(request)
.await
.map_err(|e| ClientError::Io(e.to_string()))?;
let mut reader = BufReader::new(stream);
let mut header_line = String::new();
reader
.read_line(&mut header_line)
.await
.map_err(|e| ClientError::Io(e.to_string()))?;
if header_line.is_empty() {
return Err(ClientError::BadPlusHeader(
"the server closed without a header".into(),
));
}
let header = parse_header(&header_line)?;
let mut body = Vec::new();
match header {
PlusHeader::Length(count) => {
reader
.take(count)
.read_to_end(&mut body)
.await
.map_err(|e| ClientError::Io(e.to_string()))?;
},
_ => {
reader
.read_to_end(&mut body)
.await
.map_err(|e| ClientError::Io(e.to_string()))?;
},
}
if matches!(header, PlusHeader::PeriodTerminated | PlusHeader::Error) {
body = strip_period_terminator(body);
}
Ok((header, body))
}
fn strip_period_terminator(mut body: Vec<u8>) -> Vec<u8> {
for terminator in [
b"\r\n.\r\n".as_slice(),
b"\n.\n".as_slice(),
b"\r\n.".as_slice(),
b"\n.".as_slice(),
] {
if body.ends_with(terminator) {
let keep = if terminator.starts_with(b"\r\n") { 2 } else { 1 };
body.truncate(body.len() - terminator.len() + keep);
return body;
}
}
body
}
#[cfg(test)]
mod tests {
use super::*;
fn split(u: &str) -> (char, String) {
split_path(&Url::parse(u).unwrap())
}
#[test]
fn root_path_is_a_menu() {
assert_eq!(split("gopher://example.org/"), ('1', String::new()));
assert_eq!(split("gopher://example.org"), ('1', String::new()));
}
#[test]
fn type_and_selector_split_at_the_first_char() {
assert_eq!(
split("gopher://example.org/0/about.txt"),
('0', "/about.txt".into())
);
assert_eq!(split("gopher://example.org/1/dir"), ('1', "/dir".into()));
}
#[test]
fn mime_inference() {
assert_eq!(mime_for_item_type('0'), "text/plain");
assert_eq!(mime_for_item_type('1'), "application/gopher-menu");
assert_eq!(mime_for_item_type('9'), "application/octet-stream");
}
#[tokio::test]
async fn a_url_without_a_host_is_refused_before_connecting() {
let error = fetch("gopher:///0/x").await.unwrap_err();
assert!(matches!(error, ClientError::BadUrl(_)), "got {error:?}");
}
#[test]
fn plus_tokens_match_the_spec() {
assert_eq!(PlusRequest::Item(None).token(), "+");
assert_eq!(
PlusRequest::Item(Some("text/plain".into())).token(),
"+text/plain"
);
assert_eq!(PlusRequest::Attributes.token(), "!");
assert_eq!(PlusRequest::DirectoryAttributes.token(), "$");
}
#[test]
fn the_period_terminator_goes_but_the_last_newline_stays() {
assert_eq!(
strip_period_terminator(b"one\r\ntwo\r\n.\r\n".to_vec()),
b"one\r\ntwo\r\n".to_vec()
);
assert_eq!(
strip_period_terminator(b"one\ntwo\n.\n".to_vec()),
b"one\ntwo\n".to_vec()
);
}
#[test]
fn a_body_that_merely_ends_in_a_period_is_left_alone() {
assert_eq!(
strip_period_terminator(b"see fig. 1.".to_vec()),
b"see fig. 1.".to_vec()
);
}
}