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(&url, 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(
117 url: &Url,
118 host: &str,
119 port: u16,
120 request: &[u8],
121) -> Result<Vec<u8>, ClientError> {
122 if url.scheme() == "gophers" {
123 #[cfg(feature = "tls")]
124 {
125 let mut stream = crate::tls::connect(host, port).await?;
126 return send_and_read(&mut stream, request).await;
127 }
128 #[cfg(not(feature = "tls"))]
129 {
130 return Err(ClientError::BadUrl(
133 "gophers:// needs the `tls` feature".into(),
134 ));
135 }
136 }
137 let mut stream = TcpStream::connect((host, port))
138 .await
139 .map_err(|e| ClientError::Connect(format!("tcp {host}:{port}: {e}")))?;
140 send_and_read(&mut stream, request).await
141}
142
143async fn send_and_read<S>(stream: &mut S, request: &[u8]) -> Result<Vec<u8>, ClientError>
145where
146 S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
147{
148 stream
149 .write_all(request)
150 .await
151 .map_err(|e| ClientError::Io(e.to_string()))?;
152 let mut buf = Vec::new();
153 stream
154 .read_to_end(&mut buf)
155 .await
156 .map_err(|e| ClientError::Io(e.to_string()))?;
157 Ok(buf)
158}
159
160fn split_path(url: &Url) -> (char, String) {
163 let path = url.path();
164 let trimmed = path.strip_prefix('/').unwrap_or(path);
165 let mut chars = trimmed.chars();
166 match chars.next() {
167 Some(item_type) => (item_type, chars.as_str().to_string()),
168 None => ('1', String::new()),
169 }
170}
171
172pub fn mime_for_item_type(item_type: char) -> &'static str {
178 match item_type {
179 '0' => "text/plain",
180 '1' | '7' => "application/gopher-menu",
181 'h' => "text/html",
182 'g' => "image/gif",
183 'I' | ':' => "image/*",
184 's' | '<' => "audio/*",
185 _ => "application/octet-stream",
186 }
187}
188
189#[derive(Clone, Debug, PartialEq, Eq)]
194pub struct PlusReply {
195 pub header: PlusHeader,
196 pub body: Vec<u8>,
197}
198
199pub async fn fetch_plus(url: &str, request: PlusRequest) -> Result<PlusReply, ClientError> {
205 let url = Url::parse(url).map_err(|e| ClientError::BadUrl(e.to_string()))?;
206 let host = url
207 .host_str()
208 .ok_or_else(|| ClientError::BadUrl("gopher URL has no host".into()))?;
209 let port = url.port().unwrap_or(DEFAULT_PORT);
210 let (_, selector) = split_path(&url);
211 let search = url.query().unwrap_or("");
212
213 let line = format!("{selector}\t{search}\t{}\r\n", request.token());
214 let (header, body) = plus_exchange(&url, host, port, line.as_bytes()).await?;
215
216 if header == PlusHeader::Error {
217 return Err(ClientError::PlusError(
218 String::from_utf8_lossy(&body).trim().to_string(),
219 ));
220 }
221 Ok(PlusReply { header, body })
222}
223
224pub async fn fetch_attributes(url: &str) -> Result<Vec<AttributeBlock>, ClientError> {
226 let reply = fetch_plus(url, PlusRequest::Attributes).await?;
227 Ok(parse_attributes(&String::from_utf8_lossy(&reply.body)))
228}
229
230pub async fn fetch_directory_attributes(url: &str) -> Result<Vec<AttributeBlock>, ClientError> {
232 let reply = fetch_plus(url, PlusRequest::DirectoryAttributes).await?;
233 Ok(parse_attributes(&String::from_utf8_lossy(&reply.body)))
234}
235
236async fn plus_exchange(
239 url: &Url,
240 host: &str,
241 port: u16,
242 request: &[u8],
243) -> Result<(PlusHeader, Vec<u8>), ClientError> {
244 if url.scheme() == "gophers" {
245 #[cfg(feature = "tls")]
246 {
247 let stream = crate::tls::connect(host, port).await?;
248 return plus_over(stream, request).await;
249 }
250 #[cfg(not(feature = "tls"))]
251 {
252 return Err(ClientError::BadUrl(
253 "gophers:// needs the `tls` feature".into(),
254 ));
255 }
256 }
257 let stream = TcpStream::connect((host, port))
258 .await
259 .map_err(|e| ClientError::Connect(format!("tcp {host}:{port}: {e}")))?;
260 plus_over(stream, request).await
261}
262
263async fn plus_over<S>(mut stream: S, request: &[u8]) -> Result<(PlusHeader, Vec<u8>), ClientError>
265where
266 S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
267{
268 stream
269 .write_all(request)
270 .await
271 .map_err(|e| ClientError::Io(e.to_string()))?;
272
273 let mut reader = BufReader::new(stream);
274 let mut header_line = String::new();
275 reader
276 .read_line(&mut header_line)
277 .await
278 .map_err(|e| ClientError::Io(e.to_string()))?;
279 if header_line.is_empty() {
280 return Err(ClientError::BadPlusHeader(
281 "the server closed without a header".into(),
282 ));
283 }
284 let header = parse_header(&header_line)?;
285
286 let mut body = Vec::new();
287 match header {
288 PlusHeader::Length(count) => {
291 reader
292 .take(count)
293 .read_to_end(&mut body)
294 .await
295 .map_err(|e| ClientError::Io(e.to_string()))?;
296 },
297 _ => {
298 reader
299 .read_to_end(&mut body)
300 .await
301 .map_err(|e| ClientError::Io(e.to_string()))?;
302 },
303 }
304
305 if matches!(header, PlusHeader::PeriodTerminated | PlusHeader::Error) {
306 body = strip_period_terminator(body);
307 }
308 Ok((header, body))
309}
310
311fn strip_period_terminator(mut body: Vec<u8>) -> Vec<u8> {
314 for terminator in [
315 b"\r\n.\r\n".as_slice(),
316 b"\n.\n".as_slice(),
317 b"\r\n.".as_slice(),
318 b"\n.".as_slice(),
319 ] {
320 if body.ends_with(terminator) {
321 let keep = if terminator.starts_with(b"\r\n") { 2 } else { 1 };
322 body.truncate(body.len() - terminator.len() + keep);
323 return body;
324 }
325 }
326 body
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332
333 fn split(u: &str) -> (char, String) {
334 split_path(&Url::parse(u).unwrap())
335 }
336
337 #[test]
338 fn root_path_is_a_menu() {
339 assert_eq!(split("gopher://example.org/"), ('1', String::new()));
340 assert_eq!(split("gopher://example.org"), ('1', String::new()));
341 }
342
343 #[test]
344 fn type_and_selector_split_at_the_first_char() {
345 assert_eq!(
346 split("gopher://example.org/0/about.txt"),
347 ('0', "/about.txt".into())
348 );
349 assert_eq!(split("gopher://example.org/1/dir"), ('1', "/dir".into()));
350 }
351
352 #[test]
353 fn mime_inference() {
354 assert_eq!(mime_for_item_type('0'), "text/plain");
355 assert_eq!(mime_for_item_type('1'), "application/gopher-menu");
356 assert_eq!(mime_for_item_type('9'), "application/octet-stream");
357 }
358
359 #[tokio::test]
360 async fn a_url_without_a_host_is_refused_before_connecting() {
361 let error = fetch("gopher:///0/x").await.unwrap_err();
362 assert!(matches!(error, ClientError::BadUrl(_)), "got {error:?}");
363 }
364
365 #[test]
366 fn plus_tokens_match_the_spec() {
367 assert_eq!(PlusRequest::Item(None).token(), "+");
368 assert_eq!(
369 PlusRequest::Item(Some("text/plain".into())).token(),
370 "+text/plain"
371 );
372 assert_eq!(PlusRequest::Attributes.token(), "!");
373 assert_eq!(PlusRequest::DirectoryAttributes.token(), "$");
374 }
375
376 #[test]
377 fn the_period_terminator_goes_but_the_last_newline_stays() {
378 assert_eq!(
379 strip_period_terminator(b"one\r\ntwo\r\n.\r\n".to_vec()),
380 b"one\r\ntwo\r\n".to_vec()
381 );
382 assert_eq!(
383 strip_period_terminator(b"one\ntwo\n.\n".to_vec()),
384 b"one\ntwo\n".to_vec()
385 );
386 }
387
388 #[test]
389 fn a_body_that_merely_ends_in_a_period_is_left_alone() {
390 assert_eq!(
391 strip_period_terminator(b"see fig. 1.".to_vec()),
392 b"see fig. 1.".to_vec()
393 );
394 }
395}