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