use std::path::Path;
#[cfg(any(target_os = "macos", target_os = "windows"))]
use std::process::Command;
use std::sync::Mutex;
const APP_SALT: &str = "canboat";
pub fn unique_number(state_dir: Option<&Path>) -> u32 {
match machine_string() {
Some(id) => unique_number_for(&id),
None => stored_unique_number(state_dir),
}
}
const ALL_ONES: u32 = 0x1f_ffff;
const UNIQUE_NUMBER_FILE: &str = "unique-number";
fn unique_number_for(machine: &str) -> u32 {
let unique = (fnv1a_64(format!("{APP_SALT}|{machine}").as_bytes()) as u32) & ALL_ONES;
if unique == ALL_ONES {
ALL_ONES - 1
} else {
unique
}
}
fn stored_unique_number(state_dir: Option<&Path>) -> u32 {
static UNSTORED: Mutex<Option<u32>> = Mutex::new(None);
let mut unstored = UNSTORED.lock().unwrap_or_else(|e| e.into_inner());
let path = state_dir.map(|d| d.join(UNIQUE_NUMBER_FILE));
if let Some(n) = path.as_deref().and_then(read_unique_number) {
return n;
}
let n = unstored.unwrap_or_else(random_unique_number);
match path.as_deref().map(|p| store_unique_number(p, n)) {
Some(Ok(stored)) => {
log::info!(
"cannot identify this machine; stored random unique number {stored} in {}",
path.as_deref().unwrap().display()
);
*unstored = None;
return stored;
}
Some(Err(e)) => log::warn!(
"cannot identify this machine, nor store random unique number {n} in {}: {e}; \
pass --unique to keep the NAME across restarts",
path.as_deref().unwrap().display()
),
None if unstored.is_some() => return n,
None => log::warn!(
"cannot identify this machine; using random unique number {n} for this run \
(pass --unique to keep the NAME across restarts)"
),
}
*unstored = Some(n);
n
}
fn read_unique_number(path: &Path) -> Option<u32> {
let n: u32 = std::fs::read_to_string(path).ok()?.trim().parse().ok()?;
(n < ALL_ONES).then_some(n)
}
fn store_unique_number(path: &Path, n: u32) -> std::io::Result<u32> {
let dir = path.parent().unwrap_or(Path::new("."));
std::fs::create_dir_all(dir)?;
let lock = std::fs::File::create(dir.join(format!("{UNIQUE_NUMBER_FILE}.lock")))?;
lock.lock()?; if let Some(theirs) = read_unique_number(path) {
return Ok(theirs);
}
let tmp = dir.join(format!(".{UNIQUE_NUMBER_FILE}.{}.tmp", std::process::id()));
std::fs::write(&tmp, format!("{n}\n"))?;
std::fs::rename(&tmp, path).inspect_err(|_| {
let _ = std::fs::remove_file(&tmp);
})?;
Ok(n)
}
fn random_unique_number() -> u32 {
use std::hash::{BuildHasher, Hasher};
let mut h = std::collections::hash_map::RandomState::new().build_hasher();
h.write(APP_SALT.as_bytes());
(h.finish() % u64::from(ALL_ONES)) as u32
}
fn fnv1a_64(bytes: &[u8]) -> u64 {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325; for &b in bytes {
hash ^= b as u64;
hash = hash.wrapping_mul(0x0000_0100_0000_01b3); }
hash
}
#[cfg(target_os = "linux")]
fn machine_string() -> Option<String> {
for p in ["/etc/machine-id", "/var/lib/dbus/machine-id"] {
if let Ok(s) = std::fs::read_to_string(p) {
let s = s.trim();
if is_machine_id(s) {
return Some(s.to_string());
}
}
}
None
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn is_machine_id(s: &str) -> bool {
s.len() == 32
&& s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
&& s.bytes().any(|b| b != b'0')
}
#[cfg(target_os = "macos")]
fn machine_string() -> Option<String> {
let out = Command::new("ioreg")
.args(["-rd1", "-c", "IOPlatformExpertDevice"])
.output()
.ok()?;
let text = String::from_utf8_lossy(&out.stdout);
let line = text.lines().find(|l| l.contains("IOPlatformUUID"))?;
let start = line.find("= \"")? + 3;
let end = line[start..].find('"')? + start;
Some(line[start..end].to_string()).filter(|s| !s.is_empty())
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
fn machine_string() -> Option<String> {
None
}
#[cfg(target_os = "windows")]
fn machine_string() -> Option<String> {
let out = Command::new("reg")
.args([
"query",
r"HKLM\SOFTWARE\Microsoft\Cryptography",
"/v",
"MachineGuid",
])
.output()
.ok()?;
let text = String::from_utf8_lossy(&out.stdout);
let line = text.lines().find(|l| l.contains("MachineGuid"))?;
Some(line.split_whitespace().last()?.to_string()).filter(|s| !s.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
static STORED: Mutex<()> = Mutex::new(());
#[test]
fn fnv1a_matches_the_reference_vectors() {
assert_eq!(fnv1a_64(b""), 0xcbf2_9ce4_8422_2325);
assert_eq!(fnv1a_64(b"a"), 0xaf63_dc4c_8601_ec8c);
assert_eq!(fnv1a_64(b"foobar"), 0x8594_4171_f739_67e8);
}
#[test]
fn unique_number_is_the_low_21_bits_of_the_salted_hash() {
let machine = "4c4c4544003210508051b2c04f4a3132";
let n = unique_number_for(machine);
assert_eq!(
n,
(fnv1a_64(format!("canboat|{machine}").as_bytes()) & 0x1f_ffff) as u32
);
assert_ne!(
n,
(fnv1a_64(format!("canboatjs|{machine}").as_bytes()) & 0x1f_ffff) as u32
);
}
#[test]
fn unique_number_is_never_all_ones() {
let all_ones = (0u32..)
.map(|i| i.to_string())
.find(|m| fnv1a_64(format!("canboat|{m}").as_bytes()) & 0x1f_ffff == 0x1f_ffff)
.unwrap();
assert_eq!(unique_number_for(&all_ones), 0x1f_fffe);
}
#[test]
fn machine_id_accepts_systemd_id() {
assert!(is_machine_id("4c4c4544003210508051b2c04f4a3132"));
}
#[test]
fn machine_id_refuses_unbooted_image() {
assert!(!is_machine_id(""));
assert!(!is_machine_id("uninitialized"));
assert!(!is_machine_id(&"0".repeat(32)));
assert!(!is_machine_id("4C4C4544003210508051B2C04F4A3132"));
assert!(!is_machine_id("4c4c4544003210508051b2c04f4a313"));
assert!(!is_machine_id("4c4c4544003210508051b2c04f4a313g"));
}
#[test]
fn random_unique_number_is_stored_and_reused() {
let _guard = STORED.lock().unwrap_or_else(|e| e.into_inner());
let dir = std::env::temp_dir().join(format!("canboat-unique-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let n = stored_unique_number(Some(&dir));
assert!(n < 0x1f_ffff);
let stored = std::fs::read_to_string(dir.join(UNIQUE_NUMBER_FILE)).unwrap();
assert_eq!(stored.trim().parse::<u32>().unwrap(), n);
assert_eq!(stored_unique_number(Some(&dir)), n);
std::fs::write(dir.join(UNIQUE_NUMBER_FILE), "424242\n").unwrap();
assert_eq!(stored_unique_number(Some(&dir)), 424242);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_first_stored_unique_number_wins() {
let dir = std::env::temp_dir().join(format!("canboat-unique-race-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join(UNIQUE_NUMBER_FILE);
assert_eq!(store_unique_number(&path, 1234).unwrap(), 1234);
assert_eq!(store_unique_number(&path, 5678).unwrap(), 1234);
assert_eq!(read_unique_number(&path), Some(1234));
std::fs::write(&path, "x").unwrap();
assert_eq!(store_unique_number(&path, 5678).unwrap(), 5678);
assert_eq!(read_unique_number(&path), Some(5678));
let mut names: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.map(|e| e.unwrap().file_name().into_string().unwrap())
.collect();
names.sort();
assert_eq!(
names,
[UNIQUE_NUMBER_FILE, &format!("{UNIQUE_NUMBER_FILE}.lock")],
"no temporary file left"
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn concurrent_stores_agree() {
let dir = std::env::temp_dir().join(format!("canboat-unique-conc-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(UNIQUE_NUMBER_FILE);
std::fs::write(&path, "garbage").unwrap();
let path = &path;
let got: Vec<u32> = std::thread::scope(|s| {
let handles: Vec<_> = (0..8)
.map(|i| s.spawn(move || store_unique_number(path, 1000 + i).unwrap()))
.collect();
handles.into_iter().map(|h| h.join().unwrap()).collect()
});
assert!(got.iter().all(|&n| n == got[0]), "{got:?}");
assert_eq!(read_unique_number(path), Some(got[0]));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn an_unstored_number_is_stored_later() {
let _guard = STORED.lock().unwrap_or_else(|e| e.into_inner());
let dir = std::env::temp_dir().join(format!("canboat-unique-late-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let first = stored_unique_number(None);
assert_eq!(stored_unique_number(Some(&dir)), first);
assert_eq!(
read_unique_number(&dir.join(UNIQUE_NUMBER_FILE)),
Some(first)
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_stored_all_ones_or_garbage_is_ignored() {
let dir = std::env::temp_dir().join(format!("canboat-unique-bad-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(UNIQUE_NUMBER_FILE);
for bad in ["2097151", "x", ""] {
std::fs::write(&path, bad).unwrap();
assert_eq!(read_unique_number(&path), None, "{bad:?}");
}
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn random_unique_numbers_differ() {
assert_ne!(random_unique_number(), random_unique_number());
}
}