use std::io::Read;
use std::path::{Path, PathBuf};
use crate::error::Error;
use crate::source::{Probe, Source, SourceKind};
use crate::sources::util::{NormalizeOutcome, classify, read_capped};
macro_rules! file_source {
($name:ident, $kind:expr, $default:expr, $doc:literal) => {
#[doc = $doc]
#[derive(Debug, Clone)]
pub struct $name {
path: PathBuf,
}
impl $name {
#[doc = concat!("Read from the standard path (`", $default, "`).")]
#[must_use]
pub fn new() -> Self {
Self {
path: PathBuf::from($default),
}
}
#[must_use]
pub fn at(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() }
}
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
}
impl Default for $name {
fn default() -> Self {
Self::new()
}
}
impl Source for $name {
fn kind(&self) -> SourceKind {
$kind
}
fn probe(&self) -> Result<Option<Probe>, Error> {
read_machine_id_file($kind, &self.path)
}
}
};
}
file_source!(
MachineIdFile,
SourceKind::MachineId,
"/etc/machine-id",
"`/etc/machine-id` — the systemd-managed primary host identifier on modern Linux.\n\n\
# Known-duplicate filtering\n\n\
A non-trivial fraction of Linux installs ship or end up with machine-id\n\
values that are identical across many machines (Whonix's deliberate\n\
anti-fingerprinting constant; official container images that bake a\n\
single hex value into the filesystem layer; synthetic all-same-nibble\n\
values from broken image builds). Returning one of those would produce\n\
a silently non-unique identity shared by every host that inherits it,\n\
so this source additionally rejects, by returning `Ok(None)` with a\n\
`log::debug!` entry:\n\n\
- A curated list of public, citable shared values (`MACHINE_ID_DENYLIST`).\n\
- Any 32-hex-digit value whose nibbles are all the same character\n\
(`00…0`, `11…1`, `aa…a`, etc.). The systemd spec forbids all-zero\n\
machine-ids outright; the rest are only ever seen on synthetic or\n\
corrupt images.\n\n\
Anything not matching the filter passes through unchanged — the intent\n\
is to reject *known* garbage, not to gate on machine-id shape. A false\n\
positive here drops a legitimate host from identity resolution, so a\n\
missing entry is strictly preferable to an over-broad rule."
);
file_source!(
DbusMachineIdFile,
SourceKind::DbusMachineId,
"/var/lib/dbus/machine-id",
"`/var/lib/dbus/machine-id` — D-Bus machine ID. Often a symlink to `/etc/machine-id` \
but present on its own on some minimal images. Shares the same \
known-duplicate filter as [`MachineIdFile`]."
);
#[derive(Debug, Clone)]
pub struct DmiProductUuid {
path: PathBuf,
}
impl DmiProductUuid {
#[must_use]
pub fn new() -> Self {
Self {
path: PathBuf::from("/sys/class/dmi/id/product_uuid"),
}
}
#[must_use]
pub fn at(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() }
}
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
}
impl Default for DmiProductUuid {
fn default() -> Self {
Self::new()
}
}
impl Source for DmiProductUuid {
fn kind(&self) -> SourceKind {
SourceKind::Dmi
}
fn probe(&self) -> Result<Option<Probe>, Error> {
read_dmi_file(&self.path)
}
}
const MACHINE_ID_DENYLIST: &[&str] = &[
"b08dfa6083e7567a1921a715000001fb",
"d495c4b7bb8244639186ef65305fd685",
"e28a15f597cd4693bb61f1f3e8447cbd",
"4c010dc413ad444698de6ee4677331b9",
"a7570853ab864bbbbfc8c54b14eeaf8f",
"5b4bb40898b2416087b6224f176978fb",
"3948e4ca87b64871b31c9a49920b9834",
"835aa90928e143e3ae09efcd0c5cb118",
];
fn is_machine_id_garbage(value: &str) -> bool {
let lower = value.to_ascii_lowercase();
MACHINE_ID_DENYLIST.contains(&lower.as_str()) || is_all_same_nibble_hex32(&lower)
}
fn is_all_same_nibble_hex32(value: &str) -> bool {
let bytes = value.as_bytes();
bytes.len() == 32 && bytes[0].is_ascii_hexdigit() && bytes.iter().all(|b| *b == bytes[0])
}
fn read_machine_id_file(kind: SourceKind, path: &Path) -> Result<Option<Probe>, Error> {
match read_id_file(kind, path)? {
Some(probe) if is_machine_id_garbage(probe.value()) => {
log::debug!(
"host-identity: {kind:?} value {} matches a known-duplicate machine-id; \
falling through",
probe.value()
);
Ok(None)
}
other => Ok(other),
}
}
const DMI_PLACEHOLDER_UUIDS: &[&str] = &[
"03000200-0400-0500-0006-000700080009",
];
fn is_dmi_garbage(value: &str) -> bool {
let lower = value.to_ascii_lowercase();
if DMI_PLACEHOLDER_UUIDS.iter().any(|p| *p == lower) {
return true;
}
is_all_same_nibble_uuid(&lower)
}
fn is_all_same_nibble_uuid(value: &str) -> bool {
let mut chars = value.chars().filter(|c| *c != '-');
let Some(first) = chars.next() else {
return false;
};
if !first.is_ascii_hexdigit() {
return false;
}
let mut count = 1usize;
for c in chars {
if c != first {
return false;
}
count += 1;
}
count == 32
}
fn read_dmi_file(path: &Path) -> Result<Option<Probe>, Error> {
match read_id_file(SourceKind::Dmi, path)? {
Some(probe) if is_dmi_garbage(probe.value()) => {
log::debug!(
"host-identity: DMI product_uuid {} matches a known vendor-placeholder; \
falling through",
probe.value()
);
Ok(None)
}
other => Ok(other),
}
}
fn open_id_file(kind: SourceKind, path: &Path) -> Result<Option<std::fs::File>, Error> {
match std::fs::File::open(path) {
Ok(file) => Ok(Some(file)),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
log::debug!(
"host-identity: permission denied reading {}",
path.display()
);
Ok(None)
}
Err(source) => Err(Error::Io {
source_kind: kind,
path: PathBuf::from(path),
source,
}),
}
}
fn read_id_file(kind: SourceKind, path: &Path) -> Result<Option<Probe>, Error> {
match read_capped(path) {
Ok(content) => match classify(&content) {
NormalizeOutcome::Usable(value) => Ok(Some(Probe::new(kind, value))),
NormalizeOutcome::Sentinel => Err(Error::Uninitialized {
source_kind: kind,
path: PathBuf::from(path),
}),
NormalizeOutcome::Empty => Ok(None),
},
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
log::debug!(
"host-identity: permission denied reading {}",
path.display()
);
Ok(None)
}
Err(source) => Err(Error::Io {
source_kind: kind,
path: PathBuf::from(path),
source,
}),
}
}
#[derive(Debug, Clone)]
pub struct LinuxHostIdFile {
path: PathBuf,
}
impl LinuxHostIdFile {
#[must_use]
pub fn new() -> Self {
Self {
path: PathBuf::from("/etc/hostid"),
}
}
#[must_use]
pub fn at(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() }
}
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
}
impl Default for LinuxHostIdFile {
fn default() -> Self {
Self::new()
}
}
impl Source for LinuxHostIdFile {
fn kind(&self) -> SourceKind {
SourceKind::LinuxHostId
}
fn probe(&self) -> Result<Option<Probe>, Error> {
read_linux_hostid(&self.path)
}
}
fn read_linux_hostid(path: &Path) -> Result<Option<Probe>, Error> {
let Some(file) = open_id_file(SourceKind::LinuxHostId, path)? else {
return Ok(None);
};
let mut buf = Vec::with_capacity(5);
file.take(5)
.read_to_end(&mut buf)
.map_err(|source| Error::Io {
source_kind: SourceKind::LinuxHostId,
path: PathBuf::from(path),
source,
})?;
let Ok(bytes): Result<[u8; 4], _> = buf.as_slice().try_into() else {
log::debug!(
"host-identity: /etc/hostid at {} is {} bytes, expected 4; falling through",
path.display(),
buf.len(),
);
return Ok(None);
};
let value = u32::from_ne_bytes(bytes);
if value == 0 || value == u32::MAX {
log::debug!(
"host-identity: /etc/hostid at {} is {value:#010x} (unset/sentinel); falling through",
path.display()
);
return Ok(None);
}
Ok(Some(Probe::new(
SourceKind::LinuxHostId,
format!("{value:08x}"),
)))
}
#[must_use]
pub(crate) fn in_container() -> bool {
const MARKERS: &[&str] = &["docker", "kubepods", "containerd", "podman", "lxc", "crio"];
Path::new("/.dockerenv").exists()
|| std::fs::read_to_string("/proc/1/cgroup").is_ok_and(|cgroup| {
cgroup
.split(['/', ':', '-', '.', '_', '\n'])
.any(|seg| MARKERS.contains(&seg))
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::{NamedTempFile, TempDir};
#[test]
fn machine_id_file_rejects_uninitialized_sentinel() {
let mut f = NamedTempFile::new().unwrap();
writeln!(f, "uninitialized").unwrap();
let err = read_id_file(SourceKind::MachineId, f.path()).expect_err("sentinel must error");
match err {
Error::Uninitialized { path, source_kind } => {
assert_eq!(path, f.path());
assert_eq!(source_kind, SourceKind::MachineId);
}
other => panic!("expected Uninitialized, got {other:?}"),
}
}
#[test]
fn machine_id_file_accepts_normal_value() {
let mut f = NamedTempFile::new().unwrap();
writeln!(f, "abc123").unwrap();
let probe = read_id_file(SourceKind::MachineId, f.path())
.unwrap()
.unwrap();
assert_eq!(probe.value(), "abc123");
}
#[test]
fn machine_id_file_missing_is_none() {
let dir = TempDir::new().unwrap();
let missing = dir.path().join("definitely-not-there");
let probe = read_id_file(SourceKind::MachineId, &missing).unwrap();
assert!(probe.is_none());
}
#[test]
fn machine_id_file_empty_is_none() {
let f = NamedTempFile::new().unwrap();
let probe = read_id_file(SourceKind::MachineId, f.path()).unwrap();
assert!(probe.is_none());
}
#[test]
fn machine_id_file_whitespace_only_is_none() {
let mut f = NamedTempFile::new().unwrap();
write!(f, " \n\t ").unwrap();
let probe = read_id_file(SourceKind::MachineId, f.path()).unwrap();
assert!(probe.is_none());
}
#[test]
fn machine_id_file_reports_io_error_for_directory() {
let dir = TempDir::new().unwrap();
let err = read_id_file(SourceKind::MachineId, dir.path())
.expect_err("reading a directory must error");
match err {
Error::Io { path, .. } => assert_eq!(path, dir.path()),
other => panic!("expected Io, got {other:?}"),
}
}
#[cfg(unix)]
#[test]
fn machine_id_file_permission_denied_is_none() {
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
struct PermGuard(PathBuf);
impl Drop for PermGuard {
fn drop(&mut self) {
let _ = std::fs::set_permissions(&self.0, std::fs::Permissions::from_mode(0o600));
}
}
if nix_is_root() {
return;
}
let mut f = NamedTempFile::new().unwrap();
writeln!(f, "abc123").unwrap();
let path: &Path = f.path();
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o000)).unwrap();
let _guard = PermGuard(path.to_path_buf());
let probe = read_id_file(SourceKind::MachineId, path)
.expect("permission denied should be swallowed to Ok(None)");
assert!(probe.is_none());
}
fn machine_id_probe(kind: SourceKind, body: &str) -> Option<Probe> {
let mut f = NamedTempFile::new().unwrap();
write!(f, "{body}").unwrap();
read_machine_id_file(kind, f.path()).unwrap()
}
#[test]
fn machine_id_rejects_whonix_constant() {
assert!(
machine_id_probe(SourceKind::MachineId, "b08dfa6083e7567a1921a715000001fb\n").is_none()
);
}
#[test]
fn machine_id_rejects_whonix_constant_uppercase() {
assert!(
machine_id_probe(SourceKind::MachineId, "B08DFA6083E7567A1921A715000001FB\n").is_none()
);
}
#[test]
fn machine_id_rejects_oraclelinux_9_constant() {
assert!(
machine_id_probe(SourceKind::MachineId, "d495c4b7bb8244639186ef65305fd685\n").is_none()
);
}
#[test]
fn machine_id_rejects_oraclelinux_8_constant() {
assert!(
machine_id_probe(SourceKind::MachineId, "e28a15f597cd4693bb61f1f3e8447cbd\n").is_none()
);
}
#[test]
fn machine_id_rejects_jrei_systemd_debian_constant() {
assert!(
machine_id_probe(SourceKind::MachineId, "4c010dc413ad444698de6ee4677331b9\n").is_none()
);
}
#[test]
fn machine_id_rejects_jrei_systemd_ubuntu_constant() {
assert!(
machine_id_probe(SourceKind::MachineId, "a7570853ab864bbbbfc8c54b14eeaf8f\n").is_none()
);
}
#[test]
fn machine_id_rejects_geerlingguy_ansible_ubuntu_constant() {
assert!(
machine_id_probe(SourceKind::MachineId, "5b4bb40898b2416087b6224f176978fb\n").is_none()
);
}
#[test]
fn machine_id_rejects_geerlingguy_ansible_debian_constant() {
assert!(
machine_id_probe(SourceKind::MachineId, "3948e4ca87b64871b31c9a49920b9834\n").is_none()
);
}
#[test]
fn machine_id_rejects_geerlingguy_ansible_rocky_constant() {
assert!(
machine_id_probe(SourceKind::MachineId, "835aa90928e143e3ae09efcd0c5cb118\n").is_none()
);
}
#[test]
fn machine_id_rejects_all_zero_hex32() {
assert!(machine_id_probe(SourceKind::MachineId, &"0".repeat(32)).is_none());
}
#[test]
fn machine_id_rejects_all_same_nibble_hex32() {
assert!(machine_id_probe(SourceKind::MachineId, &"a".repeat(32)).is_none());
assert!(machine_id_probe(SourceKind::MachineId, &"F".repeat(32)).is_none());
}
#[test]
fn machine_id_accepts_plausible_real_value() {
let probe =
machine_id_probe(SourceKind::MachineId, "4c4c4544003957108052b4c04f384833\n").unwrap();
assert_eq!(probe.value(), "4c4c4544003957108052b4c04f384833");
}
#[test]
fn machine_id_filter_trims_whitespace_before_matching() {
assert!(
machine_id_probe(
SourceKind::MachineId,
" b08dfa6083e7567a1921a715000001fb \n\t"
)
.is_none()
);
}
#[test]
fn dbus_machine_id_rejects_whonix_constant() {
assert!(
machine_id_probe(
SourceKind::DbusMachineId,
"b08dfa6083e7567a1921a715000001fb\n"
)
.is_none()
);
}
#[test]
fn read_id_file_does_not_apply_machine_id_filter() {
let mut f = NamedTempFile::new().unwrap();
write!(f, "{}", "0".repeat(32)).unwrap();
let probe = read_id_file(SourceKind::MachineId, f.path())
.unwrap()
.unwrap();
assert_eq!(probe.value(), "0".repeat(32));
}
#[test]
fn machine_id_file_probe_applies_filter() {
let mut f = NamedTempFile::new().unwrap();
writeln!(f, "b08dfa6083e7567a1921a715000001fb").unwrap();
let probe = MachineIdFile::at(f.path()).probe().unwrap();
assert!(probe.is_none());
}
#[test]
fn dbus_machine_id_file_probe_applies_filter() {
let mut f = NamedTempFile::new().unwrap();
writeln!(f, "b08dfa6083e7567a1921a715000001fb").unwrap();
let probe = DbusMachineIdFile::at(f.path()).probe().unwrap();
assert!(probe.is_none());
}
#[test]
fn is_all_same_nibble_hex32_rejects_short_values() {
assert!(!is_all_same_nibble_hex32("aaa"));
assert!(!is_all_same_nibble_hex32(""));
assert!(!is_all_same_nibble_hex32(&"a".repeat(31)));
assert!(!is_all_same_nibble_hex32(&"a".repeat(33)));
}
#[test]
fn is_all_same_nibble_hex32_rejects_non_hex() {
assert!(!is_all_same_nibble_hex32(&"z".repeat(32)));
}
fn dmi_tempfile(body: &str) -> NamedTempFile {
let mut f = NamedTempFile::new().unwrap();
write!(f, "{body}").unwrap();
f
}
fn dmi_probe(body: &str) -> Option<Probe> {
let f = dmi_tempfile(body);
read_dmi_file(f.path()).unwrap()
}
#[test]
fn dmi_rejects_all_zero_uuid() {
assert!(dmi_probe("00000000-0000-0000-0000-000000000000\n").is_none());
}
#[test]
fn dmi_rejects_all_f_uuid_lower() {
assert!(dmi_probe("ffffffff-ffff-ffff-ffff-ffffffffffff\n").is_none());
}
#[test]
fn dmi_rejects_all_f_uuid_upper() {
assert!(dmi_probe("FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF\n").is_none());
}
#[test]
fn dmi_rejects_all_same_nibble_1() {
assert!(dmi_probe("11111111-1111-1111-1111-111111111111\n").is_none());
}
#[test]
fn dmi_rejects_all_same_nibble_a() {
assert!(dmi_probe("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa\n").is_none());
}
#[test]
fn dmi_rejects_supermicro_ami_placeholder() {
assert!(dmi_probe("03000200-0400-0500-0006-000700080009\n").is_none());
}
#[test]
fn dmi_rejects_supermicro_ami_placeholder_uppercase() {
assert!(
dmi_probe(
"03000200-0400-0500-0006-000700080009"
.to_ascii_uppercase()
.as_str()
)
.is_none()
);
}
#[test]
fn dmi_rejects_garbage_with_trailing_whitespace() {
assert!(dmi_probe(" 00000000-0000-0000-0000-000000000000 \n\t").is_none());
}
#[test]
fn dmi_accepts_plausible_real_uuid() {
let probe = dmi_probe("4c4c4544-0039-5710-8052-b4c04f384833\n").unwrap();
assert_eq!(probe.value(), "4c4c4544-0039-5710-8052-b4c04f384833");
}
#[test]
fn dmi_accepts_non_uuid_shape() {
let probe = dmi_probe("abcdef\n").unwrap();
assert_eq!(probe.value(), "abcdef");
}
#[test]
fn machine_id_file_accepts_hyphenated_all_zero_uuid() {
let probe = machine_id_probe(
SourceKind::MachineId,
"00000000-0000-0000-0000-000000000000\n",
)
.unwrap();
assert_eq!(probe.value(), "00000000-0000-0000-0000-000000000000");
}
fn write_hostid(bytes: &[u8]) -> NamedTempFile {
let mut f = NamedTempFile::new().unwrap();
f.write_all(bytes).unwrap();
f
}
#[test]
fn linux_hostid_reads_native_endian_bytes() {
let file_bytes = [0x8f, 0x8f, 0x98, 0x4f];
let expected = format!("{:08x}", u32::from_ne_bytes(file_bytes));
let f = write_hostid(&file_bytes);
let probe = read_linux_hostid(f.path()).unwrap().unwrap();
assert_eq!(probe.kind(), SourceKind::LinuxHostId);
assert_eq!(probe.value(), expected);
}
#[test]
fn linux_hostid_pads_small_values_to_eight_hex_digits() {
let file_bytes = 0x0000_0042_u32.to_ne_bytes();
let f = write_hostid(&file_bytes);
let probe = read_linux_hostid(f.path()).unwrap().unwrap();
assert_eq!(probe.value(), "00000042");
}
#[test]
fn linux_hostid_missing_is_none() {
let dir = TempDir::new().unwrap();
let missing = dir.path().join("absent");
assert!(read_linux_hostid(&missing).unwrap().is_none());
}
#[test]
fn linux_hostid_wrong_size_too_small_is_none() {
let f = write_hostid(&[0x01, 0x02, 0x03]);
assert!(read_linux_hostid(f.path()).unwrap().is_none());
}
#[test]
fn linux_hostid_wrong_size_too_large_is_none() {
let f = write_hostid(b"4f988f8f-0000-0000-0000-000000000000\n");
assert!(read_linux_hostid(f.path()).unwrap().is_none());
}
#[test]
fn linux_hostid_empty_is_none() {
let f = write_hostid(&[]);
assert!(read_linux_hostid(f.path()).unwrap().is_none());
}
#[test]
fn linux_hostid_rejects_all_zero() {
let f = write_hostid(&[0, 0, 0, 0]);
assert!(read_linux_hostid(f.path()).unwrap().is_none());
}
#[test]
fn linux_hostid_rejects_all_ff() {
let f = write_hostid(&[0xff, 0xff, 0xff, 0xff]);
assert!(read_linux_hostid(f.path()).unwrap().is_none());
}
#[test]
fn linux_hostid_reports_io_error_for_directory() {
let dir = TempDir::new().unwrap();
let err = read_linux_hostid(dir.path())
.expect_err("reading a directory must surface as Error::Io");
match err {
Error::Io {
path, source_kind, ..
} => {
assert_eq!(path, dir.path());
assert_eq!(source_kind, SourceKind::LinuxHostId);
}
other => panic!("expected Io, got {other:?}"),
}
}
#[cfg(unix)]
#[test]
fn linux_hostid_permission_denied_is_none() {
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
struct PermGuard(PathBuf);
impl Drop for PermGuard {
fn drop(&mut self) {
let _ = std::fs::set_permissions(&self.0, std::fs::Permissions::from_mode(0o600));
}
}
if nix_is_root() {
return;
}
let f = write_hostid(&[0x01, 0x02, 0x03, 0x04]);
std::fs::set_permissions(f.path(), std::fs::Permissions::from_mode(0o000)).unwrap();
let _guard = PermGuard(f.path().to_path_buf());
assert!(read_linux_hostid(f.path()).unwrap().is_none());
}
#[cfg(unix)]
fn nix_is_root() -> bool {
std::fs::read_to_string("/proc/self/status")
.ok()
.is_some_and(|s| effective_uid_from_status(&s) == Some("0"))
}
#[cfg(unix)]
fn effective_uid_from_status(status: &str) -> Option<&str> {
status
.lines()
.find_map(|l| l.strip_prefix("Uid:")?.split_whitespace().nth(1))
}
#[cfg(unix)]
#[test]
fn effective_uid_from_status_extracts_second_field() {
let status = "\
Name:\tbash
Uid:\t1000\t0\t1000\t1000
Gid:\t1000\t1000\t1000\t1000
";
assert_eq!(effective_uid_from_status(status), Some("0"));
}
#[cfg(unix)]
#[test]
fn effective_uid_from_status_handles_common_shapes() {
assert_eq!(
effective_uid_from_status("Uid:\t1000\t1000\t1000\t1000\n"),
Some("1000"),
);
assert_eq!(effective_uid_from_status("Uid:\t0\t0\t0\t0\n"), Some("0"),);
assert_eq!(effective_uid_from_status("Name:\tthing\n"), None);
assert_eq!(effective_uid_from_status("Uid:\t1000\n"), None);
assert_eq!(effective_uid_from_status("Uid:\n"), None);
assert_eq!(effective_uid_from_status(" Uid:\t0\t0\t0\t0\n"), None);
}
}