1
use std::{
2
    net::hostname,
3
    os::unix::fs::{MetadataExt, PermissionsExt},
4
    path::Path,
5
};
6

            
7
use chrono::{DateTime, Duration, Timelike, Utc};
8
use itertools::Itertools;
9
use nix::sys::stat::stat;
10
use users::all_users;
11
use uucore::utmpx::{Utmpx, UtmpxRecord};
12

            
13
use 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.
22
pub 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.
78
pub 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.
102
4
fn read_file_content_if_exists(path: &Path) -> anyhow::Result<Option<String>> {
103
4
    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
4
    if file_is_readable {
118
        Ok(Some(std::fs::read_to_string(path)?.trim().to_string()))
119
    } else {
120
4
        Ok(None)
121
    }
122
4
}
123

            
124
/// Retrieve local user information for the given username.
125
///
126
/// Returns None if the user does not exist.
127
1
fn get_local_user(
128
1
    username: &str,
129
1
    utmp_records: Option<Vec<UtmpxRecord>>,
130
1
    _request_info: &FingerRequestInfo,
131
1
    ignore_list: Option<&IgnoreList>,
132
1
) -> anyhow::Result<Option<FingerResponseStructuredUserEntry>> {
133
1
    tracing::trace!(
134
        "Retrieving local user information for username: {}",
135
        username
136
    );
137
1
    let username = username.to_string();
138
1
    let user_entry = match nix::unistd::User::from_name(&username) {
139
1
        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
1
    if ignore_list.is_some_and(|ignore_list| ignore_list.ignores_uid(user_entry.uid.as_raw())) {
151
        return Ok(None);
152
1
    }
153

            
154
1
    let nofinger_path = user_entry.dir.join(".nofinger");
155
1
    if nofinger_path.exists() {
156
        return Ok(None);
157
1
    }
158

            
159
1
    let full_name = user_entry.name;
160
1
    let home_dir = user_entry.dir.clone();
161
1
    let shell = user_entry.shell;
162

            
163
1
    let gecos_fields: Vec<&str> = full_name.split(',').collect();
164

            
165
1
    let office = gecos_fields.get(1).map(|s| s.to_string());
166
1
    let office_phone = gecos_fields.get(2).map(|s| s.to_string());
167
1
    let home_phone = gecos_fields.get(3).map(|s| s.to_string());
168

            
169
1
    let hostname = hostname()?.to_str().unwrap_or("localhost").to_string();
170

            
171
1
    let utmpx_records = match utmp_records {
172
        Some(records) => records,
173
1
        None => Utmpx::iter_all_records()
174
1
            .filter(|entry| entry.user() == username)
175
1
            .filter(|entry| entry.is_user_process())
176
1
            .collect::<Vec<_>>(),
177
    };
178

            
179
    // TODO: Don't use utmp entries for this, read from lastlog instead
180
1
    let user_never_logged_in = utmpx_records.is_empty();
181

            
182
1
    let now = Utc::now().with_nanosecond(0).unwrap_or(Utc::now());
183
1
    let sessions: Vec<FingerResponseUserSession> = utmpx_records
184
1
        .into_iter()
185
1
        .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
1
        .collect();
233

            
234
1
    let email_status = local_email::detect_new_mail_for_user(&username, &home_dir)?;
235

            
236
1
    let forward_path = user_entry.dir.join(".forward");
237
1
    let forward = read_file_content_if_exists(&forward_path)?;
238

            
239
1
    let pgpkey_path = user_entry.dir.join(".pgpkey");
240
1
    let pgpkey = read_file_content_if_exists(&pgpkey_path)?;
241

            
242
1
    let project_path = user_entry.dir.join(".project");
243
1
    let project = read_file_content_if_exists(&project_path)?;
244

            
245
1
    let plan_path = user_entry.dir.join(".plan");
246
1
    let plan = read_file_content_if_exists(&plan_path)?;
247

            
248
1
    Ok(Some(FingerResponseStructuredUserEntry::new(
249
1
        username,
250
1
        full_name,
251
1
        home_dir,
252
1
        shell,
253
1
        office,
254
1
        office_phone,
255
1
        home_phone,
256
1
        user_never_logged_in,
257
1
        sessions,
258
1
        forward,
259
1
        email_status,
260
1
        pgpkey,
261
1
        project,
262
1
        plan,
263
1
    )))
264
1
}
265

            
266
#[cfg(test)]
267
mod tests {
268
    use super::*;
269

            
270
    #[test]
271
1
    fn test_finger_root() {
272
1
        let user_entry = get_local_user(
273
1
            "root",
274
1
            None,
275
1
            &FingerRequestInfo::Long {
276
1
                prevent_files: false,
277
1
            },
278
1
            None,
279
        )
280
1
        .unwrap()
281
1
        .unwrap();
282
1
        assert_eq!(user_entry.username, "root");
283
1
    }
284

            
285
    // TODO: test serialization roundtrip
286
}