Skip to main content

roowho2_lib/proto/
finger_protocol.rs

1use std::path::PathBuf;
2
3use chrono::{DateTime, Datelike, Duration, NaiveDate, NaiveTime, TimeDelta, Utc, Weekday};
4use itertools::Itertools;
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
8pub struct FingerRequest {
9    long: bool,
10    name: String,
11}
12
13impl FingerRequest {
14    pub fn new(long: bool, name: String) -> Self {
15        Self { long, name }
16    }
17
18    pub fn to_bytes(&self) -> Vec<u8> {
19        let mut result = Vec::new();
20        if self.long {
21            result.extend(b"/W ");
22        }
23
24        result.extend(self.name.as_bytes());
25        result.extend(b"\r\n");
26
27        result
28    }
29
30    pub fn from_bytes(bytes: &[u8]) -> Self {
31        let (long, name) = if &bytes[..3] == b"/W " {
32            (true, &bytes[3..])
33        } else {
34            (false, bytes)
35        };
36
37        let name = match name.strip_suffix(b"\r\n") {
38            Some(new_name) => new_name,
39            None => name,
40        };
41
42        Self::new(long, String::from_utf8_lossy(name).to_string())
43    }
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
47pub struct RawFingerResponse(String);
48
49impl RawFingerResponse {
50    pub fn new(content: String) -> Self {
51        Self(content)
52    }
53
54    pub fn get_inner(&self) -> &str {
55        &self.0
56    }
57
58    pub fn into_inner(self) -> String {
59        self.0
60    }
61
62    pub fn is_empty(&self) -> bool {
63        self.0.is_empty()
64    }
65
66    pub fn from_bytes(bytes: &[u8]) -> Self {
67        if bytes.is_empty() {
68            return Self(String::new());
69        }
70
71        fn normalize(c: u8) -> u8 {
72            if c == (b'\r' | 0x80) || c == (b'\n' | 0x80) {
73                c & 0x7f
74            } else {
75                c
76            }
77        }
78
79        let normalized: Vec<u8> = bytes
80            .iter()
81            .copied()
82            .map(normalize)
83            .chain(std::iter::once(normalize(*bytes.last().unwrap())))
84            .map_windows(|[a, b]| {
85                if *a == b'\r' && *b == b'\n' {
86                    None
87                } else {
88                    Some(*a)
89                }
90            })
91            .flatten()
92            .collect();
93
94        let result = String::from_utf8_lossy(&normalized).to_string();
95
96        Self(result)
97    }
98
99    pub fn to_bytes(&self) -> Vec<u8> {
100        let mut out = Vec::with_capacity(self.0.len() + 2);
101
102        for &b in self.0.as_bytes() {
103            if b == b'\n' {
104                out.extend_from_slice(b"\r\n");
105            } else {
106                out.push(b);
107            }
108        }
109
110        if !self.0.ends_with('\n') {
111            out.extend_from_slice(b"\r\n");
112        }
113
114        out
115    }
116}
117
118impl From<String> for RawFingerResponse {
119    fn from(s: String) -> Self {
120        Self::new(s)
121    }
122}
123
124impl From<&str> for RawFingerResponse {
125    fn from(s: &str) -> Self {
126        Self::new(s.to_string())
127    }
128}
129
130/// Parse the time serialization format commonly used by bsd-finger
131fn parse_bsd_finger_time(time: &str) -> anyhow::Result<DateTime<Utc>> {
132    let time_parts: Vec<_> = time.split_ascii_whitespace().collect();
133
134    let time = &time_parts[..time_parts.len() - 1].join(" ");
135    let _timezone = time_parts[time_parts.len() - 1];
136
137    let now = Utc::now();
138    let mut parts = time.split_whitespace();
139
140    let weekday = match parts.next() {
141        Some("Mon") => Weekday::Mon,
142        Some("Tue") => Weekday::Tue,
143        Some("Wed") => Weekday::Wed,
144        Some("Thu") => Weekday::Thu,
145        Some("Fri") => Weekday::Fri,
146        Some("Sat") => Weekday::Sat,
147        Some("Sun") => Weekday::Sun,
148        _ => anyhow::bail!("Invalid weekday in login time: {}", time),
149    };
150
151    let month = match parts.next() {
152        Some("Jan") => 1,
153        Some("Feb") => 2,
154        Some("Mar") => 3,
155        Some("Apr") => 4,
156        Some("May") => 5,
157        Some("Jun") => 6,
158        Some("Jul") => 7,
159        Some("Aug") => 8,
160        Some("Sep") => 9,
161        Some("Oct") => 10,
162        Some("Nov") => 11,
163        Some("Dec") => 12,
164        _ => anyhow::bail!("Invalid month in login time: {}", time),
165    };
166
167    let day: u32 = parts
168        .next()
169        .and_then(|d| d.parse().ok())
170        .ok_or_else(|| anyhow::anyhow!("Invalid day in login time: {}", time))?;
171
172    let time_part = parts
173        .next()
174        .ok_or_else(|| anyhow::anyhow!("Missing time in login time: {}", time))?;
175
176    let clock = NaiveTime::parse_from_str(time_part, "%H:%M").map_err(|e| {
177        anyhow::anyhow!(
178            "Failed to parse time component in login time: {}: {}",
179            time,
180            e
181        )
182    })?;
183
184    const MAX_YEARS_BACK: i32 = 10;
185
186    for offset in 0..=MAX_YEARS_BACK {
187        let year = now.year() - offset;
188
189        let date = match NaiveDate::from_ymd_opt(year, month, day) {
190            Some(d) => d,
191            None => continue,
192        };
193
194        if date.weekday() != weekday {
195            continue;
196        }
197
198        let dt = date.and_time(clock);
199
200        if dt <= now.naive_utc() {
201            // TODO: apply timezone if we are able to parse it.
202            //       if not, try to get the local timezone offset.
203            //       if not, assume UTC.
204
205            return Ok(DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc));
206        }
207    }
208
209    Err(anyhow::anyhow!(
210        "Could not infer year for login time {} within {} years",
211        time,
212        MAX_YEARS_BACK
213    ))
214}
215
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
217pub struct FingerResponseUserEntry {
218    /// The unix username of this user, as noted in passwd
219    pub username: String,
220
221    /// The full name of this user, as noted in passwd
222    pub full_name: String,
223
224    /// The path to the home directory of this user, as noted in passwd
225    pub home_dir: PathBuf,
226
227    /// The path to the shell of this user, as noted in passwd
228    pub shell: PathBuf,
229
230    /// Office location, if available
231    pub office: Option<String>,
232
233    /// Office phone number, if available
234    pub office_phone: Option<String>,
235
236    /// Home phone number, if available
237    pub home_phone: Option<String>,
238
239    /// Whether the user has never logged in to this host
240    pub never_logged_in: bool,
241
242    /// A list of user sessions, sourced from utmp entries
243    pub sessions: Vec<FingerResponseUserSession>,
244
245    /// Contents of ~/.forward, if it exists
246    pub forward_status: Option<String>,
247
248    /// Whether the user has new or unread mail
249    pub mail_status: Option<MailStatus>,
250
251    /// Contents of ~/.pgpkey, if it exists
252    pub pgp_key: Option<String>,
253
254    /// Contents of ~/.project, if it exists
255    pub project: Option<String>,
256
257    /// Contents of ~/.plan, if it exists
258    pub plan: Option<String>,
259}
260
261impl FingerResponseUserEntry {
262    #[allow(clippy::too_many_arguments)]
263    pub fn new(
264        username: String,
265        full_name: String,
266        home_dir: PathBuf,
267        shell: PathBuf,
268        office: Option<String>,
269        office_phone: Option<String>,
270        home_phone: Option<String>,
271        never_logged_in: bool,
272        sessions: Vec<FingerResponseUserSession>,
273        forward_status: Option<String>,
274        mail_status: Option<MailStatus>,
275        pgp_key: Option<String>,
276        project: Option<String>,
277        plan: Option<String>,
278    ) -> Self {
279        debug_assert!(
280            !never_logged_in || sessions.is_empty(),
281            "User cannot be marked as never logged in while having active sessions"
282        );
283
284        Self {
285            username,
286            full_name,
287            home_dir,
288            shell,
289            office,
290            office_phone,
291            home_phone,
292            never_logged_in,
293            sessions,
294            forward_status,
295            mail_status,
296            pgp_key,
297            project,
298            plan,
299        }
300    }
301
302    /// Try parsing a [FingerResponseUserEntry] from the text format used by bsd-finger.
303    pub fn try_from_raw_finger_response(
304        response: &RawFingerResponse,
305        username: String,
306    ) -> anyhow::Result<Self> {
307        let content = response.get_inner();
308        let lines: Vec<&str> = content.lines().collect();
309
310        if lines.len() < 2 {
311            return Err(anyhow::anyhow!(
312                "Unexpected finger response format for user {}",
313                username
314            ));
315        }
316
317        let first_line = lines[0];
318        let second_line = lines[1];
319
320        let full_name = first_line
321            .split("Name:")
322            .nth(1)
323            .ok_or_else(|| {
324                anyhow::anyhow!(
325                    "Failed to parse full name from finger response for user {}",
326                    username
327                )
328            })?
329            .trim()
330            .to_string();
331
332        let home_dir = second_line
333            .split("Directory:")
334            .nth(1)
335            .and_then(|s| s.split("Shell:").next())
336            .map(|s| s.trim())
337            .map(PathBuf::from)
338            .ok_or_else(|| {
339                anyhow::anyhow!(
340                    "Failed to parse home directory from finger response for user {}",
341                    username
342                )
343            })?;
344
345        let shell = second_line
346            .split("Shell:")
347            .nth(1)
348            .map(|s| s.trim())
349            .map(PathBuf::from)
350            .ok_or_else(|| {
351                anyhow::anyhow!(
352                    "Failed to parse shell from finger response for user {}",
353                    username
354                )
355            })?;
356
357        let mut current_index = 2;
358
359        let mut office: Option<String> = None;
360        let mut office_phone: Option<String> = None;
361        let mut home_phone: Option<String> = None;
362
363        // TODO: handle case where office details contains comma, use last comma as separator
364        if let Some(line) = lines.get(current_index)
365            && line.trim().starts_with("Office:")
366        {
367            let office_line = line.trim().trim_start_matches("Office:").trim();
368            if let Some((office_loc, phone)) = office_line.split_once(',') {
369                office = Some(office_loc.trim().to_string());
370                office_phone = Some(phone.trim().to_string());
371            } else {
372                office = Some(office_line.to_string());
373            }
374            current_index += 1;
375        }
376        if let Some(line) = lines.get(current_index)
377            && line.trim().starts_with("Office Phone:")
378        {
379            let phone = line.trim().trim_start_matches("Office Phone:").trim();
380            office_phone = Some(phone.to_string());
381            current_index += 1;
382        }
383        if let Some(line) = lines.get(current_index)
384            && line.trim().starts_with("Home Phone:")
385        {
386            let phone = line.trim().trim_start_matches("Home Phone:").trim();
387            home_phone = Some(phone.to_string());
388            current_index += 1;
389        }
390
391        let never_logged_in = lines
392            .iter()
393            .skip(current_index)
394            .take(1)
395            .any(|&line| line.trim() == "Never logged in.");
396
397        let sessions: Vec<_> = lines
398            .iter()
399            .skip(current_index)
400            .take_while(|line| line.starts_with("On since"))
401            .filter_map(|line| {
402                match FingerResponseUserSession::try_from_finger_response_line(line) {
403                    Ok(session) => Some(session),
404                    Err(_) => {
405                        tracing::warn!("Failed to parse user session from line: {}", line);
406                        None
407                    }
408                }
409            })
410            .collect();
411
412        if never_logged_in {
413            debug_assert!(
414                sessions.is_empty(),
415                "User cannot be marked as never logged in while having active sessions"
416            );
417        }
418
419        current_index += if never_logged_in { 1 } else { sessions.len() };
420
421        let next_line = lines.get(current_index);
422
423        // TODO: handle multi-line case
424        let forward_status = if let Some(line) = next_line
425            && line.trim().starts_with("Mail forwarded to ")
426        {
427            Some(line.trim().trim_prefix("Mail forwarded to ").to_string())
428        } else {
429            None
430        };
431
432        // TODO: parse forward_status, mail_status, plan from remaining lines
433
434        Ok(Self::new(
435            username,
436            full_name,
437            home_dir,
438            shell,
439            office,
440            office_phone,
441            home_phone,
442            never_logged_in,
443            sessions,
444            forward_status,
445            None,
446            None,
447            None,
448            None,
449        ))
450    }
451}
452
453#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
454pub enum MailStatus {
455    NoMail,
456    NewMailReceived(DateTime<Utc>),
457    UnreadSince(DateTime<Utc>),
458    MailLastRead(DateTime<Utc>),
459}
460
461impl MailStatus {
462    pub fn try_from_finger_response_line(str: &str) -> anyhow::Result<Self> {
463        if str.trim() == "No mail." {
464            Ok(Self::NoMail)
465        } else if str.trim().starts_with("New mail received") {
466            let datetime = parse_bsd_finger_time(str.trim().trim_prefix("New mail received "))?;
467            Ok(Self::NewMailReceived(datetime))
468        } else if str.trim().starts_with("Unread since") {
469            let datetime = parse_bsd_finger_time(str.trim().trim_prefix("Unread since "))?;
470            Ok(Self::UnreadSince(datetime))
471        } else if str.trim().starts_with("Mail last read") {
472            let datetime = parse_bsd_finger_time(str.trim().trim_prefix("Mail last read "))?;
473            Ok(Self::MailLastRead(datetime))
474        } else {
475            anyhow::bail!("")
476        }
477    }
478}
479
480#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
481pub struct FingerResponseUserSession {
482    /// The tty on which this session exists
483    pub tty: String,
484
485    /// When the user logged in and created this session
486    pub login_time: DateTime<Utc>,
487
488    /// The amount of time since the use last interacted with the tty
489    pub idle_time: TimeDelta,
490
491    /// The hostname of the machine where this session is running
492    pub host: String,
493
494    /// Whether this tty is writable, and thus can receive messages via `mesg(1)`
495    pub messages_on: bool,
496}
497
498impl FingerResponseUserSession {
499    pub fn new(
500        tty: String,
501        login_time: DateTime<Utc>,
502        idle_time: TimeDelta,
503        host: String,
504        messages_on: bool,
505    ) -> Self {
506        Self {
507            tty,
508            login_time,
509            idle_time,
510            host,
511            messages_on,
512        }
513    }
514
515    /// Parse the idle time from the text string generated by bsd-finger
516    fn parse_idle_time(str: &str) -> anyhow::Result<Duration> {
517        // Parse idle time from finger response format.
518        // This has four cases: "    ", "MMMMM", "HH:MM", "DDd"
519        if str.trim().is_empty() {
520            Ok(Duration::zero())
521        } else if str.contains(':') {
522            let parts: Vec<&str> = str.split(':').collect();
523            if parts.len() != 2 {
524                return Err(anyhow::anyhow!("Invalid idle time format: {}", str));
525            }
526            let hours: i64 = parts[0].parse().map_err(|e| {
527                anyhow::anyhow!("Failed to parse hours from idle time {}: {}", str, e)
528            })?;
529            let minutes: i64 = parts[1].parse().map_err(|e| {
530                anyhow::anyhow!("Failed to parse minutes from idle time {}: {}", str, e)
531            })?;
532            Ok(Duration::hours(hours) + Duration::minutes(minutes))
533        } else if str.ends_with('d') {
534            let days_str = str.trim_end_matches('d');
535            let days: i64 = days_str.parse().map_err(|e| {
536                anyhow::anyhow!("Failed to parse days from idle time {}: {}", str, e)
537            })?;
538            Ok(Duration::days(days))
539        } else {
540            let minutes: i64 = str.parse().map_err(|e| {
541                anyhow::anyhow!("Failed to parse minutes from idle time {}: {}", str, e)
542            })?;
543            Ok(Duration::minutes(minutes))
544        }
545    }
546
547    /// Try parsing a [FingerResponseUserSession] from the text format used by bsd-finger.
548    pub fn try_from_finger_response_line(line: &str) -> anyhow::Result<Self> {
549        let parts: Vec<&str> = line.split_whitespace().collect();
550
551        debug_assert!(parts[0] == "On");
552        debug_assert!(parts[1] == "since");
553
554        let login_time_str = parts
555            .iter()
556            .take_while(|&&s| s != "on")
557            .skip(2)
558            .cloned()
559            .join(" ");
560
561        let login_time = parse_bsd_finger_time(&login_time_str)?;
562
563        let tty = parts
564            .iter()
565            .skip_while(|&&s| s != "on")
566            .nth(1)
567            .ok_or_else(|| anyhow::anyhow!("Failed to find tty in finger session line: {line}"))?
568            .trim_end_matches(',')
569            .to_string();
570
571        let idle_time_str = parts
572            .iter()
573            .skip_while(|&&s| s != "idle")
574            .nth(1)
575            .ok_or_else(|| {
576                anyhow::anyhow!("Failed to find idle time in finger session line: {line}")
577            })?
578            .trim_end_matches(',');
579        let idle_time = Self::parse_idle_time(idle_time_str)?;
580
581        let host = parts
582            .iter()
583            .skip_while(|&&s| s != "from")
584            .nth(1)
585            .unwrap_or(&"")
586            .to_string();
587
588        let messages_on = !line.ends_with("(messages off)");
589
590        Ok(Self::new(tty, login_time, idle_time, host, messages_on))
591    }
592}
593
594#[cfg(test)]
595mod tests {
596    use chrono::Timelike;
597
598    use super::*;
599
600    #[test]
601    fn test_finger_raw_serialization_roundrip() {
602        let request = FingerRequest::new(true, "alice".to_string());
603        let bytes = request.to_bytes();
604        let deserialized = FingerRequest::from_bytes(&bytes);
605        assert_eq!(request, deserialized);
606
607        let request2 = FingerRequest::new(false, "bob".to_string());
608        let bytes2 = request2.to_bytes();
609        let deserialized2 = FingerRequest::from_bytes(&bytes2);
610        assert_eq!(request2, deserialized2);
611
612        let response = RawFingerResponse::new("Hello, World!\nThis is a test.\n".to_string());
613        let response_bytes = response.to_bytes();
614        let deserialized_response = RawFingerResponse::from_bytes(&response_bytes);
615        assert_eq!(response, deserialized_response);
616
617        let response2 = RawFingerResponse::new("Single line response\n".to_string());
618        let response_bytes2 = response2.to_bytes();
619        let deserialized_response2 = RawFingerResponse::from_bytes(&response_bytes2);
620        assert_eq!(response2, deserialized_response2);
621    }
622
623    #[test]
624    fn test_parse_bsd_finger_time() {
625        let cases = vec![
626            "Mon Mar  1 10:00 (UTC)",
627            "Tue Feb 28 23:59 (UTC)",
628            "Wed Dec 31 00:00 (UTC)",
629            "Wed Dec 31 00:00 (GMT)",
630        ];
631
632        for input in cases {
633            let datetime = parse_bsd_finger_time(input);
634            assert!(
635                datetime.is_ok(),
636                "Failed to parse datetime for input: {}",
637                input
638            );
639        }
640    }
641
642    #[test]
643    fn test_parse_idle_time() {
644        let cases = vec![("    ", 0), ("5", 5), ("1:30", 90), ("2d", 2880)];
645
646        for (input, expected_minutes) in cases {
647            let duration = FingerResponseUserSession::parse_idle_time(input).unwrap();
648            assert_eq!(
649                duration.num_minutes(),
650                expected_minutes,
651                "Failed on input: {}",
652                input
653            );
654        }
655    }
656
657    #[test]
658    fn test_finger_user_session_parsing() {
659        let line = "On since Mon Mar  1 10:00 (UTC) on pts/0, idle 5:00, from host.example.com";
660        let session = FingerResponseUserSession::try_from_finger_response_line(line).unwrap();
661        assert_eq!(session.tty, "pts/0");
662        assert_eq!(session.host, "host.example.com");
663        assert_eq!(session.login_time.weekday(), Weekday::Mon);
664        assert_eq!(session.login_time.hour(), 10);
665        assert_eq!(session.idle_time.num_minutes(), 300);
666        assert!(session.messages_on);
667
668        let line_off = "On since Mon Mar  1 10:00 (UTC) on pts/1, idle 10, from another.host.com (messages off)";
669        let session_off =
670            FingerResponseUserSession::try_from_finger_response_line(line_off).unwrap();
671        assert_eq!(session_off.tty, "pts/1");
672        assert_eq!(session_off.host, "another.host.com");
673        assert_eq!(session_off.login_time.weekday(), Weekday::Mon);
674        assert_eq!(session_off.login_time.hour(), 10);
675        assert_eq!(session_off.idle_time.num_minutes(), 10);
676        assert!(!session_off.messages_on);
677    }
678
679    #[test]
680    fn test_finger_user_entry_parsing_basic() {
681        let response_content = indoc::indoc! {"
682              Login: alice           			Name: Alice Wonderland
683              Directory: /home/alice             	Shell: /bin/bash
684              On since Mon Mar  1 10:00 (UTC) on pts/0, idle 5:00, from host.example.com
685              No Mail.
686              No Plan.
687            "}
688        .trim();
689
690        let response = RawFingerResponse::from(response_content.to_string());
691        let user_entry =
692            FingerResponseUserEntry::try_from_raw_finger_response(&response, "alice".to_string())
693                .unwrap();
694        assert_eq!(user_entry.username, "alice");
695        assert_eq!(user_entry.full_name, "Alice Wonderland");
696        assert_eq!(user_entry.home_dir, PathBuf::from("/home/alice"));
697        assert_eq!(user_entry.shell, PathBuf::from("/bin/bash"));
698        assert_eq!(user_entry.sessions.len(), 1);
699        assert_eq!(user_entry.sessions[0].tty, "pts/0");
700        assert_eq!(user_entry.sessions[0].host, "host.example.com");
701    }
702
703    #[test]
704    fn test_finger_user_entry_parsing_single_line_office_phone() {
705        let response_content = indoc::indoc! {"
706              Login: alice           			Name: Alice Wonderland
707              Directory: /home/alice             	Shell: /bin/bash
708              Office: 123 Main St, 012-345-6789
709              Home Phone: +0-123-456-7890
710              On since Mon Mar  1 10:00 (UTC) on pts/0, idle 5:00, from host.example.com
711              No Mail.
712              No Plan.
713            "}
714        .trim();
715
716        let response = RawFingerResponse::from(response_content.to_string());
717        let user_entry =
718            FingerResponseUserEntry::try_from_raw_finger_response(&response, "alice".to_string())
719                .unwrap();
720
721        assert_eq!(user_entry.office, Some("123 Main St".to_string()));
722        assert_eq!(user_entry.office_phone, Some("012-345-6789".to_string()));
723        assert_eq!(user_entry.home_phone, Some("+0-123-456-7890".to_string()));
724    }
725
726    #[test]
727    fn test_finger_user_entry_parsing_multiline_office_phone() {
728        let response_content = indoc::indoc! {"
729              Login: alice           			Name: Alice Wonderland
730              Directory: /home/alice             	Shell: /bin/bash
731              Office: 123 Main St
732              Office Phone: 012-345-6789
733              Home Phone: +0-123-456-7890
734              On since Mon Mar  1 10:00 (UTC) on pts/0, idle 5:00, from host.example.com
735              No Mail.
736              No Plan.
737            "}
738        .trim();
739
740        let response = RawFingerResponse::from(response_content.to_string());
741        let user_entry =
742            FingerResponseUserEntry::try_from_raw_finger_response(&response, "alice".to_string())
743                .unwrap();
744
745        assert_eq!(user_entry.office, Some("123 Main St".to_string()));
746        assert_eq!(user_entry.office_phone, Some("012-345-6789".to_string()));
747        assert_eq!(user_entry.home_phone, Some("+0-123-456-7890".to_string()));
748    }
749
750    #[test]
751    fn test_finger_user_entry_parsing_never_logged_in() {
752        let response_content = indoc::indoc! {"
753              Login: bob           			Name: Bob Builder
754              Directory: /home/bob             	Shell: /bin/zsh
755              Never logged in.
756              No Mail.
757              No Plan.
758            "}
759        .trim();
760
761        let response = RawFingerResponse::from(response_content.to_string());
762        let user_entry =
763            FingerResponseUserEntry::try_from_raw_finger_response(&response, "bob".to_string())
764                .unwrap();
765
766        assert!(user_entry.never_logged_in);
767        assert!(user_entry.sessions.is_empty());
768    }
769}