From 11b97cb1b91e1e1e9f240dcb70ec5428ae80a549 Mon Sep 17 00:00:00 2001 From: h7x4 Date: Thu, 30 Apr 2026 22:00:09 +0900 Subject: [PATCH] proto/finger: add classic formatter, split up parser --- src/proto/finger_protocol.rs | 831 +--------------- .../finger_protocol/classic_formatter.rs | 137 +++ src/proto/finger_protocol/parser.rs | 907 ++++++++++++++++++ src/server/fingerd/local_user_info.rs | 27 +- 4 files changed, 1080 insertions(+), 822 deletions(-) create mode 100644 src/proto/finger_protocol/classic_formatter.rs create mode 100644 src/proto/finger_protocol/parser.rs diff --git a/src/proto/finger_protocol.rs b/src/proto/finger_protocol.rs index 710d773..ef4668d 100644 --- a/src/proto/finger_protocol.rs +++ b/src/proto/finger_protocol.rs @@ -1,12 +1,16 @@ -use std::{path::PathBuf, str::FromStr}; +mod classic_formatter; +mod parser; -use anyhow::Context; -use chrono::{ - DateTime, Datelike, Duration, NaiveDate, NaiveTime, TimeDelta, TimeZone, Timelike, Utc, Weekday, -}; -use itertools::Itertools; +use std::path::PathBuf; + +use chrono::{DateTime, TimeDelta, Utc}; use serde::{Deserialize, Serialize}; +use crate::proto::finger_protocol::{ + classic_formatter::classic_format_finger_response_structured_user_entry, + parser::try_parse_structured_user_entry_from_raw_finger_response, +}; + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct FingerRequest { long: bool, @@ -130,101 +134,6 @@ impl From<&str> for RawFingerResponse { } } -/// Parse the time serialization format commonly used by bsd-finger -fn parse_bsd_finger_time(time: &str) -> anyhow::Result> { - let time_parts: Vec<_> = time.split_ascii_whitespace().collect(); - - let time = &time_parts[..time_parts.len() - 1].join(" "); - let timezone = time_parts[time_parts.len() - 1] - .trim_start_matches('(') - .trim_end_matches(')'); - - let tz = chrono_tz::Tz::from_str(timezone) - .context(format!("Failed to parse timezone in login time: {}", time))?; - - let mut parts = time.split_whitespace(); - - let weekday = match parts.next() { - Some("Mon") => Weekday::Mon, - Some("Tue") => Weekday::Tue, - Some("Wed") => Weekday::Wed, - Some("Thu") => Weekday::Thu, - Some("Fri") => Weekday::Fri, - Some("Sat") => Weekday::Sat, - Some("Sun") => Weekday::Sun, - _ => anyhow::bail!("Invalid weekday in login time: {}", time), - }; - - let month = match parts.next() { - Some("Jan") => 1, - Some("Feb") => 2, - Some("Mar") => 3, - Some("Apr") => 4, - Some("May") => 5, - Some("Jun") => 6, - Some("Jul") => 7, - Some("Aug") => 8, - Some("Sep") => 9, - Some("Oct") => 10, - Some("Nov") => 11, - Some("Dec") => 12, - _ => anyhow::bail!("Invalid month in login time: {}", time), - }; - - let day: u32 = parts - .next() - .and_then(|d| d.parse().ok()) - .ok_or_else(|| anyhow::anyhow!("Invalid day in login time: {}", time))?; - - let time_part = parts - .next() - .ok_or_else(|| anyhow::anyhow!("Missing time in login time: {}", time))?; - - let clock = NaiveTime::parse_from_str(time_part, "%H:%M").map_err(|e| { - anyhow::anyhow!( - "Failed to parse time component in login time: {}: {}", - time, - e - ) - })?; - - let now = Utc::now(); - const MAX_YEARS_BACK: i32 = 10; - let mut year = None; - for offset in 0..=MAX_YEARS_BACK { - let year_ = now.year() - offset; - - let date = match NaiveDate::from_ymd_opt(year_, month, day) { - Some(d) => d, - None => continue, - }; - - if date.weekday() != weekday { - continue; - } - - year = Some(year_); - } - - if year.is_none() { - return Err(anyhow::anyhow!( - "Could not find a valid year for login time {} within {} years", - time, - MAX_YEARS_BACK - )); - } - - tz.with_ymd_and_hms(year.unwrap(), month, day, clock.hour(), clock.minute(), 0) - .single() - .ok_or_else(|| { - anyhow::anyhow!( - "Failed to convert login time to timezone-aware datetime: {}", - time - ) - }) - .map(|dt| dt.with_timezone(&Utc)) -} - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum FingerResponseUserEntry { Structured(Box), @@ -322,262 +231,11 @@ impl FingerResponseStructuredUserEntry { response: &RawFingerResponse, username: String, ) -> anyhow::Result { - let content = response.get_inner(); - let lines: Vec<&str> = content.lines().collect(); + try_parse_structured_user_entry_from_raw_finger_response(response, username) + } - if lines.len() < 2 { - return Err(anyhow::anyhow!( - "Unexpected finger response format for user {}", - username - )); - } - - let first_line = lines[0]; - let second_line = lines[1]; - - let full_name = first_line - .split("Name:") - .nth(1) - .ok_or_else(|| { - anyhow::anyhow!( - "Failed to parse full name from finger response for user {}", - username - ) - })? - .trim() - .to_string(); - - let home_dir = second_line - .split("Directory:") - .nth(1) - .and_then(|s| s.split("Shell:").next()) - .map(|s| s.trim()) - .map(PathBuf::from) - .ok_or_else(|| { - anyhow::anyhow!( - "Failed to parse home directory from finger response for user {}", - username - ) - })?; - - let shell = second_line - .split("Shell:") - .nth(1) - .map(|s| s.trim()) - .map(PathBuf::from) - .ok_or_else(|| { - anyhow::anyhow!( - "Failed to parse shell from finger response for user {}", - username - ) - })?; - - let mut current_index = 2; - - let mut office: Option = None; - let mut office_phone: Option = None; - let mut home_phone: Option = None; - - // TODO: handle case where office details contains comma, use last comma as separator - if let Some(line) = lines.get(current_index) - && line.trim().starts_with("Office:") - { - let office_line = line.trim().trim_start_matches("Office:").trim(); - if let Some((office_loc, phone)) = office_line.split_once(',') { - office = Some(office_loc.trim().to_string()); - office_phone = Some(phone.trim().to_string()); - } else { - office = Some(office_line.to_string()); - } - current_index += 1; - } - if let Some(line) = lines.get(current_index) - && line.trim().starts_with("Office Phone:") - { - let phone = line.trim().trim_start_matches("Office Phone:").trim(); - office_phone = Some(phone.to_string()); - current_index += 1; - } - if let Some(line) = lines.get(current_index) - && line.trim().starts_with("Home Phone:") - { - let phone = line.trim().trim_start_matches("Home Phone:").trim(); - home_phone = Some(phone.to_string()); - current_index += 1; - } - - let never_logged_in = lines - .iter() - .skip(current_index) - .take(1) - .any(|&line| line.trim() == "Never logged in."); - - let sessions: Vec<_> = lines - .iter() - .skip(current_index) - .take_while(|line| line.starts_with("On since")) - .filter_map(|line| { - match FingerResponseUserSession::try_from_finger_response_line(line) { - Ok(session) => Some(session), - Err(_) => { - tracing::warn!("Failed to parse user session from line: {}", line); - None - } - } - }) - .collect(); - - if never_logged_in { - debug_assert!( - sessions.is_empty(), - "User cannot be marked as never logged in while having active sessions" - ); - } - - current_index += if never_logged_in { 1 } else { sessions.len() }; - - let next_line = lines.get(current_index); - - // TODO: handle multi-line case - let forward_status = if let Some(line) = next_line - && line.trim().starts_with("Mail forwarded to ") - { - current_index += 1; - Some(line.trim().trim_prefix("Mail forwarded to ").to_string()) - } else { - None - }; - - let next_line = lines.get(current_index); - - let mail_status = if let Some(line) = next_line - && line.trim().starts_with("New mail received") - { - current_index += 2; - let mail_status_str = - format!("{}\n{}", line, lines.get(current_index - 1).unwrap_or(&"")); - Some(MailStatus::try_from_finger_response_lines( - &mail_status_str, - )?) - } else if let Some(line) = next_line - && (line.trim().starts_with("Mail last read")) - { - current_index += 1; - Some(MailStatus::try_from_finger_response_lines(line)?) - } else if let Some(line) = next_line - && line.trim() == "No mail." - { - current_index += 1; - Some(MailStatus::NoMail) - } else { - tracing::warn!( - "Failed to parse mail status for user {} from line: {:?}", - username, - next_line - ); - None - }; - - let next_line = lines.get(current_index); - - let pgpkey = if let Some(line) = next_line - && line.trim().starts_with("PGP key:") - { - current_index += 1; - let mut pgp_lines = Vec::new(); - while let Some(line) = lines.get(current_index) { - let trimmed = line.trim(); - if trimmed.starts_with("Project:") || trimmed.starts_with("Plan:") { - break; - } - pgp_lines.push(trimmed); - current_index += 1; - } - Some(pgp_lines.join("\n")) - } else { - None - }; - - let next_line = lines.get(current_index); - - let project = if let Some(line) = next_line - && line.trim().starts_with("Project:") - { - if line.trim().trim_start_matches("Project:").trim().is_empty() { - current_index += 1; - - let mut project_lines = Vec::new(); - while let Some(line) = lines.get(current_index) { - let trimmed = line.trim(); - if trimmed.starts_with("Plan:") { - break; - } - project_lines.push(trimmed); - current_index += 1; - } - Some(project_lines.join("\n")) - } else { - current_index += 1; - Some( - line.trim() - .trim_start_matches("Project:") - .trim() - .to_string(), - ) - } - } else { - None - }; - - let next_line = lines.get(current_index); - - let plan = if let Some(line) = next_line - && line.trim().starts_with("Plan:") - { - if line.trim().trim_start_matches("Plan:").trim().is_empty() { - current_index += 1; - let mut plan_lines = Vec::new(); - while let Some(line) = lines.get(current_index) { - plan_lines.push(line.trim()); - current_index += 1; - } - Some(plan_lines.join("\n")) - } else { - current_index += 1; - Some(line.trim().trim_start_matches("Plan:").trim().to_string()) - } - } else if let Some(line) = next_line - && line.trim() == "No Plan." - { - current_index += 1; - None - } else { - None - }; - - debug_assert!( - current_index == lines.len(), - "Not all lines in finger response were parsed for user {}. Unparsed lines: {:?}", - username, - &lines[current_index..] - ); - - Ok(Self::new( - username, - full_name, - home_dir, - shell, - office, - office_phone, - home_phone, - never_logged_in, - sessions, - forward_status, - mail_status, - pgpkey, - project, - plan, - )) + pub fn classic_format(&self) -> String { + classic_format_finger_response_structured_user_entry(self) } } @@ -591,40 +249,6 @@ pub enum MailStatus { MailLastRead(DateTime), } -impl MailStatus { - pub fn try_from_finger_response_lines(str: &str) -> anyhow::Result { - if str.trim() == "No mail." { - Ok(Self::NoMail) - } else if str.trim().starts_with("New mail received") { - let mut lines = str.lines(); - let received_time_line = parse_bsd_finger_time( - lines - .next() - .ok_or_else(|| anyhow::anyhow!("Missing received time line in mail status"))? - .trim() - .trim_start_matches("New mail received "), - )?; - let unread_since_line = parse_bsd_finger_time( - lines - .next() - .ok_or_else(|| anyhow::anyhow!("Missing unread since line in mail status"))? - .trim() - .trim_start_matches("Unread since "), - )?; - - Ok(Self::NewMailReceived { - received_time: received_time_line, - unread_since: unread_since_line, - }) - } else if str.trim().starts_with("Mail last read") { - let datetime = parse_bsd_finger_time(str.trim().trim_prefix("Mail last read "))?; - Ok(Self::MailLastRead(datetime)) - } else { - anyhow::bail!("Unrecognized mail status line in finger response: {}", str); - } - } -} - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct FingerResponseUserSession { /// The tty on which this session exists @@ -633,11 +257,11 @@ pub struct FingerResponseUserSession { /// When the user logged in and created this session pub login_time: DateTime, - /// The amount of time since the use last interacted with the tty - pub idle_time: TimeDelta, + /// The hostname or address of the machine from which the user is logged in, if available + pub host: Option, - /// The hostname of the machine where this session is running - pub host: String, + /// The amount of time since the use last interacted with the tty + pub idle_time: Option, /// Whether this tty is writable, and thus can receive messages via `mesg(1)` pub messages_on: bool, @@ -647,102 +271,22 @@ impl FingerResponseUserSession { pub fn new( tty: String, login_time: DateTime, - idle_time: TimeDelta, - host: String, + host: Option, + idle_time: Option, messages_on: bool, ) -> Self { Self { tty, login_time, - idle_time, host, + idle_time, messages_on, } } - - /// Parse the idle time from the text string generated by bsd-finger - fn parse_idle_time(str: &str) -> anyhow::Result { - // Parse idle time from finger response format. - // This has four cases: " ", "MMMMM", "HH:MM", "DDd" - if str.trim().is_empty() { - Ok(Duration::zero()) - } else if str.contains(':') { - let parts: Vec<&str> = str.split(':').collect(); - if parts.len() != 2 { - return Err(anyhow::anyhow!("Invalid idle time format: {}", str)); - } - let hours: i64 = parts[0].parse().map_err(|e| { - anyhow::anyhow!("Failed to parse hours from idle time {}: {}", str, e) - })?; - let minutes: i64 = parts[1].parse().map_err(|e| { - anyhow::anyhow!("Failed to parse minutes from idle time {}: {}", str, e) - })?; - Ok(Duration::hours(hours) + Duration::minutes(minutes)) - } else if str.ends_with('d') { - let days_str = str.trim_end_matches('d'); - let days: i64 = days_str.parse().map_err(|e| { - anyhow::anyhow!("Failed to parse days from idle time {}: {}", str, e) - })?; - Ok(Duration::days(days)) - } else { - let minutes: i64 = str.parse().map_err(|e| { - anyhow::anyhow!("Failed to parse minutes from idle time {}: {}", str, e) - })?; - Ok(Duration::minutes(minutes)) - } - } - - /// Try parsing a [FingerResponseUserSession] from the text format used by bsd-finger. - pub fn try_from_finger_response_line(line: &str) -> anyhow::Result { - let parts: Vec<&str> = line.split_whitespace().collect(); - - debug_assert!(parts[0] == "On"); - debug_assert!(parts[1] == "since"); - - let login_time_str = parts - .iter() - .take_while(|&&s| s != "on") - .skip(2) - .cloned() - .join(" "); - - let login_time = parse_bsd_finger_time(&login_time_str)?; - - let tty = parts - .iter() - .skip_while(|&&s| s != "on") - .nth(1) - .ok_or_else(|| anyhow::anyhow!("Failed to find tty in finger session line: {line}"))? - .trim_end_matches(',') - .to_string(); - - let idle_time_str = parts - .iter() - .skip_while(|&&s| s != "idle") - .nth(1) - .ok_or_else(|| { - anyhow::anyhow!("Failed to find idle time in finger session line: {line}") - })? - .trim_end_matches(','); - let idle_time = Self::parse_idle_time(idle_time_str)?; - - let host = parts - .iter() - .skip_while(|&&s| s != "from") - .nth(1) - .unwrap_or(&"") - .to_string(); - - let messages_on = !line.ends_with("(messages off)"); - - Ok(Self::new(tty, login_time, idle_time, host, messages_on)) - } } #[cfg(test)] mod tests { - use chrono::{TimeZone, Timelike}; - use super::*; #[test] @@ -767,337 +311,4 @@ mod tests { let deserialized_response2 = RawFingerResponse::from_bytes(&response_bytes2); assert_eq!(response2, deserialized_response2); } - - #[test] - fn test_parse_bsd_finger_time() { - let cases = vec![ - "Mon Mar 1 10:00 (UTC)", - "Tue Feb 28 23:59 (UTC)", - "Wed Dec 31 00:00 (UTC)", - "Wed Dec 31 00:00 (GMT)", - "Wed Dec 31 00:00 (Asia/Tokyo)", - ]; - - for input in cases { - let datetime = parse_bsd_finger_time(input); - assert!( - datetime.is_ok(), - "Failed to parse datetime for input: {}", - input - ); - } - } - - #[test] - fn test_parse_idle_time() { - let cases = vec![(" ", 0), ("5", 5), ("1:30", 90), ("2d", 2880)]; - - for (input, expected_minutes) in cases { - let duration = FingerResponseUserSession::parse_idle_time(input).unwrap(); - assert_eq!( - duration.num_minutes(), - expected_minutes, - "Failed on input: {}", - input - ); - } - } - - #[test] - fn test_finger_user_session_parsing() { - let line = "On since Mon Mar 1 10:00 (UTC) on pts/0, idle 5:00, from host.example.com"; - let session = FingerResponseUserSession::try_from_finger_response_line(line).unwrap(); - assert_eq!(session.tty, "pts/0"); - assert_eq!(session.host, "host.example.com"); - assert_eq!(session.login_time.weekday(), Weekday::Mon); - assert_eq!(session.login_time.hour(), 10); - assert_eq!(session.idle_time.num_minutes(), 300); - assert!(session.messages_on); - - let line_off = "On since Mon Mar 1 10:00 (UTC) on pts/1, idle 10, from another.host.com (messages off)"; - let session_off = - FingerResponseUserSession::try_from_finger_response_line(line_off).unwrap(); - assert_eq!(session_off.tty, "pts/1"); - assert_eq!(session_off.host, "another.host.com"); - assert_eq!(session_off.login_time.weekday(), Weekday::Mon); - assert_eq!(session_off.login_time.hour(), 10); - assert_eq!(session_off.idle_time.num_minutes(), 10); - assert!(!session_off.messages_on); - } - - #[test] - fn test_finger_user_entry_parsing_basic() { - let response_content = indoc::indoc! {" - Login: alice Name: Alice Wonderland - Directory: /home/alice Shell: /bin/bash - On since Mon Mar 1 10:00 (UTC) on pts/0, idle 5:00, from host.example.com - No mail. - No Plan. - "} - .trim(); - - let response = RawFingerResponse::from(response_content.to_string()); - let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response( - &response, - "alice".to_string(), - ) - .unwrap(); - assert_eq!(user_entry.username, "alice"); - assert_eq!(user_entry.full_name, "Alice Wonderland"); - assert_eq!(user_entry.home_dir, PathBuf::from("/home/alice")); - assert_eq!(user_entry.shell, PathBuf::from("/bin/bash")); - assert_eq!(user_entry.sessions.len(), 1); - assert_eq!(user_entry.sessions[0].tty, "pts/0"); - assert_eq!(user_entry.sessions[0].host, "host.example.com"); - } - - #[test] - fn test_finger_user_entry_parsing_single_line_office_phone() { - let response_content = indoc::indoc! {" - Login: alice Name: Alice Wonderland - Directory: /home/alice Shell: /bin/bash - Office: 123 Main St, 012-345-6789 - Home Phone: +0-123-456-7890 - On since Mon Mar 1 10:00 (UTC) on pts/0, idle 5:00, from host.example.com - No mail. - No Plan. - "} - .trim(); - - let response = RawFingerResponse::from(response_content.to_string()); - let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response( - &response, - "alice".to_string(), - ) - .unwrap(); - - assert_eq!(user_entry.office, Some("123 Main St".to_string())); - assert_eq!(user_entry.office_phone, Some("012-345-6789".to_string())); - assert_eq!(user_entry.home_phone, Some("+0-123-456-7890".to_string())); - } - - #[test] - fn test_finger_user_entry_parsing_multiline_office_phone() { - let response_content = indoc::indoc! {" - Login: alice Name: Alice Wonderland - Directory: /home/alice Shell: /bin/bash - Office: 123 Main St - Office Phone: 012-345-6789 - Home Phone: +0-123-456-7890 - On since Mon Mar 1 10:00 (UTC) on pts/0, idle 5:00, from host.example.com - No mail. - No Plan. - "} - .trim(); - - let response = RawFingerResponse::from(response_content.to_string()); - let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response( - &response, - "alice".to_string(), - ) - .unwrap(); - - assert_eq!(user_entry.office, Some("123 Main St".to_string())); - assert_eq!(user_entry.office_phone, Some("012-345-6789".to_string())); - assert_eq!(user_entry.home_phone, Some("+0-123-456-7890".to_string())); - } - - #[test] - fn test_finger_user_entry_parsing_never_logged_in() { - let response_content = indoc::indoc! {" - Login: bob Name: Bob Builder - Directory: /home/bob Shell: /bin/zsh - Never logged in. - No mail. - No Plan. - "} - .trim(); - - let response = RawFingerResponse::from(response_content.to_string()); - let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response( - &response, - "bob".to_string(), - ) - .unwrap(); - - assert!(user_entry.never_logged_in); - assert!(user_entry.sessions.is_empty()); - } - - #[test] - fn test_finger_user_entry_parsing_no_mail() { - let response_content = indoc::indoc! {" - Login: bob Name: Bob Builder - Directory: /home/bob Shell: /bin/zsh - Never logged in. - No mail. - No Plan. - "} - .trim(); - - let response = RawFingerResponse::from(response_content.to_string()); - let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response( - &response, - "bob".to_string(), - ) - .unwrap(); - assert_eq!(user_entry.mail_status, Some(MailStatus::NoMail)); - } - - #[test] - fn test_finger_user_entry_parsing_new_mail_received() { - let response_content = indoc::indoc! {" - Login: bob Name: Bob Builder - Directory: /home/bob Shell: /bin/zsh - Never logged in. - New mail received Mon Mar 1 10:00 (UTC) - Unread since Mon Mar 1 09:00 (UTC) - No Plan. - "} - .trim(); - - let response = RawFingerResponse::from(response_content.to_string()); - let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response( - &response, - "bob".to_string(), - ) - .unwrap(); - assert_eq!( - user_entry.mail_status, - Some(MailStatus::NewMailReceived { - received_time: Utc.with_ymd_and_hms(2021, 3, 1, 10, 0, 0).unwrap(), - unread_since: Utc.with_ymd_and_hms(2021, 3, 1, 9, 0, 0).unwrap(), - }) - ); - } - - #[test] - fn test_finger_user_entry_parsing_mail_last_read() { - let response_content = indoc::indoc! {" - Login: bob Name: Bob Builder - Directory: /home/bob Shell: /bin/zsh - Never logged in. - Mail last read Mon Mar 1 10:00 (UTC) - No Plan. - "} - .trim(); - - let response = RawFingerResponse::from(response_content.to_string()); - let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response( - &response, - "bob".to_string(), - ) - .unwrap(); - assert_eq!( - user_entry.mail_status, - Some(MailStatus::MailLastRead( - Utc.with_ymd_and_hms(2021, 3, 1, 10, 0, 0).unwrap() - )) - ); - } - - #[test] - fn test_finger_user_entry_parsing_single_line_plan_project() { - let response_content = indoc::indoc! {" - Login: bob Name: Bob Builder - Directory: /home/bob Shell: /bin/zsh - Never logged in. - Mail last read Mon Mar 1 10:00 (UTC) - Project: Build a new house. - Plan: Build a new house. - "} - .trim(); - - let response = RawFingerResponse::from(response_content.to_string()); - let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response( - &response, - "bob".to_string(), - ) - .unwrap(); - - assert_eq!(user_entry.project, Some("Build a new house.".to_string())); - assert_eq!(user_entry.plan, Some("Build a new house.".to_string())); - } - - #[test] - fn test_finger_user_entry_parsing_multiline_pgp_plan_project() { - let response_content = indoc::indoc! {" - Login: bob Name: Bob Builder - Directory: /home/bob Shell: /bin/zsh - Never logged in. - Mail last read Mon Mar 1 10:00 (UTC) - PGP key: - -----BEGIN PGP KEY----- - Version: GnuPG v1 - ABCDEFGHIJKLMNOPQRSTUVWXYZ - -----END PGP KEY----- - Project: - Build a new house. - - Need to buy materials. - Plan: - Build a new house. - - Need to buy materials. - "} - .trim(); - - let response = RawFingerResponse::from(response_content.to_string()); - let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response( - &response, - "bob".to_string(), - ) - .unwrap(); - assert_eq!( - user_entry.pgp_key, - Some("-----BEGIN PGP KEY-----\nVersion: GnuPG v1\nABCDEFGHIJKLMNOPQRSTUVWXYZ\n-----END PGP KEY-----".to_string()), - ); - - assert_eq!( - user_entry.project, - Some("Build a new house.\n\nNeed to buy materials.".to_string()) - ); - assert_eq!( - user_entry.plan, - Some("Build a new house.\n\nNeed to buy materials.".to_string()) - ); - } - - #[test] - fn test_finger_user_entry_parsing_plan_keyword_in_plan() { - let response_content = indoc::indoc! {" - Login: bob Name: Bob Builder - Directory: /home/bob Shell: /bin/zsh - Never logged in. - Mail last read Mon Mar 1 10:00 (UTC) - Project: - I put an extra Plan: keyword here for kaos. - - :3:3:3 - Plan: - Build a new house. - - Plan: - Need to buy materials. - - The plan is to build a new house. - "} - .trim(); - - let response = RawFingerResponse::from(response_content.to_string()); - let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response( - &response, - "bob".to_string(), - ) - .unwrap(); - - assert_eq!( - user_entry.project, - Some("I put an extra Plan: keyword here for kaos.\n\n:3:3:3".to_string()) - ); - assert_eq!( - user_entry.plan, - Some("Build a new house.\n\nPlan:\nNeed to buy materials.\n\nThe plan is to build a new house.".to_string()) - ); - } } diff --git a/src/proto/finger_protocol/classic_formatter.rs b/src/proto/finger_protocol/classic_formatter.rs new file mode 100644 index 0000000..1bcb051 --- /dev/null +++ b/src/proto/finger_protocol/classic_formatter.rs @@ -0,0 +1,137 @@ +use chrono::{Duration, TimeDelta}; + +use crate::proto::finger_protocol::{FingerResponseStructuredUserEntry, MailStatus}; + +pub fn classic_format_finger_response_structured_user_entry( + entry: &FingerResponseStructuredUserEntry, +) -> String { + let mut result = String::new(); + + result += &format!( + "Login: {:<16}\t\t\tName: {}\n", + entry.username, entry.full_name + ); + result += &format!( + "Directory: {:<24}\tShell: {}\n", + entry.home_dir.display(), + entry.shell.display() + ); + + if let Some(office) = &entry.office { + result += &format!("Office: {}\n", office); + } + if let Some(office_phone) = &entry.office_phone { + result += &format!("Office Phone: {}\n", office_phone); + } + if let Some(home_phone) = &entry.home_phone { + result += &format!("Home Phone: {}\n", home_phone); + } + + if entry.never_logged_in { + result += "Never logged in.\n"; + } else { + let max_tty_len = entry + .sessions + .iter() + .map(|s| s.tty.len()) + .max() + .unwrap_or(0); + + for session in &entry.sessions { + result += &format!( + "On since {} on {}", + session.login_time.format("%a %b %e %H:%M (%Z)"), + session.tty, + ); + + if let Some(ref host) = session.host { + result += &format!("from {}\n", host); + } + + if let Some(idle_time) = session.idle_time { + result += &format!( + " {: result += "No mail.\n", + MailStatus::NewMailReceived { + received_time, + unread_since, + } => { + result += &format!( + "New mail received {}\nUnread since {}\n", + received_time.format("%a %b %e %H:%M (%Z)"), + unread_since.format("%a %b %e %H:%M (%Z)") + ); + } + MailStatus::MailLastRead(last_read) => { + result += &format!( + "Mail last read {}\n", + last_read.format("%a %b %e %H:%M (%Z)") + ); + } + } + } + + if let Some(pgp_key) = &entry.pgp_key { + result += &format!("PGP key:\n{}\n", pgp_key); + } + + if let Some(project) = &entry.project { + result += &format!("Project:\n{}\n", project); + } + + if let Some(plan) = &entry.plan { + result += &format!("Plan:\n{}\n", plan); + } else { + result += "No Plan.\n"; + } + + result.trim().to_string() +} + +fn format_idle_time_for_finger(idle_time: TimeDelta) -> String { + debug_assert!( + idle_time.num_seconds() >= 0, + "Idle time should never be negative" + ); + + let mut result = String::new(); + + let days = idle_time.num_days(); + let hours = (idle_time - Duration::days(days)).num_hours(); + let minutes = (idle_time - Duration::days(days) - Duration::hours(hours)).num_minutes(); + let seconds = + (idle_time - Duration::days(days) - Duration::hours(hours) - Duration::minutes(minutes)) + .num_seconds(); + + if days > 0 { + result += &format!("{} day{} ", days, if days == 1 { "" } else { "s" }); + } + if hours > 0 { + result += &format!("{} hour{} ", hours, if hours == 1 { "" } else { "s" }); + } + if minutes > 0 && days == 0 { + result += &format!("{} minute{} ", minutes, if minutes == 1 { "" } else { "s" }); + } + if seconds > 0 && days == 0 && hours == 0 { + result += &format!("{} second{} ", seconds, if seconds == 1 { "" } else { "s" }); + } + + result +} diff --git a/src/proto/finger_protocol/parser.rs b/src/proto/finger_protocol/parser.rs new file mode 100644 index 0000000..e80b128 --- /dev/null +++ b/src/proto/finger_protocol/parser.rs @@ -0,0 +1,907 @@ +use std::{path::PathBuf, str::FromStr}; + +use anyhow::Context; +use chrono::{ + DateTime, Datelike, Duration, NaiveDate, NaiveTime, TimeZone, Timelike, Utc, Weekday, +}; +use itertools::Itertools; + +use crate::proto::finger_protocol::{ + FingerResponseStructuredUserEntry, FingerResponseUserSession, MailStatus, RawFingerResponse, +}; + +/// Parse the time serialization format commonly used by bsd-finger +fn parse_bsd_finger_time(time: &str) -> anyhow::Result> { + let time_parts: Vec<_> = time.split_ascii_whitespace().collect(); + + let time = &time_parts[..time_parts.len() - 1].join(" "); + let timezone = time_parts[time_parts.len() - 1] + .trim_start_matches('(') + .trim_end_matches(')'); + + let tz = chrono_tz::Tz::from_str(timezone) + .context(format!("Failed to parse timezone in login time: {}", time))?; + + let mut parts = time.split_whitespace(); + + let weekday = match parts.next() { + Some("Mon") => Weekday::Mon, + Some("Tue") => Weekday::Tue, + Some("Wed") => Weekday::Wed, + Some("Thu") => Weekday::Thu, + Some("Fri") => Weekday::Fri, + Some("Sat") => Weekday::Sat, + Some("Sun") => Weekday::Sun, + _ => anyhow::bail!("Invalid weekday in login time: {}", time), + }; + + let month = match parts.next() { + Some("Jan") => 1, + Some("Feb") => 2, + Some("Mar") => 3, + Some("Apr") => 4, + Some("May") => 5, + Some("Jun") => 6, + Some("Jul") => 7, + Some("Aug") => 8, + Some("Sep") => 9, + Some("Oct") => 10, + Some("Nov") => 11, + Some("Dec") => 12, + _ => anyhow::bail!("Invalid month in login time: {}", time), + }; + + let day: u32 = parts + .next() + .and_then(|d| d.parse().ok()) + .ok_or_else(|| anyhow::anyhow!("Invalid day in login time: {}", time))?; + + let time_part = parts + .next() + .ok_or_else(|| anyhow::anyhow!("Missing time in login time: {}", time))?; + + let clock = NaiveTime::parse_from_str(time_part, "%H:%M").map_err(|e| { + anyhow::anyhow!( + "Failed to parse time component in login time: {}: {}", + time, + e + ) + })?; + + let now = Utc::now(); + const MAX_YEARS_BACK: i32 = 10; + let mut year = None; + for offset in 0..=MAX_YEARS_BACK { + let year_ = now.year() - offset; + + let date = match NaiveDate::from_ymd_opt(year_, month, day) { + Some(d) => d, + None => continue, + }; + + if date.weekday() != weekday { + continue; + } + + year = Some(year_); + } + + if year.is_none() { + return Err(anyhow::anyhow!( + "Could not find a valid year for login time {} within {} years", + time, + MAX_YEARS_BACK + )); + } + + tz.with_ymd_and_hms(year.unwrap(), month, day, clock.hour(), clock.minute(), 0) + .single() + .ok_or_else(|| { + anyhow::anyhow!( + "Failed to convert login time to timezone-aware datetime: {}", + time + ) + }) + .map(|dt| dt.with_timezone(&Utc)) +} + +pub fn try_parse_structured_user_entry_from_raw_finger_response( + response: &RawFingerResponse, + username: String, +) -> anyhow::Result { + let content = response.get_inner(); + let lines: Vec<&str> = content.lines().collect(); + + if lines.len() < 2 { + return Err(anyhow::anyhow!( + "Unexpected finger response format for user {}", + username + )); + } + + let first_line = lines[0]; + let second_line = lines[1]; + + let full_name = parse_full_name(first_line, &username)?; + let home_dir = parse_home_dir(second_line, &username)?; + let shell = parse_shell(second_line, &username)?; + + let mut current_index = 2; + + let (office, office_phone, home_phone) = parse_gecos_fields(&lines, &mut current_index)?; + + let never_logged_in = lines[current_index].trim() == "Never logged in."; + + let user_sessions = if never_logged_in { + current_index += 1; + vec![] + } else { + parse_user_sessions(&lines, &mut current_index) + }; + + let forward_status = parse_forward_status(&lines, &mut current_index); + let mail_status = parse_mail_status(&lines, &mut current_index)?; + let pgp_key = parse_pgp_key(&lines, &mut current_index); + let project = parse_project(&lines, &mut current_index); + let plan = parse_plan(&lines, &mut current_index); + + debug_assert!( + current_index == lines.len(), + "Not all lines in finger response were parsed for user {}. Unparsed lines: {:?}", + username, + &lines[current_index..] + ); + + Ok(FingerResponseStructuredUserEntry::new( + username, + full_name, + home_dir, + shell, + office, + office_phone, + home_phone, + never_logged_in, + user_sessions, + forward_status, + mail_status, + pgp_key, + project, + plan, + )) +} + +fn parse_full_name(first_line: &str, username: &str) -> anyhow::Result { + Ok(first_line + .split("Name:") + .nth(1) + .ok_or_else(|| { + anyhow::anyhow!( + "Failed to parse full name from finger response for user {}", + username + ) + })? + .trim() + .to_string()) +} + +fn parse_home_dir(second_line: &str, username: &str) -> anyhow::Result { + second_line + .split("Directory:") + .nth(1) + .and_then(|s| s.split("Shell:").next()) + .map(|s| s.trim()) + .map(PathBuf::from) + .ok_or_else(|| { + anyhow::anyhow!( + "Failed to parse home directory from finger response for user {}", + username + ) + }) +} + +fn parse_shell(second_line: &str, username: &str) -> anyhow::Result { + second_line + .split("Shell:") + .nth(1) + .map(|s| s.trim()) + .map(PathBuf::from) + .ok_or_else(|| { + anyhow::anyhow!( + "Failed to parse shell from finger response for user {}", + username + ) + }) +} + +fn parse_gecos_fields( + lines: &[&str], + current_index: &mut usize, +) -> anyhow::Result<(Option, Option, Option)> { + let mut office: Option = None; + let mut office_phone: Option = None; + let mut home_phone: Option = None; + + // TODO: handle case where office details contains comma, use last comma as separator + if let Some(line) = lines.get(*current_index) + && line.trim().starts_with("Office:") + { + let office_line = line.trim().trim_start_matches("Office:").trim(); + if let Some((office_loc, phone)) = office_line.split_once(',') { + office = Some(office_loc.trim().to_string()); + office_phone = Some(phone.trim().to_string()); + } else { + office = Some(office_line.to_string()); + } + *current_index += 1; + } + if let Some(line) = lines.get(*current_index) + && line.trim().starts_with("Office Phone:") + { + let phone = line.trim().trim_start_matches("Office Phone:").trim(); + office_phone = Some(phone.to_string()); + *current_index += 1; + } + if let Some(line) = lines.get(*current_index) + && line.trim().starts_with("Home Phone:") + { + let phone = line.trim().trim_start_matches("Home Phone:").trim(); + home_phone = Some(phone.to_string()); + *current_index += 1; + } + + Ok((office, office_phone, home_phone)) +} + +// TODO: take lines that start with "On since", and if they contain "from" then check if the next line contains "idle" and if so, join them +fn parse_user_sessions( + lines: &[&str], + current_index: &mut usize, +) -> Vec { + let sessions: Vec<_> = lines + .iter() + .skip(*current_index) + .take_while(|line| line.starts_with("On since")) + .filter_map(|line| match parse_user_session(line) { + Ok(session) => Some(session), + Err(_) => { + tracing::warn!("Failed to parse user session from line: {}", line); + None + } + }) + .collect(); + + *current_index += sessions.len(); + + sessions +} + +/// Try parsing a [FingerResponseUserSession] from the text format used by bsd-finger. +pub fn parse_user_session(line: &str) -> anyhow::Result { + let parts: Vec<&str> = line.split_whitespace().collect(); + + debug_assert!(parts[0] == "On"); + debug_assert!(parts[1] == "since"); + + let login_time_str = parts + .iter() + .take_while(|&&s| s != "on") + .skip(2) + .cloned() + .join(" "); + + let login_time = parse_bsd_finger_time(&login_time_str)?; + + let tty = parts + .iter() + .skip_while(|&&s| s != "on") + .nth(1) + .ok_or_else(|| anyhow::anyhow!("Failed to find tty in finger session line: {line}"))? + .trim_end_matches(',') + .to_string(); + + let host = parts + .iter() + .skip_while(|&&s| s != "from") + .nth(1) + .map(|s| s.to_string()); + + // FIXME: parse idle time + let idle_time = None; + + let messages_on = !line.ends_with("(messages off)"); + + Ok(FingerResponseUserSession::new( + tty, + login_time, + host, + idle_time, + messages_on, + )) +} + +/// Parse the idle time from the text string generated by bsd-finger +fn parse_user_session_idle_time(str: &str) -> anyhow::Result { + // Parse idle time from finger response format. + // This has four cases: " ", "MMMMM", "HH:MM", "DDd" + if str.trim().is_empty() { + Ok(Duration::zero()) + } else if str.contains(':') { + let parts: Vec<&str> = str.split(':').collect(); + if parts.len() != 2 { + return Err(anyhow::anyhow!("Invalid idle time format: {}", str)); + } + let hours: i64 = parts[0] + .parse() + .map_err(|e| anyhow::anyhow!("Failed to parse hours from idle time {}: {}", str, e))?; + let minutes: i64 = parts[1].parse().map_err(|e| { + anyhow::anyhow!("Failed to parse minutes from idle time {}: {}", str, e) + })?; + Ok(Duration::hours(hours) + Duration::minutes(minutes)) + } else if str.ends_with('d') { + let days_str = str.trim_end_matches('d'); + let days: i64 = days_str + .parse() + .map_err(|e| anyhow::anyhow!("Failed to parse days from idle time {}: {}", str, e))?; + Ok(Duration::days(days)) + } else { + let minutes: i64 = str.parse().map_err(|e| { + anyhow::anyhow!("Failed to parse minutes from idle time {}: {}", str, e) + })?; + Ok(Duration::minutes(minutes)) + } +} + +fn parse_forward_status(lines: &[&str], current_index: &mut usize) -> Option { + let next_line = lines.get(*current_index); + + // TODO: handle multi-line case + if let Some(line) = next_line + && line.trim().starts_with("Mail forwarded to ") + { + *current_index += 1; + Some(line.trim().trim_prefix("Mail forwarded to ").to_string()) + } else { + None + } +} + +fn parse_mail_status( + lines: &[&str], + current_index: &mut usize, +) -> anyhow::Result> { + let next_line = lines.get(*current_index); + + if let Some(line) = next_line + && line.trim().starts_with("New mail received") + { + let received_time_line = + parse_bsd_finger_time(line.trim().trim_start_matches("New mail received "))?; + + let unread_since_line = parse_bsd_finger_time( + lines + .get(*current_index + 1) + .ok_or_else(|| anyhow::anyhow!("Missing unread since line in mail status"))? + .trim() + .trim_start_matches("Unread since "), + )?; + + *current_index += 2; + + Ok(Some(MailStatus::NewMailReceived { + received_time: received_time_line, + unread_since: unread_since_line, + })) + } else if let Some(line) = next_line + && (line.trim().starts_with("Mail last read")) + { + *current_index += 1; + let datetime = parse_bsd_finger_time(line.trim().trim_prefix("Mail last read "))?; + Ok(Some(MailStatus::MailLastRead(datetime))) + } else if let Some(line) = next_line + && line.trim() == "No mail." + { + *current_index += 1; + Ok(Some(MailStatus::NoMail)) + } else { + tracing::warn!("Failed to parse mail status from line: {:?}", next_line); + Ok(None) + } +} + +fn parse_pgp_key(lines: &[&str], current_index: &mut usize) -> Option { + let next_line = lines.get(*current_index); + + if let Some(line) = next_line + && line.trim().starts_with("PGP key:") + { + *current_index += 1; + let mut pgp_lines = Vec::new(); + while let Some(line) = lines.get(*current_index) { + let trimmed = line.trim(); + if trimmed.starts_with("Project:") || trimmed.starts_with("Plan:") { + break; + } + pgp_lines.push(trimmed); + *current_index += 1; + } + Some(pgp_lines.join("\n")) + } else { + None + } +} + +fn parse_project(lines: &[&str], current_index: &mut usize) -> Option { + let next_line = lines.get(*current_index); + + if let Some(line) = next_line + && line.trim().starts_with("Project:") + { + if line.trim().trim_start_matches("Project:").trim().is_empty() { + *current_index += 1; + + let mut project_lines = Vec::new(); + while let Some(line) = lines.get(*current_index) { + let trimmed = line.trim(); + if trimmed.starts_with("Plan:") { + break; + } + project_lines.push(trimmed); + *current_index += 1; + } + Some(project_lines.join("\n")) + } else { + *current_index += 1; + Some( + line.trim() + .trim_start_matches("Project:") + .trim() + .to_string(), + ) + } + } else { + None + } +} + +fn parse_plan(lines: &[&str], current_index: &mut usize) -> Option { + let next_line = lines.get(*current_index); + + if let Some(line) = next_line + && line.trim().starts_with("Plan:") + { + if line.trim().trim_start_matches("Plan:").trim().is_empty() { + *current_index += 1; + let mut plan_lines = Vec::new(); + while let Some(line) = lines.get(*current_index) { + plan_lines.push(line.trim()); + *current_index += 1; + } + Some(plan_lines.join("\n")) + } else { + *current_index += 1; + Some(line.trim().trim_start_matches("Plan:").trim().to_string()) + } + } else if let Some(line) = next_line + && line.trim() == "No Plan." + { + *current_index += 1; + None + } else { + None + } +} + +#[cfg(test)] +mod tests { + use chrono::{TimeZone, Timelike}; + + use super::*; + + #[test] + fn test_parse_bsd_finger_time() { + let cases = vec![ + "Mon Mar 1 10:00 (UTC)", + "Tue Feb 28 23:59 (UTC)", + "Wed Dec 31 00:00 (UTC)", + "Wed Dec 31 00:00 (GMT)", + "Wed Dec 31 00:00 (Asia/Tokyo)", + ]; + + for input in cases { + let datetime = parse_bsd_finger_time(input); + assert!( + datetime.is_ok(), + "Failed to parse datetime for input: {}", + input + ); + } + } + + #[test] + fn test_parse_user_session_idle_time() { + let cases = vec![(" ", 0), ("5", 5), ("1:30", 90), ("2d", 2880)]; + + for (input, expected_minutes) in cases { + let duration = parse_user_session_idle_time(input).unwrap(); + assert_eq!( + duration.num_minutes(), + expected_minutes, + "Failed on input: {}", + input + ); + } + } + + #[test] + fn test_finger_user_session_parsing() { + let line = "On since Mon Mar 1 10:00 (UTC) on pts/0 from host.example.com"; + let session = parse_user_session(line).unwrap(); + assert_eq!(session.tty, "pts/0"); + assert_eq!(session.host, Some("host.example.com".into())); + assert_eq!(session.login_time.weekday(), Weekday::Mon); + assert_eq!(session.login_time.hour(), 10); + assert_eq!(session.idle_time, None); + assert!(session.messages_on); + + let line_off = + "On since Mon Mar 1 10:00 (UTC) on pts/1 from another.host.com (messages off)"; + let session_off = parse_user_session(line_off).unwrap(); + assert_eq!(session_off.tty, "pts/1"); + assert_eq!(session_off.host, Some("another.host.com".into())); + assert_eq!(session_off.login_time.weekday(), Weekday::Mon); + assert_eq!(session_off.login_time.hour(), 10); + assert_eq!(session_off.idle_time, None); + assert!(!session_off.messages_on); + + let line_idle = "On since Mon Mar 1 10:00 (UTC) on pts/2 1 day 5 hours 30 minutes idle"; + let session_idle = parse_user_session(line_idle).unwrap(); + assert_eq!(session_idle.tty, "pts/2"); + assert_eq!(session_idle.host, None); + assert_eq!(session_idle.login_time.weekday(), Weekday::Mon); + assert_eq!(session_idle.login_time.hour(), 10); + assert_eq!( + session_idle.idle_time.unwrap().num_minutes(), + 1 * 24 * 60 + 5 * 60 + 30 + ); + assert!(session_idle.messages_on); + + let line_idle_off = "On since Mon Mar 1 10:00 (UTC) on pts/3 from host.example.com 47 minutes 1 second idle (messages off)"; + let session_idle_off = parse_user_session(line_idle_off).unwrap(); + assert_eq!(session_idle_off.tty, "pts/3"); + assert_eq!(session_idle_off.host, Some("host.example.com".into())); + assert_eq!(session_idle_off.login_time.weekday(), Weekday::Mon); + assert_eq!(session_idle_off.login_time.hour(), 10); + assert_eq!(session_idle_off.idle_time.unwrap().num_minutes(), 47); + assert!(!session_idle_off.messages_on); + + let line_host_and_idle = indoc::indoc! {" + On since Mon Mar 1 10:00 (UTC) on pts/4 from host.example.com + 2 hours idle + "} + .trim(); + let session_host_and_idle = parse_user_session(line_host_and_idle).unwrap(); + assert_eq!(session_host_and_idle.tty, "pts/4"); + assert_eq!(session_host_and_idle.host, Some("host.example.com".into())); + assert_eq!(session_host_and_idle.login_time.weekday(), Weekday::Mon); + assert_eq!(session_host_and_idle.login_time.hour(), 10); + assert_eq!( + session_host_and_idle.idle_time.unwrap().num_minutes(), + 60 * 2 + ); + assert!(session_host_and_idle.messages_on); + } + + #[test] + fn test_finger_user_entry_parsing_basic() { + let response_content = indoc::indoc! {" + Login: alice Name: Alice Wonderland + Directory: /home/alice Shell: /bin/bash + On since Mon Mar 1 10:00 (UTC) on pts/0 from host.example.com + No mail. + No Plan. + "} + .trim(); + + let response = RawFingerResponse::from(response_content.to_string()); + let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response( + &response, + "alice".to_string(), + ) + .unwrap(); + assert_eq!(user_entry.username, "alice"); + assert_eq!(user_entry.full_name, "Alice Wonderland"); + assert_eq!(user_entry.home_dir, PathBuf::from("/home/alice")); + assert_eq!(user_entry.shell, PathBuf::from("/bin/bash")); + assert_eq!(user_entry.sessions.len(), 1); + assert_eq!(user_entry.sessions[0].tty, "pts/0"); + assert_eq!(user_entry.sessions[0].host, Some("host.example.com".into())); + } + + #[test] + fn test_finger_user_entry_parsing_idle_sessions() { + let response_content = indoc::indoc! {" + Login: alice Name: Alice Wonderland + Directory: /home/alice Shell: /bin/bash + On since Thu Apr 30 05:45 (CEST) on pts/4 from 10.0.0.192 + 6 hours 34 minutes idle + On since Thu Apr 30 05:46 (CEST) on pts/5 from 10.0.0.192 + 6 hours 33 minutes idle + No mail. + No Plan. + "} + .trim(); + + let response = RawFingerResponse::from(response_content.to_string()); + let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response( + &response, + "alice".to_string(), + ) + .unwrap(); + + assert_eq!(user_entry.sessions.len(), 2); + + assert_eq!(user_entry.sessions[0].tty, "pts/4"); + assert_eq!(user_entry.sessions[0].host, Some("10.0.0.192".into())); + assert_eq!( + user_entry.sessions[0].idle_time.unwrap().num_minutes(), + 6 * 60 + 34 + ); + + assert_eq!(user_entry.sessions[1].tty, "pts/5"); + assert_eq!(user_entry.sessions[1].host, Some("10.0.0.192".into())); + assert_eq!( + user_entry.sessions[1].idle_time.unwrap().num_minutes(), + 6 * 60 + 33 + ); + } + + #[test] + fn test_finger_user_entry_parsing_single_line_office_phone() { + let response_content = indoc::indoc! {" + Login: alice Name: Alice Wonderland + Directory: /home/alice Shell: /bin/bash + Office: 123 Main St, 012-345-6789 + Home Phone: +0-123-456-7890 + On since Mon Mar 1 10:00 (UTC) on pts/0, idle 5:00, from host.example.com + No mail. + No Plan. + "} + .trim(); + + let response = RawFingerResponse::from(response_content.to_string()); + let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response( + &response, + "alice".to_string(), + ) + .unwrap(); + + assert_eq!(user_entry.office, Some("123 Main St".to_string())); + assert_eq!(user_entry.office_phone, Some("012-345-6789".to_string())); + assert_eq!(user_entry.home_phone, Some("+0-123-456-7890".to_string())); + } + + #[test] + fn test_finger_user_entry_parsing_multiline_office_phone() { + let response_content = indoc::indoc! {" + Login: alice Name: Alice Wonderland + Directory: /home/alice Shell: /bin/bash + Office: 123 Main St + Office Phone: 012-345-6789 + Home Phone: +0-123-456-7890 + On since Mon Mar 1 10:00 (UTC) on pts/0, idle 5:00, from host.example.com + No mail. + No Plan. + "} + .trim(); + + let response = RawFingerResponse::from(response_content.to_string()); + let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response( + &response, + "alice".to_string(), + ) + .unwrap(); + + assert_eq!(user_entry.office, Some("123 Main St".to_string())); + assert_eq!(user_entry.office_phone, Some("012-345-6789".to_string())); + assert_eq!(user_entry.home_phone, Some("+0-123-456-7890".to_string())); + } + + #[test] + fn test_finger_user_entry_parsing_never_logged_in() { + let response_content = indoc::indoc! {" + Login: bob Name: Bob Builder + Directory: /home/bob Shell: /bin/zsh + Never logged in. + No mail. + No Plan. + "} + .trim(); + + let response = RawFingerResponse::from(response_content.to_string()); + let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response( + &response, + "bob".to_string(), + ) + .unwrap(); + + assert!(user_entry.never_logged_in); + assert!(user_entry.sessions.is_empty()); + } + + #[test] + fn test_finger_user_entry_parsing_no_mail() { + let response_content = indoc::indoc! {" + Login: bob Name: Bob Builder + Directory: /home/bob Shell: /bin/zsh + Never logged in. + No mail. + No Plan. + "} + .trim(); + + let response = RawFingerResponse::from(response_content.to_string()); + let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response( + &response, + "bob".to_string(), + ) + .unwrap(); + assert_eq!(user_entry.mail_status, Some(MailStatus::NoMail)); + } + + #[test] + fn test_finger_user_entry_parsing_new_mail_received() { + let response_content = indoc::indoc! {" + Login: bob Name: Bob Builder + Directory: /home/bob Shell: /bin/zsh + Never logged in. + New mail received Mon Mar 1 10:00 (UTC) + Unread since Mon Mar 1 09:00 (UTC) + No Plan. + "} + .trim(); + + let response = RawFingerResponse::from(response_content.to_string()); + let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response( + &response, + "bob".to_string(), + ) + .unwrap(); + assert_eq!( + user_entry.mail_status, + Some(MailStatus::NewMailReceived { + received_time: Utc.with_ymd_and_hms(2021, 3, 1, 10, 0, 0).unwrap(), + unread_since: Utc.with_ymd_and_hms(2021, 3, 1, 9, 0, 0).unwrap(), + }) + ); + } + + #[test] + fn test_finger_user_entry_parsing_mail_last_read() { + let response_content = indoc::indoc! {" + Login: bob Name: Bob Builder + Directory: /home/bob Shell: /bin/zsh + Never logged in. + Mail last read Mon Mar 1 10:00 (UTC) + No Plan. + "} + .trim(); + + let response = RawFingerResponse::from(response_content.to_string()); + let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response( + &response, + "bob".to_string(), + ) + .unwrap(); + assert_eq!( + user_entry.mail_status, + Some(MailStatus::MailLastRead( + Utc.with_ymd_and_hms(2021, 3, 1, 10, 0, 0).unwrap() + )) + ); + } + + #[test] + fn test_finger_user_entry_parsing_single_line_plan_project() { + let response_content = indoc::indoc! {" + Login: bob Name: Bob Builder + Directory: /home/bob Shell: /bin/zsh + Never logged in. + Mail last read Mon Mar 1 10:00 (UTC) + Project: Build a new house. + Plan: Build a new house. + "} + .trim(); + + let response = RawFingerResponse::from(response_content.to_string()); + let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response( + &response, + "bob".to_string(), + ) + .unwrap(); + + assert_eq!(user_entry.project, Some("Build a new house.".to_string())); + assert_eq!(user_entry.plan, Some("Build a new house.".to_string())); + } + + #[test] + fn test_finger_user_entry_parsing_multiline_pgp_plan_project() { + let response_content = indoc::indoc! {" + Login: bob Name: Bob Builder + Directory: /home/bob Shell: /bin/zsh + Never logged in. + Mail last read Mon Mar 1 10:00 (UTC) + PGP key: + -----BEGIN PGP KEY----- + Version: GnuPG v1 + ABCDEFGHIJKLMNOPQRSTUVWXYZ + -----END PGP KEY----- + Project: + Build a new house. + + Need to buy materials. + Plan: + Build a new house. + + Need to buy materials. + "} + .trim(); + + let response = RawFingerResponse::from(response_content.to_string()); + let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response( + &response, + "bob".to_string(), + ) + .unwrap(); + assert_eq!( + user_entry.pgp_key, + Some("-----BEGIN PGP KEY-----\nVersion: GnuPG v1\nABCDEFGHIJKLMNOPQRSTUVWXYZ\n-----END PGP KEY-----".to_string()), + ); + + assert_eq!( + user_entry.project, + Some("Build a new house.\n\nNeed to buy materials.".to_string()) + ); + assert_eq!( + user_entry.plan, + Some("Build a new house.\n\nNeed to buy materials.".to_string()) + ); + } + + #[test] + fn test_finger_user_entry_parsing_plan_keyword_in_plan() { + let response_content = indoc::indoc! {" + Login: bob Name: Bob Builder + Directory: /home/bob Shell: /bin/zsh + Never logged in. + Mail last read Mon Mar 1 10:00 (UTC) + Project: + I put an extra Plan: keyword here for kaos. + + :3:3:3 + Plan: + Build a new house. + + Plan: + Need to buy materials. + + The plan is to build a new house. + "} + .trim(); + + let response = RawFingerResponse::from(response_content.to_string()); + let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response( + &response, + "bob".to_string(), + ) + .unwrap(); + + assert_eq!( + user_entry.project, + Some("I put an extra Plan: keyword here for kaos.\n\n:3:3:3".to_string()) + ); + assert_eq!( + user_entry.plan, + Some("Build a new house.\n\nPlan:\nNeed to buy materials.\n\nThe plan is to build a new house.".to_string()) + ); + } +} diff --git a/src/server/fingerd/local_user_info.rs b/src/server/fingerd/local_user_info.rs index 387c15f..ccf4bff 100644 --- a/src/server/fingerd/local_user_info.rs +++ b/src/server/fingerd/local_user_info.rs @@ -170,28 +170,31 @@ fn get_local_user( let tty_device_stat = stat(&Path::new("/dev").join(entry.tty_device())).ok(); - let idle_time = tty_device_stat - .and_then(|st| { - let last_active = DateTime::::from_timestamp_secs(st.st_atime)?; - Some((now - last_active).max(Duration::zero())) - }) - .unwrap_or(Duration::zero()); + let idle_time = tty_device_stat.and_then(|st| { + let last_active = DateTime::::from_timestamp_secs(st.st_atime)?; + let result = (now - last_active).max(Duration::zero()); + if result == Duration::zero() { + None + } else { + debug_assert!( + result.num_seconds() >= 0, + "Idle time should never be negative" + ); + + Some(result) + } + }); // Check if the write permission for "others" is set let messages_on = tty_device_stat .map(|st| st.st_mode & 0o002 != 0) .unwrap_or(false); - debug_assert!( - idle_time.num_seconds() >= 0, - "Idle time should never be negative" - ); - Some(FingerResponseUserSession::new( entry.tty_device(), login_time, + Some(hostname.clone()), idle_time, - hostname.clone(), messages_on, )) })