use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use uuid::Uuid;
#[allow(clippy::duration_suboptimal_units)]
const PROFILE_TTL: Duration = Duration::from_secs(7 * 24 * 3600);
const DEFAULT_USER_AGENT: &str = "claude-cli/2.1.109 (external, cli)";
const DEFAULT_PACKAGE_VERSION: &str = "0.74.0";
const DEFAULT_RUNTIME_VERSION: &str = "v24.14.1";
const DEFAULT_OS: &str = "MacOS";
const DEFAULT_ARCH: &str = "arm64";
#[derive(Clone, Debug)]
pub struct DeviceProfile {
pub user_agent: String,
pub package_version: String,
pub runtime_version: String,
pub os: String,
pub arch: String,
pub session_id: String,
pub device_id: String,
version: Option<(u32, u32, u32)>,
}
impl Default for DeviceProfile {
fn default() -> Self {
Self::baseline()
}
}
impl DeviceProfile {
fn baseline() -> Self {
Self {
user_agent: DEFAULT_USER_AGENT.to_string(),
package_version: DEFAULT_PACKAGE_VERSION.to_string(),
runtime_version: DEFAULT_RUNTIME_VERSION.to_string(),
os: DEFAULT_OS.to_string(),
arch: DEFAULT_ARCH.to_string(),
session_id: Uuid::new_v4().to_string(),
device_id: hex::encode(rand::random::<[u8; 32]>()),
version: parse_version(DEFAULT_USER_AGENT),
}
}
fn from_ua(ua: &str) -> Self {
Self {
user_agent: ua.to_string(),
package_version: DEFAULT_PACKAGE_VERSION.to_string(),
runtime_version: DEFAULT_RUNTIME_VERSION.to_string(),
os: DEFAULT_OS.to_string(),
arch: DEFAULT_ARCH.to_string(),
session_id: Uuid::new_v4().to_string(),
device_id: hex::encode(rand::random::<[u8; 32]>()),
version: parse_version(ua),
}
}
}
fn parse_version(ua: &str) -> Option<(u32, u32, u32)> {
let after_slash = ua.split('/').nth(1)?;
let ver_str = after_slash.split([' ', '(']).next()?;
let mut parts = ver_str.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next()?.parse().ok()?;
Some((major, minor, patch))
}
struct CachedEntry {
profile: DeviceProfile,
created: Instant,
}
pub struct DeviceProfileCache {
inner: Mutex<HashMap<String, CachedEntry>>,
}
impl DeviceProfileCache {
#[must_use]
pub fn new() -> Self {
Self {
inner: Mutex::new(HashMap::new()),
}
}
#[must_use]
pub fn resolve(&self, scope_key: &str) -> DeviceProfile {
let hashed = hash_key(scope_key);
let mut map = self.inner.lock().expect("profile cache lock poisoned");
if let Some(entry) = map.get(&hashed)
&& entry.created.elapsed() < PROFILE_TTL
{
return entry.profile.clone();
}
let profile = DeviceProfile::baseline();
map.insert(
hashed,
CachedEntry {
profile: profile.clone(),
created: Instant::now(),
},
);
profile
}
#[must_use]
pub fn resolve_or_upgrade(&self, scope_key: &str, incoming_ua: Option<&str>) -> DeviceProfile {
let hashed = hash_key(scope_key);
let mut map = self.inner.lock().expect("profile cache lock poisoned");
let now = Instant::now();
let entry = map.entry(hashed).or_insert_with(|| CachedEntry {
profile: DeviceProfile::baseline(),
created: now,
});
if entry.created.elapsed() >= PROFILE_TTL {
entry.profile = DeviceProfile::baseline();
entry.created = now;
}
if let Some(ua) = incoming_ua {
let incoming_ver = parse_version(ua);
if let (Some(new), Some(old)) = (incoming_ver, entry.profile.version) {
if new > old {
entry.profile = DeviceProfile::from_ua(ua);
entry.created = now;
}
} else if incoming_ver.is_some() && entry.profile.version.is_none() {
entry.profile = DeviceProfile::from_ua(ua);
entry.created = now;
}
}
entry.profile.clone()
}
}
impl Default for DeviceProfileCache {
fn default() -> Self {
Self::new()
}
}
fn hash_key(scope_key: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(scope_key.as_bytes());
hex::encode(hasher.finalize())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn baseline_profile_has_expected_values() {
let p = DeviceProfile::baseline();
assert_eq!(p.user_agent, DEFAULT_USER_AGENT);
assert_eq!(p.os, DEFAULT_OS);
assert_eq!(p.arch, DEFAULT_ARCH);
assert_eq!(p.version, Some((2, 1, 109)));
assert!(!p.session_id.is_empty());
assert_eq!(p.device_id.len(), 64);
}
#[test]
fn parse_version_happy_path() {
assert_eq!(
parse_version("claude-cli/2.1.109 (external, cli)"),
Some((2, 1, 109))
);
}
#[test]
fn parse_version_no_slash() {
assert_eq!(parse_version("no-version-here"), None);
}
#[test]
fn parse_version_partial() {
assert_eq!(parse_version("cli/1.2"), None); }
#[test]
fn resolve_returns_stable_profile() {
let cache = DeviceProfileCache::new();
let a = cache.resolve("key-1");
let b = cache.resolve("key-1");
assert_eq!(a.user_agent, b.user_agent);
}
#[test]
fn resolve_or_upgrade_upgrades_version() {
let cache = DeviceProfileCache::new();
let _ = cache.resolve("k");
let upgraded = cache.resolve_or_upgrade("k", Some("claude-cli/3.0.0 (external, cli)"));
assert_eq!(upgraded.version, Some((3, 0, 0)));
assert!(upgraded.user_agent.contains("3.0.0"));
}
#[test]
fn resolve_or_upgrade_does_not_downgrade() {
let cache = DeviceProfileCache::new();
let _ = cache.resolve("k");
let after = cache.resolve_or_upgrade("k", Some("claude-cli/1.0.0 (external, cli)"));
assert_eq!(after.version, Some((2, 1, 109)));
}
#[test]
fn different_scope_keys_get_independent_profiles() {
let cache = DeviceProfileCache::new();
let _ = cache.resolve_or_upgrade("a", Some("claude-cli/9.9.9 (external, cli)"));
let b = cache.resolve("b");
assert_eq!(b.version, Some((2, 1, 109)));
}
#[test]
fn session_id_is_stable_within_cache() {
let cache = DeviceProfileCache::new();
let a = cache.resolve("k");
let b = cache.resolve("k");
assert_eq!(a.session_id, b.session_id);
assert_eq!(a.device_id, b.device_id);
}
#[test]
fn different_scopes_get_different_ids() {
let cache = DeviceProfileCache::new();
let a = cache.resolve("scope-a");
let b = cache.resolve("scope-b");
assert_ne!(a.session_id, b.session_id);
assert_ne!(a.device_id, b.device_id);
}
#[test]
fn hash_key_is_deterministic() {
assert_eq!(hash_key("hello"), hash_key("hello"));
assert_ne!(hash_key("hello"), hash_key("world"));
}
}