Split anonymous ID into server (UUID-on-disk) and CLI (MAC-based hash) strategies

Server is long-lived on a fixed host, so a persisted UUID at ~/.arc/.id is
appropriate. CLI runs ephemerally, so an MD5 of the MAC address avoids file
I/O and is stable per-machine. CLI falls back to ~/.arc/.id if it exists
for migration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-07 22:00:44 -05:00
parent 2b8d6a40a1
commit f4b530c691
5 changed files with 92 additions and 12 deletions

42
Cargo.lock generated
View file

@ -364,6 +364,8 @@ dependencies = [
"chrono",
"console 0.15.11",
"dirs",
"mac_address",
"md5",
"regex",
"reqwest 0.12.28",
"serde",
@ -2525,6 +2527,16 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mac_address"
version = "1.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303"
dependencies = [
"nix 0.29.0",
"winapi",
]
[[package]]
name = "markup5ever"
version = "0.35.0"
@ -2584,12 +2596,27 @@ dependencies = [
"digest",
]
[[package]]
name = "md5"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771"
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "memoffset"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
dependencies = [
"autocfg",
]
[[package]]
name = "mime"
version = "0.3.17"
@ -2681,6 +2708,19 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nix"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
"memoffset",
]
[[package]]
name = "nix"
version = "0.31.2"
@ -3216,7 +3256,7 @@ checksum = "ccd9713fe2c91c3c85ac388b31b89de339365d2c995146e630b5e0da9d06526a"
dependencies = [
"futures",
"indexmap 2.13.0",
"nix",
"nix 0.31.2",
"tokio",
"tracing",
"windows",

View file

@ -42,6 +42,8 @@ semver = "1"
aho-corasick = "1"
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "chrono"] }
dirs = "6"
mac_address = "1"
md5 = "0.7"
indicatif = "0.18"
termimad = "0.34"
toml = "0.8"

View file

@ -23,6 +23,8 @@ base64.workspace = true
dirs.workspace = true
tracing.workspace = true
anyhow.workspace = true
mac_address.workspace = true
md5.workspace = true
[build-dependencies]
toml = "0.8"

View file

@ -3,13 +3,14 @@ use std::fs;
use std::path::PathBuf;
use uuid::Uuid;
fn anonymous_id_path() -> Result<PathBuf> {
fn dot_id_path() -> Result<PathBuf> {
let home = dirs::home_dir().context("could not determine home directory")?;
Ok(home.join(".arc").join("anonymous_id"))
Ok(home.join(".arc").join(".id"))
}
pub fn load_or_create_anonymous_id() -> Result<String> {
let path = anonymous_id_path()?;
/// Server: UUID persisted at ~/.arc/.id
pub fn load_or_create_server_id() -> Result<String> {
let path = dot_id_path()?;
if let Ok(contents) = fs::read_to_string(&path) {
let id = contents.trim().to_string();
@ -31,17 +32,40 @@ pub fn load_or_create_anonymous_id() -> Result<String> {
Ok(id)
}
/// CLI: prefer existing ~/.arc/.id, else MD5 of MAC address
pub fn compute_cli_id() -> Result<String> {
let path = dot_id_path()?;
if let Ok(contents) = fs::read_to_string(&path) {
let id = contents.trim().to_string();
if !id.is_empty() {
return Ok(id);
}
}
let mac = mac_address::get_mac_address()
.context("failed to get MAC address")?
.context("no MAC address found")?;
let digest = md5::compute(mac.bytes());
Ok(format!("{:x}", digest))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn load_or_create_returns_stable_id() {
// Calls the real function, which uses ~/.arc/anonymous_id
let id1 = load_or_create_anonymous_id().unwrap();
let id2 = load_or_create_anonymous_id().unwrap();
fn compute_cli_id_returns_non_empty_string() {
let id = compute_cli_id().unwrap();
assert!(!id.is_empty());
}
#[test]
fn load_or_create_server_id_returns_stable_uuid() {
let id1 = load_or_create_server_id().unwrap();
let id2 = load_or_create_server_id().unwrap();
assert_eq!(id1, id2);
// Should be a valid UUID
Uuid::parse_str(&id1).unwrap();
}
}

View file

@ -24,9 +24,21 @@ pub struct Telemetry {
}
impl Telemetry {
pub fn new() -> Result<Self> {
pub fn for_server() -> Result<Self> {
let level = telemetry_level();
let anonymous_id = anonymous_id::load_or_create_anonymous_id()?;
let anonymous_id = anonymous_id::load_or_create_server_id()?;
let context = context::build_context();
Ok(Self {
level,
anonymous_id,
context,
})
}
pub fn for_cli() -> Result<Self> {
let level = telemetry_level();
let anonymous_id = anonymous_id::compute_cli_id()?;
let context = context::build_context();
Ok(Self {