proto/finger: add classic formatter, split up parser
This commit is contained in:
+21
-810
@@ -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<DateTime<Utc>> {
|
||||
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<FingerResponseStructuredUserEntry>),
|
||||
@@ -322,262 +231,11 @@ impl FingerResponseStructuredUserEntry {
|
||||
response: &RawFingerResponse,
|
||||
username: String,
|
||||
) -> anyhow::Result<Self> {
|
||||
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<String> = None;
|
||||
let mut office_phone: Option<String> = None;
|
||||
let mut home_phone: Option<String> = 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<Utc>),
|
||||
}
|
||||
|
||||
impl MailStatus {
|
||||
pub fn try_from_finger_response_lines(str: &str) -> anyhow::Result<Self> {
|
||||
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<Utc>,
|
||||
|
||||
/// 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<String>,
|
||||
|
||||
/// 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<TimeDelta>,
|
||||
|
||||
/// 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<Utc>,
|
||||
idle_time: TimeDelta,
|
||||
host: String,
|
||||
host: Option<String>,
|
||||
idle_time: Option<TimeDelta>,
|
||||
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<Duration> {
|
||||
// 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<Self> {
|
||||
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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
" {:<width$}",
|
||||
format_idle_time_for_finger(idle_time),
|
||||
width = max_tty_len + 1
|
||||
);
|
||||
}
|
||||
|
||||
if !session.messages_on {
|
||||
result += " (messages off)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(forward) = &entry.forward_status {
|
||||
result += &format!("Mail forwarded to {}\n", forward);
|
||||
}
|
||||
|
||||
if let Some(mail_status) = &entry.mail_status {
|
||||
match mail_status {
|
||||
MailStatus::NoMail => 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
|
||||
}
|
||||
@@ -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<DateTime<Utc>> {
|
||||
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<FingerResponseStructuredUserEntry> {
|
||||
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<String> {
|
||||
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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<String>, Option<String>, Option<String>)> {
|
||||
let mut office: Option<String> = None;
|
||||
let mut office_phone: Option<String> = None;
|
||||
let mut home_phone: Option<String> = 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<FingerResponseUserSession> {
|
||||
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<FingerResponseUserSession> {
|
||||
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<Duration> {
|
||||
// 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<String> {
|
||||
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<Option<MailStatus>> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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::<Utc>::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::<Utc>::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,
|
||||
))
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user