1use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
24use tokio::net::TcpStream;
25use url::Url;
26
27use crate::plus::{AttributeBlock, MalformedHeader, PlusHeader, parse_attributes, parse_header};
28
29pub use crate::plus::PlusRequest;
33
34pub const DEFAULT_PORT: u16 = 70;
36
37#[derive(Clone, Debug, PartialEq, Eq)]
41pub enum ClientError {
42 BadUrl(String),
44 Connect(String),
46 Io(String),
48 BadPlusHeader(String),
51 PlusError(String),
53}
54
55impl From<MalformedHeader> for ClientError {
56 fn from(error: MalformedHeader) -> Self {
57 Self::BadPlusHeader(error.0)
58 }
59}
60
61impl std::fmt::Display for ClientError {
62 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63 match self {
64 Self::BadUrl(m) => write!(f, "bad url: {m}"),
65 Self::Connect(m) => write!(f, "connect: {m}"),
66 Self::Io(m) => write!(f, "io: {m}"),
67 Self::BadPlusHeader(m) => write!(f, "malformed gopher+ header: {m}"),
68 Self::PlusError(m) => write!(f, "gopher+ error: {m}"),
69 }
70 }
71}
72
73impl std::error::Error for ClientError {}
74
75#[derive(Clone, Debug, PartialEq, Eq)]
77pub struct Response {
78 pub mime: String,
82 pub body: Vec<u8>,
84}
85
86pub async fn fetch(url: &str) -> Result<Response, ClientError> {
91 let url = Url::parse(url).map_err(|e| ClientError::BadUrl(e.to_string()))?;
92 let host = url
93 .host_str()
94 .ok_or_else(|| ClientError::BadUrl("gopher URL has no host".into()))?;
95 let port = url.port().unwrap_or(DEFAULT_PORT);
96 let (item_type, selector) = split_path(&url);
97
98 let mut request = selector;
99 if let Some(query) = url.query() {
101 request.push('\t');
102 request.push_str(query);
103 }
104 request.push_str("\r\n");
105
106 let body = exchange(host, port, request.as_bytes()).await?;
107 Ok(Response {
108 mime: mime_for_item_type(item_type).to_string(),
109 body,
110 })
111}
112
113async fn exchange(host: &str, port: u16, request: &[u8]) -> Result<Vec<u8>, ClientError> {
116 let mut stream = TcpStream::connect((host, port))
117 .await
118 .map_err(|e| ClientError::Connect(format!("tcp {host}:{port}: {e}")))?;
119 stream
120 .write_all(request)
121 .await
122 .map_err(|e| ClientError::Io(e.to_string()))?;
123 let mut buf = Vec::new();
124 stream
125 .read_to_end(&mut buf)
126 .await
127 .map_err(|e| ClientError::Io(e.to_string()))?;
128 Ok(buf)
129}
130
131fn split_path(url: &Url) -> (char, String) {
134 let path = url.path();
135 let trimmed = path.strip_prefix('/').unwrap_or(path);
136 let mut chars = trimmed.chars();
137 match chars.next() {
138 Some(item_type) => (item_type, chars.as_str().to_string()),
139 None => ('1', String::new()),
140 }
141}
142
143pub fn mime_for_item_type(item_type: char) -> &'static str {
149 match item_type {
150 '0' => "text/plain",
151 '1' | '7' => "application/gopher-menu",
152 'h' => "text/html",
153 'g' => "image/gif",
154 'I' | ':' => "image/*",
155 's' | '<' => "audio/*",
156 _ => "application/octet-stream",
157 }
158}
159
160#[derive(Clone, Debug, PartialEq, Eq)]
165pub struct PlusReply {
166 pub header: PlusHeader,
167 pub body: Vec<u8>,
168}
169
170pub async fn fetch_plus(url: &str, request: PlusRequest) -> Result<PlusReply, ClientError> {
176 let url = Url::parse(url).map_err(|e| ClientError::BadUrl(e.to_string()))?;
177 let host = url
178 .host_str()
179 .ok_or_else(|| ClientError::BadUrl("gopher URL has no host".into()))?;
180 let port = url.port().unwrap_or(DEFAULT_PORT);
181 let (_, selector) = split_path(&url);
182 let search = url.query().unwrap_or("");
183
184 let line = format!("{selector}\t{search}\t{}\r\n", request.token());
185 let (header, body) = plus_exchange(host, port, line.as_bytes()).await?;
186
187 if header == PlusHeader::Error {
188 return Err(ClientError::PlusError(
189 String::from_utf8_lossy(&body).trim().to_string(),
190 ));
191 }
192 Ok(PlusReply { header, body })
193}
194
195pub async fn fetch_attributes(url: &str) -> Result<Vec<AttributeBlock>, ClientError> {
197 let reply = fetch_plus(url, PlusRequest::Attributes).await?;
198 Ok(parse_attributes(&String::from_utf8_lossy(&reply.body)))
199}
200
201pub async fn fetch_directory_attributes(url: &str) -> Result<Vec<AttributeBlock>, ClientError> {
203 let reply = fetch_plus(url, PlusRequest::DirectoryAttributes).await?;
204 Ok(parse_attributes(&String::from_utf8_lossy(&reply.body)))
205}
206
207async fn plus_exchange(
210 host: &str,
211 port: u16,
212 request: &[u8],
213) -> Result<(PlusHeader, Vec<u8>), ClientError> {
214 let mut stream = TcpStream::connect((host, port))
215 .await
216 .map_err(|e| ClientError::Connect(format!("tcp {host}:{port}: {e}")))?;
217 stream
218 .write_all(request)
219 .await
220 .map_err(|e| ClientError::Io(e.to_string()))?;
221
222 let mut reader = BufReader::new(stream);
223 let mut header_line = String::new();
224 reader
225 .read_line(&mut header_line)
226 .await
227 .map_err(|e| ClientError::Io(e.to_string()))?;
228 if header_line.is_empty() {
229 return Err(ClientError::BadPlusHeader(
230 "the server closed without a header".into(),
231 ));
232 }
233 let header = parse_header(&header_line)?;
234
235 let mut body = Vec::new();
236 match header {
237 PlusHeader::Length(count) => {
240 reader
241 .take(count)
242 .read_to_end(&mut body)
243 .await
244 .map_err(|e| ClientError::Io(e.to_string()))?;
245 },
246 _ => {
247 reader
248 .read_to_end(&mut body)
249 .await
250 .map_err(|e| ClientError::Io(e.to_string()))?;
251 },
252 }
253
254 if matches!(header, PlusHeader::PeriodTerminated | PlusHeader::Error) {
255 body = strip_period_terminator(body);
256 }
257 Ok((header, body))
258}
259
260fn strip_period_terminator(mut body: Vec<u8>) -> Vec<u8> {
263 for terminator in [
264 b"\r\n.\r\n".as_slice(),
265 b"\n.\n".as_slice(),
266 b"\r\n.".as_slice(),
267 b"\n.".as_slice(),
268 ] {
269 if body.ends_with(terminator) {
270 let keep = if terminator.starts_with(b"\r\n") { 2 } else { 1 };
271 body.truncate(body.len() - terminator.len() + keep);
272 return body;
273 }
274 }
275 body
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281
282 fn split(u: &str) -> (char, String) {
283 split_path(&Url::parse(u).unwrap())
284 }
285
286 #[test]
287 fn root_path_is_a_menu() {
288 assert_eq!(split("gopher://example.org/"), ('1', String::new()));
289 assert_eq!(split("gopher://example.org"), ('1', String::new()));
290 }
291
292 #[test]
293 fn type_and_selector_split_at_the_first_char() {
294 assert_eq!(
295 split("gopher://example.org/0/about.txt"),
296 ('0', "/about.txt".into())
297 );
298 assert_eq!(split("gopher://example.org/1/dir"), ('1', "/dir".into()));
299 }
300
301 #[test]
302 fn mime_inference() {
303 assert_eq!(mime_for_item_type('0'), "text/plain");
304 assert_eq!(mime_for_item_type('1'), "application/gopher-menu");
305 assert_eq!(mime_for_item_type('9'), "application/octet-stream");
306 }
307
308 #[tokio::test]
309 async fn a_url_without_a_host_is_refused_before_connecting() {
310 let error = fetch("gopher:///0/x").await.unwrap_err();
311 assert!(matches!(error, ClientError::BadUrl(_)), "got {error:?}");
312 }
313
314 #[test]
315 fn plus_tokens_match_the_spec() {
316 assert_eq!(PlusRequest::Item(None).token(), "+");
317 assert_eq!(
318 PlusRequest::Item(Some("text/plain".into())).token(),
319 "+text/plain"
320 );
321 assert_eq!(PlusRequest::Attributes.token(), "!");
322 assert_eq!(PlusRequest::DirectoryAttributes.token(), "$");
323 }
324
325 #[test]
326 fn the_period_terminator_goes_but_the_last_newline_stays() {
327 assert_eq!(
328 strip_period_terminator(b"one\r\ntwo\r\n.\r\n".to_vec()),
329 b"one\r\ntwo\r\n".to_vec()
330 );
331 assert_eq!(
332 strip_period_terminator(b"one\ntwo\n.\n".to_vec()),
333 b"one\ntwo\n".to_vec()
334 );
335 }
336
337 #[test]
338 fn a_body_that_merely_ends_in_a_period_is_left_alone() {
339 assert_eq!(
340 strip_period_terminator(b"see fig. 1.".to_vec()),
341 b"see fig. 1.".to_vec()
342 );
343 }
344}