teksilo_telemetry/
install_id.rs1use std::time::{Duration, SystemTime};
20
21use serde::{Deserialize, Serialize};
22use teksilo_settings::{AppPaths, Migrator, SettingsFile, SettingsFileError, Versioned};
23use uuid::Uuid;
24
25pub const ROTATION_INTERVAL: Duration = Duration::from_secs(60 * 60 * 24 * 395);
27
28#[derive(Clone, Debug, Serialize, Deserialize)]
30pub struct InstallIdFile {
31 #[serde(default)]
32 pub version: u32,
33 #[serde(default)]
34 pub uuid: String,
35 #[serde(default = "InstallIdFile::epoch_now")]
36 pub generated_at: SystemTime,
37}
38
39impl InstallIdFile {
40 fn epoch_now() -> SystemTime {
41 SystemTime::UNIX_EPOCH
42 }
43}
44
45impl Default for InstallIdFile {
46 fn default() -> Self {
47 Self {
48 version: 0,
49 uuid: String::new(),
50 generated_at: SystemTime::UNIX_EPOCH,
51 }
52 }
53}
54
55impl Versioned for InstallIdFile {
56 const CURRENT_VERSION: u32 = 1;
57 fn version(&self) -> u32 {
58 self.version
59 }
60 fn set_version(&mut self, v: u32) {
61 self.version = v;
62 }
63}
64
65#[derive(Clone)]
66pub struct InstallId {
67 file: SettingsFile<InstallIdFile>,
68}
69
70impl InstallId {
71 pub fn open_or_create(paths: &AppPaths, delay: Duration) -> Result<Self, SettingsFileError> {
79 Self::open_with_clock(paths, delay, SystemTime::now())
80 }
81
82 pub fn open_with_clock(
85 paths: &AppPaths,
86 delay: Duration,
87 now: SystemTime,
88 ) -> Result<Self, SettingsFileError> {
89 let _ = delay;
93 let migrator = Migrator::<InstallIdFile>::new();
94 let file = SettingsFile::load(paths.config_file("telemetry-install-id"), migrator)?;
95
96 let snap = file.snapshot();
97 let needs_rotation = snap.uuid.is_empty()
98 || now
99 .duration_since(snap.generated_at)
100 .map(|d| d > ROTATION_INTERVAL)
101 .unwrap_or(true);
102 if needs_rotation {
103 file.mutate(|f| {
104 f.uuid = Uuid::new_v4().to_string();
105 f.generated_at = now;
106 })?;
107 }
108
109 Ok(Self { file })
110 }
111
112 pub fn get(&self) -> String {
115 self.file.snapshot().uuid
116 }
117
118 pub fn with<R>(&self, f: impl FnOnce(&str) -> R) -> R {
121 let snap = self.file.borrow();
122 f(&snap.uuid)
123 }
124
125 pub fn clear(&self) -> Result<(), SettingsFileError> {
129 self.file.replace(InstallIdFile::default())
130 }
131
132 pub fn rotate(&self) -> Result<String, SettingsFileError> {
136 let new = Uuid::new_v4().to_string();
137 let new_clone = new.clone();
138 self.file.mutate(|f| {
139 f.uuid = new_clone;
140 f.generated_at = SystemTime::now();
141 })?;
142 Ok(new)
143 }
144
145 pub fn flush_now(&self) -> Result<(), SettingsFileError> {
146 self.file.flush_now()
147 }
148
149 pub fn path(&self) -> &std::path::Path {
150 self.file.path()
151 }
152}
153
154impl std::fmt::Debug for InstallId {
155 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156 f.debug_struct("InstallId")
157 .field("uuid", &self.get())
158 .field("path", &self.file.path())
159 .finish()
160 }
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166 use tempfile::tempdir;
167
168 fn open(dir: &std::path::Path) -> InstallId {
169 let paths = AppPaths::for_testing(dir);
170 InstallId::open_or_create(&paths, Duration::ZERO).unwrap()
171 }
172
173 #[test]
174 fn first_open_generates_uuid() {
175 let dir = tempdir().unwrap();
176 let id = open(dir.path());
177 let uuid = id.get();
178 assert!(!uuid.is_empty());
179 assert!(Uuid::parse_str(&uuid).is_ok());
180 }
181
182 #[test]
183 fn second_open_returns_same_uuid() {
184 let dir = tempdir().unwrap();
185 let first = open(dir.path()).get();
186 let second = open(dir.path()).get();
187 assert_eq!(first, second, "UUID should persist across reopens");
188 }
189
190 #[test]
191 fn clear_then_open_generates_new_uuid() {
192 let dir = tempdir().unwrap();
193 let first;
194 {
195 let id = open(dir.path());
196 first = id.get();
197 id.clear().unwrap();
198 id.flush_now().unwrap();
199 }
200 let id = open(dir.path());
201 let second = id.get();
202 assert_ne!(first, second);
203 }
204
205 #[test]
206 fn rotation_overdue_generates_new_uuid() {
207 let dir = tempdir().unwrap();
208 let paths = AppPaths::for_testing(dir.path());
209
210 let old_time = SystemTime::UNIX_EPOCH + Duration::from_secs(0);
211 let id = InstallId::open_with_clock(&paths, Duration::ZERO, old_time).unwrap();
212 let first = id.get();
213 id.flush_now().unwrap();
214
215 let later = old_time + Duration::from_secs(60 * 60 * 24 * 30 * 14);
217 let id = InstallId::open_with_clock(&paths, Duration::ZERO, later).unwrap();
218 let second = id.get();
219
220 assert_ne!(first, second, "UUID should rotate after 13 months");
221 }
222
223 #[test]
224 fn manual_rotate_changes_uuid() {
225 let dir = tempdir().unwrap();
226 let id = open(dir.path());
227 let first = id.get();
228 let new = id.rotate().unwrap();
229 assert_ne!(first, new);
230 assert_eq!(id.get(), new);
231 }
232}