1
use std::path::PathBuf;
2

            
3
use chrono::{DateTime, Datelike, Duration, NaiveDate, NaiveTime, TimeDelta, Utc, Weekday};
4
use itertools::Itertools;
5
use serde::{Deserialize, Serialize};
6

            
7
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
8
pub struct FingerRequest {
9
    long: bool,
10
    name: String,
11
}
12

            
13
impl FingerRequest {
14
4
    pub fn new(long: bool, name: String) -> Self {
15
4
        Self { long, name }
16
4
    }
17

            
18
2
    pub fn to_bytes(&self) -> Vec<u8> {
19
2
        let mut result = Vec::new();
20
2
        if self.long {
21
1
            result.extend(b"/W ");
22
1
        }
23

            
24
2
        result.extend(self.name.as_bytes());
25
2
        result.extend(b"\r\n");
26

            
27
2
        result
28
2
    }
29

            
30
2
    pub fn from_bytes(bytes: &[u8]) -> Self {
31
2
        let (long, name) = if &bytes[..3] == b"/W " {
32
1
            (true, &bytes[3..])
33
        } else {
34
1
            (false, bytes)
35
        };
36

            
37
2
        let name = match name.strip_suffix(b"\r\n") {
38
2
            Some(new_name) => new_name,
39
            None => name,
40
        };
41

            
42
2
        Self::new(long, String::from_utf8_lossy(name).to_string())
43
2
    }
44
}
45

            
46
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
47
pub struct RawFingerResponse(String);
48

            
49
impl RawFingerResponse {
50
6
    pub fn new(content: String) -> Self {
51
6
        Self(content)
52
6
    }
53

            
54
4
    pub fn get_inner(&self) -> &str {
55
4
        &self.0
56
4
    }
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
2
    pub fn from_bytes(bytes: &[u8]) -> Self {
67
2
        if bytes.is_empty() {
68
            return Self(String::new());
69
2
        }
70

            
71
56
        fn normalize(c: u8) -> u8 {
72
56
            if c == (b'\r' | 0x80) || c == (b'\n' | 0x80) {
73
                c & 0x7f
74
            } else {
75
56
                c
76
            }
77
56
        }
78

            
79
2
        let normalized: Vec<u8> = bytes
80
2
            .iter()
81
2
            .copied()
82
2
            .map(normalize)
83
2
            .chain(std::iter::once(normalize(*bytes.last().unwrap())))
84
54
            .map_windows(|[a, b]| {
85
54
                if *a == b'\r' && *b == b'\n' {
86
3
                    None
87
                } else {
88
51
                    Some(*a)
89
                }
90
54
            })
91
2
            .flatten()
92
2
            .collect();
93

            
94
2
        let result = String::from_utf8_lossy(&normalized).to_string();
95

            
96
2
        Self(result)
97
2
    }
98

            
99
2
    pub fn to_bytes(&self) -> Vec<u8> {
100
2
        let mut out = Vec::with_capacity(self.0.len() + 2);
101

            
102
51
        for &b in self.0.as_bytes() {
103
51
            if b == b'\n' {
104
3
                out.extend_from_slice(b"\r\n");
105
48
            } else {
106
48
                out.push(b);
107
48
            }
108
        }
109

            
110
2
        if !self.0.ends_with('\n') {
111
            out.extend_from_slice(b"\r\n");
112
2
        }
113

            
114
2
        out
115
2
    }
116
}
117

            
118
impl From<String> for RawFingerResponse {
119
4
    fn from(s: String) -> Self {
120
4
        Self::new(s)
121
4
    }
122
}
123

            
124
impl 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
131
9
fn parse_bsd_finger_time(time: &str) -> anyhow::Result<DateTime<Utc>> {
132
9
    let time_parts: Vec<_> = time.split_ascii_whitespace().collect();
133

            
134
9
    let time = &time_parts[..time_parts.len() - 1].join(" ");
135
9
    let _timezone = time_parts[time_parts.len() - 1];
136

            
137
9
    let now = Utc::now();
138
9
    let mut parts = time.split_whitespace();
139

            
140
9
    let weekday = match parts.next() {
141
9
        Some("Mon") => Weekday::Mon,
142
3
        Some("Tue") => Weekday::Tue,
143
2
        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
9
    let month = match parts.next() {
152
9
        Some("Jan") => 1,
153
9
        Some("Feb") => 2,
154
8
        Some("Mar") => 3,
155
2
        Some("Apr") => 4,
156
2
        Some("May") => 5,
157
2
        Some("Jun") => 6,
158
2
        Some("Jul") => 7,
159
2
        Some("Aug") => 8,
160
2
        Some("Sep") => 9,
161
2
        Some("Oct") => 10,
162
2
        Some("Nov") => 11,
163
2
        Some("Dec") => 12,
164
        _ => anyhow::bail!("Invalid month in login time: {}", time),
165
    };
166

            
167
9
    let day: u32 = parts
168
9
        .next()
169
9
        .and_then(|d| d.parse().ok())
170
9
        .ok_or_else(|| anyhow::anyhow!("Invalid day in login time: {}", time))?;
171

            
172
9
    let time_part = parts
173
9
        .next()
174
9
        .ok_or_else(|| anyhow::anyhow!("Missing time in login time: {}", time))?;
175

            
176
9
    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
44
    for offset in 0..=MAX_YEARS_BACK {
187
44
        let year = now.year() - offset;
188

            
189
44
        let date = match NaiveDate::from_ymd_opt(year, month, day) {
190
44
            Some(d) => d,
191
            None => continue,
192
        };
193

            
194
44
        if date.weekday() != weekday {
195
35
            continue;
196
9
        }
197

            
198
9
        let dt = date.and_time(clock);
199

            
200
9
        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
9
            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
9
}
215

            
216
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
217
pub 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

            
261
impl FingerResponseUserEntry {
262
    #[allow(clippy::too_many_arguments)]
263
5
    pub fn new(
264
5
        username: String,
265
5
        full_name: String,
266
5
        home_dir: PathBuf,
267
5
        shell: PathBuf,
268
5
        office: Option<String>,
269
5
        office_phone: Option<String>,
270
5
        home_phone: Option<String>,
271
5
        never_logged_in: bool,
272
5
        sessions: Vec<FingerResponseUserSession>,
273
5
        forward_status: Option<String>,
274
5
        mail_status: Option<MailStatus>,
275
5
        pgp_key: Option<String>,
276
5
        project: Option<String>,
277
5
        plan: Option<String>,
278
5
    ) -> Self {
279
5
        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
5
        Self {
285
5
            username,
286
5
            full_name,
287
5
            home_dir,
288
5
            shell,
289
5
            office,
290
5
            office_phone,
291
5
            home_phone,
292
5
            never_logged_in,
293
5
            sessions,
294
5
            forward_status,
295
5
            mail_status,
296
5
            pgp_key,
297
5
            project,
298
5
            plan,
299
5
        }
300
5
    }
301

            
302
    /// Try parsing a [FingerResponseUserEntry] from the text format used by bsd-finger.
303
4
    pub fn try_from_raw_finger_response(
304
4
        response: &RawFingerResponse,
305
4
        username: String,
306
4
    ) -> anyhow::Result<Self> {
307
4
        let content = response.get_inner();
308
4
        let lines: Vec<&str> = content.lines().collect();
309

            
310
4
        if lines.len() < 2 {
311
            return Err(anyhow::anyhow!(
312
                "Unexpected finger response format for user {}",
313
                username
314
            ));
315
4
        }
316

            
317
4
        let first_line = lines[0];
318
4
        let second_line = lines[1];
319

            
320
4
        let full_name = first_line
321
4
            .split("Name:")
322
4
            .nth(1)
323
4
            .ok_or_else(|| {
324
                anyhow::anyhow!(
325
                    "Failed to parse full name from finger response for user {}",
326
                    username
327
                )
328
            })?
329
4
            .trim()
330
4
            .to_string();
331

            
332
4
        let home_dir = second_line
333
4
            .split("Directory:")
334
4
            .nth(1)
335
4
            .and_then(|s| s.split("Shell:").next())
336
4
            .map(|s| s.trim())
337
4
            .map(PathBuf::from)
338
4
            .ok_or_else(|| {
339
                anyhow::anyhow!(
340
                    "Failed to parse home directory from finger response for user {}",
341
                    username
342
                )
343
            })?;
344

            
345
4
        let shell = second_line
346
4
            .split("Shell:")
347
4
            .nth(1)
348
4
            .map(|s| s.trim())
349
4
            .map(PathBuf::from)
350
4
            .ok_or_else(|| {
351
                anyhow::anyhow!(
352
                    "Failed to parse shell from finger response for user {}",
353
                    username
354
                )
355
            })?;
356

            
357
4
        let mut current_index = 2;
358

            
359
4
        let mut office: Option<String> = None;
360
4
        let mut office_phone: Option<String> = None;
361
4
        let mut home_phone: Option<String> = None;
362

            
363
        // TODO: handle case where office details contains comma, use last comma as separator
364
4
        if let Some(line) = lines.get(current_index)
365
4
            && line.trim().starts_with("Office:")
366
        {
367
2
            let office_line = line.trim().trim_start_matches("Office:").trim();
368
2
            if let Some((office_loc, phone)) = office_line.split_once(',') {
369
1
                office = Some(office_loc.trim().to_string());
370
1
                office_phone = Some(phone.trim().to_string());
371
1
            } else {
372
1
                office = Some(office_line.to_string());
373
1
            }
374
2
            current_index += 1;
375
2
        }
376
4
        if let Some(line) = lines.get(current_index)
377
4
            && line.trim().starts_with("Office Phone:")
378
1
        {
379
1
            let phone = line.trim().trim_start_matches("Office Phone:").trim();
380
1
            office_phone = Some(phone.to_string());
381
1
            current_index += 1;
382
3
        }
383
4
        if let Some(line) = lines.get(current_index)
384
4
            && line.trim().starts_with("Home Phone:")
385
2
        {
386
2
            let phone = line.trim().trim_start_matches("Home Phone:").trim();
387
2
            home_phone = Some(phone.to_string());
388
2
            current_index += 1;
389
2
        }
390

            
391
4
        let never_logged_in = lines
392
4
            .iter()
393
4
            .skip(current_index)
394
4
            .take(1)
395
4
            .any(|&line| line.trim() == "Never logged in.");
396

            
397
4
        let sessions: Vec<_> = lines
398
4
            .iter()
399
4
            .skip(current_index)
400
7
            .take_while(|line| line.starts_with("On since"))
401
4
            .filter_map(|line| {
402
3
                match FingerResponseUserSession::try_from_finger_response_line(line) {
403
3
                    Ok(session) => Some(session),
404
                    Err(_) => {
405
                        tracing::warn!("Failed to parse user session from line: {}", line);
406
                        None
407
                    }
408
                }
409
3
            })
410
4
            .collect();
411

            
412
4
        if never_logged_in {
413
1
            debug_assert!(
414
                sessions.is_empty(),
415
                "User cannot be marked as never logged in while having active sessions"
416
            );
417
3
        }
418

            
419
4
        current_index += if never_logged_in { 1 } else { sessions.len() };
420

            
421
4
        let next_line = lines.get(current_index);
422

            
423
        // TODO: handle multi-line case
424
4
        let forward_status = if let Some(line) = next_line
425
4
            && line.trim().starts_with("Mail forwarded to ")
426
        {
427
            Some(line.trim().trim_prefix("Mail forwarded to ").to_string())
428
        } else {
429
4
            None
430
        };
431

            
432
        // TODO: parse forward_status, mail_status, plan from remaining lines
433

            
434
4
        Ok(Self::new(
435
4
            username,
436
4
            full_name,
437
4
            home_dir,
438
4
            shell,
439
4
            office,
440
4
            office_phone,
441
4
            home_phone,
442
4
            never_logged_in,
443
4
            sessions,
444
4
            forward_status,
445
4
            None,
446
4
            None,
447
4
            None,
448
4
            None,
449
4
        ))
450
4
    }
451
}
452

            
453
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
454
pub enum MailStatus {
455
    NoMail,
456
    NewMailReceived(DateTime<Utc>),
457
    UnreadSince(DateTime<Utc>),
458
    MailLastRead(DateTime<Utc>),
459
}
460

            
461
impl 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)]
481
pub 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

            
498
impl FingerResponseUserSession {
499
5
    pub fn new(
500
5
        tty: String,
501
5
        login_time: DateTime<Utc>,
502
5
        idle_time: TimeDelta,
503
5
        host: String,
504
5
        messages_on: bool,
505
5
    ) -> Self {
506
5
        Self {
507
5
            tty,
508
5
            login_time,
509
5
            idle_time,
510
5
            host,
511
5
            messages_on,
512
5
        }
513
5
    }
514

            
515
    /// Parse the idle time from the text string generated by bsd-finger
516
9
    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
9
        if str.trim().is_empty() {
520
1
            Ok(Duration::zero())
521
8
        } else if str.contains(':') {
522
5
            let parts: Vec<&str> = str.split(':').collect();
523
5
            if parts.len() != 2 {
524
                return Err(anyhow::anyhow!("Invalid idle time format: {}", str));
525
5
            }
526
5
            let hours: i64 = parts[0].parse().map_err(|e| {
527
                anyhow::anyhow!("Failed to parse hours from idle time {}: {}", str, e)
528
            })?;
529
5
            let minutes: i64 = parts[1].parse().map_err(|e| {
530
                anyhow::anyhow!("Failed to parse minutes from idle time {}: {}", str, e)
531
            })?;
532
5
            Ok(Duration::hours(hours) + Duration::minutes(minutes))
533
3
        } else if str.ends_with('d') {
534
1
            let days_str = str.trim_end_matches('d');
535
1
            let days: i64 = days_str.parse().map_err(|e| {
536
                anyhow::anyhow!("Failed to parse days from idle time {}: {}", str, e)
537
            })?;
538
1
            Ok(Duration::days(days))
539
        } else {
540
2
            let minutes: i64 = str.parse().map_err(|e| {
541
                anyhow::anyhow!("Failed to parse minutes from idle time {}: {}", str, e)
542
            })?;
543
2
            Ok(Duration::minutes(minutes))
544
        }
545
9
    }
