From 9c6a0dec2f4460077ec6e5dbcf617e672c8e8e3d Mon Sep 17 00:00:00 2001 From: h7x4 Date: Thu, 12 Feb 2026 10:23:11 +0900 Subject: [PATCH] fingerd: move parsing logic to `proto`, add support for more fields --- Cargo.lock | 16 ++ Cargo.toml | 1 + src/lib.rs | 1 + src/proto/finger_protocol.rs | 528 ++++++++++++++++++++++++++++++++++- src/server/fingerd.rs | 57 +--- 5 files changed, 541 insertions(+), 62 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 098d33e..b596cec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -287,6 +287,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "equivalent" version = "1.0.2" @@ -535,6 +541,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.17" @@ -848,6 +863,7 @@ dependencies = [ "clap_complete", "futures-util", "indoc", + "itertools", "nix 0.31.1", "sd-notify", "serde", diff --git a/Cargo.toml b/Cargo.toml index 3ec65bb..cedfdbd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ serde_json = "1.0.149" uucore = { version = "0.5.0", features = ["utmpx"] } zlink = { version = "0.3.0", features = ["introspection"] } clap_complete = "4.5.65" +itertools = "0.14.0" [features] default = ["systemd"] diff --git a/src/lib.rs b/src/lib.rs index 69cc830..ee5d18b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ #![feature(iter_map_windows)] #![feature(gethostname)] +#![feature(trim_prefix_suffix)] pub mod proto; pub mod server; diff --git a/src/proto/finger_protocol.rs b/src/proto/finger_protocol.rs index 5ddefef..4467d21 100644 --- a/src/proto/finger_protocol.rs +++ b/src/proto/finger_protocol.rs @@ -1,3 +1,7 @@ +use std::path::PathBuf; + +use chrono::{DateTime, Datelike, Duration, NaiveDate, NaiveTime, TimeDelta, Utc, Weekday}; +use itertools::Itertools; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -40,9 +44,9 @@ impl FingerRequest { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] -pub struct FingerResponse(String); +pub struct RawFingerResponse(String); -impl FingerResponse { +impl RawFingerResponse { pub fn new(content: String) -> Self { Self(content) } @@ -111,24 +115,422 @@ impl FingerResponse { } } -impl From for FingerResponse { +impl From for RawFingerResponse { fn from(s: String) -> Self { Self::new(s) } } -impl From<&str> for FingerResponse { +impl From<&str> for RawFingerResponse { fn from(s: &str) -> Self { Self::new(s.to_string()) } } +/// 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]; + + let now = Utc::now(); + 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 + ) + })?; + + const MAX_YEARS_BACK: i32 = 10; + + 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; + } + + let dt = date.and_time(clock); + + if dt <= now.naive_utc() { + // TODO: apply timezone if we are able to parse it. + // if not, try to get the local timezone offset. + // if not, assume UTC. + + return Ok(DateTime::::from_utc(dt, Utc)); + } + } + + Err(anyhow::anyhow!( + "Could not infer year for login time {} within {} years", + time, + MAX_YEARS_BACK + )) +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FingerResponseUserEntry { + /// The unix username of this user, as noted in passwd + pub username: String, + + /// The full name of this user, as noted in passwd + pub full_name: String, + + /// The path to the home directory of this user, as noted in passwd + pub home_dir: PathBuf, + + /// The path to the shell of this user, as noted in passwd + pub shell: PathBuf, + + /// Office location, if available + pub office: Option, + + /// Office phone number, if available + pub office_phone: Option, + + /// Home phone number, if available + pub home_phone: Option, + + /// Whether the user has never logged in to this host + pub never_logged_in: bool, + + /// A list of user sessions, sourced from utmp entries + pub sessions: Vec, + + /// Contents of ~/.pgpkey, if it exists + pub pgp_key: Option, + + /// Contents of ~/.forward, if it exists + pub forward_status: Option, + + /// Whether the user has new or unread mail + pub mail_status: Option, + + /// Contents of ~/.plan or ~/.project, if either exists + pub plan: Option, +} + +impl FingerResponseUserEntry { + pub fn new( + username: String, + full_name: String, + home_dir: PathBuf, + shell: PathBuf, + office: Option, + office_phone: Option, + home_phone: Option, + never_logged_in: bool, + sessions: Vec, + pgp_key: Option, + forward_status: Option, + mail_status: Option, + plan: Option, + ) -> Self { + debug_assert!( + !never_logged_in || sessions.is_empty(), + "User cannot be marked as never logged in while having active sessions" + ); + + Self { + username, + full_name, + home_dir, + shell, + office, + office_phone, + home_phone, + never_logged_in, + sessions, + pgp_key, + forward_status, + mail_status, + plan, + } + } + + /// Try parsing a [FingerResponseUserEntry] from the text format used by bsd-finger. + pub fn try_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 = 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 never_logged_in = lines + .iter() + .skip(2) + .take(1) + .any(|&line| line.trim() == "Never logged in."); + + let sessions = lines + .iter() + .skip(2) + .take_while(|line| line.starts_with("On since")) + .filter_map(|line| { + match FingerResponseUserSession::try_from_finger_response_line(line) { + Ok(session) => Some(session), + // TODO: log warning if parsing fails + Err(_) => None, + } + }) + .collect(); + + // TODO: parse forward_status, mail_status, plan from remaining lines + + Ok(Self::new( + username, + full_name, + home_dir, + shell, + None, + None, + None, + never_logged_in, + sessions, + None, + None, + None, + None, + )) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum MailStatus { + NoMail, + NewMailReceived(DateTime), + UnreadSince(DateTime), + MailLastRead(DateTime), +} + +impl MailStatus { + pub fn try_from_finger_response_line(str: &str) -> anyhow::Result { + if str.trim() == "No mail." { + Ok(Self::NoMail) + } else if str.trim().starts_with("New mail received") { + let datetime = parse_bsd_finger_time(str.trim().trim_prefix("New mail received "))?; + Ok(Self::NewMailReceived(datetime)) + } else if str.trim().starts_with("Unread since") { + let datetime = parse_bsd_finger_time(str.trim().trim_prefix("Unread since "))?; + Ok(Self::UnreadSince(datetime)) + } 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!("") + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FingerResponseUserSession { + /// The tty on which this session exists + pub tty: String, + + /// 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 of the machine where this session is running + pub host: String, +} + +impl FingerResponseUserSession { + pub fn new(tty: String, login_time: DateTime, idle_time: TimeDelta, host: String) -> Self { + Self { + tty, + login_time, + idle_time, + host, + } + } + + /// Parse the login time from the text string generated by bsd-finger + + /// 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(); + + Ok(Self { + tty, + login_time, + idle_time, + host, + }) + } +} + #[cfg(test)] mod tests { + use chrono::Timelike; + use super::*; #[test] - fn test_finger_serialization_roundrip() { + fn test_finger_raw_serialization_roundrip() { let request = FingerRequest::new(true, "alice".to_string()); let bytes = request.to_bytes(); let deserialized = FingerRequest::from_bytes(&bytes); @@ -139,14 +541,122 @@ mod tests { let deserialized2 = FingerRequest::from_bytes(&bytes2); assert_eq!(request2, deserialized2); - let response = FingerResponse::new("Hello, World!\nThis is a test.\n".to_string()); + let response = RawFingerResponse::new("Hello, World!\nThis is a test.\n".to_string()); let response_bytes = response.to_bytes(); - let deserialized_response = FingerResponse::from_bytes(&response_bytes); + let deserialized_response = RawFingerResponse::from_bytes(&response_bytes); assert_eq!(response, deserialized_response); - let response2 = FingerResponse::new("Single line response\n".to_string()); + let response2 = RawFingerResponse::new("Single line response\n".to_string()); let response_bytes2 = response2.to_bytes(); - let deserialized_response2 = FingerResponse::from_bytes(&response_bytes2); + 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)", + ]; + + 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); + } + + #[test] + fn test_basic_finger_user_entry_parsing() { + 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 = + FingerResponseUserEntry::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_single_line_office_phone_finger_user_entry_parsing() { + 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 = + FingerResponseUserEntry::try_from_raw_finger_response(&response, "alice".to_string()) + .unwrap(); + } + + #[test] + fn test_multiline_office_phone_finger_user_entry_parsing() { + 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 = + FingerResponseUserEntry::try_from_raw_finger_response(&response, "alice".to_string()) + .unwrap(); + } } diff --git a/src/server/fingerd.rs b/src/server/fingerd.rs index fee403c..931d3ed 100644 --- a/src/server/fingerd.rs +++ b/src/server/fingerd.rs @@ -14,7 +14,7 @@ use tokio::{ }; use uucore::utmpx::Utmpx; -use crate::proto::finger_protocol::{FingerRequest, FingerResponse}; +use crate::proto::finger_protocol::{FingerRequest, RawFingerResponse}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct FingerUserEntry { @@ -56,7 +56,7 @@ impl FingerUserEntry { /// Try parsing a FingerUserEntry from the text format used by the classic finger implementations. pub fn try_from_finger_response( - response: &FingerResponse, + response: &RawFingerResponse, username: String, ) -> anyhow::Result { let content = response.get_inner(); @@ -390,7 +390,7 @@ pub fn get_local_user(username: &str) -> anyhow::Result> /// Retrieve remote user information for the given username on the specified host. /// /// Returns None if the user does not exist or no information is available. -async fn get_remote_user(username: &str, host: &str) -> anyhow::Result> { +async fn get_remote_user(username: &str, host: &str) -> anyhow::Result> { let addr = format!("{}:79", host); let socket_addrs: Vec = addr.to_socket_addrs()?.collect(); @@ -412,7 +412,7 @@ async fn get_remote_user(username: &str, host: &str) -> anyhow::Result anyhow::Result