Skip to main content

finger_protocol/
client.rs

1//! The classic finger client (`finger://`, port 79, RFC 1288).
2//!
3//! The request is a username and a CRLF; the reply is free-form text meant for
4//! a human. There is no status line, no MIME type, and no structure: whatever
5//! the remote `fingerd` felt like printing is the answer. That looseness is
6//! why [WebFinger](crate::webfinger) exists.
7//!
8//! An empty username asks for a listing of everyone logged in, which most
9//! modern hosts refuse or answer emptily.
10//!
11//! ```no_run
12//! # async fn run() -> Result<(), finger_protocol::ClientError> {
13//! let reply = finger_protocol::fetch("finger://example.org/alice").await?;
14//! println!("{}", reply.text());
15//! # Ok(())
16//! # }
17//! ```
18
19use tokio::io::{AsyncReadExt, AsyncWriteExt};
20use tokio::net::TcpStream;
21use url::Url;
22
23/// Finger's well-known port.
24pub const DEFAULT_PORT: u16 = 79;
25
26/// What can go wrong fingering a host.
27#[derive(Clone, Debug, PartialEq, Eq)]
28pub enum ClientError {
29    /// The URL could not be parsed, or it lacks a host.
30    BadUrl(String),
31    /// The TCP connection could not be established.
32    Connect(String),
33    /// A read or write failed mid-exchange.
34    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/// One finger request.
50#[derive(Clone, Debug, Default, PartialEq, Eq)]
51pub struct Query {
52    /// The user to ask about. `None` requests the host's listing.
53    pub user: Option<String>,
54    /// RFC 1288's `/W` switch, asking the server for its longer answer. Servers
55    /// are free to ignore it.
56    pub verbose: bool,
57}
58
59impl Query {
60    /// A query for one user.
61    pub fn user(name: impl Into<String>) -> Self {
62        Self {
63            user: Some(name.into()),
64            verbose: false,
65        }
66    }
67
68    /// The same query with RFC 1288's `/W` switch set.
69    pub fn verbose(mut self) -> Self {
70        self.verbose = true;
71        self
72    }
73
74    /// The wire form: `{W}{S}{U}{C}` in RFC 1288's grammar.
75    ///
76    /// ```
77    /// use finger_protocol::Query;
78    ///
79    /// assert_eq!(Query::user("alice").wire(), "alice\r\n");
80    /// assert_eq!(Query::user("alice").verbose().wire(), "/W alice\r\n");
81    /// assert_eq!(Query::default().wire(), "\r\n");
82    /// ```
83    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/// A finger reply: free-form text, as bytes.
94///
95/// Kept as bytes rather than a `String` because RFC 1288 fixes no encoding and
96/// real servers answer in whatever the host's locale was in 1994.
97#[derive(Clone, Debug, PartialEq, Eq)]
98pub struct Response {
99    pub body: Vec<u8>,
100}
101
102impl Response {
103    /// The reply as text, replacing anything that is not valid UTF-8.
104    pub fn text(&self) -> std::borrow::Cow<'_, str> {
105        String::from_utf8_lossy(&self.body)
106    }
107}
108
109/// Fetch a `finger://` URL.
110///
111/// Both `finger://host/user` and `finger://user@host` name a user; a bare
112/// `finger://host/` asks for the listing.
113pub 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
122/// Run a finger query against a host directly.
123pub 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
139/// The user a finger URL names: the path if present, else the userinfo, else
140/// none for a host listing.
141fn 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}