pub(crate) const UT_LINESIZE: usize = 32;
pub(crate) const UT_NAMESIZE: usize = 32;
pub(crate) const UT_HOSTSIZE: usize = 256;
pub(crate) const UTMP_RECORD_SIZE: usize = 384;
pub(crate) const LASTLOG_RECORD_SIZE: usize = 292;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum UtType {
Empty,
RunLvl,
BootTime,
NewTime,
OldTime,
InitProcess,
LoginProcess,
UserProcess,
DeadProcess,
Accounting,
Other(i16),
}
impl UtType {
#[must_use]
pub(crate) const fn from_raw(v: i16) -> Self {
match v {
0 => Self::Empty,
1 => Self::RunLvl,
2 => Self::BootTime,
3 => Self::NewTime,
4 => Self::OldTime,
5 => Self::InitProcess,
6 => Self::LoginProcess,
7 => Self::UserProcess,
8 => Self::DeadProcess,
9 => Self::Accounting,
n => Self::Other(n),
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct UtmpRecord {
pub ut_type: UtType,
pub pid: i32,
pub line: String,
pub _id: [u8; 4],
pub user: String,
pub host: String,
pub _e_termination: i16,
pub _e_exit: i16,
pub _session: i32,
pub tv_sec: i32,
pub _tv_usec: i32,
pub addr_v6: [u8; 16],
}
impl UtmpRecord {
#[must_use]
pub(crate) fn from_bytes(bytes: &[u8]) -> Option<Self> {
if bytes.len() != UTMP_RECORD_SIZE {
return None;
}
let ut_type = i16::from_ne_bytes(read_arr::<2>(bytes, 0)?);
let pid = i32::from_ne_bytes(read_arr::<4>(bytes, 4)?);
let line = read_cstr_at(bytes, 8, UT_LINESIZE)?;
let id = read_arr::<4>(bytes, 40)?;
let user = read_cstr_at(bytes, 44, UT_NAMESIZE)?;
let host = read_cstr_at(bytes, 76, UT_HOSTSIZE)?;
let e_termination = i16::from_ne_bytes(read_arr::<2>(bytes, 332)?);
let e_exit = i16::from_ne_bytes(read_arr::<2>(bytes, 334)?);
let session = i32::from_ne_bytes(read_arr::<4>(bytes, 336)?);
let timestamp_secs = i32::from_ne_bytes(read_arr::<4>(bytes, 340)?);
let timestamp_micros = i32::from_ne_bytes(read_arr::<4>(bytes, 344)?);
let addr_v6 = read_arr::<16>(bytes, 348)?;
Some(Self { ut_type: UtType::from_raw(ut_type), pid, line, _id: id, user, host, _e_termination: e_termination, _e_exit: e_exit, _session: session, tv_sec: timestamp_secs, _tv_usec: timestamp_micros, addr_v6 })
}
#[must_use]
pub(crate) fn addr_is_ipv4(&self) -> bool {
let a = self.addr_u32_le();
let mapped = a[0] == 0 && a[1] == 0 && a[2] == u32::from_be(0x0000_ffff);
mapped || (a[1] == 0 && a[2] == 0 && a[3] == 0)
}
#[must_use]
pub(crate) fn addr_string(&self) -> String {
use std::fmt::Write as _;
if self.addr_v6.iter().all(|b| *b == 0) {
return String::new();
}
let a = self.addr_u32_le();
if self.addr_is_ipv4() {
let mapped = a[0] == 0 && a[1] == 0 && a[2] == u32::from_be(0x0000_ffff);
let raw = if mapped { a[3] } else { a[0] };
let octets = raw.to_le_bytes();
return octets.iter().enumerate().fold(String::new(), |mut acc, (i, byte)| {
if i > 0 {
acc.push('.');
}
acc.push_str(&byte.to_string());
acc
});
}
let mut s = String::with_capacity(39);
for (i, pair) in self.addr_v6.chunks_exact(2).enumerate() {
if i > 0 {
s.push(':');
}
let group: u16 = pair.iter().fold(0u16, |acc, b| (acc << 8) | u16::from(*b));
let _ = write!(s, "{group:x}");
}
s
}
fn addr_u32_le(&self) -> [u32; 4] {
let mut out = [0u32; 4];
for (slot, chunk) in out.iter_mut().zip(self.addr_v6.chunks_exact(4)) {
if let Ok(arr) = <[u8; 4]>::try_from(chunk) {
*slot = u32::from_ne_bytes(arr);
}
}
out
}
}
#[must_use]
pub(crate) fn parse_utmp(bytes: &[u8]) -> Vec<UtmpRecord> {
bytes.chunks_exact(UTMP_RECORD_SIZE).filter_map(UtmpRecord::from_bytes).collect()
}
#[derive(Debug, Clone)]
pub(crate) struct LastlogRecord {
pub uid: u32,
pub ll_time: i32,
pub ll_line: String,
pub ll_host: String,
}
#[must_use]
pub(crate) fn parse_lastlog(bytes: &[u8]) -> Vec<LastlogRecord> {
bytes
.chunks_exact(LASTLOG_RECORD_SIZE)
.enumerate()
.filter_map(|(uid, chunk)| {
let ll_time = i32::from_ne_bytes(read_arr::<4>(chunk, 0)?);
let ll_line = read_cstr_at(chunk, 4, UT_LINESIZE)?;
let ll_host = read_cstr_at(chunk, 4 + UT_LINESIZE, UT_HOSTSIZE)?;
Some(LastlogRecord { uid: u32::try_from(uid).unwrap_or(u32::MAX), ll_time, ll_line, ll_host })
})
.collect()
}
#[must_use]
pub(crate) fn parse_passwd(bytes: &[u8]) -> Vec<(u32, String)> {
let text = String::from_utf8_lossy(bytes);
let mut out = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let mut parts = line.split(':');
let user = parts.next().unwrap_or("").to_owned();
let _passwd = parts.next();
let uid_str = parts.next().unwrap_or("");
if user.is_empty() {
continue;
}
if let Ok(uid) = uid_str.parse::<u32>() {
out.push((uid, user));
}
}
out
}
fn read_arr<const N: usize>(buf: &[u8], off: usize) -> Option<[u8; N]> {
let end = off.checked_add(N)?;
let slice = buf.get(off..end)?;
<[u8; N]>::try_from(slice).ok()
}
fn read_cstr_at(buf: &[u8], off: usize, len: usize) -> Option<String> {
let end = off.checked_add(len)?;
let slice = buf.get(off..end)?;
let nul = slice.iter().position(|b| *b == 0).unwrap_or(slice.len());
let payload = slice.get(..nul)?;
Some(String::from_utf8_lossy(payload).into_owned())
}
#[cfg(test)]
#[expect(clippy::pedantic, reason = "unit test -- lints are suppressed per project policy")]
mod tests {
use super::*;
fn make_utmp(ut_type: i16, pid: i32, line: &str, user: &str, host: &str, tv_sec: i32) -> [u8; UTMP_RECORD_SIZE] {
let mut buf = [0u8; UTMP_RECORD_SIZE];
buf[0..2].copy_from_slice(&ut_type.to_ne_bytes());
buf[4..8].copy_from_slice(&pid.to_ne_bytes());
let lb = line.as_bytes();
buf[8..8 + lb.len().min(UT_LINESIZE)].copy_from_slice(&lb[..lb.len().min(UT_LINESIZE)]);
let ub = user.as_bytes();
buf[44..44 + ub.len().min(UT_NAMESIZE)].copy_from_slice(&ub[..ub.len().min(UT_NAMESIZE)]);
let hb = host.as_bytes();
buf[76..76 + hb.len().min(UT_HOSTSIZE)].copy_from_slice(&hb[..hb.len().min(UT_HOSTSIZE)]);
buf[340..344].copy_from_slice(&tv_sec.to_ne_bytes());
buf
}
#[test]
fn record_size_matches_glibc() {
assert_eq!(UTMP_RECORD_SIZE, 384);
assert_eq!(LASTLOG_RECORD_SIZE, 292);
}
#[test]
fn parses_user_process_record() {
let raw = make_utmp(7, 1234, "pts/0", "alice", "10.0.0.5", 1_700_000_000);
let rec = UtmpRecord::from_bytes(&raw).expect("parses");
assert_eq!(rec.ut_type, UtType::UserProcess);
assert_eq!(rec.pid, 1234);
assert_eq!(rec.line, "pts/0");
assert_eq!(rec.user, "alice");
assert_eq!(rec.host, "10.0.0.5");
assert_eq!(rec.tv_sec, 1_700_000_000);
}
#[test]
fn parses_boot_pseudo_record() {
let raw = make_utmp(2, 0, "~", "reboot", "6.8.0-110-generic", 1_777_418_477);
let rec = UtmpRecord::from_bytes(&raw).expect("parses");
assert_eq!(rec.ut_type, UtType::BootTime);
assert_eq!(rec.line, "~");
assert_eq!(rec.user, "reboot");
assert!(rec.host.starts_with("6.8."));
}
#[test]
fn parse_utmp_drops_partial_trailing_record() {
let mut buf = Vec::new();
buf.extend_from_slice(&make_utmp(2, 0, "~", "reboot", "k1", 1));
buf.extend_from_slice(&make_utmp(2, 0, "~", "reboot", "k2", 2));
buf.extend(std::iter::repeat_n(0u8, 100));
let recs = parse_utmp(&buf);
assert_eq!(recs.len(), 2);
}
#[test]
fn parses_lastlog_indexed_by_uid() {
let mut buf = vec![0u8; LASTLOG_RECORD_SIZE * 3];
buf[292..296].copy_from_slice(&1_700_000_000_i32.to_ne_bytes());
buf[296..296 + 5].copy_from_slice(b"pts/1");
buf[(296 + UT_LINESIZE)..(296 + UT_LINESIZE + 9)].copy_from_slice(b"10.0.0.10");
buf[584..588].copy_from_slice(&1_700_000_500_i32.to_ne_bytes());
buf[588..588 + 5].copy_from_slice(b"pts/2");
let recs = parse_lastlog(&buf);
assert_eq!(recs.len(), 3);
assert_eq!(recs[0].uid, 0);
assert_eq!(recs[0].ll_time, 0);
assert_eq!(recs[1].uid, 1);
assert_eq!(recs[1].ll_time, 1_700_000_000);
assert_eq!(recs[1].ll_line, "pts/1");
assert_eq!(recs[1].ll_host, "10.0.0.10");
assert_eq!(recs[2].uid, 2);
assert_eq!(recs[2].ll_time, 1_700_000_500);
}
#[test]
fn parses_passwd_skips_garbage() {
let raw = b"# comment\n\
root:x:0:0:root:/root:/bin/bash\n\
\n\
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\n\
bogus_no_uid::not-a-number:::::\n";
let entries = parse_passwd(raw);
assert_eq!(entries.len(), 2);
assert_eq!(entries[0], (0, "root".to_owned()));
assert_eq!(entries[1], (1, "daemon".to_owned()));
}
#[test]
fn ipv4_address_renders_dotted() {
let mut rec = UtmpRecord::from_bytes(&make_utmp(7, 1, "pts/0", "u", "h", 1)).expect("parses");
rec.addr_v6[0] = 10;
rec.addr_v6[1] = 0;
rec.addr_v6[2] = 0;
rec.addr_v6[3] = 5;
assert_eq!(rec.addr_string(), "10.0.0.5");
}
}