1use std::path::PathBuf;
2
3use chrono::{DateTime, Datelike, Duration, NaiveDate, NaiveTime, TimeDelta, Utc, Weekday};
4use itertools::Itertools;
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
8pub struct FingerRequest {
9 long: bool,
10 name: String,
11}
12
13impl FingerRequest {
14 pub fn new(long: bool, name: String) -> Self {
15 Self { long, name }
16 }
17
18 pub fn to_bytes(&self) -> Vec<u8> {
19 let mut result = Vec::new();
20 if self.long {
21 result.extend(b"/W ");
22 }
23
24 result.extend(self.name.as_bytes());
25 result.extend(b"\r\n");
26
27 result
28 }
29
30 pub fn from_bytes(bytes: &[u8]) -> Self {
31 let (long, name) = if &bytes[..3] == b"/W " {
32 (true, &bytes[3..])
33 } else {
34 (false, bytes)
35 };
36
37 let name = match name.strip_suffix(b"\r\n") {
38 Some(new_name) => new_name,
39 None => name,
40 };
41
42 Self::new(long, String::from_utf8_lossy(name).to_string())
43 }
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
47pub struct RawFingerResponse(String);
48
49impl RawFingerResponse {
50 pub fn new(content: String) -> Self {
51 Self(content)
52 }
53
54 pub fn get_inner(&self) -> &str {
55 &self.0
56 }
57
58 pub fn into_inner(self) -> String {
59 self.0
60 }
61
62 pub fn is_empty(&self) -> bool {
63 self.0.is_empty()
64 }
65
66 pub fn from_bytes(bytes: &[u8]) -> Self {
67 if bytes.is_empty() {
68 return Self(String::new());
69 }
70
71 fn normalize(c: u8) -> u8 {
72 if c == (b'\r' | 0x80) || c == (b'\n' | 0x80) {
73 c & 0x7f
74 } else {
75 c
76 }
77 }
78
79 let normalized: Vec<u8> = bytes
80 .iter()
81 .copied()
82 .map(normalize)
83 .chain(std::iter::once(normalize(*bytes.last().unwrap())))
84 .map_windows(|[a, b]| {
85 if *a == b'\r' && *b == b'\n' {
86 None
87 } else {
88 Some(*a)
89 }
90 })
91 .flatten()
92 .collect();
93
94 let result = String::from_utf8_lossy(&normalized).to_string();
95
96 Self(result)
97 }
98
99 pub fn to_bytes(&self) -> Vec<u8> {
100 let mut out = Vec::with_capacity(self.0.len() + 2);
101
102 for &b in self.0.as_bytes() {
103 if b == b'\n' {
104 out.extend_from_slice(b"\r\n");
105 } else {
106 out.push(b);
107 }
108 }
109
110 if !self.0.ends_with('\n') {
111 out.extend_from_slice(b"\r\n");
112 }
113
114 out
115 }
116}
117
118impl From<String> for RawFingerResponse {
119 fn from(s: String) -> Self {
120 Self::new(s)
121 }
122}
123
124impl From<&str> for RawFingerResponse {
125 fn from(s: &str) -> Self {
126 Self::new(s.to_string())
127 }
128}
129
130fn parse_bsd_finger_time(time: &str) -> anyhow::Result<DateTime<Utc>> {
132 let time_parts: Vec<_> = time.split_ascii_whitespace().collect();
133
134 let time = &time_parts[..time_parts.len() - 1].join(" ");
135 let _timezone = time_parts[time_parts.len() - 1];
136
137 let now = Utc::now();
138 let mut parts = time.split_whitespace();
139
140 let weekday = match parts.next() {
141 Some("Mon") => Weekday::Mon,
142 Some("Tue") => Weekday::Tue,
143 Some("Wed") => Weekday::Wed,
144 Some("Thu") => Weekday::Thu,
145 Some("Fri") => Weekday::Fri,
146 Some("Sat") => Weekday::Sat,
147 Some("Sun") => Weekday::Sun,
148 _ => anyhow::bail!("Invalid weekday in login time: {}", time),
149 };
150
151 let month = match parts.next() {
152 Some("Jan") => 1,
153 Some("Feb") => 2,
154 Some("Mar") => 3,
155 Some("Apr") => 4,
156 Some("May") => 5,
157 Some("Jun") => 6,
158 Some("Jul") => 7,
159 Some("Aug") => 8,
160 Some("Sep") => 9,
161 Some("Oct") => 10,
162 Some("Nov") => 11,
163 Some("Dec") => 12,
164 _ => anyhow::bail!("Invalid month in login time: {}", time),
165 };
166
167 let day: u32 = parts
168 .next()
169 .and_then(|d| d.parse().ok())
170 .ok_or_else(|| anyhow::anyhow!("Invalid day in login time: {}", time))?;
171
172 let time_part = parts
173 .next()
174 .ok_or_else(|| anyhow::anyhow!("Missing time in login time: {}", time))?;
175
176 let clock = NaiveTime::parse_from_str(time_part, "%H:%M").map_err(|e| {
177 anyhow::anyhow!(
178 "Failed to parse time component in login time: {}: {}",
179 time,
180 e
181 )
182 })?;
183
184 const MAX_YEARS_BACK: i32 = 10;
185
186 for offset in 0..=MAX_YEARS_BACK {
187 let year = now.year() - offset;
188
189 let date = match NaiveDate::from_ymd_opt(year, month, day) {
190 Some(d) => d,
191 None => continue,
192 };
193
194 if date.weekday() != weekday {
195 continue;
196 }
197
198 let dt = date.and_time(clock);
199
200 if dt <= now.naive_utc() {
201 return Ok(DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc));
206 }
207 }
208
209 Err(anyhow::anyhow!(
210 "Could not infer year for login time {} within {} years",
211 time,
212 MAX_YEARS_BACK
213 ))
214}
215
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
217pub struct FingerResponseUserEntry {
218 pub username: String,
220
221 pub full_name: String,
223
224 pub home_dir: PathBuf,
226
227 pub shell: PathBuf,
229
230 pub office: Option<String>,
232
233 pub office_phone: Option<String>,
235
236 pub home_phone: Option<String>,
238
239 pub never_logged_in: bool,
241
242 pub sessions: Vec<FingerResponseUserSession>,
244
245 pub forward_status: Option<String>,
247
248 pub mail_status: Option<MailStatus>,
250
251 pub pgp_key: Option<String>,
253
254 pub project: Option<String>,
256
257 pub plan: Option<String>,
259}
260
261impl FingerResponseUserEntry {
262 #[allow(clippy::too_many_arguments)]
263 pub fn new(
264 username: String,
265 full_name: String,
266 home_dir: PathBuf,
267 shell: PathBuf,
268 office: Option<String>,
269 office_phone: Option<String>,
270 home_phone: Option<String>,
271 never_logged_in: bool,
272 sessions: Vec<FingerResponseUserSession>,
273 forward_status: Option<String>,
274 mail_status: Option<MailStatus>,
275 pgp_key: Option<String>,
276 project: Option<String>,
277 plan: Option<String>,
278 ) -> Self {
279 debug_assert!(
280 !never_logged_in || sessions.is_empty(),
281 "User cannot be marked as never logged in while having active sessions"
282 );
283
284 Self {
285 username,
286 full_name,
287 home_dir,
288 shell,
289 office,
290 office_phone,
291 home_phone,
292 never_logged_in,
293 sessions,
294 forward_status,
295 mail_status,
296 pgp_key,
297 project,
298 plan,
299 }
300 }
301
302 pub fn try_from_raw_finger_response(
304 response: &RawFingerResponse,
305 username: String,
306 ) -> anyhow::Result<Self> {
307 let content = response.get_inner();
308 let lines: Vec<&str> = content.lines().collect();
309
310 if lines.len() < 2 {
311 return Err(anyhow::anyhow!(
312 "Unexpected finger response format for user {}",
313 username
314 ));
315 }
316
317 let first_line = lines[0];
318 let second_line = lines[1];
319
320 let full_name = first_line
321 .split("Name:")
322 .nth(1)
323 .ok_or_else(|| {
324 anyhow::anyhow!(
325 "Failed to parse full name from finger response for user {}",
326 username
327 )
328 })?
329 .trim()
330 .to_string();
331
332 let home_dir = second_line
333 .split("Directory:")
334 .nth(1)
335 .and_then(|s| s.split("Shell:").next())
336 .map(|s| s.trim())
337 .map(PathBuf::from)
338 .ok_or_else(|| {
339 anyhow::anyhow!(
340 "Failed to parse home directory from finger response for user {}",
341 username
342 )
343 })?;
344
345 let shell = second_line
346 .split("Shell:")
347 .nth(1)
348 .map(|s| s.trim())
349 .map(PathBuf::from)
350 .ok_or_else(|| {
351 anyhow::anyhow!(
352 "Failed to parse shell from finger response for user {}",
353 username
354 )
355 })?;
356
357 let mut current_index = 2;
358
359 let mut office: Option<String> = None;
360 let mut office_phone: Option<String> = None;
361 let mut home_phone: Option<String> = None;
362
363 if let Some(line) = lines.get(current_index)
365 && line.trim().starts_with("Office:")
366 {
367 let office_line = line.trim().trim_start_matches("Office:").trim();
368 if let Some((office_loc, phone)) = office_line.split_once(',') {
369 office = Some(office_loc.trim().to_string());
370 office_phone = Some(phone.trim().to_string());
371 } else {
372 office = Some(office_line.to_string());
373 }
374 current_index += 1;
375 }
376 if let Some(line) = lines.get(current_index)
377 && line.trim().starts_with("Office Phone:")
378 {
379 let phone = line.trim().trim_start_matches("Office Phone:").trim();
380 office_phone = Some(phone.to_string());
381 current_index += 1;
382 }
383 if let Some(line) = lines.get(current_index)
384 && line.trim().starts_with("Home Phone:")
385 {
386 let phone = line.trim().trim_start_matches("Home Phone:").trim();
387 home_phone = Some(phone.to_string());
388 current_index += 1;
389 }
390
391 let never_logged_in = lines
392 .iter()
393 .skip(current_index)
394 .take(1)
395 .any(|&line| line.trim() == "Never logged in.");
396
397 let sessions: Vec<_> = lines
398 .iter()
399 .skip(current_index)
400 .take_while(|line| line.starts_with("On since"))
401 .filter_map(|line| {
402 match FingerResponseUserSession::try_from_finger_response_line(line) {
403 Ok(session) => Some(session),
404 Err(_) => {
405 tracing::warn!("Failed to parse user session from line: {}", line);
406 None
407 }
408 }
409 })
410 .collect();
411
412 if never_logged_in {
413 debug_assert!(
414 sessions.is_empty(),
415 "User cannot be marked as never logged in while having active sessions"
416 );
417 }
418
419 current_index += if never_logged_in { 1 } else { sessions.len() };
420
421 let next_line = lines.get(current_index);
422
423 let forward_status = if let Some(line) = next_line
425 && line.trim().starts_with("Mail forwarded to ")
426 {
427 Some(line.trim().trim_prefix("Mail forwarded to ").to_string())
428 } else {
429 None
430 };
431
432 Ok(Self::new(
435 username,
436 full_name,
437 home_dir,
438 shell,
439 office,
440 office_phone,
441 home_phone,
442 never_logged_in,
443 sessions,
444 forward_status,
445 None,
446 None,
447 None,
448 None,
449 ))
450 }
451}
452
453#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
454pub enum MailStatus {
455 NoMail,
456 NewMailReceived(DateTime<Utc>),
457 UnreadSince(DateTime<Utc>),
458 MailLastRead(DateTime<Utc>),
459}
460
461impl MailStatus {
462 pub fn try_from_finger_response_line(str: &str) -> anyhow::Result<Self> {
463 if str.trim() == "No mail." {
464 Ok(Self::NoMail)
465 } else if str.trim().starts_with("New mail received") {
466 let datetime = parse_bsd_finger_time(str.trim().trim_prefix("New mail received "))?;
467 Ok(Self::NewMailReceived(datetime))
468 } else if str.trim().starts_with("Unread since") {
469 let datetime = parse_bsd_finger_time(str.trim().trim_prefix("Unread since "))?;
470 Ok(Self::UnreadSince(datetime))
471 } else if str.trim().starts_with("Mail last read") {
472 let datetime = parse_bsd_finger_time(str.trim().trim_prefix("Mail last read "))?;
473 Ok(Self::MailLastRead(datetime))
474 } else {
475 anyhow::bail!("")
476 }
477 }
478}
479
480#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
481pub struct FingerResponseUserSession {
482 pub tty: String,
484
485 pub login_time: DateTime<Utc>,
487
488 pub idle_time: TimeDelta,
490
491 pub host: String,
493
494 pub messages_on: bool,
496}
497
498impl FingerResponseUserSession {
499 pub fn new(
500 tty: String,
501 login_time: DateTime<Utc>,
502 idle_time: TimeDelta,
503 host: String,
504 messages_on: bool,
505 ) -> Self {
506 Self {
507 tty,
508 login_time,
509 idle_time,
510 host,
511 messages_on,
512 }
513 }
514
515 fn parse_idle_time(str: &str) -> anyhow::Result<Duration> {
517 if str.trim().is_empty() {
520 Ok(Duration::zero())
521 } else if str.contains(':') {
522 let parts: Vec<&str> = str.split(':').collect();
523 if parts.len() != 2 {
524 return Err(anyhow::anyhow!("Invalid idle time format: {}", str));
525 }
526 let hours: i64 = parts[0].parse().map_err(|e| {
527 anyhow::anyhow!("Failed to parse hours from idle time {}: {}", str, e)
528 })?;
529 let minutes: i64 = parts[1].parse().map_err(|e| {
530 anyhow::anyhow!("Failed to parse minutes from idle time {}: {}", str, e)
531 })?;
532 Ok(Duration::hours(hours) + Duration::minutes(minutes))
533 } else if str.ends_with('d') {
534 let days_str = str.trim_end_matches('d');
535 let days: i64 = days_str.parse().map_err(|e| {
536 anyhow::anyhow!("Failed to parse days from idle time {}: {}", str, e)
537 })?;
538 Ok(Duration::days(days))
539 } else {
540 let minutes: i64 = str.parse().map_err(|e| {
541 anyhow::anyhow!("Failed to parse minutes from idle time {}: {}", str, e)
542 })?;
543 Ok(Duration::minutes(minutes))
544 }
545 }
546
547 pub fn try_from_finger_response_line(line: &str) -> anyhow::Result<Self> {
549 let parts: Vec<&str> = line.split_whitespace().collect();
550
551 debug_assert!(parts[0] == "On");
552 debug_assert!(parts[1] == "since");
553
554 let login_time_str = parts
555 .iter()
556 .take_while(|&&s| s != "on")
557 .skip(2)
558 .cloned()
559 .join(" ");
560
561 let login_time = parse_bsd_finger_time(&login_time_str)?;
562
563 let tty = parts
564 .iter()
565 .skip_while(|&&s| s != "on")
566 .nth(1)
567 .ok_or_else(|| anyhow::anyhow!("Failed to find tty in finger session line: {line}"))?
568 .trim_end_matches(',')
569 .to_string();
570
571 let idle_time_str = parts
572 .iter()
573 .skip_while(|&&s| s != "idle")
574 .nth(1)
575 .ok_or_else(|| {
576 anyhow::anyhow!("Failed to find idle time in finger session line: {line}")
577 })?
578 .trim_end_matches(',');
579 let idle_time = Self::parse_idle_time(idle_time_str)?;
580
581 let host = parts
582 .iter()
583 .skip_while(|&&s| s != "from")
584 .nth(1)
585 .unwrap_or(&"")
586 .to_string();
587
588 let messages_on = !line.ends_with("(messages off)");
589
590 Ok(Self::new(tty, login_time, idle_time, host, messages_on))
591 }
592}
593
594#[cfg(test)]
595mod tests {
596 use chrono::Timelike;
597
598 use super::*;
599
600 #[test]
601 fn test_finger_raw_serialization_roundrip() {
602 let request = FingerRequest::new(true, "alice".to_string());
603 let bytes = request.to_bytes();
604 let deserialized = FingerRequest::from_bytes(&bytes);
605 assert_eq!(request, deserialized);
606
607 let request2 = FingerRequest::new(false, "bob".to_string());
608 let bytes2 = request2.to_bytes();
609 let deserialized2 = FingerRequest::from_bytes(&bytes2);
610 assert_eq!(request2, deserialized2);
611
612 let response = RawFingerResponse::new("Hello, World!\nThis is a test.\n".to_string());
613 let response_bytes = response.to_bytes();
614 let deserialized_response = RawFingerResponse::from_bytes(&response_bytes);
615 assert_eq!(response, deserialized_response);
616
617 let response2 = RawFingerResponse::new("Single line response\n".to_string());
618 let response_bytes2 = response2.to_bytes();
619 let deserialized_response2 = RawFingerResponse::from_bytes(&response_bytes2);
620 assert_eq!(response2, deserialized_response2);
621 }
622
623 #[test]
624 fn test_parse_bsd_finger_time() {
625 let cases = vec![
626 "Mon Mar 1 10:00 (UTC)",
627 "Tue Feb 28 23:59 (UTC)",
628 "Wed Dec 31 00:00 (UTC)",
629 "Wed Dec 31 00:00 (GMT)",
630 ];
631
632 for input in cases {
633 let datetime = parse_bsd_finger_time(input);
634 assert!(
635 datetime.is_ok(),
636 "Failed to parse datetime for input: {}",
637 input
638 );
639 }
640 }
641
642 #[test]
643 fn test_parse_idle_time() {
644 let cases = vec![(" ", 0), ("5", 5), ("1:30", 90), ("2d", 2880)];
645
646 for (input, expected_minutes) in cases {
647 let duration = FingerResponseUserSession::parse_idle_time(input).unwrap();
648 assert_eq!(
649 duration.num_minutes(),
650 expected_minutes,
651 "Failed on input: {}",
652 input
653 );
654 }
655 }
656
657 #[test]
658 fn test_finger_user_session_parsing() {
659 let line = "On since Mon Mar 1 10:00 (UTC) on pts/0, idle 5:00, from host.example.com";
660 let session = FingerResponseUserSession::try_from_finger_response_line(line).unwrap();
661 assert_eq!(session.tty, "pts/0");
662 assert_eq!(session.host, "host.example.com");
663 assert_eq!(session.login_time.weekday(), Weekday::Mon);
664 assert_eq!(session.login_time.hour(), 10);
665 assert_eq!(session.idle_time.num_minutes(), 300);
666 assert!(session.messages_on);
667
668 let line_off = "On since Mon Mar 1 10:00 (UTC) on pts/1, idle 10, from another.host.com (messages off)";
669 let session_off =
670 FingerResponseUserSession::try_from_finger_response_line(line_off).unwrap();
671 assert_eq!(session_off.tty, "pts/1");
672 assert_eq!(session_off.host, "another.host.com");
673 assert_eq!(session_off.login_time.weekday(), Weekday::Mon);
674 assert_eq!(session_off.login_time.hour(), 10);
675 assert_eq!(session_off.idle_time.num_minutes(), 10);
676 assert!(!session_off.messages_on);
677 }
678
679 #[test]
680 fn test_finger_user_entry_parsing_basic() {
681 let response_content = indoc::indoc! {"
682 Login: alice Name: Alice Wonderland
683 Directory: /home/alice Shell: /bin/bash
684 On since Mon Mar 1 10:00 (UTC) on pts/0, idle 5:00, from host.example.com
685 No Mail.
686 No Plan.
687 "}
688 .trim();
689
690 let response = RawFingerResponse::from(response_content.to_string());
691 let user_entry =
692 FingerResponseUserEntry::try_from_raw_finger_response(&response, "alice".to_string())
693 .unwrap();
694 assert_eq!(user_entry.username, "alice");
695 assert_eq!(user_entry.full_name, "Alice Wonderland");
696 assert_eq!(user_entry.home_dir, PathBuf::from("/home/alice"));
697 assert_eq!(user_entry.shell, PathBuf::from("/bin/bash"));
698 assert_eq!(user_entry.sessions.len(), 1);
699 assert_eq!(user_entry.sessions[0].tty, "pts/0");
700 assert_eq!(user_entry.sessions[0].host, "host.example.com");
701 }
702
703 #[test]
704 fn test_finger_user_entry_parsing_single_line_office_phone() {
705 let response_content = indoc::indoc! {"
706 Login: alice Name: Alice Wonderland
707 Directory: /home/alice Shell: /bin/bash
708 Office: 123 Main St, 012-345-6789
709 Home Phone: +0-123-456-7890
710 On since Mon Mar 1 10:00 (UTC) on pts/0, idle 5:00, from host.example.com
711 No Mail.
712 No Plan.
713 "}
714 .trim();
715
716 let response = RawFingerResponse::from(response_content.to_string());
717 let user_entry =
718 FingerResponseUserEntry::try_from_raw_finger_response(&response, "alice".to_string())
719 .unwrap();
720
721 assert_eq!(user_entry.office, Some("123 Main St".to_string()));
722 assert_eq!(user_entry.office_phone, Some("012-345-6789".to_string()));
723 assert_eq!(user_entry.home_phone, Some("+0-123-456-7890".to_string()));
724 }
725
726 #[test]
727 fn test_finger_user_entry_parsing_multiline_office_phone() {
728 let response_content = indoc::indoc! {"
729 Login: alice Name: Alice Wonderland
730 Directory: /home/alice Shell: /bin/bash
731 Office: 123 Main St
732 Office Phone: 012-345-6789
733 Home Phone: +0-123-456-7890
734 On since Mon Mar 1 10:00 (UTC) on pts/0, idle 5:00, from host.example.com
735 No Mail.
736 No Plan.
737 "}
738 .trim();
739
740 let response = RawFingerResponse::from(response_content.to_string());
741 let user_entry =
742 FingerResponseUserEntry::try_from_raw_finger_response(&response, "alice".to_string())
743 .unwrap();
744
745 assert_eq!(user_entry.office, Some("123 Main St".to_string()));
746 assert_eq!(user_entry.office_phone, Some("012-345-6789".to_string()));
747 assert_eq!(user_entry.home_phone, Some("+0-123-456-7890".to_string()));
748 }
749
750 #[test]
751 fn test_finger_user_entry_parsing_never_logged_in() {
752 let response_content = indoc::indoc! {"
753 Login: bob Name: Bob Builder
754 Directory: /home/bob Shell: /bin/zsh
755 Never logged in.
756 No Mail.
757 No Plan.
758 "}
759 .trim();
760
761 let response = RawFingerResponse::from(response_content.to_string());
762 let user_entry =
763 FingerResponseUserEntry::try_from_raw_finger_response(&response, "bob".to_string())
764 .unwrap();
765
766 assert!(user_entry.never_logged_in);
767 assert!(user_entry.sessions.is_empty());
768 }
769}