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