Skip to main content

teksilo_telemetry/
install_id.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Pseudonymous-mode install identifier.
5//!
6//! [`InstallId`] is a stable random UUID v4 generated at first run,
7//! persisted via [`SettingsFile<InstallIdFile>`] and rotated every 13
8//! months (CNIL Sheet n°14 cookie/tracker lifespan ceiling).
9//!
10//! Anonymous mode does not construct an `InstallId` at all —
11//! [`UsageReporter::install_id`](teksilo_core::telemetry::UsageReporter::install_id)
12//! returns `None` unconditionally there.
13//!
14//! Rotation happens at `open_or_create` time. The caller is expected
15//! to invoke `erase_remote_data()` *before* rotation so the user
16//! doesn't lose the only handle to their server data — orchestrated
17//! by `DynamicReporter` / `TelemetryBundle::open`.
18
19use std::time::{Duration, SystemTime};
20
21use serde::{Deserialize, Serialize};
22use teksilo_settings::{AppPaths, Migrator, SettingsFile, SettingsFileError, Versioned};
23use uuid::Uuid;
24
25/// 13 months in seconds — the CNIL cookie/tracker lifespan ceiling.
26pub const ROTATION_INTERVAL: Duration = Duration::from_secs(60 * 60 * 24 * 395);
27
28/// Persisted install identifier.
29#[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    /// Open or create the install id file at
72    /// `paths.config_file("telemetry-install-id")`.
73    ///
74    /// Generates a fresh UUID on first run or when rotation is overdue
75    /// (≥13 months). The caller MUST call `erase_remote_data()` before
76    /// rotation if a rotation is expected — once the local UUID is
77    /// gone, the user loses the only handle to their server data.
78    pub fn open_or_create(paths: &AppPaths, delay: Duration) -> Result<Self, SettingsFileError> {
79        Self::open_with_clock(paths, delay, SystemTime::now())
80    }
81
82    /// Constructor that accepts an explicit `now` for tests with a
83    /// mocked clock. Production code should use `open_or_create`.
84    pub fn open_with_clock(
85        paths: &AppPaths,
86        delay: Duration,
87        now: SystemTime,
88    ) -> Result<Self, SettingsFileError> {
89        // `delay` is vestigial — see `ConsentStore::open_with_clock`. The install
90        // id is written once and then rotated ~yearly; there is no burst to
91        // debounce, and `SettingsFile` writes synchronously under a lock now.
92        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    /// The current UUID. Always non-empty on a successfully opened
113    /// `InstallId`.
114    pub fn get(&self) -> String {
115        self.file.snapshot().uuid
116    }
117
118    /// `Some` view of the UUID without cloning. Holds a `Ref` guard
119    /// — see [`SettingsFile::borrow`].
120    pub fn with<R>(&self, f: impl FnOnce(&str) -> R) -> R {
121        let snap = self.file.borrow();
122        f(&snap.uuid)
123    }
124
125    /// Wipe the local UUID. Called by `discard_pending` on revoke
126    /// and by `erase_remote_data` after a successful server delete.
127    /// A subsequent `open_or_create` will regenerate.
128    pub fn clear(&self) -> Result<(), SettingsFileError> {
129        self.file.replace(InstallIdFile::default())
130    }
131
132    /// Force a fresh UUID right now (e.g. user wants to rotate
133    /// preemptively for privacy reasons). Should be preceded by
134    /// `erase_remote_data()` so the old server records are deleted.
135    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        // Move the clock forward by 14 months — past the 13-month ceiling.
216        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}