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