Skip to main content

uucore/features/
systemd_logind.rs

1// This file is part of the uutils coreutils package.
2//
3// For the full copyright and license information, please view the LICENSE
4// file that was distributed with this source code.
5//
6// spell-checker:ignore logind libsystemd btime unref RAII testuser GETPW sysconf
7
8//! Systemd-logind support for reading login records
9//!
10//! This module provides systemd-logind based implementation for reading
11//! login records as an alternative to traditional utmp/utmpx files.
12//! When the systemd-logind feature is enabled and systemd is available,
13//! this will be used instead of traditional utmp files.
14
15use std::ffi::CStr;
16use std::mem::MaybeUninit;
17use std::time::{SystemTime, UNIX_EPOCH};
18
19use crate::error::{UResult, USimpleError};
20
21/// FFI bindings for libsystemd login and D-Bus functions
22mod ffi {
23    use std::ffi::c_char;
24    use std::os::raw::{c_int, c_uint};
25
26    #[link(name = "systemd")]
27    unsafe extern "C" {
28        pub fn sd_get_sessions(sessions: *mut *mut *mut c_char) -> c_int;
29        pub fn sd_session_get_uid(session: *const c_char, uid: *mut c_uint) -> c_int;
30        pub fn sd_session_get_start_time(session: *const c_char, usec: *mut u64) -> c_int;
31        pub fn sd_session_get_tty(session: *const c_char, tty: *mut *mut c_char) -> c_int;
32        pub fn sd_session_get_remote_host(
33            session: *const c_char,
34            remote_host: *mut *mut c_char,
35        ) -> c_int;
36        pub fn sd_session_get_display(session: *const c_char, display: *mut *mut c_char) -> c_int;
37        pub fn sd_session_get_type(session: *const c_char, session_type: *mut *mut c_char)
38        -> c_int;
39        pub fn sd_session_get_seat(session: *const c_char, seat: *mut *mut c_char) -> c_int;
40
41    }
42}
43
44/// Safe wrapper functions for libsystemd FFI calls
45mod login {
46    use super::ffi;
47    use std::ffi::{CStr, CString};
48    use std::ptr;
49    use std::time::SystemTime;
50
51    /// Get all active sessions
52    pub fn get_sessions() -> Result<Vec<String>, Box<dyn std::error::Error>> {
53        let mut sessions_ptr: *mut *mut libc::c_char = ptr::null_mut();
54
55        let result = unsafe { ffi::sd_get_sessions(&raw mut sessions_ptr) };
56
57        if result < 0 {
58            return Err(format!("sd_get_sessions failed: {result}").into());
59        }
60
61        let mut sessions = Vec::new();
62        if !sessions_ptr.is_null() {
63            let mut i = 0;
64            loop {
65                let session_ptr = unsafe { *sessions_ptr.add(i) };
66                if session_ptr.is_null() {
67                    break;
68                }
69
70                let session_cstr = unsafe { CStr::from_ptr(session_ptr) };
71                sessions.push(session_cstr.to_string_lossy().into_owned());
72
73                unsafe { libc::free(session_ptr.cast()) };
74                i += 1;
75            }
76
77            unsafe { libc::free(sessions_ptr.cast()) };
78        }
79
80        Ok(sessions)
81    }
82
83    /// Get UID for a session
84    pub fn get_session_uid(session_id: &str) -> Result<u32, Box<dyn std::error::Error>> {
85        let session_cstring = CString::new(session_id)?;
86        let mut uid: std::os::raw::c_uint = 0;
87
88        let result = unsafe { ffi::sd_session_get_uid(session_cstring.as_ptr(), &raw mut uid) };
89
90        if result < 0 {
91            return Err(
92                format!("sd_session_get_uid failed for session '{session_id}': {result}").into(),
93            );
94        }
95
96        Ok(uid)
97    }
98
99    /// Get start time for a session (in microseconds since Unix epoch)
100    pub fn get_session_start_time(session_id: &str) -> Result<u64, Box<dyn std::error::Error>> {
101        let session_cstring = CString::new(session_id)?;
102        let mut usec: u64 = 0;
103
104        let result =
105            unsafe { ffi::sd_session_get_start_time(session_cstring.as_ptr(), &raw mut usec) };
106
107        if result < 0 {
108            return Err(format!(
109                "sd_session_get_start_time failed for session '{session_id}': {result}",
110            )
111            .into());
112        }
113
114        Ok(usec)
115    }
116
117    /// Get TTY for a session
118    pub fn get_session_tty(session_id: &str) -> Result<Option<String>, Box<dyn std::error::Error>> {
119        let session_cstring = CString::new(session_id)?;
120        let mut tty_ptr: *mut libc::c_char = ptr::null_mut();
121
122        let result = unsafe { ffi::sd_session_get_tty(session_cstring.as_ptr(), &raw mut tty_ptr) };
123
124        if result < 0 {
125            return Err(
126                format!("sd_session_get_tty failed for session '{session_id}': {result}").into(),
127            );
128        }
129
130        if tty_ptr.is_null() {
131            return Ok(None);
132        }
133
134        let tty_cstr = unsafe { CStr::from_ptr(tty_ptr) };
135        let tty_string = tty_cstr.to_string_lossy().into_owned();
136
137        unsafe { libc::free(tty_ptr.cast()) };
138
139        Ok(Some(tty_string))
140    }
141
142    /// Get remote host for a session
143    pub fn get_session_remote_host(
144        session_id: &str,
145    ) -> Result<Option<String>, Box<dyn std::error::Error>> {
146        let session_cstring = CString::new(session_id)?;
147        let mut host_ptr: *mut libc::c_char = ptr::null_mut();
148
149        let result =
150            unsafe { ffi::sd_session_get_remote_host(session_cstring.as_ptr(), &raw mut host_ptr) };
151
152        if result < 0 {
153            return Err(format!(
154                "sd_session_get_remote_host failed for session '{session_id}': {result}",
155            )
156            .into());
157        }
158
159        if host_ptr.is_null() {
160            return Ok(None);
161        }
162
163        let host_cstr = unsafe { CStr::from_ptr(host_ptr) };
164        let host_string = host_cstr.to_string_lossy().into_owned();
165
166        unsafe { libc::free(host_ptr.cast()) };
167
168        Ok(Some(host_string))
169    }
170
171    /// Get display for a session
172    pub fn get_session_display(
173        session_id: &str,
174    ) -> Result<Option<String>, Box<dyn std::error::Error>> {
175        let session_cstring = CString::new(session_id)?;
176        let mut display_ptr: *mut libc::c_char = ptr::null_mut();
177
178        let result =
179            unsafe { ffi::sd_session_get_display(session_cstring.as_ptr(), &raw mut display_ptr) };
180
181        if result < 0 {
182            return Err(format!(
183                "sd_session_get_display failed for session '{session_id}': {result}",
184            )
185            .into());
186        }
187
188        if display_ptr.is_null() {
189            return Ok(None);
190        }
191
192        let display_cstr = unsafe { CStr::from_ptr(display_ptr) };
193        let display_string = display_cstr.to_string_lossy().into_owned();
194
195        unsafe { libc::free(display_ptr.cast()) };
196
197        Ok(Some(display_string))
198    }
199
200    /// Get type for a session
201    pub fn get_session_type(
202        session_id: &str,
203    ) -> Result<Option<String>, Box<dyn std::error::Error>> {
204        let session_cstring = CString::new(session_id)?;
205        let mut type_ptr: *mut libc::c_char = ptr::null_mut();
206
207        let result =
208            unsafe { ffi::sd_session_get_type(session_cstring.as_ptr(), &raw mut type_ptr) };
209
210        if result < 0 {
211            return Err(
212                format!("sd_session_get_type failed for session '{session_id}': {result}").into(),
213            );
214        }
215
216        if type_ptr.is_null() {
217            return Ok(None);
218        }
219
220        let type_cstr = unsafe { CStr::from_ptr(type_ptr) };
221        let type_string = type_cstr.to_string_lossy().into_owned();
222
223        unsafe { libc::free(type_ptr.cast()) };
224
225        Ok(Some(type_string))
226    }
227
228    /// Get seat for a session
229    pub fn get_session_seat(
230        session_id: &str,
231    ) -> Result<Option<String>, Box<dyn std::error::Error>> {
232        let session_cstring = CString::new(session_id)?;
233        let mut seat_ptr: *mut libc::c_char = ptr::null_mut();
234
235        let result =
236            unsafe { ffi::sd_session_get_seat(session_cstring.as_ptr(), &raw mut seat_ptr) };
237
238        if result < 0 {
239            return Err(
240                format!("sd_session_get_seat failed for session '{session_id}': {result}").into(),
241            );
242        }
243
244        if seat_ptr.is_null() {
245            return Ok(None);
246        }
247
248        let seat_cstr = unsafe { CStr::from_ptr(seat_ptr) };
249        let seat_string = seat_cstr.to_string_lossy().into_owned();
250
251        unsafe { libc::free(seat_ptr.cast()) };
252
253        Ok(Some(seat_string))
254    }
255
256    /// Get system boot time using systemd random-seed file fallback
257    ///
258    /// TODO: This replicates GNU coreutils' fallback behavior for compatibility.
259    /// GNU coreutils uses the mtime of /var/lib/systemd/random-seed as a heuristic for boot time
260    /// when utmp is unavailable, rather than querying systemd's authoritative KernelTimestamp.
261    /// This creates inconsistency: `uptime -s` shows the actual kernel boot time
262    /// while `who -b` shows ~1 minute later when systemd services start.
263    ///
264    /// Ideally, both should use the same source (KernelTimestamp) for semantic consistency.
265    /// Consider proposing to GNU coreutils to use systemd's KernelTimestamp property instead.
266    pub fn get_boot_time() -> Result<SystemTime, Box<dyn std::error::Error>> {
267        use std::fs;
268
269        let metadata = fs::metadata("/var/lib/systemd/random-seed")
270            .map_err(|e| format!("Failed to read /var/lib/systemd/random-seed: {e}"))?;
271
272        metadata
273            .modified()
274            .map_err(|e| format!("Failed to get modification time: {e}").into())
275    }
276}
277
278/// Login record compatible with utmpx structure
279#[derive(Debug, Clone)]
280pub struct SystemdLoginRecord {
281    pub user: String,
282    pub session_id: String,
283    pub seat_or_tty: String,
284    pub raw_device: String,
285    pub host: String,
286    pub login_time: SystemTime,
287    pub pid: u32,
288    pub session_leader_pid: u32,
289    pub record_type: SystemdRecordType,
290}
291
292#[derive(Debug, Clone, Copy, PartialEq)]
293pub enum SystemdRecordType {
294    UserProcess = 7,  // USER_PROCESS
295    LoginProcess = 6, // LOGIN_PROCESS
296    BootTime = 2,     // BOOT_TIME
297}
298
299impl SystemdLoginRecord {
300    /// Check if this is a user process record
301    pub fn is_user_process(&self) -> bool {
302        !self.user.is_empty() && self.record_type == SystemdRecordType::UserProcess
303    }
304
305    /// Get login time as time::OffsetDateTime compatible with utmpx
306    pub fn login_time_offset(&self) -> time::OffsetDateTime {
307        let duration = self
308            .login_time
309            .duration_since(UNIX_EPOCH)
310            .unwrap_or_default();
311        let ts_nanos: i128 = (duration.as_nanos()).try_into().unwrap_or(0);
312        let local_offset = time::OffsetDateTime::now_local()
313            .map_or_else(|_| time::UtcOffset::UTC, time::OffsetDateTime::offset);
314        time::OffsetDateTime::from_unix_timestamp_nanos(ts_nanos)
315            .unwrap_or_else(|_| {
316                time::OffsetDateTime::now_local()
317                    .unwrap_or_else(|_| time::OffsetDateTime::now_utc())
318            })
319            .to_offset(local_offset)
320    }
321}
322
323/// Read login records from systemd-logind using safe wrapper functions
324/// This matches the approach used by GNU coreutils read_utmp_from_systemd()
325pub fn read_login_records() -> UResult<Vec<SystemdLoginRecord>> {
326    let mut records = Vec::new();
327
328    // Add boot time record first
329    if let Ok(boot_time) = login::get_boot_time() {
330        let boot_record = SystemdLoginRecord {
331            user: "reboot".to_string(),
332            session_id: "boot".to_string(),
333            seat_or_tty: "~".to_string(), // Traditional boot time indicator
334            raw_device: String::new(),
335            host: String::new(),
336            login_time: boot_time,
337            pid: 0,
338            session_leader_pid: 0,
339            record_type: SystemdRecordType::BootTime,
340        };
341        records.push(boot_record);
342    }
343
344    // Get all active sessions using safe wrapper
345    let mut sessions = login::get_sessions()
346        .map_err(|e| USimpleError::new(1, format!("Failed to get systemd sessions: {e}")))?;
347
348    // Sort sessions consistently for reproducible output (reverse for TTY sessions first)
349    sessions.sort();
350    sessions.reverse();
351
352    // Iterate through all sessions
353    for session_id in sessions {
354        // Get session UID using safe wrapper
355        let Ok(uid) = login::get_session_uid(&session_id) else {
356            continue;
357        };
358
359        // Get username from UID
360        let user = unsafe {
361            let mut passwd = MaybeUninit::<libc::passwd>::uninit();
362
363            // Get recommended buffer size, fall back if indeterminate
364            let buf_size = {
365                let size = libc::sysconf(libc::_SC_GETPW_R_SIZE_MAX);
366                if size == -1 {
367                    16384 // Value was indeterminate, use fallback from getpwuid_r man page
368                } else {
369                    size as usize
370                }
371            };
372            let mut buf = vec![0u8; buf_size];
373            let mut result: *mut libc::passwd = std::ptr::null_mut();
374
375            let ret = libc::getpwuid_r(
376                uid,
377                passwd.as_mut_ptr(),
378                buf.as_mut_ptr().cast(),
379                buf.len(),
380                &raw mut result,
381            );
382
383            if ret == 0 && !result.is_null() {
384                let passwd = passwd.assume_init();
385                CStr::from_ptr(passwd.pw_name)
386                    .to_string_lossy()
387                    .into_owned()
388            } else {
389                format!("{uid}") // fallback to UID if username not found
390            }
391        };
392
393        // Get start time using safe wrapper, fallback to epoch if unavailable
394        let start_time = login::get_session_start_time(&session_id).map_or(UNIX_EPOCH, |usec| {
395            UNIX_EPOCH + std::time::Duration::from_micros(usec)
396        });
397
398        // Get TTY using safe wrapper
399        let mut tty = login::get_session_tty(&session_id)
400            .ok()
401            .flatten()
402            .unwrap_or_default();
403
404        // Get seat using safe wrapper
405        let mut seat = login::get_session_seat(&session_id)
406            .ok()
407            .flatten()
408            .unwrap_or_default();
409
410        // Strip any existing prefixes from systemd values (if any)
411        if tty.starts_with('?') {
412            tty = tty[1..].to_string();
413        }
414        if seat.starts_with('?') {
415            seat = seat[1..].to_string();
416        }
417
418        // Get remote host using safe wrapper
419        let remote_host = login::get_session_remote_host(&session_id)
420            .ok()
421            .flatten()
422            .unwrap_or_default();
423
424        // Get display using safe wrapper (for GUI sessions)
425        let display = login::get_session_display(&session_id)
426            .ok()
427            .flatten()
428            .unwrap_or_default();
429
430        // Get session type using safe wrapper (currently unused but available)
431        let _session_type = login::get_session_type(&session_id)
432            .ok()
433            .flatten()
434            .unwrap_or_default();
435
436        // Determine host (use remote_host if available)
437        // If host is local (non-remote) we use display,
438        let host = if remote_host.is_empty() {
439            display.clone()
440        } else {
441            remote_host
442        };
443
444        // Skip sessions that have neither TTY nor seat (e.g., manager sessions)
445        if tty.is_empty() && seat.is_empty() && display.is_empty() {
446            continue;
447        }
448
449        // A single session can be associated with both a TTY and a seat.
450        // GNU `who` and `pinky` create separate records for each.
451        // We replicate that behavior here.
452        // Order: seat first, then TTY to match expected output
453
454        // Helper closure to create a record
455        let create_record = |seat_or_tty: String,
456                             raw_device: String,
457                             user: String,
458                             session_id: String,
459                             host: String| {
460            SystemdLoginRecord {
461                user,
462                session_id,
463                seat_or_tty,
464                raw_device,
465                host,
466                login_time: start_time,
467                pid: 0, // systemd doesn't directly provide session leader PID in this context
468                session_leader_pid: 0,
469                record_type: SystemdRecordType::UserProcess,
470            }
471        };
472
473        // Create records based on available seat/tty/display
474        if !seat.is_empty() && !tty.is_empty() {
475            // Both seat and tty - need 2 records, clone for first.
476            // The seat is prefixed with '?' to match GNU's output.
477            let seat_formatted = format!("?{seat}");
478            records.push(create_record(
479                seat_formatted,
480                seat,
481                user.clone(),
482                session_id.clone(),
483                host.clone(),
484            ));
485
486            let tty_formatted = if tty.starts_with("tty") {
487                format!("*{tty}")
488            } else {
489                tty.clone()
490            };
491            records.push(create_record(tty_formatted, tty, user, session_id, host)); // Move for second (and last) record
492        } else if !seat.is_empty() {
493            // Only seat
494            let seat_formatted = format!("?{seat}");
495            records.push(create_record(seat_formatted, seat, user, session_id, host));
496        } else if !tty.is_empty() {
497            // Only tty
498            let tty_formatted = if tty.starts_with("tty") {
499                format!("*{tty}")
500            } else {
501                tty.clone()
502            };
503            records.push(create_record(tty_formatted, tty, user, session_id, host));
504        } else if !display.is_empty() {
505            // Only display
506            // No raw device for display sessions
507            records.push(create_record(
508                display,
509                String::new(),
510                user,
511                session_id,
512                host,
513            ));
514        }
515    }
516
517    Ok(records)
518}
519
520/// Wrapper to provide utmpx-compatible interface for a single record
521pub struct SystemdUtmpxCompat {
522    record: SystemdLoginRecord,
523}
524
525impl SystemdUtmpxCompat {
526    /// Create new instance from a SystemdLoginRecord
527    pub fn new(record: SystemdLoginRecord) -> Self {
528        Self { record }
529    }
530
531    /// A.K.A. ut.ut_type
532    pub fn record_type(&self) -> i16 {
533        self.record.record_type as i16
534    }
535
536    /// A.K.A. ut.ut_pid
537    pub fn pid(&self) -> i32 {
538        self.record.pid as i32
539    }
540
541    /// A.K.A. ut.ut_id
542    pub fn terminal_suffix(&self) -> String {
543        // Extract last part of session ID or use session ID
544        self.record.session_id.clone()
545    }
546
547    /// A.K.A. ut.ut_user
548    pub fn user(&self) -> String {
549        self.record.user.clone()
550    }
551
552    /// A.K.A. ut.ut_host
553    pub fn host(&self) -> String {
554        self.record.host.clone()
555    }
556
557    /// A.K.A. ut.ut_line
558    pub fn tty_device(&self) -> String {
559        // Return raw device name for device access if available, otherwise formatted seat_or_tty
560        if self.record.raw_device.is_empty() {
561            self.record.seat_or_tty.clone()
562        } else {
563            self.record.raw_device.clone()
564        }
565    }
566
567    /// Login time
568    pub fn login_time(&self) -> time::OffsetDateTime {
569        self.record.login_time_offset()
570    }
571
572    /// Exit status (not available from systemd)
573    pub fn exit_status(&self) -> (i16, i16) {
574        (0, 0) // Not available from systemd
575    }
576
577    /// Check if this is a user process record
578    pub fn is_user_process(&self) -> bool {
579        self.record.is_user_process()
580    }
581
582    /// Canonical host name
583    pub fn canon_host(&self) -> String {
584        // Simple implementation - just return the host as-is
585        // Could be enhanced with DNS lookup like the original
586        self.record.host.clone()
587    }
588}
589
590/// Container for reading multiple systemd records
591pub struct SystemdUtmpxIter {
592    records: Vec<SystemdLoginRecord>,
593    current_index: usize,
594}
595
596impl SystemdUtmpxIter {
597    /// Create new instance and read records from systemd-logind
598    pub fn new() -> UResult<Self> {
599        let records = read_login_records()?;
600        Ok(Self {
601            records,
602            current_index: 0,
603        })
604    }
605
606    /// Create empty iterator (for when systemd initialization fails)
607    pub fn empty() -> Self {
608        Self {
609            records: Vec::new(),
610            current_index: 0,
611        }
612    }
613
614    /// Get next record (similar to getutxent)
615    pub fn next_record(&mut self) -> Option<SystemdUtmpxCompat> {
616        if self.current_index >= self.records.len() {
617            return None;
618        }
619
620        let record = self.records[self.current_index].clone();
621        self.current_index += 1;
622
623        Some(SystemdUtmpxCompat::new(record))
624    }
625
626    /// Get all records at once
627    pub fn get_all_records(&self) -> Vec<SystemdUtmpxCompat> {
628        self.records
629            .iter()
630            .cloned()
631            .map(SystemdUtmpxCompat::new)
632            .collect()
633    }
634
635    /// Reset iterator to beginning
636    pub fn reset(&mut self) {
637        self.current_index = 0;
638    }
639
640    /// Get number of records
641    pub fn len(&self) -> usize {
642        self.records.len()
643    }
644
645    /// Check if empty
646    pub fn is_empty(&self) -> bool {
647        self.records.is_empty()
648    }
649}
650
651impl Iterator for SystemdUtmpxIter {
652    type Item = SystemdUtmpxCompat;
653
654    fn next(&mut self) -> Option<Self::Item> {
655        self.next_record()
656    }
657}
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662
663    #[test]
664    fn test_empty_iterator() {
665        let mut iter = SystemdUtmpxIter::empty();
666
667        assert_eq!(iter.len(), 0);
668        assert!(iter.is_empty());
669        assert!(iter.next().is_none());
670        assert!(iter.next_record().is_none());
671    }
672
673    #[test]
674    fn test_iterator_with_mock_data() {
675        // Create iterator with mock records
676        let mock_records = vec![
677            SystemdLoginRecord {
678                session_id: "session1".to_string(),
679                user: "user1".to_string(),
680                seat_or_tty: "tty1".to_string(),
681                raw_device: "tty1".to_string(),
682                host: "host1".to_string(),
683                login_time: UNIX_EPOCH,
684                pid: 1234,
685                session_leader_pid: 1234,
686                record_type: SystemdRecordType::UserProcess,
687            },
688            SystemdLoginRecord {
689                session_id: "session2".to_string(),
690                user: "user2".to_string(),
691                seat_or_tty: "pts/0".to_string(),
692                raw_device: "pts/0".to_string(),
693                host: "host2".to_string(),
694                login_time: UNIX_EPOCH,
695                pid: 5678,
696                session_leader_pid: 5678,
697                record_type: SystemdRecordType::UserProcess,
698            },
699        ];
700
701        let mut iter = SystemdUtmpxIter {
702            records: mock_records,
703            current_index: 0,
704        };
705
706        assert_eq!(iter.len(), 2);
707        assert!(!iter.is_empty());
708
709        // Test iterator behavior
710        let first = iter.next();
711        assert!(first.is_some());
712
713        let second = iter.next();
714        assert!(second.is_some());
715
716        let third = iter.next();
717        assert!(third.is_none());
718
719        // Iterator should be exhausted
720        assert!(iter.next().is_none());
721    }
722
723    #[test]
724    fn test_get_all_records() {
725        let mock_records = vec![SystemdLoginRecord {
726            session_id: "session1".to_string(),
727            user: "user1".to_string(),
728            seat_or_tty: "tty1".to_string(),
729            raw_device: "tty1".to_string(),
730            host: "host1".to_string(),
731            login_time: UNIX_EPOCH,
732            pid: 1234,
733            session_leader_pid: 1234,
734            record_type: SystemdRecordType::UserProcess,
735        }];
736
737        let iter = SystemdUtmpxIter {
738            records: mock_records,
739            current_index: 0,
740        };
741
742        let all_records = iter.get_all_records();
743        assert_eq!(all_records.len(), 1);
744    }
745
746    #[test]
747    fn test_systemd_record_conversion() {
748        // Test that SystemdLoginRecord converts correctly to SystemdUtmpxCompat
749        let record = SystemdLoginRecord {
750            session_id: "c1".to_string(),
751            user: "testuser".to_string(),
752            seat_or_tty: "seat0".to_string(),
753            raw_device: "seat0".to_string(),
754            host: "localhost".to_string(),
755            login_time: UNIX_EPOCH + std::time::Duration::from_secs(1000),
756            pid: 9999,
757            session_leader_pid: 9999,
758            record_type: SystemdRecordType::UserProcess,
759        };
760
761        let compat = SystemdUtmpxCompat::new(record);
762
763        // Test the actual conversion logic
764        assert_eq!(compat.user(), "testuser");
765        assert_eq!(compat.tty_device().as_str(), "seat0");
766        assert_eq!(compat.host(), "localhost");
767    }
768}