1use std::collections::HashMap;
6use std::io::BufRead;
7use std::time::{SystemTime, UNIX_EPOCH};
8
9#[cfg(unix)]
10use std::ffi::CStr;
11
12pub const DEFAULT_WATCHFMT: &str = "%n has %a %l from %m.";
14
15pub const DEFAULT_WATCHFMT_NOHOST: &str = "%n has %a %l.";
17
18#[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#[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#[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
226pub 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
272pub 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
431pub 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
500pub 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
562pub 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}