use std::{collections::HashMap, path::PathBuf, time::Duration};
#[cfg(any(test, unix))]
use std::collections::HashSet;
use jiff::{Timestamp, civil::DateTime, tz::TimeZone};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use tokio::task::spawn_blocking;
use tracing::{debug, trace, warn};
use super::SweepContext;
use crate::doctor::check::Check;
const WARN_AGE: Duration = Duration::from_secs(12 * 3600);
#[derive(Debug, Clone)]
struct ExternalUser {
name: String,
line: String,
login: Timestamp,
source: Option<String>,
tailscale_login: Option<String>,
session_id: Option<u32>,
connected_since: Timestamp,
}
pub async fn run(_ctx: SweepContext) -> Check {
let mut users = match collect_users().await {
Ok(CollectOutcome::Users(u)) => u,
Ok(CollectOutcome::Unavailable(reason)) => {
return Check::skip("external_users", "could not enumerate logins", reason);
}
Err(err) => {
return Check::skip(
"external_users",
"could not enumerate logins",
err.to_string(),
);
}
};
for user in &mut users {
if let Some(src) = user.source.as_deref()
&& looks_like_tailscale_ip(src)
{
match tailscale_whois(src).await {
Ok(login) => user.tailscale_login = login,
Err(err) => trace!(?err, ip=%src, "tailscale whois failed"),
}
}
}
let now = Timestamp::now();
let state_path = state_file_path();
let prior = state_path.as_deref().map(load_state).unwrap_or_default();
apply_presence_state(&mut users, &prior, now);
if let Some(path) = &state_path {
save_state(path, &snapshot_state(&users));
}
if users.is_empty() {
return Check::pass("external_users", "no interactive users connected")
.with_detail("count", 0)
.with_detail("users", Value::Array(Vec::new()));
}
let oldest_age = users
.iter()
.map(|u| session_age(now, u.connected_since))
.max()
.unwrap_or(Duration::ZERO);
let summary = format!(
"{} user{}; oldest session {}",
users.len(),
if users.len() == 1 { "" } else { "s" },
humanise_age(oldest_age),
);
let user_details = serde_json::Value::Array(users.iter().map(user_to_json).collect());
let check = if oldest_age >= WARN_AGE {
Check::warning(
"external_users",
summary,
format!(
"a session has been connected for over {}h",
WARN_AGE.as_secs() / 3600
),
)
} else {
Check::pass("external_users", summary)
};
check
.with_detail("count", users.len())
.with_detail("users", user_details)
}
fn user_to_json(u: &ExternalUser) -> Value {
let mut obj = json!({
"name": u.name,
"line": u.line,
"login": u.login.to_string(),
"connected_since": u.connected_since.to_string(),
});
if let Some(src) = &u.source {
obj["source"] = Value::String(src.clone());
}
if let Some(login) = &u.tailscale_login {
obj["tailscale"] = Value::String(login.clone());
}
if let Some(sid) = u.session_id {
obj["session_id"] = Value::from(sid);
}
obj
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct PresenceState {
#[serde(default)]
entries: HashMap<String, Timestamp>,
}
fn presence_key(u: &ExternalUser) -> String {
if let Some(login) = &u.tailscale_login {
format!("ts:{login}")
} else if let Some(sid) = u.session_id {
format!("winsid:{}:{}", u.name, sid)
} else {
format!("tty:{}:{}", u.name, u.line)
}
}
fn apply_presence_state(users: &mut [ExternalUser], prior: &PresenceState, now: Timestamp) {
for user in users {
let key = presence_key(user);
user.connected_since = match prior.entries.get(&key) {
Some(&earlier) if earlier <= now => earlier,
Some(_) => now,
None => now,
};
}
}
fn snapshot_state(users: &[ExternalUser]) -> PresenceState {
let mut entries = HashMap::with_capacity(users.len());
for user in users {
entries.insert(presence_key(user), user.connected_since);
}
PresenceState { entries }
}
fn state_file_path() -> Option<PathBuf> {
dirs::cache_dir().map(|d| d.join("bestool").join("doctor-external-users.json"))
}
fn load_state(path: &std::path::Path) -> PresenceState {
match std::fs::read(path) {
Ok(bytes) => match serde_json::from_slice::<PresenceState>(&bytes) {
Ok(v) => v,
Err(err) => {
debug!(%err, ?path, "ignoring unparseable external_users state");
PresenceState::default()
}
},
Err(err) if err.kind() == std::io::ErrorKind::NotFound => PresenceState::default(),
Err(err) => {
debug!(%err, ?path, "could not read external_users state");
PresenceState::default()
}
}
}
fn save_state(path: &std::path::Path, state: &PresenceState) {
if let Some(parent) = path.parent()
&& let Err(err) = std::fs::create_dir_all(parent)
{
warn!(%err, ?parent, "could not create external_users state dir");
return;
}
let json = match serde_json::to_vec(state) {
Ok(b) => b,
Err(err) => {
warn!(%err, "could not serialise external_users state");
return;
}
};
let tmp = path.with_extension("json.tmp");
if let Err(err) = std::fs::write(&tmp, &json) {
warn!(%err, ?tmp, "could not write external_users state");
return;
}
if let Err(err) = std::fs::rename(&tmp, path) {
warn!(%err, ?path, "could not rename external_users state");
}
}
fn session_age(now: Timestamp, login: Timestamp) -> Duration {
let secs = now.as_second().saturating_sub(login.as_second());
Duration::from_secs(secs.max(0) as u64)
}
fn humanise_age(d: Duration) -> String {
let secs = d.as_secs();
let h = secs / 3600;
let m = (secs % 3600) / 60;
if h > 0 {
format!("{h}h {m}m")
} else {
format!("{m}m")
}
}
enum CollectOutcome {
Users(Vec<ExternalUser>),
Unavailable(String),
}
#[cfg(unix)]
async fn collect_users() -> miette::Result<CollectOutcome> {
let output = spawn_blocking(|| {
duct::cmd!("who")
.stdout_capture()
.stderr_capture()
.unchecked()
.run()
})
.await
.map_err(|e| miette::miette!("running who: {e}"))?
.map_err(|e| miette::miette!("running who: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
debug!(status = ?output.status, %stderr, "who returned non-zero");
return Ok(CollectOutcome::Unavailable(format!(
"who returned non-zero{}",
if stderr.is_empty() {
String::new()
} else {
format!(": {stderr}")
}
)));
}
let text = String::from_utf8_lossy(&output.stdout);
let mut users = parse_who(&text);
if let Some(live) = spawn_blocking(live_logind_ttys).await.ok().flatten() {
users.retain(|u| {
let keep = live.contains(&u.line);
if !keep {
debug!(name=%u.name, line=%u.line, "dropping utmp entry with no live logind session");
}
keep
});
} else {
let dead = spawn_blocking(dead_records).await.unwrap_or_default();
users.retain(|u| {
let keep = dead.get(&u.line).is_none_or(|&d| d < u.login);
if !keep {
debug!(name=%u.name, line=%u.line, "dropping utmp entry with newer dead record");
}
keep
});
}
Ok(CollectOutcome::Users(users))
}
#[cfg(unix)]
fn dead_records() -> HashMap<String, Timestamp> {
match duct::cmd!("who", "--dead")
.stdout_capture()
.stderr_capture()
.unchecked()
.run()
{
Ok(out) if out.status.success() => {
parse_dead_records(&String::from_utf8_lossy(&out.stdout))
}
Ok(out) => {
trace!(status = ?out.status, "who --dead returned non-zero");
HashMap::new()
}
Err(err) => {
trace!(%err, "could not run who --dead");
HashMap::new()
}
}
}
#[cfg(any(test, unix))]
fn parse_dead_records(text: &str) -> HashMap<String, Timestamp> {
let mut out: HashMap<String, Timestamp> = HashMap::new();
for line in text.lines() {
let tokens: Vec<&str> = line.split_whitespace().collect();
let Some(date_idx) = tokens.iter().position(|t| looks_like_iso_date(t)) else {
continue;
};
if date_idx == 0 || date_idx + 1 >= tokens.len() {
continue;
}
let tty = tokens[date_idx - 1];
let Some(time) = parse_local_datetime(tokens[date_idx], tokens[date_idx + 1]) else {
continue;
};
out.entry(tty.to_string())
.and_modify(|t| *t = (*t).max(time))
.or_insert(time);
}
out
}
#[cfg(unix)]
fn live_logind_ttys() -> Option<HashSet<String>> {
live_ttys_json().or_else(live_ttys_show_session)
}
#[cfg(unix)]
fn live_ttys_json() -> Option<HashSet<String>> {
let out = duct::cmd!("loginctl", "list-sessions", "--json=short")
.stdout_capture()
.stderr_capture()
.unchecked()
.run()
.ok()?;
if !out.status.success() {
trace!(status = ?out.status, "loginctl list-sessions --json unsupported or failed");
return None;
}
parse_live_ttys_json(&String::from_utf8_lossy(&out.stdout))
}
#[cfg(any(test, unix))]
fn parse_live_ttys_json(text: &str) -> Option<HashSet<String>> {
let sessions: Vec<serde_json::Value> = serde_json::from_str(text).ok()?;
if sessions.iter().any(|s| s.get("state").is_none()) {
return None;
}
Some(
sessions
.iter()
.filter(|s| {
s["state"]
.as_str()
.is_some_and(|st| !st.eq_ignore_ascii_case("closing"))
})
.filter_map(|s| s["tty"].as_str())
.map(str::to_owned)
.collect(),
)
}
#[cfg(unix)]
fn live_ttys_show_session() -> Option<HashSet<String>> {
let list = match duct::cmd!("loginctl", "list-sessions", "--no-legend")
.stdout_capture()
.stderr_capture()
.unchecked()
.run()
{
Ok(out) if out.status.success() => out,
Ok(out) => {
trace!(status = ?out.status, "loginctl list-sessions returned non-zero");
return None;
}
Err(err) => {
trace!(%err, "could not run loginctl");
return None;
}
};
let ids: Vec<String> = String::from_utf8_lossy(&list.stdout)
.lines()
.filter_map(|l| l.split_whitespace().next().map(str::to_owned))
.collect();
if ids.is_empty() {
return Some(HashSet::new());
}
let mut args = vec![
"show-session".to_string(),
"-p".into(),
"State".into(),
"-p".into(),
"TTY".into(),
];
args.extend(ids);
match duct::cmd("loginctl", args)
.stdout_capture()
.stderr_capture()
.unchecked()
.run()
{
Ok(out) if out.status.success() => {
Some(parse_live_ttys(&String::from_utf8_lossy(&out.stdout)))
}
Ok(out) => {
trace!(status = ?out.status, "loginctl show-session returned non-zero");
None
}
Err(err) => {
trace!(%err, "could not run loginctl show-session");
None
}
}
}
#[cfg(any(test, unix))]
fn parse_live_ttys(text: &str) -> HashSet<String> {
let mut live = HashSet::new();
for block in text.split("\n\n") {
let mut tty = None;
let mut closing = false;
for line in block.lines() {
if let Some(v) = line.trim().strip_prefix("TTY=") {
if !v.is_empty() {
tty = Some(v.to_string());
}
} else if let Some(v) = line.trim().strip_prefix("State=") {
closing = v.eq_ignore_ascii_case("closing");
}
}
if !closing && let Some(tty) = tty {
live.insert(tty);
}
}
live
}
#[cfg(windows)]
async fn collect_users() -> miette::Result<CollectOutcome> {
let output = spawn_blocking(|| {
duct::cmd!("quser")
.stdout_capture()
.stderr_capture()
.unchecked()
.run()
})
.await
.map_err(|e| miette::miette!("running quser: {e}"))?
.map_err(|e| miette::miette!("running quser: {e}"))?;
let stderr = String::from_utf8_lossy(&output.stderr);
if !output.status.success() {
debug!(
status = ?output.status,
%stderr,
"quser returned non-zero"
);
if stderr.to_lowercase().contains("no user exists for *") {
return Ok(CollectOutcome::Users(Vec::new()));
}
return Ok(CollectOutcome::Unavailable(format!(
"quser returned non-zero{}",
if stderr.trim().is_empty() {
String::new()
} else {
format!(": {}", stderr.trim())
}
)));
}
let text = String::from_utf8_lossy(&output.stdout);
Ok(CollectOutcome::Users(parse_quser(&text)))
}
#[cfg(not(any(unix, windows)))]
async fn collect_users() -> miette::Result<CollectOutcome> {
Ok(CollectOutcome::Unavailable(
"session enumeration not implemented for this platform".into(),
))
}
#[cfg(any(test, unix))]
fn parse_who(text: &str) -> Vec<ExternalUser> {
let mut out = Vec::new();
for line in text.lines() {
let line = line.trim_end();
if line.is_empty() {
continue;
}
let (head, comment) = if let Some(open) = line.find('(')
&& line.ends_with(')')
{
(
line[..open].trim_end(),
Some(&line[open + 1..line.len() - 1]),
)
} else {
(line, None)
};
let tokens: Vec<&str> = head.split_whitespace().collect();
if tokens.len() < 4 {
trace!(?line, "skipping unparseable who line");
continue;
}
let name = tokens[0].to_string();
let tty = tokens[1].to_string();
let date_idx = tokens
.iter()
.skip(2)
.position(|t| looks_like_iso_date(t))
.map(|i| i + 2);
let Some(date_idx) = date_idx else {
trace!(?line, "no date column in who line");
continue;
};
if date_idx + 1 >= tokens.len() {
trace!(?line, "no time column in who line");
continue;
}
let date_str = tokens[date_idx];
let time_str = tokens[date_idx + 1];
let Some(login) = parse_local_datetime(date_str, time_str) else {
trace!(?date_str, ?time_str, "could not parse login time");
continue;
};
let source = comment
.map(str::trim)
.filter(|c| !c.is_empty() && !c.eq_ignore_ascii_case("local"))
.map(str::to_owned);
out.push(ExternalUser {
name,
line: tty,
login,
source,
tailscale_login: None,
session_id: None,
connected_since: login,
});
}
out
}
#[cfg(any(test, unix))]
fn looks_like_iso_date(tok: &str) -> bool {
let bytes = tok.as_bytes();
bytes.len() == 10
&& bytes[4] == b'-'
&& bytes[7] == b'-'
&& bytes[..4].iter().all(u8::is_ascii_digit)
&& bytes[5..7].iter().all(u8::is_ascii_digit)
&& bytes[8..10].iter().all(u8::is_ascii_digit)
}
#[cfg(any(test, unix))]
fn parse_local_datetime(date: &str, time: &str) -> Option<Timestamp> {
for fmt in ["%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"] {
if let Ok(dt) = DateTime::strptime(fmt, format!("{date} {time}"))
&& let Ok(zoned) = dt.to_zoned(TimeZone::system())
{
return Some(zoned.timestamp());
}
}
None
}
#[cfg(any(test, windows))]
fn parse_quser(text: &str) -> Vec<ExternalUser> {
let mut out = Vec::new();
let mut lines = text.lines();
let Some(_header) = lines.find(|l| !l.trim().is_empty()) else {
return out;
};
for line in lines {
let trimmed = line.trim_start_matches('>').trim_end();
if trimmed.is_empty() {
continue;
}
let tokens: Vec<&str> = trimmed.split_whitespace().collect();
if tokens.len() < 6 {
trace!(?line, "skipping unparseable quser line");
continue;
}
let (name, sessionname, sid, state) = if let Ok(sid) = tokens[1].parse::<u32>() {
(tokens[0], None, sid, tokens[2])
} else if tokens.len() >= 7
&& let Ok(sid) = tokens[2].parse::<u32>()
{
(tokens[0], Some(tokens[1]), sid, tokens[3])
} else {
trace!(?line, "could not locate session id column in quser line");
continue;
};
if !state.eq_ignore_ascii_case("Active") {
trace!(?line, ?state, "skipping non-active quser session");
continue;
}
let logon_tokens = &tokens[tokens.len().saturating_sub(3)..];
if logon_tokens.len() != 3 {
continue;
}
let logon_str = format!(
"{} {} {}",
logon_tokens[0], logon_tokens[1], logon_tokens[2]
);
let Some(login) = parse_quser_logon(&logon_str) else {
trace!(logon_str, "could not parse quser logon time");
continue;
};
out.push(ExternalUser {
name: name.to_string(),
line: sessionname.unwrap_or("(disconnected)").to_string(),
login,
source: None,
tailscale_login: None,
session_id: Some(sid),
connected_since: login,
});
}
out
}
#[cfg(any(test, windows))]
fn parse_quser_logon(s: &str) -> Option<Timestamp> {
for fmt in [
"%-m/%-d/%Y %-I:%M %p",
"%m/%d/%Y %I:%M %p",
"%-m/%-d/%Y %-H:%M",
"%Y-%m-%d %H:%M",
] {
if let Ok(dt) = DateTime::strptime(fmt, s)
&& let Ok(zoned) = dt.to_zoned(TimeZone::system())
{
return Some(zoned.timestamp());
}
}
None
}
fn looks_like_tailscale_ip(s: &str) -> bool {
use std::net::IpAddr;
let s = s.split('%').next().unwrap_or(s);
let Ok(ip) = s.parse::<IpAddr>() else {
return false;
};
match ip {
IpAddr::V4(v4) => is_tailscale_v4(v4),
IpAddr::V6(v6) => is_tailscale_v6(v6),
}
}
fn is_tailscale_v4(ip: std::net::Ipv4Addr) -> bool {
let [a, b, _, _] = ip.octets();
a == 100 && (64..=127).contains(&b)
}
fn is_tailscale_v6(ip: std::net::Ipv6Addr) -> bool {
let s = ip.segments();
s[0] == 0xfd7a && s[1] == 0x115c && s[2] == 0xa1e0
}
async fn tailscale_whois(addr: &str) -> miette::Result<Option<String>> {
let stripped = addr.split('%').next().unwrap_or(addr).to_owned();
let output = spawn_blocking(move || {
duct::cmd!("tailscale", "whois", "--json", &stripped)
.stdout_capture()
.stderr_capture()
.unchecked()
.run()
})
.await
.map_err(|e| miette::miette!("running tailscale whois: {e}"))?
.map_err(|e| miette::miette!("running tailscale whois: {e}"))?;
if !output.status.success() {
return Ok(None);
}
let parsed: serde_json::Value = match serde_json::from_slice(&output.stdout) {
Ok(v) => v,
Err(err) => {
warn!(?err, "failed to parse tailscale whois JSON");
return Ok(None);
}
};
let login = parsed["UserProfile"]["LoginName"]
.as_str()
.or_else(|| parsed["UserProfile"]["DisplayName"].as_str())
.map(str::to_owned)
.filter(|l| !l.eq_ignore_ascii_case("tagged-devices"));
Ok(login)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_simple_who_line() {
let out = parse_who(
"alice pts/0 2026-05-20 12:34 (203.0.113.5)\n\
bob tty1 2026-05-20 17:18 (local)\n",
);
assert_eq!(out.len(), 2);
assert_eq!(out[0].name, "alice");
assert_eq!(out[0].line, "pts/0");
assert_eq!(out[0].source.as_deref(), Some("203.0.113.5"));
assert!(out[1].source.is_none(), "(local) should drop source");
}
#[test]
fn parses_who_dash_u_line() {
let out =
parse_who("felix pts/4 2026-05-20 17:24 10:05 15891 (tmux(6522).%1)\n");
assert_eq!(out.len(), 1);
assert_eq!(out[0].name, "felix");
assert_eq!(out[0].line, "pts/4");
assert_eq!(out[0].source.as_deref(), Some("tmux(6522).%1"));
}
#[test]
fn parses_who_without_comment() {
let out = parse_who("felix tty1 2026-05-20 17:18\n");
assert_eq!(out.len(), 1);
assert!(out[0].source.is_none());
}
#[test]
fn skips_malformed_who_lines() {
let out = parse_who("garbage\n\n \nstill garbage\n");
assert!(out.is_empty());
}
#[test]
fn parses_quser_console_and_rdp() {
let text = " USERNAME SESSIONNAME ID STATE IDLE TIME LOGON TIME\n\
>administrator console 1 Active none 5/21/2026 4:00 AM\n\
bob rdp-tcp#0 2 Active 10:30 5/21/2026 3:55 AM\n";
let out = parse_quser(text);
assert_eq!(out.len(), 2);
assert_eq!(out[0].name, "administrator");
assert_eq!(out[0].line, "console");
assert_eq!(out[1].name, "bob");
assert_eq!(out[1].line, "rdp-tcp#0");
}
#[test]
fn skips_quser_disconnected_session() {
let text = " USERNAME SESSIONNAME ID STATE IDLE TIME LOGON TIME\n\
dave 3 Disc . 5/21/2026 2:00 AM\n";
let out = parse_quser(text);
assert!(out.is_empty());
}
#[test]
fn keeps_only_active_quser_sessions() {
let text = " USERNAME SESSIONNAME ID STATE IDLE TIME LOGON TIME\n\
>administrator console 1 Active none 5/21/2026 4:00 AM\n\
bob rdp-tcp#0 2 Active 10:30 5/21/2026 3:55 AM\n\
besd 3 Disc . 5/22/2026 4:17 PM\n";
let out = parse_quser(text);
assert_eq!(out.len(), 2);
assert!(out.iter().all(|u| u.name != "besd"));
}
#[test]
fn parses_live_ttys_from_show_session_blocks() {
let text = "TTY=pts/5\nState=closing\n\n\
TTY=pts/8\nState=active\n\n\
TTY=\nState=active\n\n\
State=active\nTTY=pts/9\n";
let out = parse_live_ttys(text);
assert_eq!(out.len(), 2);
assert!(out.contains("pts/8"));
assert!(out.contains("pts/9"), "property order should not matter");
}
#[test]
fn parses_live_ttys_ignores_ttyless_sessions() {
let out = parse_live_ttys("TTY=\nState=active\n");
assert!(out.is_empty());
}
#[test]
fn parses_live_ttys_empty_input() {
assert!(parse_live_ttys("").is_empty());
}
#[test]
fn live_tty_with_closing_twin_is_still_live() {
let text = "TTY=pts/5\nState=closing\n\n\
TTY=pts/5\nState=active\n\n\
TTY=pts/0\nState=closing\n";
let out = parse_live_ttys(text);
assert_eq!(out.len(), 1);
assert!(out.contains("pts/5"));
assert!(!out.contains("pts/0"));
}
#[test]
fn parses_live_ttys_json_with_state_field() {
let text = r#"[
{"session":"187","uid":1000,"user":"ubuntu","seat":null,"tty":"pts/5","state":"closing","idle":true},
{"session":"214","uid":1000,"user":"ubuntu","seat":null,"tty":"pts/8","state":"active","idle":true},
{"session":"4","uid":1000,"user":"ubuntu","seat":null,"tty":null,"state":"active","idle":false}
]"#;
let out = parse_live_ttys_json(text).expect("state field present");
assert_eq!(out.len(), 1);
assert!(out.contains("pts/8"));
}
#[test]
fn parses_live_ttys_json_without_state_field_falls_back() {
let text = r#"[{"session":"3","uid":1000,"user":"ubuntu","seat":null,"tty":"pts/5","idle":false}]"#;
assert_eq!(parse_live_ttys_json(text), None);
}
#[test]
fn parses_live_ttys_json_keeps_reclaimed_tty() {
let text = r#"[
{"session":"187","uid":1000,"user":"ubuntu","seat":null,"tty":"pts/5","state":"closing","idle":true},
{"session":"731","uid":1000,"user":"ubuntu","seat":null,"tty":"pts/5","state":"active","idle":false},
{"session":"29","uid":1000,"user":"ubuntu","seat":null,"tty":"pts/0","state":"closing","idle":true}
]"#;
let out = parse_live_ttys_json(text).expect("state field present");
assert_eq!(out.len(), 1);
assert!(out.contains("pts/5"));
}
#[test]
fn parses_live_ttys_json_rejects_invalid() {
assert_eq!(parse_live_ttys_json("not json"), None);
}
#[test]
fn parses_live_ttys_json_empty_list() {
assert_eq!(parse_live_ttys_json("[]"), Some(HashSet::new()));
}
#[test]
fn parses_dead_records() {
let text = " pts/1 2026-06-01 13:56 62942 id=ts/1 term=0 exit=1\n\
pts/9 2026-06-02 13:33 289783 id=ts/9 term=2 exit=0\n\
garbage line\n";
let out = parse_dead_records(text);
assert_eq!(out.len(), 2);
assert!(out.contains_key("pts/1"));
assert!(out.contains_key("pts/9"));
}
#[test]
fn parses_dead_records_keeps_latest_per_tty() {
let text = "pts/1 2026-06-01 13:56 62942 id=ts/1 term=0 exit=1\n\
pts/1 2026-06-03 08:00 70001 id=ts/1 term=0 exit=0\n";
let out = parse_dead_records(text);
assert_eq!(out.len(), 1);
let expected = parse_local_datetime("2026-06-03", "08:00").unwrap();
assert_eq!(out["pts/1"], expected);
}
#[test]
fn dead_record_filter_drops_ended_logins_keeps_reused_ttys() {
let mut users = parse_who(
"ubuntu pts/1 2026-06-01 04:01 (100.95.192.1)\n\
ubuntu pts/9 2026-06-05 11:58 (100.94.77.1)\n\
ubuntu pts/8 2026-06-02 03:26 (100.82.152.128)\n",
);
assert_eq!(users.len(), 3);
let dead = parse_dead_records(
"pts/1 2026-06-01 13:56 62942 id=ts/1 term=0 exit=1\n\
pts/9 2026-06-02 13:33 289783 id=ts/9 term=2 exit=0\n",
);
users.retain(|u| dead.get(&u.line).is_none_or(|&d| d < u.login));
assert_eq!(users.len(), 2);
assert!(users.iter().any(|u| u.line == "pts/9"), "reused tty kept");
assert!(users.iter().any(|u| u.line == "pts/8"), "no dead record");
assert!(
users.iter().all(|u| u.line != "pts/1"),
"ended login dropped"
);
}
#[test]
fn stale_utmp_entries_filtered_by_live_set() {
let mut users = parse_who(
"ubuntu pts/0 2026-06-01 03:56 (100.72.244.79)\n\
ubuntu pts/1 2026-06-01 04:01 (100.95.192.1)\n\
ubuntu pts/3 2026-06-01 06:58 (100.82.152.128)\n\
ubuntu pts/5 2026-06-02 00:17 (100.94.77.1)\n\
ubuntu pts/8 2026-06-02 03:26 (100.82.152.128)\n\
ubuntu pts/9 2026-06-05 11:58 (fd7a:115c:a1e0::3701:2c8a)\n",
);
let live: HashSet<String> = ["pts/8".to_string(), "pts/9".to_string()].into();
users.retain(|u| live.contains(&u.line));
assert_eq!(users.len(), 2);
assert_eq!(users[0].line, "pts/8");
assert_eq!(users[1].line, "pts/9");
}
#[test]
fn looks_like_tailscale_ip_handles_cgnat_range() {
assert!(looks_like_tailscale_ip("100.64.0.1"));
assert!(looks_like_tailscale_ip("100.127.255.255"));
assert!(looks_like_tailscale_ip("100.100.0.5%eth0"));
assert!(!looks_like_tailscale_ip("203.0.113.5"));
assert!(!looks_like_tailscale_ip("100.63.0.1"));
assert!(!looks_like_tailscale_ip("100.128.0.1"));
assert!(!looks_like_tailscale_ip("not-an-ip"));
}
#[test]
fn looks_like_tailscale_ip_handles_ipv6_ula() {
assert!(looks_like_tailscale_ip("fd7a:115c:a1e0::1"));
assert!(looks_like_tailscale_ip(
"fd7a:115c:a1e0:ab12:1234:5678:9abc:def0"
));
assert!(looks_like_tailscale_ip("fd7a:115c:a1e0::1%eth0"));
assert!(!looks_like_tailscale_ip("fd00::1"));
assert!(!looks_like_tailscale_ip("2001:db8::1"));
assert!(!looks_like_tailscale_ip("::1"));
assert!(!looks_like_tailscale_ip("fe80::1%eth0"));
}
#[test]
fn session_age_clamps_at_zero() {
let now = Timestamp::from_second(1000).unwrap();
let future = Timestamp::from_second(2000).unwrap();
assert_eq!(session_age(now, future), Duration::ZERO);
}
#[test]
fn session_age_computes_positive_delta() {
let earlier = Timestamp::from_second(1000).unwrap();
let now = Timestamp::from_second(1000 + 3600).unwrap();
assert_eq!(session_age(now, earlier), Duration::from_secs(3600));
}
#[test]
fn parses_quser_captures_session_id() {
let text = " USERNAME SESSIONNAME ID STATE IDLE TIME LOGON TIME\n\
>administrator console 1 Active none 5/21/2026 4:00 AM\n\
bob rdp-tcp#2 2 Active 10:30 5/21/2026 3:55 AM\n";
let out = parse_quser(text);
assert_eq!(out.len(), 2);
assert_eq!(out[0].session_id, Some(1));
assert_eq!(out[1].session_id, Some(2));
}
fn mk_user(name: &str, line: &str, ts: i64) -> ExternalUser {
let login = Timestamp::from_second(ts).unwrap();
ExternalUser {
name: name.into(),
line: line.into(),
login,
source: None,
tailscale_login: None,
session_id: None,
connected_since: login,
}
}
fn mk_user_ts(name: &str, login_ts: &str, ts_login: Option<&str>) -> ExternalUser {
ExternalUser {
name: name.into(),
line: "rdp-tcp#1".into(),
login: login_ts.parse().unwrap(),
source: None,
tailscale_login: ts_login.map(str::to_owned),
session_id: Some(1),
connected_since: login_ts.parse().unwrap(),
}
}
#[test]
fn presence_key_prefers_tailscale_identity() {
let u = mk_user_ts("besd", "2026-05-22T16:17:00Z", Some("alice@example.com"));
assert_eq!(presence_key(&u), "ts:alice@example.com");
}
#[test]
fn presence_key_falls_back_to_session_id_on_windows() {
let u = mk_user_ts("besd", "2026-05-22T16:17:00Z", None);
assert_eq!(presence_key(&u), "winsid:besd:1");
}
#[test]
fn presence_key_falls_back_to_user_at_line_otherwise() {
let u = mk_user("alice", "pts/0", 1000);
assert_eq!(presence_key(&u), "tty:alice:pts/0");
}
#[test]
fn apply_presence_state_preserves_earlier_first_seen() {
let mut users = vec![mk_user_ts(
"besd",
"2026-05-22T16:17:00Z",
Some("alice@example.com"),
)];
let now: Timestamp = "2026-05-23T04:47:00Z".parse().unwrap();
let earlier: Timestamp = "2026-05-23T04:30:00Z".parse().unwrap();
let mut state = PresenceState::default();
state.entries.insert("ts:alice@example.com".into(), earlier);
apply_presence_state(&mut users, &state, now);
assert_eq!(users[0].connected_since, earlier);
}
#[test]
fn apply_presence_state_uses_now_for_unseen_keys() {
let mut users = vec![mk_user_ts(
"besd",
"2026-05-22T16:17:00Z",
Some("alice@example.com"),
)];
let now: Timestamp = "2026-05-23T04:47:00Z".parse().unwrap();
apply_presence_state(&mut users, &PresenceState::default(), now);
assert_eq!(users[0].connected_since, now);
}
#[test]
fn snapshot_state_only_captures_currently_present_users() {
let users = vec![mk_user_ts(
"besd",
"2026-05-22T16:17:00Z",
Some("alice@example.com"),
)];
let snap = snapshot_state(&users);
assert_eq!(snap.entries.len(), 1);
assert!(snap.entries.contains_key("ts:alice@example.com"));
}
#[test]
fn apply_presence_state_clamps_future_first_seen_to_now() {
let mut users = vec![mk_user_ts(
"besd",
"2026-05-22T16:17:00Z",
Some("alice@example.com"),
)];
let now: Timestamp = "2026-05-23T04:47:00Z".parse().unwrap();
let future: Timestamp = "2030-01-01T00:00:00Z".parse().unwrap();
let mut state = PresenceState::default();
state.entries.insert("ts:alice@example.com".into(), future);
apply_presence_state(&mut users, &state, now);
assert_eq!(users[0].connected_since, now);
}
#[test]
fn humanise_age_formats_h_m() {
assert_eq!(humanise_age(Duration::from_secs(0)), "0m");
assert_eq!(humanise_age(Duration::from_secs(59)), "0m");
assert_eq!(humanise_age(Duration::from_secs(60)), "1m");
assert_eq!(humanise_age(Duration::from_secs(3600)), "1h 0m");
assert_eq!(humanise_age(Duration::from_secs(3600 * 25 + 60)), "25h 1m");
}
}