546

            
547
    /// Try parsing a [FingerResponseUserSession] from the text format used by bsd-finger.
548
5
    pub fn try_from_finger_response_line(line: &str) -> anyhow::Result<Self> {
549
5
        let parts: Vec<&str> = line.split_whitespace().collect();
550

            
551
5
        debug_assert!(parts[0] == "On");
552
5
        debug_assert!(parts[1] == "since");
553

            
554
5
        let login_time_str = parts
555
5
            .iter()
556
40
            .take_while(|&&s| s != "on")
557
5
            .skip(2)
558
5
            .cloned()
559
5
            .join(" ");
560

            
561
5
        let login_time = parse_bsd_finger_time(&login_time_str)?;
562

            
563
5
        let tty = parts
564
5
            .iter()
565
40
            .skip_while(|&&s| s != "on")
566
5
            .nth(1)
567
5
            .ok_or_else(|| anyhow::anyhow!("Failed to find tty in finger session line: {line}"))?
568
5
            .trim_end_matches(',')
569
5
            .to_string();
570

            
571
5
        let idle_time_str = parts
572
5
            .iter()
573
50
            .skip_while(|&&s| s != "idle")
574
5
            .nth(1)
575
5
            .ok_or_else(|| {
576
                anyhow::anyhow!("Failed to find idle time in finger session line: {line}")
577
            })?
578
5
            .trim_end_matches(',');
579
5
        let idle_time = Self::parse_idle_time(idle_time_str)?;
580

            
581
5
        let host = parts
582
5
            .iter()
583
60
            .skip_while(|&&s| s != "from")
584
5
            .nth(1)
585
5
            .unwrap_or(&"")
586
5
            .to_string();
587

            
588
5
        let messages_on = !line.ends_with("(messages off)");
589

            
590
5
        Ok(Self::new(tty, login_time, idle_time, host, messages_on))
591
5
    }
