Skip to main content

roowho2_lib/proto/
finger_protocol.rs

1mod classic_formatter;
2mod parser;
3
4use std::path::PathBuf;
5
6use chrono::{DateTime, TimeDelta, Utc};
7use serde::{Deserialize, Serialize};
8
9use crate::{
10    proto::finger_protocol::{
11        classic_formatter::classic_format_finger_response_structured_user_entry,
12        parser::try_parse_structured_user_entry_from_raw_finger_response,
13    },
14    util::duration_serde,
15};
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct FingerRequest {
19    long: bool,
20    name: String,
21}
22
23impl FingerRequest {
24    pub fn new(long: bool, name: String) -> Self {
25        Self { long, name }
26    }
27
28    pub fn to_bytes(&self) -> Vec<u8> {
29        let mut result = Vec::new();
30        if self.long {
31            result.extend(b"/W ");
32        }
33
34        result.extend(self.name.as_bytes());
35        result.extend(b"\r\n");
36
37        result
38    }
39
40    pub fn from_bytes(bytes: &[u8]) -> Self {
41        let (long, name) = if &bytes[..3] == b"/W " {
42            (true, &bytes[3..])
43        } else {
44            (false, bytes)
45        };
46
47        let name = match name.strip_suffix(b"\r\n") {
48            Some(new_name) => new_name,
49            None => name,
50        };
51
52        Self::new(long, String::from_utf8_lossy(name).to_string())
53    }
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
57pub struct RawFingerResponse(String);
58
59impl RawFingerResponse {
60    pub fn new(content: String) -> Self {
61        Self(content)
62    }
63
64    pub fn get_inner(&self) -> &str {
65        &self.0
66    }
67
68    pub fn into_inner(self) -> String {
69        self.0
70    }
71
72    pub fn is_empty(&self) -> bool {
73        self.0.is_empty()
74    }
75
76    pub fn from_bytes(bytes: &[u8]) -> Self {
77        if bytes.is_empty() {
78            return Self(String::new());
79        }
80
81        fn normalize(c: u8) -> u8 {
82            if c == (b'\r' | 0x80) || c == (b'\n' | 0x80) {
83                c & 0x7f
84            } else {
85                c
86            }
87        }
88
89        let normalized: Vec<u8> = bytes
90            .iter()
91            .copied()
92            .map(normalize)
93            .chain(std::iter::once(normalize(*bytes.last().unwrap())))
94            .map_windows(|[a, b]| {
95                if *a == b'\r' && *b == b'\n' {
96                    None
97                } else {
98                    Some(*a)
99                }
100            })
101            .flatten()
102            .collect();
103
104        let result = String::from_utf8_lossy(&normalized).to_string();
105
106        Self(result)
107    }
108
109    pub fn to_bytes(&self) -> Vec<u8> {
110        let mut out = Vec::with_capacity(self.0.len() + 2);
111
112        for &b in self.0.as_bytes() {
113            if b == b'\n' {
114                out.extend_from_slice(b"\r\n");
115            } else {
116                out.push(b);
117            }
118        }
119
120        if !self.0.ends_with('\n') {
121            out.extend_from_slice(b"\r\n");
122        }
123
124        out
125    }
126}
127
128impl From<String> for RawFingerResponse {
129    fn from(s: String) -> Self {
130        Self::new(s)
131    }
132}
133
134impl From<&str> for RawFingerResponse {
135    fn from(s: &str) -> Self {
136        Self::new(s.to_string())
137    }
138}
139
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141pub enum FingerResponseUserEntry {
142    Structured(Box<FingerResponseStructuredUserEntry>),
143    Raw(String),
144}
145
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
147pub struct FingerResponseStructuredUserEntry {
148    /// The unix username of this user, as noted in passwd
149    pub username: String,
150
151    /// The full name of this user, as noted in passwd
152    pub full_name: String,
153
154    /// The path to the home directory of this user, as noted in passwd
155    pub home_dir: PathBuf,
156
157    /// The path to the shell of this user, as noted in passwd
158    pub shell: PathBuf,
159
160    /// Office location, if available
161    pub office: Option<String>,
162
163    /// Office phone number, if available
164    pub office_phone: Option<String>,
165
166    /// Home phone number, if available
167    pub home_phone: Option<String>,
168
169    /// Whether the user has never logged in to this host
170    pub never_logged_in: bool,
171
172    /// A list of user sessions, sourced from utmp entries
173    pub sessions: Vec<FingerResponseUserSession>,
174
175    /// Contents of ~/.forward, if it exists
176    pub forward_status: Option<String>,
177
178    /// Whether the user has new or unread mail
179    pub mail_status: Option<MailStatus>,
180
181    /// Contents of ~/.pgpkey, if it exists
182    pub pgp_key: Option<String>,
183
184    /// Contents of ~/.project, if it exists
185    pub project: Option<String>,
186
187    /// Contents of ~/.plan, if it exists
188    pub plan: Option<String>,
189}
190
191impl FingerResponseStructuredUserEntry {
192    #[allow(clippy::too_many_arguments)]
193    pub fn new(
194        username: String,
195        full_name: String,
196        home_dir: PathBuf,
197        shell: PathBuf,
198        office: Option<String>,
199        office_phone: Option<String>,
200        home_phone: Option<String>,
201        never_logged_in: bool,
202        sessions: Vec<FingerResponseUserSession>,
203        forward_status: Option<String>,
204        mail_status: Option<MailStatus>,
205        pgp_key: Option<String>,
206        project: Option<String>,
207        plan: Option<String>,
208    ) -> Self {
209        debug_assert!(
210            !never_logged_in || sessions.is_empty(),
211            "User cannot be marked as never logged in while having active sessions"
212        );
213
214        Self {
215            username,
216            full_name,
217            home_dir,
218            shell,
219            office,
220            office_phone,
221            home_phone,
222            never_logged_in,
223            sessions,
224            forward_status,
225            mail_status,
226            pgp_key,
227            project,
228            plan,
229        }
230    }
231
232    /// Try parsing a [FingerResponseUserEntry] from the text format used by bsd-finger.
233    pub fn try_from_raw_finger_response(
234        response: &RawFingerResponse,
235        username: String,
236    ) -> anyhow::Result<Self> {
237        try_parse_structured_user_entry_from_raw_finger_response(response, username)
238    }
239
240    pub fn classic_format(&self) -> String {
241        classic_format_finger_response_structured_user_entry(self)
242    }
243}
244
245#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
246pub enum MailStatus {
247    NoMail,
248    NewMailReceived {
249        #[serde(with = "chrono::serde::ts_seconds")]
250        received_time: DateTime<Utc>,
251        #[serde(with = "chrono::serde::ts_seconds")]
252        unread_since: DateTime<Utc>,
253    },
254    MailLastRead(#[serde(with = "chrono::serde::ts_seconds")] DateTime<Utc>),
255}
256
257#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
258pub struct FingerResponseUserSession {
259    /// The tty on which this session exists
260    pub tty: String,
261
262    /// When the user logged in and created this session
263    #[serde(with = "chrono::serde::ts_seconds")]
264    pub login_time: DateTime<Utc>,
265
266    /// The hostname or address of the machine from which the user is logged in, if available
267    pub host: Option<String>,
268
269    /// The amount of time since the use last interacted with the tty
270    #[serde(with = "duration_serde::duration_seconds_option")]
271    pub idle_time: Option<TimeDelta>,
272
273    /// Whether this tty is writable, and thus can receive messages via `mesg(1)`
274    pub messages_on: bool,
275}
276
277impl FingerResponseUserSession {
278    pub fn new(
279        tty: String,
280        login_time: DateTime<Utc>,
281        host: Option<String>,
282        idle_time: Option<TimeDelta>,
283        messages_on: bool,
284    ) -> Self {
285        Self {
286            tty,
287            login_time,
288            host,
289            idle_time,
290            messages_on,
291        }
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    #[test]
300    fn test_finger_raw_serialization_roundrip() {
301        let request = FingerRequest::new(true, "alice".to_string());
302        let bytes = request.to_bytes();
303        let deserialized = FingerRequest::from_bytes(&bytes);
304        assert_eq!(request, deserialized);
305
306        let request2 = FingerRequest::new(false, "bob".to_string());
307        let bytes2 = request2.to_bytes();
308        let deserialized2 = FingerRequest::from_bytes(&bytes2);
309        assert_eq!(request2, deserialized2);
310
311        let response = RawFingerResponse::new("Hello, World!\nThis is a test.\n".to_string());
312        let response_bytes = response.to_bytes();
313        let deserialized_response = RawFingerResponse::from_bytes(&response_bytes);
314        assert_eq!(response, deserialized_response);
315
316        let response2 = RawFingerResponse::new("Single line response\n".to_string());
317        let response_bytes2 = response2.to_bytes();
318        let deserialized_response2 = RawFingerResponse::from_bytes(&response_bytes2);
319        assert_eq!(response2, deserialized_response2);
320    }
321}