From f4b530c6912137745cd6c7ec97b8ec2779e0b697 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sat, 7 Mar 2026 22:00:44 -0500 Subject: [PATCH] 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 --- Cargo.lock | 42 ++++++++++++++++++- Cargo.toml | 2 + crates/arc-util/Cargo.toml | 2 + crates/arc-util/src/telemetry/anonymous_id.rs | 42 +++++++++++++++---- crates/arc-util/src/telemetry/mod.rs | 16 ++++++- 5 files changed, 92 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2506235d1..29c196e15 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index c1cc4bc9d..421b80778 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/crates/arc-util/Cargo.toml b/crates/arc-util/Cargo.toml index a2c746cd0..456ec6242 100644 --- a/crates/arc-util/Cargo.toml +++ b/crates/arc-util/Cargo.toml @@ -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" diff --git a/crates/arc-util/src/telemetry/anonymous_id.rs b/crates/arc-util/src/telemetry/anonymous_id.rs index 8fc4b0f4e..86a61af01 100644 --- a/crates/arc-util/src/telemetry/anonymous_id.rs +++ b/crates/arc-util/src/telemetry/anonymous_id.rs @@ -3,13 +3,14 @@ use std::fs; use std::path::PathBuf; use uuid::Uuid; -fn anonymous_id_path() -> Result { +fn dot_id_path() -> Result { 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 { - let path = anonymous_id_path()?; +/// Server: UUID persisted at ~/.arc/.id +pub fn load_or_create_server_id() -> Result { + 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 { Ok(id) } +/// CLI: prefer existing ~/.arc/.id, else MD5 of MAC address +pub fn compute_cli_id() -> Result { + 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(); } } diff --git a/crates/arc-util/src/telemetry/mod.rs b/crates/arc-util/src/telemetry/mod.rs index 4b4075ad1..79da2e8c2 100644 --- a/crates/arc-util/src/telemetry/mod.rs +++ b/crates/arc-util/src/telemetry/mod.rs @@ -24,9 +24,21 @@ pub struct Telemetry { } impl Telemetry { - pub fn new() -> Result { + pub fn for_server() -> Result { 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 { + let level = telemetry_level(); + let anonymous_id = anonymous_id::compute_cli_id()?; let context = context::build_context(); Ok(Self {