use std::time::{Duration, SystemTime};
use serde::{Deserialize, Serialize};
use teksilo_settings::{AppPaths, Migrator, SettingsFile, SettingsFileError, Versioned};
use uuid::Uuid;
pub const ROTATION_INTERVAL: Duration = Duration::from_secs(60 * 60 * 24 * 395);
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct InstallIdFile {
#[serde(default)]
pub version: u32,
#[serde(default)]
pub uuid: String,
#[serde(default = "InstallIdFile::epoch_now")]
pub generated_at: SystemTime,
}
impl InstallIdFile {
fn epoch_now() -> SystemTime {
SystemTime::UNIX_EPOCH
}
}
impl Default for InstallIdFile {
fn default() -> Self {
Self {
version: 0,
uuid: String::new(),
generated_at: SystemTime::UNIX_EPOCH,
}
}
}
impl Versioned for InstallIdFile {
const CURRENT_VERSION: u32 = 1;
fn version(&self) -> u32 {
self.version
}
fn set_version(&mut self, v: u32) {
self.version = v;
}
}
#[derive(Clone)]
pub struct InstallId {
file: SettingsFile<InstallIdFile>,
}
impl InstallId {
pub fn open_or_create(paths: &AppPaths, delay: Duration) -> Result<Self, SettingsFileError> {
Self::open_with_clock(paths, delay, SystemTime::now())
}
pub fn open_with_clock(
paths: &AppPaths,
delay: Duration,
now: SystemTime,
) -> Result<Self, SettingsFileError> {
let _ = delay;
let migrator = Migrator::<InstallIdFile>::new();
let file = SettingsFile::load(paths.config_file("telemetry-install-id"), migrator)?;
let snap = file.snapshot();
let needs_rotation = snap.uuid.is_empty()
|| now
.duration_since(snap.generated_at)
.map(|d| d > ROTATION_INTERVAL)
.unwrap_or(true);
if needs_rotation {
file.mutate(|f| {
f.uuid = Uuid::new_v4().to_string();
f.generated_at = now;
})?;
}
Ok(Self { file })
}
pub fn get(&self) -> String {
self.file.snapshot().uuid
}
pub fn with<R>(&self, f: impl FnOnce(&str) -> R) -> R {
let snap = self.file.borrow();
f(&snap.uuid)
}
pub fn clear(&self) -> Result<(), SettingsFileError> {
self.file.replace(InstallIdFile::default())
}
pub fn rotate(&self) -> Result<String, SettingsFileError> {
let new = Uuid::new_v4().to_string();
let new_clone = new.clone();
self.file.mutate(|f| {
f.uuid = new_clone;
f.generated_at = SystemTime::now();
})?;
Ok(new)
}
pub fn flush_now(&self) -> Result<(), SettingsFileError> {
self.file.flush_now()
}
pub fn path(&self) -> &std::path::Path {
self.file.path()
}
}
impl std::fmt::Debug for InstallId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("InstallId")
.field("uuid", &self.get())
.field("path", &self.file.path())
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn open(dir: &std::path::Path) -> InstallId {
let paths = AppPaths::for_testing(dir);
InstallId::open_or_create(&paths, Duration::ZERO).unwrap()
}
#[test]
fn first_open_generates_uuid() {
let dir = tempdir().unwrap();
let id = open(dir.path());
let uuid = id.get();
assert!(!uuid.is_empty());
assert!(Uuid::parse_str(&uuid).is_ok());
}
#[test]
fn second_open_returns_same_uuid() {
let dir = tempdir().unwrap();
let first = open(dir.path()).get();
let second = open(dir.path()).get();
assert_eq!(first, second, "UUID should persist across reopens");
}
#[test]
fn clear_then_open_generates_new_uuid() {
let dir = tempdir().unwrap();
let first;
{
let id = open(dir.path());
first = id.get();
id.clear().unwrap();
id.flush_now().unwrap();
}
let id = open(dir.path());
let second = id.get();
assert_ne!(first, second);
}
#[test]
fn rotation_overdue_generates_new_uuid() {
let dir = tempdir().unwrap();
let paths = AppPaths::for_testing(dir.path());
let old_time = SystemTime::UNIX_EPOCH + Duration::from_secs(0);
let id = InstallId::open_with_clock(&paths, Duration::ZERO, old_time).unwrap();
let first = id.get();
id.flush_now().unwrap();
let later = old_time + Duration::from_secs(60 * 60 * 24 * 30 * 14);
let id = InstallId::open_with_clock(&paths, Duration::ZERO, later).unwrap();
let second = id.get();
assert_ne!(first, second, "UUID should rotate after 13 months");
}
#[test]
fn manual_rotate_changes_uuid() {
let dir = tempdir().unwrap();
let id = open(dir.path());
let first = id.get();
let new = id.rotate().unwrap();
assert_ne!(first, new);
assert_eq!(id.get(), new);
}
}