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
19// `Query` lives at the crate root: it is protocol vocabulary that the server
20// needs too, so it must not sit behind the `client` feature.
21pub use crate::Query;
22
23use tokio::io::{AsyncReadExt, AsyncWriteExt};
24use tokio::net::TcpStream;
25use url::Url;
26
27/// Finger's well-known port.
28pub const DEFAULT_PORT: u16 = 79;
29
30/// What can go wrong fingering a host.
31#[derive(Clone, Debug, PartialEq, Eq)]
32pub enum ClientError {
33    /// The URL could not be parsed, or it lacks a host.
34    BadUrl(String),
35    /// The TCP connection could not be established.
36    Connect(String),
37    /// A read or write failed mid-exchange.
38    Io(String),
39}
40
41impl std::fmt::Display for ClientError {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        match self {
44            Self::BadUrl(m) => write!(f, "bad url: {m}"),
45            Self::Connect(m) => write!(f, "connect: {m}"),
46            Self::Io(m) => write!(f, "io: {m}"),
47        }
48    }
49}
50
51impl std::error::Error for ClientError {}
52
53/// A finger reply: free-form text, as bytes.
54///
55/// Kept as bytes rather than a `String` because RFC 1288 fixes no encoding and
56/// real servers answer in whatever the host's locale was in 1994.
57#[derive(Clone, Debug, PartialEq, Eq)]
58pub struct Response {
59    pub body: Vec<u8>,
60}
61
62impl Response {
63    /// The reply as text, replacing anything that is not valid UTF-8.
64    pub fn text(&self) -> std::borrow::Cow<'_, str> {
65        String::from_utf8_lossy(&self.body)
66    }
67}
68
69/// Fetch a `finger://` URL.
70///
71/// Both `finger://host/user` and `finger://user@host` name a user; a bare
72/// `finger://host/` asks for the listing.
73pub async fn fetch(url: &str) -> Result<Response, ClientError> {
74    let url = Url::parse(url).map_err(|e| ClientError::BadUrl(e.to_string()))?;
75    let host = url
76        .host_str()
77        .ok_or_else(|| ClientError::BadUrl("finger URL has no host".into()))?;
78    let port = url.port().unwrap_or(DEFAULT_PORT);
79    query(host, port, &query_from_url(&url)).await
80}
81
82/// Run a finger query against a host directly.
83pub async fn query(host: &str, port: u16, request: &Query) -> Result<Response, ClientError> {
84    let mut stream = TcpStream::connect((host, port))
85        .await
86        .map_err(|e| ClientError::Connect(format!("tcp {host}:{port}: {e}")))?;
87    stream
88        .write_all(request.wire().as_bytes())
89        .await
90        .map_err(|e| ClientError::Io(e.to_string()))?;
91    let mut body = Vec::new();
92    stream
93        .read_to_end(&mut body)
94        .await
95        .map_err(|e| ClientError::Io(e.to_string()))?;
96    Ok(Response { body })
97}
98
99/// The user a finger URL names: the path if present, else the userinfo, else
100/// none for a host listing.
101fn query_from_url(url: &Url) -> Query {
102    let from_path = url.path().trim_start_matches('/');
103    let user = if !from_path.is_empty() {
104        Some(from_path.to_string())
105    } else if !url.username().is_empty() {
106        Some(url.username().to_string())
107    } else {
108        None
109    };
110    Query {
111        user,
112        verbose: false,
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    fn target(u: &str) -> Option<String> {
121        query_from_url(&Url::parse(u).unwrap()).user
122    }
123
124    #[test]
125    fn user_from_path_or_userinfo_or_listing() {
126        assert_eq!(target("finger://example.org/alice").as_deref(), Some("alice"));
127        assert_eq!(target("finger://bob@example.org").as_deref(), Some("bob"));
128        assert_eq!(target("finger://example.org/"), None);
129    }
130
131    #[test]
132    fn the_verbose_switch_rides_before_the_user() {
133        assert_eq!(Query::user("alice").verbose().wire(), "/W alice\r\n");
134        assert_eq!(Query::default().verbose().wire(), "/W\r\n");
135    }
136
137    #[tokio::test]
138    async fn a_url_without_a_host_is_refused_before_connecting() {
139        let error = fetch("finger:///alice").await.unwrap_err();
140        assert!(matches!(error, ClientError::BadUrl(_)), "got {error:?}");
141    }
142}