fingerd: use structs from proto, fix clippy lints
Build and test / build (push) Successful in 1m37s
Build and test / check (push) Successful in 1m50s
Build and test / test (push) Successful in 2m10s
Build and test / docs (push) Successful in 3m10s

This commit is contained in:
2026-02-12 11:05:05 +09:00
parent 23d2611bff
commit 2c646af236
3 changed files with 53 additions and 350 deletions
+6 -5
View File
@@ -202,7 +202,7 @@ fn parse_bsd_finger_time(time: &str) -> anyhow::Result<DateTime<Utc>> {
// if not, try to get the local timezone offset. // if not, try to get the local timezone offset.
// if not, assume UTC. // if not, assume UTC.
return Ok(DateTime::<Utc>::from_utc(dt, Utc)); return Ok(DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc));
} }
} }
@@ -259,6 +259,7 @@ pub struct FingerResponseUserEntry {
} }
impl FingerResponseUserEntry { impl FingerResponseUserEntry {
#[allow(clippy::too_many_arguments)]
pub fn new( pub fn new(
username: String, username: String,
full_name: String, full_name: String,
@@ -400,8 +401,10 @@ impl FingerResponseUserEntry {
.filter_map(|line| { .filter_map(|line| {
match FingerResponseUserSession::try_from_finger_response_line(line) { match FingerResponseUserSession::try_from_finger_response_line(line) {
Ok(session) => Some(session), Ok(session) => Some(session),
// TODO: log warning if parsing fails Err(_) => {
Err(_) => None, tracing::warn!("Failed to parse user session from line: {}", line);
None
}
} }
}) })
.collect(); .collect();
@@ -509,8 +512,6 @@ impl FingerResponseUserSession {
} }
} }
/// Parse the login time from the text string generated by bsd-finger
/// Parse the idle 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> { fn parse_idle_time(str: &str) -> anyhow::Result<Duration> {
// Parse idle time from finger response format. // Parse idle time from finger response format.
+44 -342
View File
@@ -1,321 +1,15 @@
use std::{ use std::{net::hostname, path::Path};
net::{SocketAddr, ToSocketAddrs, hostname},
path::{Path, PathBuf},
};
use chrono::{ use chrono::{DateTime, Duration, Timelike, Utc};
DateTime, Datelike, Duration, NaiveDate, NaiveTime, TimeDelta, Timelike, Utc, Weekday,
};
use nix::sys::stat::stat; use nix::sys::stat::stat;
use serde::{Deserialize, Serialize};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpStream,
};
use uucore::utmpx::Utmpx; use uucore::utmpx::Utmpx;
use crate::proto::finger_protocol::{FingerRequest, RawFingerResponse}; use crate::proto::finger_protocol::{FingerResponseUserEntry, FingerResponseUserSession};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FingerUserEntry {
pub username: String,
pub full_name: String,
pub home_dir: PathBuf,
pub shell: PathBuf,
pub sessions: Vec<FingerUserSession>,
pub pgp_key: Option<String>,
pub forward_status: Option<String>,
pub mail_status: Option<String>,
pub plan: Option<String>,
}
impl FingerUserEntry {
pub fn new(
username: String,
full_name: String,
home_dir: PathBuf,
shell: PathBuf,
sessions: Vec<FingerUserSession>,
pgp_key: Option<String>,
forward_status: Option<String>,
mail_status: Option<String>,
plan: Option<String>,
) -> Self {
Self {
username,
full_name,
home_dir,
shell,
sessions,
pgp_key,
forward_status,
mail_status,
plan,
}
}
/// Try parsing a FingerUserEntry from the text format used by the classic finger implementations.
pub fn try_from_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 sessions = lines
.iter()
.skip(2)
.take_while(|line| line.starts_with("On since"))
.filter_map(|line| {
match FingerUserSession::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, sessions, None, None, None, None,
))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FingerUserSession {
pub tty: String,
pub login_time: DateTime<Utc>,
pub idle_time: TimeDelta,
pub host: String,
}
impl FingerUserSession {
pub fn new(tty: String, login_time: DateTime<Utc>, idle_time: TimeDelta, host: String) -> Self {
Self {
tty,
login_time,
idle_time,
host,
}
}
fn parse_login_time(time: &str, _timezone: &str) -> anyhow::Result<DateTime<Utc>> {
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
))
}
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 user session from the text format used by the classic finger implementations.
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_parts = parts
.iter()
.take_while(|&&s| s != "on")
.skip(2)
.cloned()
.collect::<Vec<&str>>();
let login_time = Self::parse_login_time(
&login_time_parts[..login_time_parts.len() - 1].join(" "),
login_time_parts[login_time_parts.len() - 1],
)?;
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,
})
}
}
/// Retrieve local user information for the given username. /// Retrieve local user information for the given username.
/// ///
/// Returns None if the user does not exist. /// Returns None if the user does not exist.
pub fn get_local_user(username: &str) -> anyhow::Result<Option<FingerUserEntry>> { pub fn get_local_user(username: &str) -> anyhow::Result<Option<FingerResponseUserEntry>> {
let username = username.to_string(); let username = username.to_string();
let user_entry = match nix::unistd::User::from_name(&username) { let user_entry = match nix::unistd::User::from_name(&username) {
Ok(Some(user)) => user, Ok(Some(user)) => user,
@@ -336,7 +30,7 @@ pub fn get_local_user(username: &str) -> anyhow::Result<Option<FingerUserEntry>>
let hostname = hostname()?.to_str().unwrap_or("localhost").to_string(); let hostname = hostname()?.to_str().unwrap_or("localhost").to_string();
let now = Utc::now().with_nanosecond(0).unwrap_or(Utc::now()); let now = Utc::now().with_nanosecond(0).unwrap_or(Utc::now());
let sessions: Vec<FingerUserSession> = Utmpx::iter_all_records() let sessions: Vec<FingerResponseUserSession> = Utmpx::iter_all_records()
.filter(|entry| entry.user() == username) .filter(|entry| entry.user() == username)
.filter(|entry| entry.is_user_process()) .filter(|entry| entry.is_user_process())
.filter_map(|entry| { .filter_map(|entry| {
@@ -345,24 +39,31 @@ pub fn get_local_user(username: &str) -> anyhow::Result<Option<FingerUserEntry>>
.checked_to_utc() .checked_to_utc()
.and_then(|t| DateTime::<Utc>::from_timestamp_secs(t.unix_timestamp()))?; .and_then(|t| DateTime::<Utc>::from_timestamp_secs(t.unix_timestamp()))?;
let idle_time = stat(&Path::new("/dev").join(entry.tty_device())) let tty_device_stat = stat(&Path::new("/dev").join(entry.tty_device())).ok();
.ok()
let idle_time = tty_device_stat
.and_then(|st| { .and_then(|st| {
let last_active = DateTime::<Utc>::from_timestamp_secs(st.st_atime)?; let last_active = DateTime::<Utc>::from_timestamp_secs(st.st_atime)?;
Some((now - last_active).max(Duration::zero())) Some((now - last_active).max(Duration::zero()))
}) })
.unwrap_or(Duration::zero()); .unwrap_or(Duration::zero());
// 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!( debug_assert!(
idle_time.num_seconds() >= 0, idle_time.num_seconds() >= 0,
"Idle time should never be negative" "Idle time should never be negative"
); );
Some(FingerUserSession::new( Some(FingerResponseUserSession::new(
entry.tty_device(), entry.tty_device(),
login_time, login_time,
idle_time, idle_time,
hostname.clone(), hostname.clone(),
messages_on,
)) ))
}) })
.collect(); .collect();
@@ -382,44 +83,45 @@ pub fn get_local_user(username: &str) -> anyhow::Result<Option<FingerUserEntry>>
None None
}; };
Ok(Some(FingerUserEntry::new( Ok(Some(FingerResponseUserEntry::new(
username, full_name, home_dir, shell, sessions, None, forward, plan, None, username, full_name, home_dir, shell, None, None, None, false, sessions, forward, None,
None, None, plan,
))) )))
} }
/// 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<RawFingerResponse>> { // 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();
if socket_addrs.is_empty() { // if socket_addrs.is_empty() {
return Err(anyhow::anyhow!( // return Err(anyhow::anyhow!(
"Could not resolve address for host {}", // "Could not resolve address for host {}",
host // host
)); // ));
} // }
let socket_addr = socket_addrs[0]; // let socket_addr = socket_addrs[0];
let mut stream = TcpStream::connect(socket_addr).await?; // let mut stream = TcpStream::connect(socket_addr).await?;
let request = FingerRequest::new(false, username.to_string()); // let request = FingerRequest::new(false, username.to_string());
let request_bytes = request.to_bytes(); // let request_bytes = request.to_bytes();
stream.write_all(&request_bytes).await?; // stream.write_all(&request_bytes).await?;
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 = RawFingerResponse::from_bytes(&response_bytes); // let response = RawFingerResponse::from_bytes(&response_bytes);
if response.is_empty() { // if response.is_empty() {
Ok(None) // Ok(None)
} else { // } else {
Ok(Some(response)) // Ok(Some(response))
} // }
} // }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
+3 -3
View File
@@ -3,9 +3,9 @@ use serde::{Deserialize, Serialize};
use zlink::{ReplyError, service::MethodReply}; use zlink::{ReplyError, service::MethodReply};
use crate::{ use crate::{
proto::{WhodStatusUpdate, WhodUserEntry}, proto::{WhodStatusUpdate, WhodUserEntry, finger_protocol::FingerResponseUserEntry},
server::{ server::{
fingerd::{self, FingerUserEntry}, fingerd::{self},
rwhod::RwhodStatusStore, rwhod::RwhodStatusStore,
}, },
}; };
@@ -76,7 +76,7 @@ pub enum VarlinkFingerClientResponse {
Finger(VarlinkFingerResponse), Finger(VarlinkFingerResponse),
} }
pub type VarlinkFingerResponse = Vec<Option<FingerUserEntry>>; pub type VarlinkFingerResponse = Vec<Option<FingerResponseUserEntry>>;
#[derive(Debug, Clone, PartialEq, ReplyError)] #[derive(Debug, Clone, PartialEq, ReplyError)]
#[zlink(interface = "no.ntnu.pvv.roowho2.finger")] #[zlink(interface = "no.ntnu.pvv.roowho2.finger")]