Skip to main content

zsh/
watch.rs

1//! Login/logout watching module - port of Modules/watch.c
2//!
3//! Provides watch/log functionality for monitoring user logins/logouts.
4
5use std::collections::HashMap;
6use std::io::BufRead;
7use std::time::{SystemTime, UNIX_EPOCH};
8
9#[cfg(unix)]
10use std::ffi::CStr;
11
12/// Default watch format string
13pub const DEFAULT_WATCHFMT: &str = "%n has %a %l from %m.";
14
15/// Default watch format without host support
16pub const DEFAULT_WATCHFMT_NOHOST: &str = "%n has %a %l.";
17
18/// A utmp/utmpx entry representing a login session
19#[derive(Debug, Clone)]
20pub struct UtmpEntry {
21    pub user: String,
22    pub line: String,
23    pub host: String,
24    pub time: i64,
25    pub pid: i32,
26    pub session_type: SessionType,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum SessionType {
31    UserProcess,
32    DeadProcess,
33    LoginProcess,
34    InitProcess,
35    BootTime,
36    Unknown,
37}
38
39impl UtmpEntry {
40    pub fn is_active(&self) -> bool {
41        matches!(self.session_type, SessionType::UserProcess) && !self.user.is_empty()
42    }
43}
44
45/// Watch state for tracking login/logout events
46#[derive(Debug, Default)]
47pub struct WatchState {
48    last_check: i64,
49    last_watch: i64,
50    entries: Vec<UtmpEntry>,
51    watch_list: Vec<String>,
52    watch_fmt: String,
53    log_check_interval: i64,
54}
55
56impl WatchState {
57    pub fn new() -> Self {
58        Self {
59            last_check: 0,
60            last_watch: 0,
61            entries: Vec::new(),
62            watch_list: Vec::new(),
63            watch_fmt: DEFAULT_WATCHFMT.to_string(),
64            log_check_interval: 60,
65        }
66    }
67
68    pub fn set_watch_list(&mut self, list: Vec<String>) {
69        self.watch_list = list;
70    }
71
72    pub fn set_watch_fmt(&mut self, fmt: &str) {
73        self.watch_fmt = fmt.to_string();
74    }
75
76    pub fn set_log_check(&mut self, interval: i64) {
77        self.log_check_interval = interval;
78    }
79
80    pub fn should_check(&self) -> bool {
81        if self.watch_list.is_empty() {
82            return false;
83        }
84
85        let now = SystemTime::now()
86            .duration_since(UNIX_EPOCH)
87            .unwrap_or_default()
88            .as_secs() as i64;
89
90        now - self.last_watch > self.log_check_interval
91    }
92}
93
94/// Read utmp entries from the system
95#[cfg(target_os = "linux")]
96pub fn read_utmp() -> Vec<UtmpEntry> {
97    read_utmp_file("/var/run/utmp")
98}
99
100#[cfg(target_os = "macos")]
101pub fn read_utmp() -> Vec<UtmpEntry> {
102    read_utmpx()
103}
104
105#[cfg(not(any(target_os = "linux", target_os = "macos")))]
106pub fn read_utmp() -> Vec<UtmpEntry> {
107    Vec::new()
108}
109
110#[cfg(target_os = "macos")]
111fn read_utmpx() -> Vec<UtmpEntry> {
112    let mut entries = Vec::new();
113
114    unsafe {
115        libc::setutxent();
116
117        loop {
118            let entry = libc::getutxent();
119            if entry.is_null() {
120                break;
121            }
122
123            let ut = &*entry;
124
125            let user = CStr::from_ptr(ut.ut_user.as_ptr())
126                .to_string_lossy()
127                .into_owned();
128
129            let line = CStr::from_ptr(ut.ut_line.as_ptr())
130                .to_string_lossy()
131                .into_owned();
132
133            let host = CStr::from_ptr(ut.ut_host.as_ptr())
134                .to_string_lossy()
135                .into_owned();
136
137            let ut_type = ut.ut_type;
138            let session_type = if ut_type == libc::USER_PROCESS {
139                SessionType::UserProcess
140            } else if ut_type == libc::DEAD_PROCESS {
141                SessionType::DeadProcess
142            } else if ut_type == libc::LOGIN_PROCESS {
143                SessionType::LoginProcess
144            } else if ut_type == libc::INIT_PROCESS {
145                SessionType::InitProcess
146            } else if ut_type == libc::BOOT_TIME {
147                SessionType::BootTime
148            } else {
149                SessionType::Unknown
150            };
151
152            entries.push(UtmpEntry {
153                user,
154                line,
155                host,
156                time: ut.ut_tv.tv_sec as i64,
157                pid: ut.ut_pid,
158                session_type,
159            });
160        }
161
162        libc::endutxent();
163    }
164
165    entries
166}
167
168#[cfg(target_os = "linux")]
169fn read_utmp_file(_path: &str) -> Vec<UtmpEntry> {
170    let mut entries = Vec::new();
171
172    unsafe {
173        libc::setutxent();
174
175        loop {
176            let entry = libc::getutxent();
177            if entry.is_null() {
178                break;
179            }
180
181            let ut = &*entry;
182
183            let user = CStr::from_ptr(ut.ut_user.as_ptr())
184                .to_string_lossy()
185                .into_owned();
186
187            let line = CStr::from_ptr(ut.ut_line.as_ptr())
188                .to_string_lossy()
189                .into_owned();
190
191            let host = CStr::from_ptr(ut.ut_host.as_ptr())
192                .to_string_lossy()
193                .into_owned();
194
195            let ut_type = ut.ut_type;
196            let session_type = if ut_type == libc::USER_PROCESS {
197                SessionType::UserProcess
198            } else if ut_type == libc::DEAD_PROCESS {
199                SessionType::DeadProcess
200            } else if ut_type == libc::LOGIN_PROCESS {
201                SessionType::LoginProcess
202            } else if ut_type == libc::INIT_PROCESS {
203                SessionType::InitProcess
204            } else if ut_type == libc::BOOT_TIME {
205                SessionType::BootTime
206            } else {
207                SessionType::Unknown
208            };
209
210            entries.push(UtmpEntry {
211                user,
212                line,
213                host,
214                time: ut.ut_tv.tv_sec as i64,
215                pid: ut.ut_pid,
216                session_type,
217            });
218        }
219
220        libc::endutxent();
221    }
222
223    entries
224}
225
226/// Check if a watch pattern matches an entry field
227pub fn watch_match(pattern: &str, value: &str) -> bool {
228    if pattern == value {
229        return true;
230    }
231
232    if pattern.contains('*') || pattern.contains('?') {
233        glob_match(pattern, value)
234    } else {
235        false
236    }
237}
238
239fn glob_match(pattern: &str, text: &str) -> bool {
240    let p_chars: Vec<char> = pattern.chars().collect();
241    let t_chars: Vec<char> = text.chars().collect();
242
243    let mut p_idx = 0;
244    let mut t_idx = 0;
245    let mut star_idx: Option<usize> = None;
246    let mut match_idx = 0;
247
248    while t_idx < t_chars.len() {
249        if p_idx < p_chars.len() && (p_chars[p_idx] == '?' || p_chars[p_idx] == t_chars[t_idx]) {
250            p_idx += 1;
251            t_idx += 1;
252        } else if p_idx < p_chars.len() && p_chars[p_idx] == '*' {
253            star_idx = Some(p_idx);
254            match_idx = t_idx;
255            p_idx += 1;
256        } else if let Some(star) = star_idx {
257            p_idx = star + 1;
258            match_idx += 1;
259            t_idx = match_idx;
260        } else {
261            return false;
262        }
263    }
264
265    while p_idx < p_chars.len() && p_chars[p_idx] == '*' {
266        p_idx += 1;
267    }
268
269    p_idx == p_chars.len()
270}
271
272/// Format a watch event
273pub fn format_watch(entry: &UtmpEntry, logged_in: bool, fmt: &str) -> String {
274    let mut result = String::new();
275    let mut chars = fmt.chars().peekable();
276
277    while let Some(c) = chars.next() {
278        if c == '\\' {
279            if let Some(next) = chars.next() {
280                result.push(next);
281            }
282        } else if c == '%' {
283            if let Some(&next) = chars.peek() {
284                chars.next();
285                match next {
286                    'n' => result.push_str(&entry.user),
287                    'a' => {
288                        if logged_in {
289                            result.push_str("logged on");
290                        } else {
291                            result.push_str("logged off");
292                        }
293                    }
294                    'l' => {
295                        let line = if entry.line.starts_with("tty") {
296                            &entry.line[3..]
297                        } else {
298                            &entry.line
299                        };
300                        result.push_str(line);
301                    }
302                    'm' => {
303                        let host = entry.host.split('.').next().unwrap_or(&entry.host);
304                        result.push_str(host);
305                    }
306                    'M' => result.push_str(&entry.host),
307                    't' | '@' => {
308                        let time = format_time(entry.time, "%l:%M%p");
309                        result.push_str(&time);
310                    }
311                    'T' => {
312                        let time = format_time(entry.time, "%H:%M");
313                        result.push_str(&time);
314                    }
315                    'w' => {
316                        let time = format_time(entry.time, "%a %e");
317                        result.push_str(&time);
318                    }
319                    'W' => {
320                        let time = format_time(entry.time, "%m/%d/%y");
321                        result.push_str(&time);
322                    }
323                    'D' => {
324                        if chars.peek() == Some(&'{') {
325                            chars.next();
326                            let mut custom_fmt = String::new();
327                            while let Some(fc) = chars.next() {
328                                if fc == '}' {
329                                    break;
330                                }
331                                custom_fmt.push(fc);
332                            }
333                            let time = format_time(entry.time, &custom_fmt);
334                            result.push_str(&time);
335                        } else {
336                            let time = format_time(entry.time, "%y-%m-%d");
337                            result.push_str(&time);
338                        }
339                    }
340                    '%' => result.push('%'),
341                    '(' => {
342                        if let Some(cond_result) = format_conditional(&mut chars, entry, logged_in)
343                        {
344                            result.push_str(&cond_result);
345                        }
346                    }
347                    _ => {
348                        result.push('%');
349                        result.push(next);
350                    }
351                }
352            }
353        } else {
354            result.push(c);
355        }
356    }
357
358    result
359}
360
361fn format_conditional(
362    chars: &mut std::iter::Peekable<std::str::Chars>,
363    entry: &UtmpEntry,
364    logged_in: bool,
365) -> Option<String> {
366    let condition = chars.next()?;
367    let separator = chars.next()?;
368
369    let truth = match condition {
370        'n' => !entry.user.is_empty(),
371        'a' => logged_in,
372        'l' => {
373            if entry.line.starts_with("tty") {
374                entry.line.len() > 3
375            } else {
376                !entry.line.is_empty()
377            }
378        }
379        'm' | 'M' => !entry.host.is_empty(),
380        _ => false,
381    };
382
383    let mut true_branch = String::new();
384    let mut false_branch = String::new();
385    let mut depth = 1;
386    let mut in_true = true;
387
388    while let Some(c) = chars.next() {
389        if c == ')' {
390            depth -= 1;
391            if depth == 0 {
392                break;
393            }
394        }
395
396        if c == separator && depth == 1 {
397            in_true = false;
398            continue;
399        }
400
401        if c == '%' {
402            if chars.peek() == Some(&'(') {
403                depth += 1;
404            }
405        }
406
407        if in_true {
408            true_branch.push(c);
409        } else {
410            false_branch.push(c);
411        }
412    }
413
414    if truth {
415        Some(format_watch(entry, logged_in, &true_branch))
416    } else {
417        Some(format_watch(entry, logged_in, &false_branch))
418    }
419}
420
421fn format_time(timestamp: i64, fmt: &str) -> String {
422    use chrono::{Local, TimeZone};
423
424    if let Some(dt) = Local.timestamp_opt(timestamp, 0).single() {
425        dt.format(fmt).to_string()
426    } else {
427        String::new()
428    }
429}
430
431/// Check a watch entry against the watch list
432pub fn check_watch_entry(entry: &UtmpEntry, watch_list: &[String], current_user: &str) -> bool {
433    if watch_list.is_empty() {
434        return false;
435    }
436
437    if watch_list.first().map(|s| s.as_str()) == Some("all") {
438        return true;
439    }
440
441    let mut iter = watch_list.iter().peekable();
442
443    if iter.peek().map(|s| s.as_str()) == Some("notme") {
444        if entry.user == current_user {
445            return false;
446        }
447        iter.next();
448        if iter.peek().is_none() {
449            return true;
450        }
451    }
452
453    for pattern in iter {
454        if matches_watch_pattern(pattern, entry) {
455            return true;
456        }
457    }
458
459    false
460}
461
462fn matches_watch_pattern(pattern: &str, entry: &UtmpEntry) -> bool {
463    let mut rest = pattern;
464    let mut matched = true;
465
466    if !rest.starts_with('@') && !rest.starts_with('%') {
467        let end = rest.find(|c| c == '@' || c == '%').unwrap_or(rest.len());
468        let user_pat = &rest[..end];
469        if !watch_match(user_pat, &entry.user) {
470            matched = false;
471        }
472        rest = &rest[end..];
473    }
474
475    while !rest.is_empty() && matched {
476        if rest.starts_with('%') {
477            rest = &rest[1..];
478            let end = rest.find('@').unwrap_or(rest.len());
479            let line_pat = &rest[..end];
480            if !watch_match(line_pat, &entry.line) {
481                matched = false;
482            }
483            rest = &rest[end..];
484        } else if rest.starts_with('@') {
485            rest = &rest[1..];
486            let end = rest.find('%').unwrap_or(rest.len());
487            let host_pat = &rest[..end];
488            if !watch_match(host_pat, &entry.host) {
489                matched = false;
490            }
491            rest = &rest[end..];
492        } else {
493            break;
494        }
495    }
496
497    matched
498}
499
500/// Perform watch check and return login/logout events
501pub fn do_watch(state: &mut WatchState, current_user: &str) -> Vec<(UtmpEntry, bool)> {
502    let mut events = Vec::new();
503    let new_entries = read_utmp();
504
505    let now = SystemTime::now()
506        .duration_since(UNIX_EPOCH)
507        .unwrap_or_default()
508        .as_secs() as i64;
509
510    let old_active: HashMap<String, &UtmpEntry> = state
511        .entries
512        .iter()
513        .filter(|e| e.is_active())
514        .map(|e| (format!("{}:{}", e.user, e.line), e))
515        .collect();
516
517    let new_active: HashMap<String, &UtmpEntry> = new_entries
518        .iter()
519        .filter(|e| e.is_active())
520        .map(|e| (format!("{}:{}", e.user, e.line), e))
521        .collect();
522
523    for (key, entry) in &new_active {
524        if !old_active.contains_key(key) {
525            if check_watch_entry(entry, &state.watch_list, current_user) {
526                events.push((*entry).clone());
527                events.last_mut().unwrap();
528            }
529        }
530    }
531
532    for (key, entry) in &old_active {
533        if !new_active.contains_key(key) {
534            if check_watch_entry(entry, &state.watch_list, current_user) {
535                let logged_out = (*entry).clone();
536                events.push(logged_out);
537            }
538        }
539    }
540
541    let login_keys: std::collections::HashSet<String> = new_active
542        .keys()
543        .filter(|k| !old_active.contains_key(*k))
544        .cloned()
545        .collect();
546
547    let result: Vec<(UtmpEntry, bool)> = events
548        .into_iter()
549        .map(|e| {
550            let key = format!("{}:{}", e.user, e.line);
551            let is_login = login_keys.contains(&key);
552            (e, is_login)
553        })
554        .collect();
555
556    state.entries = new_entries;
557    state.last_watch = now;
558
559    result
560}
561
562/// Log builtin - force immediate watch check
563pub fn builtin_log(state: &mut WatchState, current_user: &str, fmt: Option<&str>) -> String {
564    let fmt_str = fmt
565        .map(|s| s.to_string())
566        .unwrap_or_else(|| state.watch_fmt.clone());
567    state.entries.clear();
568    state.last_check = 0;
569
570    let events = do_watch(state, current_user);
571    let mut output = String::new();
572
573    for (entry, logged_in) in events {
574        output.push_str(&format_watch(&entry, logged_in, &fmt_str));
575        output.push('\n');
576    }
577
578    output
579}
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584
585    #[test]
586    fn test_watch_state_new() {
587        let state = WatchState::new();
588        assert!(state.watch_list.is_empty());
589        assert_eq!(state.log_check_interval, 60);
590    }
591
592    #[test]
593    fn test_glob_match() {
594        assert!(glob_match("*", "anything"));
595        assert!(glob_match("user*", "username"));
596        assert!(glob_match("*name", "username"));
597        assert!(glob_match("user?ame", "username"));
598        assert!(!glob_match("user", "username"));
599    }
600
601    #[test]
602    fn test_watch_match() {
603        assert!(watch_match("root", "root"));
604        assert!(watch_match("*", "anyuser"));
605        assert!(!watch_match("root", "admin"));
606    }
607
608    #[test]
609    fn test_format_watch_basic() {
610        let entry = UtmpEntry {
611            user: "testuser".to_string(),
612            line: "tty1".to_string(),
613            host: "localhost".to_string(),
614            time: 0,
615            pid: 1234,
616            session_type: SessionType::UserProcess,
617        };
618
619        let result = format_watch(&entry, true, "%n has %a %l");
620        assert!(result.contains("testuser"));
621        assert!(result.contains("logged on"));
622        assert!(result.contains("1"));
623
624        let result = format_watch(&entry, false, "%n has %a");
625        assert!(result.contains("logged off"));
626    }
627
628    #[test]
629    fn test_format_watch_host() {
630        let entry = UtmpEntry {
631            user: "user".to_string(),
632            line: "pts/0".to_string(),
633            host: "host.example.com".to_string(),
634            time: 0,
635            pid: 1,
636            session_type: SessionType::UserProcess,
637        };
638
639        let result = format_watch(&entry, true, "%m");
640        assert_eq!(result, "host");
641
642        let result = format_watch(&entry, true, "%M");
643        assert_eq!(result, "host.example.com");
644    }
645
646    #[test]
647    fn test_check_watch_entry_all() {
648        let entry = UtmpEntry {
649            user: "anyone".to_string(),
650            line: "pts/0".to_string(),
651            host: "".to_string(),
652            time: 0,
653            pid: 1,
654            session_type: SessionType::UserProcess,
655        };
656
657        let watch = vec!["all".to_string()];
658        assert!(check_watch_entry(&entry, &watch, "me"));
659    }
660
661    #[test]
662    fn test_check_watch_entry_notme() {
663        let entry = UtmpEntry {
664            user: "me".to_string(),
665            line: "pts/0".to_string(),
666            host: "".to_string(),
667            time: 0,
668            pid: 1,
669            session_type: SessionType::UserProcess,
670        };
671
672        let watch = vec!["notme".to_string()];
673        assert!(!check_watch_entry(&entry, &watch, "me"));
674
675        let other = UtmpEntry {
676            user: "other".to_string(),
677            ..entry.clone()
678        };
679        assert!(check_watch_entry(&other, &watch, "me"));
680    }
681
682    #[test]
683    fn test_matches_watch_pattern() {
684        let entry = UtmpEntry {
685            user: "admin".to_string(),
686            line: "pts/0".to_string(),
687            host: "server.local".to_string(),
688            time: 0,
689            pid: 1,
690            session_type: SessionType::UserProcess,
691        };
692
693        assert!(matches_watch_pattern("admin", &entry));
694        assert!(matches_watch_pattern("admin@server.local", &entry));
695        assert!(matches_watch_pattern("admin%pts/0", &entry));
696        assert!(!matches_watch_pattern("root", &entry));
697    }
698
699    #[test]
700    fn test_session_type() {
701        let entry = UtmpEntry {
702            user: "user".to_string(),
703            line: "pts/0".to_string(),
704            host: "".to_string(),
705            time: 0,
706            pid: 1,
707            session_type: SessionType::UserProcess,
708        };
709        assert!(entry.is_active());
710
711        let dead = UtmpEntry {
712            session_type: SessionType::DeadProcess,
713            ..entry.clone()
714        };
715        assert!(!dead.is_active());
716    }
717}