fingerd: move parsing logic to proto, add support for more fields
This commit is contained in:
Generated
+16
@@ -287,6 +287,12 @@ dependencies = [
|
|||||||
"windows-sys 0.60.2",
|
"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]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -535,6 +541,15 @@ version = "1.70.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itertools"
|
||||||
|
version = "0.14.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
|
||||||
|
dependencies = [
|
||||||
|
"either",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.17"
|
version = "1.0.17"
|
||||||
@@ -848,6 +863,7 @@ dependencies = [
|
|||||||
"clap_complete",
|
"clap_complete",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"indoc",
|
"indoc",
|
||||||
|
"itertools",
|
||||||
"nix 0.31.1",
|
"nix 0.31.1",
|
||||||
"sd-notify",
|
"sd-notify",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ serde_json = "1.0.149"
|
|||||||
uucore = { version = "0.5.0", features = ["utmpx"] }
|
uucore = { version = "0.5.0", features = ["utmpx"] }
|
||||||
zlink = { version = "0.3.0", features = ["introspection"] }
|
zlink = { version = "0.3.0", features = ["introspection"] }
|
||||||
clap_complete = "4.5.65"
|
clap_complete = "4.5.65"
|
||||||
|
itertools = "0.14.0"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["systemd"]
|
default = ["systemd"]
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#![feature(iter_map_windows)]
|
#![feature(iter_map_windows)]
|
||||||
#![feature(gethostname)]
|
#![feature(gethostname)]
|
||||||
|
#![feature(trim_prefix_suffix)]
|
||||||
|
|
||||||
pub mod proto;
|
pub mod proto;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
|
|||||||
@@ -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};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -40,9 +44,9 @@ impl FingerRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
#[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 {
|
pub fn new(content: String) -> Self {
|
||||||
Self(content)
|
Self(content)
|
||||||
}
|
}
|
||||||
@@ -111,24 +115,422 @@ impl FingerResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<String> for FingerResponse {
|
impl From<String> for RawFingerResponse {
|
||||||
fn from(s: String) -> Self {
|
fn from(s: String) -> Self {
|
||||||
Self::new(s)
|
Self::new(s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<&str> for FingerResponse {
|
impl From<&str> for RawFingerResponse {
|
||||||
fn from(s: &str) -> Self {
|
fn from(s: &str) -> Self {
|
||||||
Self::new(s.to_string())
|
Self::new(s.to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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];
|
||||||
|
|
||||||
|
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::<Utc>::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<String>,
|
||||||
|
|
||||||
|
/// Office phone number, if available
|
||||||
|
pub office_phone: Option<String>,
|
||||||
|
|
||||||
|
/// Home phone number, if available
|
||||||
|
pub home_phone: Option<String>,
|
||||||
|
|
||||||
|
/// 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<FingerResponseUserSession>,
|
||||||
|
|
||||||
|
/// Contents of ~/.pgpkey, if it exists
|
||||||
|
pub pgp_key: Option<String>,
|
||||||
|
|
||||||
|
/// Contents of ~/.forward, if it exists
|
||||||
|
pub forward_status: Option<String>,
|
||||||
|
|
||||||
|
/// Whether the user has new or unread mail
|
||||||
|
pub mail_status: Option<MailStatus>,
|
||||||
|
|
||||||
|
/// Contents of ~/.plan or ~/.project, if either exists
|
||||||
|
pub plan: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FingerResponseUserEntry {
|
||||||
|
pub fn new(
|
||||||
|
username: String,
|
||||||
|
full_name: String,
|
||||||
|
home_dir: PathBuf,
|
||||||
|
shell: PathBuf,
|
||||||
|
office: Option<String>,
|
||||||
|
office_phone: Option<String>,
|
||||||
|
home_phone: Option<String>,
|
||||||
|
never_logged_in: bool,
|
||||||
|
sessions: Vec<FingerResponseUserSession>,
|
||||||
|
pgp_key: Option<String>,
|
||||||
|
forward_status: Option<String>,
|
||||||
|
mail_status: Option<MailStatus>,
|
||||||
|
plan: Option<String>,
|
||||||
|
) -> 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<Self> {
|
||||||
|
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<Utc>),
|
||||||
|
UnreadSince(DateTime<Utc>),
|
||||||
|
MailLastRead(DateTime<Utc>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MailStatus {
|
||||||
|
pub fn try_from_finger_response_line(str: &str) -> anyhow::Result<Self> {
|
||||||
|
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<Utc>,
|
||||||
|
|
||||||
|
/// 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<Utc>, 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<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();
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
tty,
|
||||||
|
login_time,
|
||||||
|
idle_time,
|
||||||
|
host,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use chrono::Timelike;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_finger_serialization_roundrip() {
|
fn test_finger_raw_serialization_roundrip() {
|
||||||
let request = FingerRequest::new(true, "alice".to_string());
|
let request = FingerRequest::new(true, "alice".to_string());
|
||||||
let bytes = request.to_bytes();
|
let bytes = request.to_bytes();
|
||||||
let deserialized = FingerRequest::from_bytes(&bytes);
|
let deserialized = FingerRequest::from_bytes(&bytes);
|
||||||
@@ -139,14 +541,122 @@ mod tests {
|
|||||||
let deserialized2 = FingerRequest::from_bytes(&bytes2);
|
let deserialized2 = FingerRequest::from_bytes(&bytes2);
|
||||||
assert_eq!(request2, deserialized2);
|
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 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);
|
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 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);
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-53
@@ -14,7 +14,7 @@ use tokio::{
|
|||||||
};
|
};
|
||||||
use uucore::utmpx::Utmpx;
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct FingerUserEntry {
|
pub struct FingerUserEntry {
|
||||||
@@ -56,7 +56,7 @@ impl FingerUserEntry {
|
|||||||
|
|
||||||
/// Try parsing a FingerUserEntry from the text format used by the classic finger implementations.
|
/// Try parsing a FingerUserEntry from the text format used by the classic finger implementations.
|
||||||
pub fn try_from_finger_response(
|
pub fn try_from_finger_response(
|
||||||
response: &FingerResponse,
|
response: &RawFingerResponse,
|
||||||
username: String,
|
username: String,
|
||||||
) -> anyhow::Result<Self> {
|
) -> anyhow::Result<Self> {
|
||||||
let content = response.get_inner();
|
let content = response.get_inner();
|
||||||
@@ -390,7 +390,7 @@ pub fn get_local_user(username: &str) -> anyhow::Result<Option<FingerUserEntry>>
|
|||||||
/// Retrieve remote user information for the given username on the specified host.
|
/// 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.
|
/// Returns None if the user does not exist or no information is available.
|
||||||
async fn get_remote_user(username: &str, host: &str) -> anyhow::Result<Option<FingerResponse>> {
|
async fn get_remote_user(username: &str, host: &str) -> anyhow::Result<Option<RawFingerResponse>> {
|
||||||
let addr = format!("{}:79", host);
|
let addr = format!("{}:79", host);
|
||||||
let socket_addrs: Vec<SocketAddr> = addr.to_socket_addrs()?.collect();
|
let socket_addrs: Vec<SocketAddr> = addr.to_socket_addrs()?.collect();
|
||||||
|
|
||||||
@@ -412,7 +412,7 @@ async fn get_remote_user(username: &str, host: &str) -> anyhow::Result<Option<Fi
|
|||||||
let mut response_bytes = Vec::new();
|
let mut response_bytes = Vec::new();
|
||||||
stream.read_to_end(&mut response_bytes).await?;
|
stream.read_to_end(&mut response_bytes).await?;
|
||||||
|
|
||||||
let response = FingerResponse::from_bytes(&response_bytes);
|
let response = RawFingerResponse::from_bytes(&response_bytes);
|
||||||
|
|
||||||
if response.is_empty() {
|
if response.is_empty() {
|
||||||
Ok(None)
|
Ok(None)
|
||||||
@@ -425,55 +425,6 @@ async fn get_remote_user(username: &str, host: &str) -> anyhow::Result<Option<Fi
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[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 = FingerUserSession::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 = FingerUserSession::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_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 = FingerResponse::from(response_content.to_string());
|
|
||||||
let user_entry =
|
|
||||||
FingerUserEntry::try_from_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]
|
#[test]
|
||||||
fn test_finger_root() {
|
fn test_finger_root() {
|
||||||
let user_entry = get_local_user("root").unwrap().unwrap();
|
let user_entry = get_local_user("root").unwrap().unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user