Skip to main content

roowho2_lib/server/fingerd/
local_user_info.rs

1use std::{
2    net::hostname,
3    os::unix::fs::{MetadataExt, PermissionsExt},
4    path::Path,
5};
6
7use chrono::{DateTime, Duration, Timelike, Utc};
8use itertools::Itertools;
9use nix::sys::stat::stat;
10use users::all_users;
11use uucore::utmpx::{Utmpx, UtmpxRecord};
12
13use crate::{
14    proto::finger_protocol::{FingerResponseStructuredUserEntry, FingerResponseUserSession},
15    server::{
16        fingerd::{FingerRequestInfo, local_email},
17        ignore_list::IgnoreList,
18    },
19};
20
21/// Search for users whose username or full name contains the search string.
22pub fn search_for_user(
23    search_string: &str,
24    match_fullnames: bool,
25    request_info: &FingerRequestInfo,
26    ignore_list: Option<&IgnoreList>,
27) -> Vec<anyhow::Result<FingerResponseStructuredUserEntry>> {
28    (unsafe { all_users() })
29        .filter_map(|user| {
30            let user = match nix::unistd::User::from_uid(user.uid().into()) {
31                Ok(Some(user)) => user,
32                Ok(None) => {
33                    tracing::warn!(
34                        "User with UID {} exists but could not retrieve user entry",
35                        user.uid()
36                    );
37                    return None;
38                }
39                Err(e) => {
40                    return Some(Err(anyhow::anyhow!(
41                        "Failed to get user entry for UID {}: {}",
42                        user.uid(),
43                        e
44                    )));
45                }
46            };
47
48            let username = user.name;
49            let full_name = String::from_utf8_lossy(
50                user.gecos
51                    .as_bytes()
52                    .split(|&b| b == b',')
53                    .next()
54                    .unwrap_or(&[]),
55            )
56            .to_string();
57
58            if ignore_list.is_some_and(|ignore_list| ignore_list.ignores_uid(user.uid.as_raw())) {
59                return None;
60            }
61
62            let matches_username = username.contains(search_string);
63            let matches_fullname = match_fullnames && full_name.contains(search_string);
64            if matches_username || matches_fullname {
65                match get_local_user(&username, None, request_info, ignore_list) {
66                    Ok(Some(user_entry)) => Some(Ok(user_entry)),
67                    Ok(None) => None, // User exists but has .nofinger, skip
68                    Err(err) => Some(Err(err)),
69                }
70            } else {
71                None
72            }
73        })
74        .collect()
75}
76
77/// Retrieve information about all users currently logged in, based on utmpx records.
78pub fn finger_utmp_users(
79    request_info: &FingerRequestInfo,
80    ignore_list: Option<&IgnoreList>,
81) -> Vec<anyhow::Result<FingerResponseStructuredUserEntry>> {
82    Utmpx::iter_all_records()
83        .filter(|entry| entry.is_user_process())
84        .into_group_map_by(|entry| entry.user())
85        .into_iter()
86        .filter(|(username, _)| {
87            !ignore_list.is_some_and(|ignore_list| ignore_list.ignores_username(username))
88        })
89        .map(|(username, records)| {
90            get_local_user(&username, Some(records), request_info, ignore_list)
91        })
92        .filter_map(|result| match result {
93            Ok(Some(user_entry)) => Some(Ok(user_entry)),
94            Ok(None) => None, // User has .nofinger, skip
95            Err(err) => Some(Err(err)),
96        })
97        .collect()
98}
99
100/// Helper function to read the content of a file if it exists and is readable,
101/// returning None if the file does not exist or is not readable.
102fn read_file_content_if_exists(path: &Path) -> anyhow::Result<Option<String>> {
103    let file_is_readable = path.exists()
104        && path.is_file()
105        && (((path.metadata()?.permissions().mode() & 0o400 != 0
106            && nix::unistd::geteuid().as_raw() == path.metadata()?.uid())
107            || (path.metadata()?.permissions().mode() & 0o040 != 0
108                && nix::unistd::getegid().as_raw() == path.metadata()?.gid())
109            || (path.metadata()?.permissions().mode() & 0o004 != 0))
110            || caps::has_cap(
111                None,
112                caps::CapSet::Effective,
113                caps::Capability::CAP_DAC_READ_SEARCH,
114            )?)
115        && path.metadata()?.len() > 0;
116
117    if file_is_readable {
118        Ok(Some(std::fs::read_to_string(path)?.trim().to_string()))
119    } else {
120        Ok(None)
121    }
122}
123
124/// Retrieve local user information for the given username.
125///
126/// Returns None if the user does not exist.
127fn get_local_user(
128    username: &str,
129    utmp_records: Option<Vec<UtmpxRecord>>,
130    _request_info: &FingerRequestInfo,
131    ignore_list: Option<&IgnoreList>,
132) -> anyhow::Result<Option<FingerResponseStructuredUserEntry>> {
133    tracing::trace!(
134        "Retrieving local user information for username: {}",
135        username
136    );
137    let username = username.to_string();
138    let user_entry = match nix::unistd::User::from_name(&username) {
139        Ok(Some(user)) => user,
140        Ok(None) => return Ok(None),
141        Err(err) => {
142            return Err(anyhow::anyhow!(
143                "Failed to get user entry for {}: {}",
144                username,
145                err
146            ));
147        }
148    };
149
150    if ignore_list.is_some_and(|ignore_list| ignore_list.ignores_uid(user_entry.uid.as_raw())) {
151        return Ok(None);
152    }
153
154    let nofinger_path = user_entry.dir.join(".nofinger");
155    if nofinger_path.exists() {
156        return Ok(None);
157    }
158
159    let full_name = user_entry.name;
160    let home_dir = user_entry.dir.clone();
161    let shell = user_entry.shell;
162
163    let gecos_fields: Vec<&str> = full_name.split(',').collect();
164
165    let office = gecos_fields.get(1).map(|s| s.to_string());
166    let office_phone = gecos_fields.get(2).map(|s| s.to_string());
167    let home_phone = gecos_fields.get(3).map(|s| s.to_string());
168
169    let hostname = hostname()?.to_str().unwrap_or("localhost").to_string();
170
171    let utmpx_records = match utmp_records {
172        Some(records) => records,
173        None => Utmpx::iter_all_records()
174            .filter(|entry| entry.user() == username)
175            .filter(|entry| entry.is_user_process())
176            .collect::<Vec<_>>(),
177    };
178
179    // TODO: Don't use utmp entries for this, read from lastlog instead
180    let user_never_logged_in = utmpx_records.is_empty();
181
182    let now = Utc::now().with_nanosecond(0).unwrap_or(Utc::now());
183    let sessions: Vec<FingerResponseUserSession> = utmpx_records
184        .into_iter()
185        .filter_map(|entry| {
186            let login_time = entry
187                .login_time()
188                .checked_to_utc()
189                .and_then(|t| DateTime::<Utc>::from_timestamp_secs(t.unix_timestamp()))?;
190
191            let tty_device_path = Path::new("/dev").join(entry.tty_device());
192            let tty_device_stat = stat(&tty_device_path).ok();
193
194            let tty_is_x_console = entry.tty_device().starts_with(':');
195
196            let idle_time = if tty_is_x_console {
197                None
198            } else {
199                tty_device_stat.and_then(|st| {
200                    let last_active = DateTime::<Utc>::from_timestamp_secs(st.st_atime)?;
201                    let result = (now - last_active).max(Duration::zero());
202                    if result == Duration::zero() {
203                        None
204                    } else {
205                        debug_assert!(
206                            result.num_seconds() >= 0,
207                            "Idle time should never be negative"
208                        );
209
210                        Some(result)
211                    }
212                })
213            };
214
215            let messages_on =
216              // X console logins does not show the tty, so messages should be considered off in that case
217              !tty_is_x_console &&
218              // Check if the user has write permissions to the tty device,
219              // indicating whether messages are on or off
220              tty_device_stat
221                .map(|st| st.st_mode & 0o220 == 0o220)
222                .unwrap_or(false);
223
224            Some(FingerResponseUserSession::new(
225                entry.tty_device(),
226                login_time,
227                Some(hostname.clone()),
228                idle_time,
229                messages_on,
230            ))
231        })
232        .collect();
233
234    let email_status = local_email::detect_new_mail_for_user(&username, &home_dir)?;
235
236    let forward_path = user_entry.dir.join(".forward");
237    let forward = read_file_content_if_exists(&forward_path)?;
238
239    let pgpkey_path = user_entry.dir.join(".pgpkey");
240    let pgpkey = read_file_content_if_exists(&pgpkey_path)?;
241
242    let project_path = user_entry.dir.join(".project");
243    let project = read_file_content_if_exists(&project_path)?;
244
245    let plan_path = user_entry.dir.join(".plan");
246    let plan = read_file_content_if_exists(&plan_path)?;
247
248    Ok(Some(FingerResponseStructuredUserEntry::new(
249        username,
250        full_name,
251        home_dir,
252        shell,
253        office,
254        office_phone,
255        home_phone,
256        user_never_logged_in,
257        sessions,
258        forward,
259        email_status,
260        pgpkey,
261        project,
262        plan,
263    )))
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn test_finger_root() {
272        let user_entry = get_local_user(
273            "root",
274            None,
275            &FingerRequestInfo::Long {
276                prevent_files: false,
277            },
278            None,
279        )
280        .unwrap()
281        .unwrap();
282        assert_eq!(user_entry.username, "root");
283    }
284
285    // TODO: test serialization roundtrip
286}