#![allow(clippy::unwrap_used, clippy::expect_used)]
mod common;
use common::hive_builder::TestHiveBuilder;
use winreg_artifacts::sam::parse;
use winreg_core::hive::Hive;
const REG_BINARY: u32 = 3;
fn build_f_record(
last_login: u64,
password_last_set: u64,
account_expires: u64,
account_flags: u32,
login_count: u16,
) -> Vec<u8> {
let mut f = vec![0u8; 72];
f[0..2].copy_from_slice(&2u16.to_le_bytes());
f[8..16].copy_from_slice(&last_login.to_le_bytes());
f[16..24].copy_from_slice(&password_last_set.to_le_bytes());
f[24..32].copy_from_slice(&account_expires.to_le_bytes());
f[56..60].copy_from_slice(&account_flags.to_le_bytes());
f[66..68].copy_from_slice(&login_count.to_le_bytes());
f
}
const FILETIME_2024: u64 = 133_485_408_000_000_000;
fn build_sam_hive_one_user(username: &str, rid_hex: &str, f_record: &[u8]) -> Vec<u8> {
let names_path = format!("SAM\\Domains\\Account\\Users\\Names\\{username}");
let rid_path = format!("SAM\\Domains\\Account\\Users\\{rid_hex}");
TestHiveBuilder::new()
.add_key(&names_path)
.add_key(&rid_path)
.add_value(&rid_path, "F", REG_BINARY, f_record)
.build()
}
fn build_sam_hive_two_users(
username1: &str,
rid_hex1: &str,
f1: &[u8],
username2: &str,
rid_hex2: &str,
f2: &[u8],
) -> Vec<u8> {
let names_path1 = format!("SAM\\Domains\\Account\\Users\\Names\\{username1}");
let names_path2 = format!("SAM\\Domains\\Account\\Users\\Names\\{username2}");
let rid_path1 = format!("SAM\\Domains\\Account\\Users\\{rid_hex1}");
let rid_path2 = format!("SAM\\Domains\\Account\\Users\\{rid_hex2}");
TestHiveBuilder::new()
.add_key(&names_path1)
.add_key(&names_path2)
.add_key(&rid_path1)
.add_value(&rid_path1, "F", REG_BINARY, f1)
.add_key(&rid_path2)
.add_value(&rid_path2, "F", REG_BINARY, f2)
.build()
}
#[test]
fn parse_empty_hive_returns_empty() {
let data = TestHiveBuilder::new().build();
let hive = Hive::from_bytes(data).unwrap();
let results = parse(&hive);
assert!(
results.is_empty(),
"empty hive (no SAM\\Domains key) should return empty Vec"
);
}
#[test]
fn parse_sam_user_returns_entry() {
let f = build_f_record(0, 0, 0, 0, 0);
let data = build_sam_hive_one_user("Administrator", "000001F4", &f);
let hive = Hive::from_bytes(data).unwrap();
let results = parse(&hive);
assert_eq!(results.len(), 1, "should return one entry");
}
#[test]
fn parse_username_extracted() {
let f = build_f_record(0, 0, 0, 0, 0);
let data = build_sam_hive_one_user("TestUser", "000003E9", &f);
let hive = Hive::from_bytes(data).unwrap();
let results = parse(&hive);
assert_eq!(results.len(), 1);
assert_eq!(
results[0].username, "TestUser",
"username should match the Names subkey"
);
}
#[test]
fn parse_disabled_flag_detected() {
let f = build_f_record(0, 0, 0, 0x0001, 0);
let data = build_sam_hive_one_user("DisabledUser", "000003EA", &f);
let hive = Hive::from_bytes(data).unwrap();
let results = parse(&hive);
assert_eq!(results.len(), 1);
assert!(
results[0].is_disabled,
"is_disabled should be true when flag 0x0001 is set"
);
assert_eq!(results[0].account_flags & 0x0001, 0x0001);
}
#[test]
fn parse_login_count_extracted() {
let f = build_f_record(0, 0, 0, 0, 42);
let data = build_sam_hive_one_user("CountUser", "000003EB", &f);
let hive = Hive::from_bytes(data).unwrap();
let results = parse(&hive);
assert_eq!(results.len(), 1);
assert_eq!(
results[0].login_count, 42,
"login_count should be extracted from F record bytes 66-67"
);
}
#[test]
fn parse_last_login_filetime_converted() {
let f = build_f_record(FILETIME_2024, 0, 0, 0, 0);
let data = build_sam_hive_one_user("LoginUser", "000003EC", &f);
let hive = Hive::from_bytes(data).unwrap();
let results = parse(&hive);
assert_eq!(results.len(), 1);
let last_login = results[0].last_login.as_deref().unwrap_or("");
assert!(
last_login.contains("2024"),
"last_login should be an ISO 8601 string containing '2024', got: {last_login:?}"
);
}
#[test]
fn parse_zero_filetime_gives_none() {
let f = build_f_record(0, 0, 0, 0, 0);
let data = build_sam_hive_one_user("ZeroUser", "000003ED", &f);
let hive = Hive::from_bytes(data).unwrap();
let results = parse(&hive);
assert_eq!(results.len(), 1);
assert!(
results[0].last_login.is_none(),
"zero FILETIME should produce None for last_login"
);
assert!(
results[0].password_last_set.is_none(),
"zero FILETIME should produce None for password_last_set"
);
}
#[test]
fn parse_short_f_record_returns_defaults() {
let short_f = vec![0u8; 4];
let data = build_sam_hive_one_user("ShortUser", "000003EE", &short_f);
let hive = Hive::from_bytes(data).unwrap();
let results = parse(&hive);
assert_eq!(results.len(), 1);
let entry = &results[0];
assert!(
entry.last_login.is_none(),
"short F record: last_login should be None"
);
assert_eq!(
entry.login_count, 0,
"short F record: login_count should default to 0"
);
assert_eq!(
entry.account_flags, 0,
"short F record: account_flags should default to 0"
);
assert!(!entry.is_disabled);
assert!(!entry.is_locked);
}
#[test]
fn parse_multiple_users_returns_all() {
let f1 = build_f_record(0, 0, 0, 0, 5);
let f2 = build_f_record(0, 0, 0, 0x0001, 10);
let data = build_sam_hive_two_users("AdminUser", "000001F4", &f1, "GuestUser", "000001F5", &f2);
let hive = Hive::from_bytes(data).unwrap();
let results = parse(&hive);
assert_eq!(results.len(), 2, "should return one entry per username");
let names: Vec<&str> = results.iter().map(|e| e.username.as_str()).collect();
assert!(names.contains(&"AdminUser"), "AdminUser should be present");
assert!(names.contains(&"GuestUser"), "GuestUser should be present");
}