592
}
593

            
594
#[cfg(test)]
595
mod tests {
596
    use chrono::Timelike;
597

            
598
    use super::*;
599

            
600
    #[test]
601
1
    fn test_finger_raw_serialization_roundrip() {
602
1
        let request = FingerRequest::new(true, "alice".to_string());
603
1
        let bytes = request.to_bytes();
604
1
        let deserialized = FingerRequest::from_bytes(&bytes);
605
1
        assert_eq!(request, deserialized);
606

            
607
1
        let request2 = FingerRequest::new(false, "bob".to_string());
608
1
        let bytes2 = request2.to_bytes();
609
1
        let deserialized2 = FingerRequest::from_bytes(&bytes2);
610
1
        assert_eq!(request2, deserialized2);
611

            
612
1
        let response = RawFingerResponse::new("Hello, World!\nThis is a test.\n".to_string());
613
1
        let response_bytes = response.to_bytes();
614
1
        let deserialized_response = RawFingerResponse::from_bytes(&response_bytes);
615
1
        assert_eq!(response, deserialized_response);
616

            
617
1
        let response2 = RawFingerResponse::new("Single line response\n".to_string());
618
1
        let response_bytes2 = response2.to_bytes();
619
1
        let deserialized_response2 = RawFingerResponse::from_bytes(&response_bytes2);
620
1
        assert_eq!(response2, deserialized_response2);
621
1
    }
622

            
623
    #[test]
624
1
    fn test_parse_bsd_finger_time() {
625
1
        let cases = vec![
626
            "Mon Mar  1 10:00 (UTC)",
627
1
            "Tue Feb 28 23:59 (UTC)",
628
1
            "Wed Dec 31 00:00 (UTC)",
629
1
            "Wed Dec 31 00:00 (GMT)",
630
        ];
631

            
632
4
        for input in cases {
633
4
            let datetime = parse_bsd_finger_time(input);
634
4
            assert!(
635
4
                datetime.is_ok(),
636
                "Failed to parse datetime for input: {}",
637
                input
638
            );
639
        }
640
1
    }
641

            
642
    #[test]
643
1
    fn test_parse_idle_time() {
644
1
        let cases = vec![("    ", 0), ("5", 5), ("1:30", 90), ("2d", 2880)];
645

            
646
4
        for (input, expected_minutes) in cases {
647
4
            let duration = FingerResponseUserSession::parse_idle_time(input).unwrap();
648
4
            assert_eq!(
649
4
                duration.num_minutes(),
650
                expected_minutes,
651
                "Failed on input: {}",
652
                input
653
            );
654
        }
655
1
    }
656

            
657
    #[test]
658
1
    fn test_finger_user_session_parsing() {
659
1
        let line = "On since Mon Mar  1 10:00 (UTC) on pts/0, idle 5:00, from host.example.com";
660
1
        let session = FingerResponseUserSession::try_from_finger_response_line(line).unwrap();
661
1
        assert_eq!(session.tty, "pts/0");
662
1
        assert_eq!(session.host, "host.example.com");
663
1
        assert_eq!(session.login_time.weekday(), Weekday::Mon);
664
1
        assert_eq!(session.login_time.hour(), 10);
665
1
        assert_eq!(session.idle_time.num_minutes(), 300);
666
1
        assert!(session.messages_on);
667

            
668
1
        let line_off = "On since Mon Mar  1 10:00 (UTC) on pts/1, idle 10, from another.host.com (messages off)";
669
1
        let session_off =
670
1
            FingerResponseUserSession::try_from_finger_response_line(line_off).unwrap();
671
1
        assert_eq!(session_off.tty, "pts/1");
672
1
        assert_eq!(session_off.host, "another.host.com");
673
1
        assert_eq!(session_off.login_time.weekday(), Weekday::Mon);
674
1
        assert_eq!(session_off.login_time.hour(), 10);
675
1
        assert_eq!(session_off.idle_time.num_minutes(), 10);
676
1
        assert!(!session_off.messages_on);
677
1
    }
678

            
679
    #[test]
680
1
    fn test_finger_user_entry_parsing_basic() {
681
1
        let response_content = indoc::indoc! {"
682
1
              Login: alice           			Name: Alice Wonderland
683
1
              Directory: /home/alice             	Shell: /bin/bash
684
1
              On since Mon Mar  1 10:00 (UTC) on pts/0, idle 5:00, from host.example.com
685
1
              No Mail.
686
1
              No Plan.
687
1
            "}
688
1
        .trim();
689

            
690
1
        let response = RawFingerResponse::from(response_content.to_string());
691
1
        let user_entry =
692
1
            FingerResponseUserEntry::try_from_raw_finger_response(&response, "alice".to_string())
693
1
                .unwrap();
694
1
        assert_eq!(user_entry.username, "alice");
695
1
        assert_eq!(user_entry.full_name, "Alice Wonderland");
696
1
        assert_eq!(user_entry.home_dir, PathBuf::from("/home/alice"));
697
1
        assert_eq!(user_entry.shell, PathBuf::from("/bin/bash"));
698
1
        assert_eq!(user_entry.sessions.len(), 1);
699
1
        assert_eq!(user_entry.sessions[0].tty, "pts/0");
700
1
        assert_eq!(user_entry.sessions[0].host, "host.example.com");
701
1
    }
702

            
703
    #[test]
704
1
    fn test_finger_user_entry_parsing_single_line_office_phone() {
705
1
        let response_content = indoc::indoc! {"
706
1
              Login: alice           			Name: Alice Wonderland
707
1
              Directory: /home/alice             	Shell: /bin/bash
708
1
              Office: 123 Main St, 012-345-6789
709
1
              Home Phone: +0-123-456-7890
710
1
              On since Mon Mar  1 10:00 (UTC) on pts/0, idle 5:00, from host.example.com
711
1
              No Mail.
712
1
              No Plan.
713
1
            "}
714
1
        .trim();
715

            
716
1
        let response = RawFingerResponse::from(response_content.to_string());
717
1
        let user_entry =
718
1
            FingerResponseUserEntry::try_from_raw_finger_response(&response, "alice".to_string())
719
1
                .unwrap();
720

            
721
1
        assert_eq!(user_entry.office, Some("123 Main St".to_string()));
722
1
        assert_eq!(user_entry.office_phone, Some("012-345-6789".to_string()));
723
1
        assert_eq!(user_entry.home_phone, Some("+0-123-456-7890".to_string()));
724
1
    }
725

            
726
    #[test]
727
1
    fn test_finger_user_entry_parsing_multiline_office_phone() {
728
1
        let response_content = indoc::indoc! {"
729
1
              Login: alice           			Name: Alice Wonderland
730
1
              Directory: /home/alice             	Shell: /bin/bash
731
1
              Office: 123 Main St
732
1
              Office Phone: 012-345-6789
733
1
              Home Phone: +0-123-456-7890
734
1
              On since Mon Mar  1 10:00 (UTC) on pts/0, idle 5:00, from host.example.com
735
1
              No Mail.
736
1
              No Plan.
737
1
            "}
738
1
        .trim();
739

            
740
1
        let response = RawFingerResponse::from(response_content.to_string());
741
1
        let user_entry =
742
1
            FingerResponseUserEntry::try_from_raw_finger_response(&response, "alice".to_string())
743
1
                .unwrap();
744

            
745
1
        assert_eq!(user_entry.office, Some("123 Main St".to_string()));
746
1
        assert_eq!(user_entry.office_phone, Some("012-345-6789".to_string()));
747
1
        assert_eq!(user_entry.home_phone, Some("+0-123-456-7890".to_string()));
748
1
    }
749

            
750
    #[test]
751
1
    fn test_finger_user_entry_parsing_never_logged_in() {
752
1
        let response_content = indoc::indoc! {"
753
1
              Login: bob           			Name: Bob Builder
754
1
              Directory: /home/bob             	Shell: /bin/zsh
755
1
              Never logged in.
756
1
              No Mail.
757
1
              No Plan.
758
1
            "}
759
1
        .trim();
760

            
761
1
        let response = RawFingerResponse::from(response_content.to_string());
762
1
        let user_entry =
763
1
            FingerResponseUserEntry::try_from_raw_finger_response(&response, "bob".to_string())
764
1
                .unwrap();
765

            
766
1
        assert!(user_entry.never_logged_in);
767
1
        assert!(user_entry.sessions.is_empty());
768
1
    }
769
}