1
use std::path::PathBuf;
2

            
3
use anyhow::Context;
4
use chrono::{
5
    DateTime, Datelike, Duration, NaiveDate, NaiveTime, TimeZone, Timelike, Utc, Weekday,
6
};
7
use itertools::Itertools;
8

            
9
use crate::proto::finger_protocol::{
10
    FingerResponseStructuredUserEntry, FingerResponseUserSession, MailStatus, RawFingerResponse,
11
};
12

            
13
/// Parse the time serialization format commonly used by bsd-finger
14
19
fn parse_bsd_finger_time(time: &str) -> anyhow::Result<DateTime<Utc>> {
15
19
    let time_parts: Vec<_> = time.split_ascii_whitespace().collect();
16

            
17
19
    let time_ = &time_parts[..time_parts.len() - 1].join(" ");
18
19
    let timezone = time_parts[time_parts.len() - 1]
19
19
        .trim_start_matches('(')
20
19
        .trim_end_matches(')');
21

            
22
19
    let tz: chrono_tz::Tz = timezone
23
19
        .parse()
24
19
        .context(format!("Failed to parse timezone in login time: {}", time))?;
25

            
26
19
    let mut parts = time_.split_whitespace();
27

            
28
19
    let weekday = match parts.next() {
29
19
        Some("Mon") => Weekday::Mon,
30
4
        Some("Tue") => Weekday::Tue,
31
3
        Some("Wed") => Weekday::Wed,
32
        Some("Thu") => Weekday::Thu,
33
        Some("Fri") => Weekday::Fri,
34
        Some("Sat") => Weekday::Sat,
35
        Some("Sun") => Weekday::Sun,
36
        _ => anyhow::bail!("Invalid weekday in login time: {}", time_),
37
    };
38

            
39
19
    let month = match parts.next() {
40
19
        Some("Jan") => 1,
41
19
        Some("Feb") => 2,
42
18
        Some("Mar") => 3,
43
3
        Some("Apr") => 4,
44
3
        Some("May") => 5,
45
3
        Some("Jun") => 6,
46
3
        Some("Jul") => 7,
47
3
        Some("Aug") => 8,
48
3
        Some("Sep") => 9,
49
3
        Some("Oct") => 10,
50
3
        Some("Nov") => 11,
51
3
        Some("Dec") => 12,
52
        _ => anyhow::bail!("Invalid month in login time: {}", time_),
53
    };
54

            
55
19
    let day: u32 = parts
56
19
        .next()
57
19
        .and_then(|d| d.parse().ok())
58
19
        .ok_or_else(|| anyhow::anyhow!("Invalid day in login time: {}", time_))?;
59

            
60
19
    let time_part = parts
61
19
        .next()
62
19
        .ok_or_else(|| anyhow::anyhow!("Missing time in login time: {}", time_))?;
63

            
64
19
    let clock = NaiveTime::parse_from_str(time_part, "%H:%M").map_err(|e| {
65
        anyhow::anyhow!(
66
            "Failed to parse time component in login time: {}: {}",
67
            time_,
68
            e
69
        )
70
    })?;
71

            
72
19
    let now = Utc::now();
73
    const MAX_YEARS_BACK: i32 = 10;
74
19
    let mut year = None;
75
209
    for offset in 0..=MAX_YEARS_BACK {
76
209
        let year_ = now.year() - offset;
77

            
78
209
        let date = match NaiveDate::from_ymd_opt(year_, month, day) {
79
209
            Some(d) => d,
80
            None => continue,
81
        };
82

            
83
209
        if date.weekday() != weekday {
84
189
            continue;
85
20
        }
86

            
87
20
        year = Some(year_);
88
    }
89

            
90
19
    if year.is_none() {
91
        return Err(anyhow::anyhow!(
92
            "Could not find a valid year for login time {} within {} years",
93
            time_,
94
            MAX_YEARS_BACK
95
        ));
96
19
    }
97

            
98
19
    tz.with_ymd_and_hms(year.unwrap(), month, day, clock.hour(), clock.minute(), 0)
99
19
        .single()
100
19
        .ok_or_else(|| {
101
            anyhow::anyhow!(
102
                "Failed to convert login time to timezone-aware datetime: {}",
103
                time_
104
            )
105
        })
106
19
        .map(|dt| dt.with_timezone(&Utc))
107
19
}
108

            
109
10
pub fn try_parse_structured_user_entry_from_raw_finger_response(
110
10
    response: &RawFingerResponse,
111
10
    username: String,
112
10
) -> anyhow::Result<FingerResponseStructuredUserEntry> {
113
10
    let content = response.get_inner();
114
10
    let lines: Vec<&str> = content.lines().collect();
115

            
116
10
    if lines.len() < 2 {
117
        return Err(anyhow::anyhow!(
118
            "Unexpected finger response format for user {}",
119
            username
120
        ));
121
10
    }
122

            
123
10
    let first_line = lines[0];
124
10
    let second_line = lines[1];
125

            
126
10
    let full_name = parse_full_name(first_line, &username)?;
127
10
    let home_dir = parse_home_dir(second_line, &username)?;
128
10
    let shell = parse_shell(second_line, &username)?;
129

            
130
10
    let mut current_index = 2;
131

            
132
10
    let (office, office_phone, home_phone) = parse_gecos_fields(&lines, &mut current_index)?;
133

            
134
10
    let never_logged_in = lines[current_index].trim() == "Never logged in.";
135

            
136
10
    let user_sessions = if never_logged_in {
137
7
        current_index += 1;
138
7
        vec![]
139
    } else {
140
3
        parse_user_sessions(&lines, &mut current_index)
141
    };
142

            
143
10
    let forward_status = parse_forward_status(&lines, &mut current_index);
144
10
    let mail_status = parse_mail_status(&lines, &mut current_index)?;
145
10
    let pgp_key = parse_pgp_key(&lines, &mut current_index);
146
10
    let project = parse_project(&lines, &mut current_index);
147
10
    let plan = parse_plan(&lines, &mut current_index);
148

            
149
10
    debug_assert!(
150
        current_index == lines.len(),
151
        "Not all lines in finger response were parsed for user {}. Unparsed lines: {:?}",
152
        username,
153
        &lines[current_index..]
154
    );
155

            
156
10
    Ok(FingerResponseStructuredUserEntry::new(
157
10
        username,
158
10
        full_name,
159
10
        home_dir,
160
10
        shell,
161
10
        office,
162
10
        office_phone,
163
10
        home_phone,
164
10
        never_logged_in,
165
10
        user_sessions,
166
10
        forward_status,
167
10
        mail_status,
168
10
        pgp_key,
169
10
        project,
170
10
        plan,
171
10
    ))
172
10
}
173

            
174
10
fn parse_full_name(first_line: &str, username: &str) -> anyhow::Result<String> {
175
10
    Ok(first_line
176
10
        .split("Name:")
177
10
        .nth(1)
178
10
        .ok_or_else(|| {
179
            anyhow::anyhow!(
180
                "Failed to parse full name from finger response for user {}",
181
                username
182
            )
183
        })?
184
10
        .trim()
185
10
        .to_string())
186
10
}
187

            
188
10
fn parse_home_dir(second_line: &str, username: &str) -> anyhow::Result<PathBuf> {
189
10
    second_line
190
10
        .split("Directory:")
191
10
        .nth(1)
192
10
        .and_then(|s| s.split("Shell:").next())
193
10
        .map(|s| s.trim())
194
10
        .map(PathBuf::from)
195
10
        .ok_or_else(|| {
196
            anyhow::anyhow!(
197
                "Failed to parse home directory from finger response for user {}",
198
                username
199
            )
200
        })
201
10
}
202

            
203
10
fn parse_shell(second_line: &str, username: &str) -> anyhow::Result<PathBuf> {
204
10
    second_line
205
10
        .split("Shell:")
206
10
        .nth(1)
207
10
        .map(|s| s.trim())
208
10
        .map(PathBuf::from)
209
10
        .ok_or_else(|| {
210
            anyhow::anyhow!(
211
                "Failed to parse shell from finger response for user {}",
212
                username
213
            )
214
        })
215
10
}
216

            
217
10
fn parse_gecos_fields(
218
10
    lines: &[&str],
219
10
    current_index: &mut usize,
220
10
) -> anyhow::Result<(Option<String>, Option<String>, Option<String>)> {
221
10
    let mut office: Option<String> = None;
222
10
    let mut office_phone: Option<String> = None;
223
10
    let mut home_phone: Option<String> = None;
224

            
225
    // TODO: handle case where office details contains comma, use last comma as separator
226
10
    if let Some(line) = lines.get(*current_index)
227
10
        && line.trim().starts_with("Office:")
228
    {
229
2
        let office_line = line.trim().trim_start_matches("Office:").trim();
230
2
        if let Some((office_loc, phone)) = office_line.split_once(',') {
231
1
            office = Some(office_loc.trim().to_string());
232
1
            office_phone = Some(phone.trim().to_string());
233
1
        } else {
234
1
            office = Some(office_line.to_string());
235
1
        }
236
2
        *current_index += 1;
237
8
    }
238
10
    if let Some(line) = lines.get(*current_index)
239
10
        && line.trim().starts_with("Office Phone:")
240
1
    {
241
1
        let phone = line.trim().trim_start_matches("Office Phone:").trim();
242
1
        office_phone = Some(phone.to_string());
243
1
        *current_index += 1;
244
9
    }
245
10
    if let Some(line) = lines.get(*current_index)
246
10
        && line.trim().starts_with("Home Phone:")
247
2
    {
248
2
        let phone = line.trim().trim_start_matches("Home Phone:").trim();
249
2
        home_phone = Some(phone.to_string());
250
2
        *current_index += 1;
251
8
    }
252

            
253
10
    Ok((office, office_phone, home_phone))
254
10
}
255

            
256
3
fn parse_user_sessions(
257
3
    lines: &[&str],
258
3
    current_index: &mut usize,
259
3
) -> Vec<FingerResponseUserSession> {
260
3
    let mut sessions = Vec::new();
261

            
262
6
    while let Some(line) = lines.get(*current_index)
263
6
        && line.starts_with("On since")
264
    {
265
3
        let line_to_parse = if line.contains("from")
266
3
            && let Some(next_line) = lines.get(*current_index + 1)
267
3
            && next_line
268
3
                .trim_suffix(" (messages off)")
269
3
                .strip_suffix("idle")
270
3
                .is_some()
271
        {
272
            *current_index += 2;
273
            line.to_string() + "\n" + next_line
274
        } else {
275
3
            *current_index += 1;
276
3
            line.to_string()
277
        };
278

            
279
3
        match parse_user_session(&line_to_parse) {
280
3
            Ok(session) => {
281
3
                sessions.push(session);
282
3
            }
283
            Err(err) => {
284
                tracing::warn!("Failed to parse user session from line: {}\n{}", line, err);
285
            }
286
        }
287
    }
288

            
289
3
    sessions
290
3
}
291

            
292
/// Try parsing a [FingerResponseUserSession] from the text format used by bsd-finger.
293
8
pub fn parse_user_session(line: &str) -> anyhow::Result<FingerResponseUserSession> {
294
8
    let parts: Vec<&str> = line.split_whitespace().collect();
295

            
296
8
    debug_assert!(parts[0] == "On");
297
8
    debug_assert!(parts[1] == "since");
298

            
299
8
    let login_time_str = parts
300
8
        .iter()
301
64
        .take_while(|&&s| s != "on")
302
8
        .skip(2)
303
8
        .cloned()
304
8
        .join(" ");
305

            
306
8
    let login_time = parse_bsd_finger_time(&login_time_str)?;
307

            
308
8
    let (tty_loc, tty_str) = parts
309
8
        .iter()
310
8
        .enumerate()
311
8
        .skip(2)
312
48
        .skip_while(|&(_, &s)| s != "on")
313
8
        .nth(1)
314
8
        .ok_or_else(|| anyhow::anyhow!("Failed to find tty in finger session line: {line}"))?;
315
8
    let tty = tty_str.trim_end_matches(',').to_string();
316

            
317
8
    let (host_loc, host) = match parts
318
8
        .iter()
319
8
        .enumerate()
320
8
        .skip(tty_loc)
321
26
        .skip_while(|&(_, &s)| s != "from")
322
8
        .nth(1)
323
    {
324
7
        Some((host_loc, host)) => (host_loc, Some(host.to_string())),
325
1
        None => (tty_loc, None),
326
    };
327

            
328
8
    let idle_str = parts
329
8
        .iter()
330
8
        .skip(host_loc + 1)
331
17
        .take_while(|&&x| x != "idle")
332
8
        .join(" ")
333
8
        .trim_end_matches("(messages off)")
334
8
        .to_string();
335

            
336
8
    let idle_time = if idle_str.is_empty() {
337
5
        None
338
    } else {
339
3
        Some(parse_user_session_idle_time(&idle_str)?)
340
    };
341

            
342
8
    let messages_on = !line.ends_with("(messages off)");
343

            
344
8
    Ok(FingerResponseUserSession::new(
345
8
        tty,
346
8
        login_time,
347
8
        host,
348
8
        idle_time,
349
8
        messages_on,
350
8
    ))
351
8
}
352

            
353
/// Parse the idle time from the text string generated by bsd-finger
354
7
fn parse_user_session_idle_time(str: &str) -> anyhow::Result<Duration> {
355
7
    let mut total_duration = Duration::zero();
356

            
357
7
    let parts: Vec<&str> = str.split_whitespace().collect();
358
7
    let mut i = 0;
359
21
    while i < parts.len() {
360
14
        let value_str = parts[i];
361
14
        let unit_str = parts
362
14
            .get(i + 1)
363
14
            .ok_or_else(|| anyhow::anyhow!("Missing time unit in idle time string: {}", str))?;
364

            
365
14
        let value: i64 = value_str
366
14
            .parse()
367
14
            .map_err(|e| anyhow::anyhow!("Failed to parse value from idle time {}: {}", str, e))?;
368

            
369
14
        match *unit_str {
370
14
            "day" | "days" => total_duration += Duration::days(value),
371
11
            "hour" | "hours" => total_duration += Duration::hours(value),
372
8
            "minute" | "minutes" => total_duration += Duration::minutes(value),
373
4
            "second" | "seconds" => total_duration += Duration::seconds(value),
374
            _ => {
375
                return Err(anyhow::anyhow!(
376
                    "Unknown time unit '{}' in idle time string: {}",
377
                    unit_str,
378
                    str
379
                ));
380
            }
381
        }
382

            
383
14
        i += 2;
384
    }
385

            
386
7
    Ok(total_duration)
387
7
}
388

            
389
10
fn parse_forward_status(lines: &[&str], current_index: &mut usize) -> Option<String> {
390
10
    let next_line = lines.get(*current_index);
391

            
392
    // TODO: handle multi-line case
393
10
    if let Some(line) = next_line
394
10
        && line.trim().starts_with("Mail forwarded to ")
395
    {
396
        *current_index += 1;
397
        Some(line.trim().trim_prefix("Mail forwarded to ").to_string())
398
    } else {
399
10
        None
400
    }
401
10
}
402

            
403
10
fn parse_mail_status(
404
10
    lines: &[&str],
405
10
    current_index: &mut usize,
406
10
) -> anyhow::Result<Option<MailStatus>> {
407
10
    let next_line = lines.get(*current_index);
408

            
409
10
    if let Some(line) = next_line
410
10
        && line.trim().starts_with("New mail received")
411
    {
412
1
        let received_time_line =
413
1
            parse_bsd_finger_time(line.trim().trim_start_matches("New mail received "))?;
414

            
415
1
        let unread_since_line = parse_bsd_finger_time(
416
1
            lines
417
1
                .get(*current_index + 1)
418
1
                .ok_or_else(|| anyhow::anyhow!("Missing unread since line in mail status"))?
419
1
                .trim()
420
1
                .trim_start_matches("Unread since "),
421
        )?;
422

            
423
1
        *current_index += 2;
424

            
425
1
        Ok(Some(MailStatus::NewMailReceived {
426
1
            received_time: received_time_line,
427
1
            unread_since: unread_since_line,
428
1
        }))
429
9
    } else if let Some(line) = next_line
430
9
        && (line.trim().starts_with("Mail last read"))
431
    {
432
4
        *current_index += 1;
433
4
        let datetime = parse_bsd_finger_time(line.trim().trim_prefix("Mail last read "))?;
434
4
        Ok(Some(MailStatus::MailLastRead(datetime)))
435
5
    } else if let Some(line) = next_line
436
5
        && line.trim() == "No mail."
437
    {
438
5
        *current_index += 1;
439
5
        Ok(Some(MailStatus::NoMail))
440
    } else {
441
        tracing::warn!("Failed to parse mail status from line: {:?}", next_line);
442
        Ok(None)
443
    }
444
10
}
445

            
446
10
fn parse_pgp_key(lines: &[&str], current_index: &mut usize) -> Option<String> {
447
10
    let next_line = lines.get(*current_index);
448

            
449
10
    if let Some(line) = next_line
450
10
        && line.trim().starts_with("PGP key:")
451
    {
452
1
        *current_index += 1;
453
1
        let mut pgp_lines = Vec::new();
454
5
        while let Some(line) = lines.get(*current_index) {
455
5
            let trimmed = line.trim();
456
5
            if trimmed.starts_with("Project:") || trimmed.starts_with("Plan:") {
457
1
                break;
458
4
            }
459
4
            pgp_lines.push(trimmed);
460
4
            *current_index += 1;
461
        }
462
1
        Some(pgp_lines.join("\n"))
463
    } else {
464
9
        None
465
    }
466
10
}
467

            
468
10
fn parse_project(lines: &[&str], current_index: &mut usize) -> Option<String> {
469
10
    let next_line = lines.get(*current_index);
470

            
471
10
    if let Some(line) = next_line
472
10
        && line.trim().starts_with("Project:")
473
    {
474
3
        if line.trim().trim_start_matches("Project:").trim().is_empty() {
475
2
            *current_index += 1;
476

            
477
2
            let mut project_lines = Vec::new();
478
8
            while let Some(line) = lines.get(*current_index) {
479
8
                let trimmed = line.trim();
480
8
                if trimmed.starts_with("Plan:") {
481
2
                    break;
482
6
                }
483
6
                project_lines.push(trimmed);
484
6
                *current_index += 1;
485
            }
486
2
            Some(project_lines.join("\n"))
487
        } else {
488
1
            *current_index += 1;
489
1
            Some(
490
1
                line.trim()
491
1
                    .trim_start_matches("Project:")
492
1
                    .trim()
493
1
                    .to_string(),
494
1
            )
495
        }
496
    } else {
497
7
        None
498
    }
499
10
}
500

            
501
10
fn parse_plan(lines: &[&str], current_index: &mut usize) -> Option<String> {
502
10
    let next_line = lines.get(*current_index);
503

            
504
10
    if let Some(line) = next_line
505
10
        && line.trim().starts_with("Plan:")
506
    {
507
3
        if line.trim().trim_start_matches("Plan:").trim().is_empty() {
508
2
            *current_index += 1;
509
2
            let mut plan_lines = Vec::new();
510
11
            while let Some(line) = lines.get(*current_index) {
511
9
                plan_lines.push(line.trim());
512
9
                *current_index += 1;
513
9
            }
514
2
            Some(plan_lines.join("\n"))
515
        } else {
516
1
            *current_index += 1;
517
1
            Some(line.trim().trim_start_matches("Plan:").trim().to_string())
518
        }
519
7
    } else if let Some(line) = next_line
520
7
        && line.trim() == "No Plan."
521
    {
522
7
        *current_index += 1;
523
7
        None
524
    } else {
525
        None
526
    }
527
10
}
528

            
529
#[cfg(test)]
530
mod tests {
531
    use chrono::{TimeZone, Timelike};
532

            
533
    use super::*;
534

            
535
    #[test]
536
1
    fn test_parse_bsd_finger_time() {
537
1
        let cases = vec![
538
            "Mon Mar  1 10:00 (UTC)",
539
1
            "Tue Feb 28 23:59 (UTC)",
540
1
            "Wed Dec 31 00:00 (UTC)",
541
1
            "Wed Dec 31 00:00 (GMT)",
542
1
            "Wed Dec 31 00:00 (Asia/Tokyo)",
543
        ];
544

            
545
5
        for input in cases {
546
5
            let datetime = parse_bsd_finger_time(input);
547
5
            assert!(
548
5
                datetime.is_ok(),
549
                "Failed to parse datetime for input: {}",
550
                input
551
            );
552
        }
553
1
    }
554

            
555
    #[test]
556
1
    fn test_parse_user_session_idle_time() {
557
1
        let cases = vec![
558
1
            ("1 second", 1),
559
1
            ("3 minutes 2 seconds", 3 * 60 + 2),
560
1
            (
561
1
                "1 day 5 hours 30 minutes",
562
1
                1 * 24 * 60 * 60 + 5 * 60 * 60 + 30 * 60,
563
1
            ),
564
1
            ("1 day 1 second", 1 * 24 * 60 * 60 + 1),
565
        ];
566

            
567
4
        for (input, expected_seconds) in cases {
568
4
            let duration = parse_user_session_idle_time(input).unwrap();
569
4
            assert_eq!(
570
4
                duration.num_seconds(),
571
                expected_seconds,
572
                "Failed on input: {}",
573
                input
574
            );
575
        }
576
1
    }
577

            
578
    #[test]
579
1
    fn test_finger_user_session_parsing() {
580
1
        let line = "On since Mon Mar  1 10:00 (UTC) on pts/0 from host.example.com";
581
1
        let session = parse_user_session(line).unwrap();
582
1
        assert_eq!(session.tty, "pts/0");
583
1
        assert_eq!(session.host, Some("host.example.com".into()));
584
1
        assert_eq!(session.login_time.weekday(), Weekday::Mon);
585
1
        assert_eq!(session.login_time.hour(), 10);
586
1
        assert_eq!(session.idle_time, None);
587
1
        assert!(session.messages_on);
588

            
589
1
        let line_off =
590
1
            "On since Mon Mar  1 10:00 (UTC) on pts/1 from another.host.com (messages off)";
591
1
        let session_off = parse_user_session(line_off).unwrap();
592
1
        assert_eq!(session_off.tty, "pts/1");
593
1
        assert_eq!(session_off.host, Some("another.host.com".into()));
594
1
        assert_eq!(session_off.login_time.weekday(), Weekday::Mon);
595
1
        assert_eq!(session_off.login_time.hour(), 10);
596
1
        assert_eq!(session_off.idle_time, None);
597
1
        assert!(!session_off.messages_on);
598

            
599
1
        let line_idle = "On since Mon Mar  1 10:00 (UTC) on pts/2 1 day 5 hours 30 minutes idle";
600
1
        let session_idle = parse_user_session(line_idle).unwrap();
601
1
        assert_eq!(session_idle.tty, "pts/2");
602
1
        assert_eq!(session_idle.host, None);
603
1
        assert_eq!(session_idle.login_time.weekday(), Weekday::Mon);
604
1
        assert_eq!(session_idle.login_time.hour(), 10);
605
1
        assert_eq!(
606
1
            session_idle.idle_time.unwrap().num_minutes(),
607
            1 * 24 * 60 + 5 * 60 + 30
608
        );
609
1
        assert!(session_idle.messages_on);
610

            
611
1
        let line_idle_off = "On since Mon Mar  1 10:00 (UTC) on pts/3 from host.example.com 47 minutes 1 second idle (messages off)";
612
1
        let session_idle_off = parse_user_session(line_idle_off).unwrap();
613
1
        assert_eq!(session_idle_off.tty, "pts/3");
614
1
        assert_eq!(session_idle_off.host, Some("host.example.com".into()));
615
1
        assert_eq!(session_idle_off.login_time.weekday(), Weekday::Mon);
616
1
        assert_eq!(session_idle_off.login_time.hour(), 10);
617
1
        assert_eq!(session_idle_off.idle_time.unwrap().num_minutes(), 47);
618
1
        assert!(!session_idle_off.messages_on);
619

            
620
1
        let line_host_and_idle = indoc::indoc! {"
621
1
          On since Mon Mar  1 10:00 (UTC) on pts/4 from host.example.com
622
1
             2 hours idle
623
1
        "}
624
1
        .trim();
625
1
        let session_host_and_idle = parse_user_session(line_host_and_idle).unwrap();
626
1
        assert_eq!(session_host_and_idle.tty, "pts/4");
627
1
        assert_eq!(session_host_and_idle.host, Some("host.example.com".into()));
628
1
        assert_eq!(session_host_and_idle.login_time.weekday(), Weekday::Mon);
629
1
        assert_eq!(session_host_and_idle.login_time.hour(), 10);
630
1
        assert_eq!(
631
1
            session_host_and_idle.idle_time.unwrap().num_minutes(),
632
            60 * 2
633
        );
634
1
        assert!(session_host_and_idle.messages_on);
635

            
636
        // FIXME: "CEST" timezone parsing is currently broken
637
        // let line_cest_timezone = indoc::indoc! {"
638
        //   On since Thu Apr 30 05:45 (CEST) on pts/4 from 10.0.0.192
639
        //      6 hours 34 minutes idle
640
        // "}
641
        // .trim();
642
        // let session_cest = parse_user_session(line_cest_timezone).unwrap();
643
        // assert_eq!(session_cest.tty, "pts/4");
644
        // assert_eq!(session_cest.host, Some("10.0.0.192".to_string()));
645
        // assert_eq!(session_cest.login_time.weekday(), Weekday::Thu);
646
        // assert_eq!(session_cest.login_time.hour(), 5);
647
        // assert_eq!(session_cest.idle_time.unwrap().num_minutes(), 6 * 60 + 34);
648
        // assert!(session_cest.messages_on);
649
1
    }
650

            
651
    #[test]
652
1
    fn test_finger_user_entry_parsing_basic() {
653
1
        let response_content = indoc::indoc! {"
654
1
          Login: alice           			Name: Alice Wonderland
655
1
          Directory: /home/alice             	Shell: /bin/bash
656
1
          On since Mon Mar  1 10:00 (UTC) on pts/0 from host.example.com
657
1
          No mail.
658
1
          No Plan.
659
1
        "}
660
1
        .trim();
661

            
662
1
        let response = RawFingerResponse::from(response_content.to_string());
663
1
        let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response(
664
1
            &response,
665
1
            "alice".to_string(),
666
        )
667
1
        .unwrap();
668
1
        assert_eq!(user_entry.username, "alice");
669
1
        assert_eq!(user_entry.full_name, "Alice Wonderland");
670
1
        assert_eq!(user_entry.home_dir, PathBuf::from("/home/alice"));
671
1
        assert_eq!(user_entry.shell, PathBuf::from("/bin/bash"));
672
1
        assert_eq!(user_entry.sessions.len(), 1);
673
1
        assert_eq!(user_entry.sessions[0].tty, "pts/0");
674
1
        assert_eq!(user_entry.sessions[0].host, Some("host.example.com".into()));
675
1
    }
676

            
677
    #[test]
678
    #[ignore = "CEST timezone parsing is currently broken"]
679
    fn test_finger_user_entry_parsing_idle_sessions() {
680
        let response_content = indoc::indoc! {"
681
        Login: alice           			Name: Alice Wonderland
682
        Directory: /home/alice             	Shell: /bin/bash
683
        On since Thu Apr 30 05:45 (CEST) on pts/4 from 10.0.0.192
684
           6 hours 34 minutes idle
685
        On since Thu Apr 30 05:46 (CEST) on pts/5 from 10.0.0.192
686
           6 hours 33 minutes idle
687
        No mail.
688
        No Plan.
689
      "}
690
        .trim();
691

            
692
        let response = RawFingerResponse::from(response_content.to_string());
693
        let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response(
694
            &response,
695
            "alice".to_string(),
696
        )
697
        .unwrap();
698

            
699
        assert_eq!(user_entry.sessions.len(), 2);
700

            
701
        assert_eq!(user_entry.sessions[0].tty, "pts/4");
702
        assert_eq!(user_entry.sessions[0].host, Some("10.0.0.192".into()));
703
        assert_eq!(
704
            user_entry.sessions[0].idle_time.unwrap().num_minutes(),
705
            6 * 60 + 34
706
        );
707

            
708
        assert_eq!(user_entry.sessions[1].tty, "pts/5");
709
        assert_eq!(user_entry.sessions[1].host, Some("10.0.0.192".into()));
710
        assert_eq!(
711
            user_entry.sessions[1].idle_time.unwrap().num_minutes(),
712
            6 * 60 + 33
713
        );
714
    }
715

            
716
    #[test]
717
1
    fn test_finger_user_entry_parsing_single_line_office_phone() {
718
1
        let response_content = indoc::indoc! {"
719
1
          Login: alice           			Name: Alice Wonderland
720
1
          Directory: /home/alice             	Shell: /bin/bash
721
1
          Office: 123 Main St, 012-345-6789
722
1
          Home Phone: +0-123-456-7890
723
1
          On since Mon Mar  1 10:00 (UTC) on pts/0, idle 5:00, from host.example.com
724
1
          No mail.
725
1
          No Plan.
726
1
        "}
727
1
        .trim();
728

            
729
1
        let response = RawFingerResponse::from(response_content.to_string());
730
1
        let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response(
731
1
            &response,
732
1
            "alice".to_string(),
733
        )
734
1
        .unwrap();
735

            
736
1
        assert_eq!(user_entry.office, Some("123 Main St".to_string()));
737
1
        assert_eq!(user_entry.office_phone, Some("012-345-6789".to_string()));
738
1
        assert_eq!(user_entry.home_phone, Some("+0-123-456-7890".to_string()));
739
1
    }
740

            
741
    #[test]
742
1
    fn test_finger_user_entry_parsing_multiline_office_phone() {
743
1
        let response_content = indoc::indoc! {"
744
1
          Login: alice           			Name: Alice Wonderland
745
1
          Directory: /home/alice             	Shell: /bin/bash
746
1
          Office: 123 Main St
747
1
          Office Phone: 012-345-6789
748
1
          Home Phone: +0-123-456-7890
749
1
          On since Mon Mar  1 10:00 (UTC) on pts/0, idle 5:00, from host.example.com
750
1
          No mail.
751
1
          No Plan.
752
1
        "}
753
1
        .trim();
754

            
755
1
        let response = RawFingerResponse::from(response_content.to_string());
756
1
        let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response(
757
1
            &response,
758
1
            "alice".to_string(),
759
        )
760
1
        .unwrap();
761

            
762
1
        assert_eq!(user_entry.office, Some("123 Main St".to_string()));
763
1
        assert_eq!(user_entry.office_phone, Some("012-345-6789".to_string()));
764
1
        assert_eq!(user_entry.home_phone, Some("+0-123-456-7890".to_string()));
765
1
    }
766

            
767
    #[test]
768
1
    fn test_finger_user_entry_parsing_never_logged_in() {
769
1
        let response_content = indoc::indoc! {"
770
1
          Login: bob           			Name: Bob Builder
771
1
          Directory: /home/bob             	Shell: /bin/zsh
772
1
          Never logged in.
773
1
          No mail.
774
1
          No Plan.
775
1
        "}
776
1
        .trim();
777

            
778
1
        let response = RawFingerResponse::from(response_content.to_string());
779
1
        let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response(
780
1
            &response,
781
1
            "bob".to_string(),
782
        )
783
1
        .unwrap();
784

            
785
1
        assert!(user_entry.never_logged_in);
786
1
        assert!(user_entry.sessions.is_empty());
787
1
    }
788

            
789
    #[test]
790
1
    fn test_finger_user_entry_parsing_no_mail() {
791
1
        let response_content = indoc::indoc! {"
792
1
        Login: bob           			Name: Bob Builder
793
1
        Directory: /home/bob             	Shell: /bin/zsh
794
1
        Never logged in.
795
1
        No mail.
796
1
        No Plan.
797
1
      "}
798
1
        .trim();
799

            
800
1
        let response = RawFingerResponse::from(response_content.to_string());
801
1
        let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response(
802
1
            &response,
803
1
            "bob".to_string(),
804
        )
805
1
        .unwrap();
806
1
        assert_eq!(user_entry.mail_status, Some(MailStatus::NoMail));
807
1
    }
808

            
809
    #[test]
810
1
    fn test_finger_user_entry_parsing_new_mail_received() {
811
1
        let response_content = indoc::indoc! {"
812
1
        Login: bob           			Name: Bob Builder
813
1
        Directory: /home/bob             	Shell: /bin/zsh
814
1
        Never logged in.
815
1
        New mail received Mon Mar  1 10:00 (UTC)
816
1
             Unread since Mon Mar  1 09:00 (UTC)
817
1
        No Plan.
818
1
      "}
819
1
        .trim();
820

            
821
1
        let response = RawFingerResponse::from(response_content.to_string());
822
1
        let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response(
823
1
            &response,
824
1
            "bob".to_string(),
825
        )
826
1
        .unwrap();
827
1
        assert_eq!(
828
            user_entry.mail_status,
829
1
            Some(MailStatus::NewMailReceived {
830
1
                received_time: Utc.with_ymd_and_hms(2021, 3, 1, 10, 0, 0).unwrap(),
831
1
                unread_since: Utc.with_ymd_and_hms(2021, 3, 1, 9, 0, 0).unwrap(),
832
1
            })
833
        );
834
1
    }
835

            
836
    #[test]
837
1
    fn test_finger_user_entry_parsing_mail_last_read() {
838
1
        let response_content = indoc::indoc! {"
839
1
        Login: bob           			Name: Bob Builder
840
1
        Directory: /home/bob             	Shell: /bin/zsh
841
1
        Never logged in.
842
1
        Mail last read Mon Mar  1 10:00 (UTC)
843
1
        No Plan.
844
1
      "}
845
1
        .trim();
846

            
847
1
        let response = RawFingerResponse::from(response_content.to_string());
848
1
        let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response(
849
1
            &response,
850
1
            "bob".to_string(),
851
        )
852
1
        .unwrap();
853
1
        assert_eq!(
854
            user_entry.mail_status,
855
1
            Some(MailStatus::MailLastRead(
856
1
                Utc.with_ymd_and_hms(2021, 3, 1, 10, 0, 0).unwrap()
857
1
            ))
858
        );
859
1
    }
860

            
861
    #[test]
862
1
    fn test_finger_user_entry_parsing_single_line_plan_project() {
863
1
        let response_content = indoc::indoc! {"
864
1
        Login: bob           			Name: Bob Builder
865
1
        Directory: /home/bob             	Shell: /bin/zsh
866
1
        Never logged in.
867
1
        Mail last read Mon Mar  1 10:00 (UTC)
868
1
        Project: Build a new house.
869
1
        Plan: Build a new house.
870
1
      "}
871
1
        .trim();
872

            
873
1
        let response = RawFingerResponse::from(response_content.to_string());
874
1
        let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response(
875
1
            &response,
876
1
            "bob".to_string(),
877
        )
878
1
        .unwrap();
879

            
880
1
        assert_eq!(user_entry.project, Some("Build a new house.".to_string()));
881
1
        assert_eq!(user_entry.plan, Some("Build a new house.".to_string()));
882
1
    }
883

            
884
    #[test]
885
1
    fn test_finger_user_entry_parsing_multiline_pgp_plan_project() {
886
1
        let response_content = indoc::indoc! {"
887
1
        Login: bob           			Name: Bob Builder
888
1
        Directory: /home/bob             	Shell: /bin/zsh
889
1
        Never logged in.
890
1
        Mail last read Mon Mar  1 10:00 (UTC)
891
1
        PGP key:
892
1
        -----BEGIN PGP KEY-----
893
1
        Version: GnuPG v1
894
1
        ABCDEFGHIJKLMNOPQRSTUVWXYZ
895
1
        -----END PGP KEY-----
896
1
        Project:
897
1
        Build a new house.
898
1

            
899
1
        Need to buy materials.
900
1
        Plan:
901
1
        Build a new house.
902
1

            
903
1
        Need to buy materials.
904
1
      "}
905
1
        .trim();
906

            
907
1
        let response = RawFingerResponse::from(response_content.to_string());
908
1
        let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response(
909
1
            &response,
910
1
            "bob".to_string(),
911
        )
912
1
        .unwrap();
913
1
        assert_eq!(
914
        user_entry.pgp_key,
915
1
        Some("-----BEGIN PGP KEY-----\nVersion: GnuPG v1\nABCDEFGHIJKLMNOPQRSTUVWXYZ\n-----END PGP KEY-----".to_string()),
916
    );
917

            
918
1
        assert_eq!(
919
            user_entry.project,
920
1
            Some("Build a new house.\n\nNeed to buy materials.".to_string())
921
        );
922
1
        assert_eq!(
923
            user_entry.plan,
924
1
            Some("Build a new house.\n\nNeed to buy materials.".to_string())
925
        );
926
1
    }
927

            
928
    #[test]
929
1
    fn test_finger_user_entry_parsing_plan_keyword_in_plan() {
930
1
        let response_content = indoc::indoc! {"
931
1
        Login: bob           			Name: Bob Builder
932
1
        Directory: /home/bob             	Shell: /bin/zsh
933
1
        Never logged in.
934
1
        Mail last read Mon Mar  1 10:00 (UTC)
935
1
        Project:
936
1
        I put an extra Plan: keyword here for kaos.
937
1

            
938
1
        :3:3:3
939
1
        Plan:
940
1
        Build a new house.
941
1

            
942
1
        Plan:
943
1
        Need to buy materials.
944
1

            
945
1
        The plan is to build a new house.
946
1
      "}
947
1
        .trim();
948

            
949
1
        let response = RawFingerResponse::from(response_content.to_string());
950
1
        let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response(
951
1
            &response,
952
1
            "bob".to_string(),
953
        )
954
1
        .unwrap();
955

            
956
1
        assert_eq!(
957
            user_entry.project,
958
1
            Some("I put an extra Plan: keyword here for kaos.\n\n:3:3:3".to_string())
959
        );
960
1
        assert_eq!(
961
        user_entry.plan,
962
1
        Some("Build a new house.\n\nPlan:\nNeed to buy materials.\n\nThe plan is to build a new house.".to_string())
963
    );
964
1
    }
965
}