Skip to main content

coreutils_rs/who/
core.rs

1/// who — show who is logged on
2///
3/// Reads utmpx records and displays information about currently logged-in users,
4/// boot time, dead processes, run level, etc.
5use std::ffi::CStr;
6use std::fmt::Write as FmtWrite;
7
8// utmpx entry type constants (from utmpx.h)
9const RUN_LVL: i16 = 1;
10const BOOT_TIME: i16 = 2;
11const NEW_TIME: i16 = 3;
12const OLD_TIME: i16 = 4;
13const INIT_PROCESS: i16 = 5;
14const LOGIN_PROCESS: i16 = 6;
15const USER_PROCESS: i16 = 7;
16const DEAD_PROCESS: i16 = 8;
17
18/// A decoded utmpx entry.
19#[derive(Clone, Debug)]
20pub struct UtmpxEntry {
21    pub ut_type: i16,
22    pub ut_pid: i32,
23    pub ut_line: String,
24    pub ut_id: String,
25    pub ut_user: String,
26    pub ut_host: String,
27    pub ut_tv_sec: i64,
28}
29
30/// Read all utmpx entries from the system database.
31///
32/// # Safety
33/// Uses libc's setutxent/getutxent/endutxent which are not thread-safe.
34/// This function must not be called concurrently.
35pub fn read_utmpx() -> Vec<UtmpxEntry> {
36    let mut entries = Vec::new();
37
38    unsafe {
39        libc::setutxent();
40        loop {
41            let entry = libc::getutxent();
42            if entry.is_null() {
43                break;
44            }
45            let e = &*entry;
46
47            let user = cstr_from_buf(&e.ut_user);
48            let line = cstr_from_buf(&e.ut_line);
49            let host = cstr_from_buf(&e.ut_host);
50            let id = cstr_from_buf(&e.ut_id);
51
52            let tv_sec = e.ut_tv.tv_sec as i64;
53
54            entries.push(UtmpxEntry {
55                ut_type: e.ut_type as i16,
56                ut_pid: e.ut_pid,
57                ut_line: line,
58                ut_id: id,
59                ut_user: user,
60                ut_host: host,
61                ut_tv_sec: tv_sec,
62            });
63        }
64        libc::endutxent();
65    }
66
67    entries
68}
69
70/// Extract a Rust String from a fixed-size C char buffer.
71unsafe fn cstr_from_buf(buf: &[libc::c_char]) -> String {
72    // Find the first NUL byte or use the entire buffer length
73    let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
74    let bytes: Vec<u8> = buf[..len].iter().map(|&c| c as u8).collect();
75    String::from_utf8_lossy(&bytes).into_owned()
76}
77
78/// Configuration for the who command, derived from CLI flags.
79#[derive(Clone, Debug, Default)]
80pub struct WhoConfig {
81    pub show_boot: bool,
82    pub show_dead: bool,
83    pub show_heading: bool,
84    pub show_login: bool,
85    pub only_current: bool,      // -m
86    pub show_init_spawn: bool,   // -p
87    pub show_count: bool,        // -q
88    pub show_runlevel: bool,     // -r
89    pub short_format: bool,      // -s (default)
90    pub show_clock_change: bool, // -t
91    pub show_mesg: bool,         // -T, -w
92    pub show_users: bool,        // -u
93    pub show_all: bool,          // -a
94    pub show_ips: bool,          // --ips
95    pub show_lookup: bool,       // --lookup
96    pub am_i: bool,              // "who am i"
97}
98
99impl WhoConfig {
100    /// Apply the --all flag: equivalent to -b -d --login -p -r -t -T -u.
101    pub fn apply_all(&mut self) {
102        self.show_boot = true;
103        self.show_dead = true;
104        self.show_login = true;
105        self.show_init_spawn = true;
106        self.show_runlevel = true;
107        self.show_clock_change = true;
108        self.show_mesg = true;
109        self.show_users = true;
110    }
111
112    /// Returns true if no specific filter flags are set,
113    /// meaning only USER_PROCESS entries should be shown (default behavior).
114    pub fn is_default_filter(&self) -> bool {
115        !self.show_boot
116            && !self.show_dead
117            && !self.show_login
118            && !self.show_init_spawn
119            && !self.show_runlevel
120            && !self.show_clock_change
121            && !self.show_users
122    }
123}
124
125/// Format a Unix timestamp as "YYYY-MM-DD HH:MM".
126pub fn format_time(tv_sec: i64) -> String {
127    if tv_sec == 0 {
128        return String::new();
129    }
130    let t = tv_sec as libc::time_t;
131    let tm = unsafe {
132        let mut tm: libc::tm = std::mem::zeroed();
133        libc::localtime_r(&t, &mut tm);
134        tm
135    };
136    format!(
137        "{:04}-{:02}-{:02} {:02}:{:02}",
138        tm.tm_year + 1900,
139        tm.tm_mon + 1,
140        tm.tm_mday,
141        tm.tm_hour,
142        tm.tm_min,
143    )
144}
145
146/// Determine the message status character for a terminal line.
147/// '+' means writable (mesg y), '-' means not writable (mesg n), '?' means unknown.
148fn mesg_status(line: &str) -> char {
149    if line.is_empty() {
150        return '?';
151    }
152    let dev_path = if line.starts_with('/') {
153        line.to_string()
154    } else {
155        format!("/dev/{}", line)
156    };
157
158    let mut stat_buf: libc::stat = unsafe { std::mem::zeroed() };
159    let c_path = std::ffi::CString::new(dev_path).unwrap_or_default();
160    let rc = unsafe { libc::stat(c_path.as_ptr(), &mut stat_buf) };
161    if rc != 0 {
162        return '?';
163    }
164    if stat_buf.st_mode & libc::S_IWGRP != 0 {
165        '+'
166    } else {
167        '-'
168    }
169}
170
171/// Compute idle time string for a terminal.
172/// Returns "." if active within the last minute, "old" if more than 24h,
173/// or "HH:MM" otherwise.
174fn idle_str(line: &str) -> String {
175    if line.is_empty() {
176        return "?".to_string();
177    }
178    let dev_path = if line.starts_with('/') {
179        line.to_string()
180    } else {
181        format!("/dev/{}", line)
182    };
183
184    let mut stat_buf: libc::stat = unsafe { std::mem::zeroed() };
185    let c_path = std::ffi::CString::new(dev_path).unwrap_or_default();
186    let rc = unsafe { libc::stat(c_path.as_ptr(), &mut stat_buf) };
187    if rc != 0 {
188        return "?".to_string();
189    }
190
191    let now = unsafe { libc::time(std::ptr::null_mut()) };
192    let atime = stat_buf.st_atime;
193    let idle_secs = now - atime;
194
195    if idle_secs < 60 {
196        ".".to_string()
197    } else if idle_secs >= 86400 {
198        "old".to_string()
199    } else {
200        let hours = idle_secs / 3600;
201        let mins = (idle_secs % 3600) / 60;
202        format!("{:02}:{:02}", hours, mins)
203    }
204}
205
206/// Get the terminal device for the current process (for "who am i" / -m).
207pub fn current_tty() -> Option<String> {
208    unsafe {
209        let name = libc::ttyname(0); // stdin
210        if name.is_null() {
211            None
212        } else {
213            let s = CStr::from_ptr(name).to_string_lossy().into_owned();
214            // Strip /dev/ prefix to match utmpx ut_line
215            Some(s.strip_prefix("/dev/").unwrap_or(&s).to_string())
216        }
217    }
218}
219
220/// Check if an entry should be displayed given the config.
221pub fn should_show(entry: &UtmpxEntry, config: &WhoConfig) -> bool {
222    if config.am_i || config.only_current {
223        // Only show entries matching the current terminal
224        if let Some(tty) = current_tty() {
225            return entry.ut_type == USER_PROCESS && entry.ut_line == tty;
226        }
227        return false;
228    }
229
230    if config.show_count {
231        return entry.ut_type == USER_PROCESS;
232    }
233
234    if config.is_default_filter() {
235        return entry.ut_type == USER_PROCESS;
236    }
237
238    match entry.ut_type {
239        BOOT_TIME => config.show_boot,
240        DEAD_PROCESS => config.show_dead,
241        LOGIN_PROCESS => config.show_login,
242        INIT_PROCESS => config.show_init_spawn,
243        RUN_LVL => config.show_runlevel,
244        NEW_TIME | OLD_TIME => config.show_clock_change,
245        USER_PROCESS => config.show_users || config.is_default_filter(),
246        _ => false,
247    }
248}
249
250/// Format a single utmpx entry as an output line.
251pub fn format_entry(entry: &UtmpxEntry, config: &WhoConfig) -> String {
252    let mut out = String::new();
253
254    // Determine name and line based on entry type
255    let (name, line) = match entry.ut_type {
256        BOOT_TIME => (String::new(), "system boot".to_string()),
257        RUN_LVL => {
258            let current = (entry.ut_pid & 0xFF) as u8 as char;
259            (String::new(), format!("run-level {}", current))
260        }
261        LOGIN_PROCESS => ("LOGIN".to_string(), entry.ut_line.clone()),
262        NEW_TIME => (String::new(), entry.ut_line.clone()),
263        OLD_TIME => (String::new(), entry.ut_line.clone()),
264        _ => (entry.ut_user.clone(), entry.ut_line.clone()),
265    };
266
267    // NAME column (left-aligned, 8 chars min)
268    let _ = write!(out, "{:<8}", name);
269
270    // Mesg status column
271    if config.show_mesg {
272        let status = if entry.ut_type == USER_PROCESS {
273            mesg_status(&entry.ut_line)
274        } else {
275            '?'
276        };
277        let _ = write!(out, " {}", status);
278    }
279
280    // LINE column
281    let _ = write!(out, " {:<12}", line);
282
283    // TIME column
284    let time_str = format_time(entry.ut_tv_sec);
285    let _ = write!(out, " {}", time_str);
286
287    // IDLE + PID for -u
288    if config.show_users || config.show_all {
289        match entry.ut_type {
290            USER_PROCESS => {
291                let idle = idle_str(&entry.ut_line);
292                let _ = write!(out, " {:>5}", idle);
293                let _ = write!(out, " {:>10}", entry.ut_pid);
294            }
295            LOGIN_PROCESS => {
296                let _ = write!(out, "   ?  {:>10}", entry.ut_pid);
297            }
298            DEAD_PROCESS => {
299                let _ = write!(out, "      {:>10}", entry.ut_pid);
300            }
301            _ => {}
302        }
303    }
304
305    // For LOGIN_PROCESS, always show id
306    if entry.ut_type == LOGIN_PROCESS {
307        if !(config.show_users || config.show_all) {
308            // Without -u, show PID with extra spacing
309            let _ = write!(out, "          {:>5}", entry.ut_pid);
310        }
311        let _ = write!(out, " id={}", entry.ut_id);
312    }
313
314    // COMMENT (host) column
315    if !entry.ut_host.is_empty() {
316        if config.show_ips {
317            let _ = write!(out, " ({})", entry.ut_host);
318        } else if config.show_lookup {
319            let resolved = lookup_host(&entry.ut_host);
320            let _ = write!(out, " ({})", resolved);
321        } else {
322            let _ = write!(out, " ({})", entry.ut_host);
323        }
324    }
325
326    out
327}
328
329/// Attempt to resolve a hostname via DNS. Falls back to original on failure.
330fn lookup_host(host: &str) -> String {
331    let c_host = match std::ffi::CString::new(host) {
332        Ok(s) => s,
333        Err(_) => return host.to_string(),
334    };
335
336    unsafe {
337        let mut hints: libc::addrinfo = std::mem::zeroed();
338        hints.ai_flags = libc::AI_CANONNAME;
339        hints.ai_family = libc::AF_UNSPEC;
340
341        let mut result: *mut libc::addrinfo = std::ptr::null_mut();
342        let rc = libc::getaddrinfo(c_host.as_ptr(), std::ptr::null(), &hints, &mut result);
343        if rc != 0 || result.is_null() {
344            return host.to_string();
345        }
346
347        let canonical = if !(*result).ai_canonname.is_null() {
348            CStr::from_ptr((*result).ai_canonname)
349                .to_string_lossy()
350                .into_owned()
351        } else {
352            host.to_string()
353        };
354
355        libc::freeaddrinfo(result);
356        canonical
357    }
358}
359
360/// Format output for the -q / --count mode.
361pub fn format_count(entries: &[UtmpxEntry]) -> String {
362    let users: Vec<&str> = entries
363        .iter()
364        .filter(|e| e.ut_type == USER_PROCESS)
365        .map(|e| e.ut_user.as_str())
366        .collect();
367
368    let mut out = String::new();
369    let _ = writeln!(out, "{}", users.join(" "));
370    let _ = write!(out, "# users={}", users.len());
371    out
372}
373
374/// Format heading line.
375pub fn format_heading(config: &WhoConfig) -> String {
376    let mut out = String::new();
377    let _ = write!(out, "{:<8}", "NAME");
378    if config.show_mesg {
379        let _ = write!(out, " S");
380    }
381    let _ = write!(out, " {:<12}", "LINE");
382    let _ = write!(out, " {:<16}", "TIME");
383    if config.show_users || config.show_all {
384        let _ = write!(out, " {:<6}", "IDLE");
385        let _ = write!(out, " {:>10}", "PID");
386    }
387    let _ = write!(out, " {}", "COMMENT");
388    out
389}
390
391/// Run the who command and return the formatted output.
392pub fn run_who(config: &WhoConfig) -> String {
393    let entries = read_utmpx();
394    let mut output = String::new();
395
396    if config.show_count {
397        return format_count(&entries);
398    }
399
400    if config.show_heading {
401        let _ = writeln!(output, "{}", format_heading(config));
402    }
403
404    for entry in &entries {
405        if should_show(entry, config) {
406            let _ = writeln!(output, "{}", format_entry(entry, config));
407        }
408    }
409
410    // Remove trailing newline for consistency
411    if output.ends_with('\n') {
412        output.pop();
413    }
414
415    output
416}