finger_protocol/
client.rs1use tokio::io::{AsyncReadExt, AsyncWriteExt};
20use tokio::net::TcpStream;
21use url::Url;
22
23pub const DEFAULT_PORT: u16 = 79;
25
26#[derive(Clone, Debug, PartialEq, Eq)]
28pub enum ClientError {
29 BadUrl(String),
31 Connect(String),
33 Io(String),
35}
36
37impl std::fmt::Display for ClientError {
38 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39 match self {
40 Self::BadUrl(m) => write!(f, "bad url: {m}"),
41 Self::Connect(m) => write!(f, "connect: {m}"),
42 Self::Io(m) => write!(f, "io: {m}"),
43 }
44 }
45}
46
47impl std::error::Error for ClientError {}
48
49#[derive(Clone, Debug, Default, PartialEq, Eq)]
51pub struct Query {
52 pub user: Option<String>,
54 pub verbose: bool,
57}
58
59impl Query {
60 pub fn user(name: impl Into<String>) -> Self {
62 Self {
63 user: Some(name.into()),
64 verbose: false,
65 }
66 }
67
68 pub fn verbose(mut self) -> Self {
70 self.verbose = true;
71 self
72 }
73
74 pub fn wire(&self) -> String {
84 let user = self.user.as_deref().unwrap_or("");
85 match (self.verbose, user.is_empty()) {
86 (true, true) => "/W\r\n".to_string(),
87 (true, false) => format!("/W {user}\r\n"),
88 (false, _) => format!("{user}\r\n"),
89 }
90 }
91}
92
93#[derive(Clone, Debug, PartialEq, Eq)]
98pub struct Response {
99 pub body: Vec<u8>,
100}
101
102impl Response {
103 pub fn text(&self) -> std::borrow::Cow<'_, str> {
105 String::from_utf8_lossy(&self.body)
106 }
107}
108
109pub async fn fetch(url: &str) -> Result<Response, ClientError> {
114 let url = Url::parse(url).map_err(|e| ClientError::BadUrl(e.to_string()))?;
115 let host = url
116 .host_str()
117 .ok_or_else(|| ClientError::BadUrl("finger URL has no host".into()))?;
118 let port = url.port().unwrap_or(DEFAULT_PORT);
119 query(host, port, &query_from_url(&url)).await
120}
121
122pub async fn query(host: &str, port: u16, request: &Query) -> Result<Response, ClientError> {
124 let mut stream = TcpStream::connect((host, port))
125 .await
126 .map_err(|e| ClientError::Connect(format!("tcp {host}:{port}: {e}")))?;
127 stream
128 .write_all(request.wire().as_bytes())
129 .await
130 .map_err(|e| ClientError::Io(e.to_string()))?;
131 let mut body = Vec::new();
132 stream
133 .read_to_end(&mut body)
134 .await
135 .map_err(|e| ClientError::Io(e.to_string()))?;
136 Ok(Response { body })
137}
138
139fn query_from_url(url: &Url) -> Query {
142 let from_path = url.path().trim_start_matches('/');
143 let user = if !from_path.is_empty() {
144 Some(from_path.to_string())
145 } else if !url.username().is_empty() {
146 Some(url.username().to_string())
147 } else {
148 None
149 };
150 Query {
151 user,
152 verbose: false,
153 }
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 fn target(u: &str) -> Option<String> {
161 query_from_url(&Url::parse(u).unwrap()).user
162 }
163
164 #[test]
165 fn user_from_path_or_userinfo_or_listing() {
166 assert_eq!(target("finger://example.org/alice").as_deref(), Some("alice"));
167 assert_eq!(target("finger://bob@example.org").as_deref(), Some("bob"));
168 assert_eq!(target("finger://example.org/"), None);
169 }
170
171 #[test]
172 fn the_verbose_switch_rides_before_the_user() {
173 assert_eq!(Query::user("alice").verbose().wire(), "/W alice\r\n");
174 assert_eq!(Query::default().verbose().wire(), "/W\r\n");
175 }
176
177 #[tokio::test]
178 async fn a_url_without_a_host_is_refused_before_connecting() {
179 let error = fetch("finger:///alice").await.unwrap_err();
180 assert!(matches!(error, ClientError::BadUrl(_)), "got {error:?}");
181 }
182}