Skip to main content

teksilo_telemetry/
consent.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Persisted consent state.
5//!
6//! [`ConsentStore`] wraps a [`SettingsFile<ConsentFile>`] from
7//! `teksilo-settings` and exposes a `Signal<ConsentState>` for widget
8//! binding. Atomic writes, debounced flush, migration, and OS-correct
9//! paths are all inherited from `SettingsFile`.
10//!
11//! Two version fields are tracked:
12//!
13//! - `ConsentFile.version` — the on-disk schema version of *this
14//!   file* (driven by `Versioned::CURRENT_VERSION`). Used by future
15//!   `ConsentFile` shape changes via `Migrator`.
16//! - `ConsentFile.consented_to_event_schema` — which version of the
17//!   *event* schema the user consented to. When the framework's
18//!   event schema bumps past this, the store resets to `Unknown`
19//!   and the widget re-prompts.
20
21use std::time::{Duration, SystemTime};
22
23use serde::{Deserialize, Serialize};
24use teksilo_core::Signal;
25use teksilo_core::telemetry::{ConsentScope, ConsentState};
26use teksilo_settings::{
27    AppPaths, Migrator, SettingsFile, SettingsFileError, SettingsStore, Versioned,
28};
29
30use crate::scopes::{
31    TELEMETRY_ANONYMOUS_METRICS, TELEMETRY_CRASH_REPORTS, TELEMETRY_FEATURE_FLAGS,
32};
33
34/// On-disk consent record. Persisted via `SettingsFile<ConsentFile>`.
35#[derive(Clone, Debug, Default, Serialize, Deserialize)]
36pub struct ConsentFile {
37    /// `ConsentFile` schema version (driven by `Versioned`).
38    #[serde(default)]
39    pub version: u32,
40    #[serde(default)]
41    pub state: PersistedConsentState,
42    #[serde(default)]
43    pub decided_at: Option<SystemTime>,
44    /// EVENT schema version at the time consent was given. When the
45    /// framework's event schema increments past this number, the
46    /// store resets to `Unknown` so the widget re-prompts.
47    #[serde(default)]
48    pub consented_to_event_schema: u32,
49    /// The endpoint the consent was given against. If the user
50    /// changes the endpoint override later, this differs from the
51    /// reporter's current endpoint and the store re-prompts (the
52    /// recipient-changed rule: consent is tied to the data recipient).
53    #[serde(default)]
54    pub endpoint_at_consent_time: String,
55}
56
57impl Versioned for ConsentFile {
58    const CURRENT_VERSION: u32 = 1;
59    fn version(&self) -> u32 {
60        self.version
61    }
62    fn set_version(&mut self, v: u32) {
63        self.version = v;
64    }
65}
66
67/// Serializable mirror of [`ConsentState`]. We don't use
68/// `serde(remote = "ConsentState")` because `ConsentState` lives in
69/// `teksilo-core` (which has no serde dep), so this enum is the
70/// persistence companion.
71#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
72pub enum PersistedConsentState {
73    #[default]
74    Unknown,
75    Granted {
76        scope: PersistedConsentScope,
77    },
78    Denied,
79}
80
81#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
82pub struct PersistedConsentScope {
83    #[serde(default)]
84    pub anonymous_metrics: bool,
85    #[serde(default)]
86    pub crash_reports: bool,
87    #[serde(default)]
88    pub feature_flags: bool,
89    #[serde(default)]
90    pub session_recording: bool,
91}
92
93impl From<ConsentScope> for PersistedConsentScope {
94    fn from(s: ConsentScope) -> Self {
95        Self {
96            anonymous_metrics: s.anonymous_metrics,
97            crash_reports: s.crash_reports,
98            feature_flags: s.feature_flags,
99            session_recording: s.session_recording,
100        }
101    }
102}
103
104impl From<PersistedConsentScope> for ConsentScope {
105    fn from(s: PersistedConsentScope) -> Self {
106        Self {
107            anonymous_metrics: s.anonymous_metrics,
108            crash_reports: s.crash_reports,
109            feature_flags: s.feature_flags,
110            session_recording: s.session_recording,
111        }
112    }
113}
114
115impl From<PersistedConsentState> for ConsentState {
116    fn from(p: PersistedConsentState) -> Self {
117        match p {
118            PersistedConsentState::Unknown => ConsentState::Unknown,
119            PersistedConsentState::Granted { scope } => ConsentState::Granted(scope.into()),
120            PersistedConsentState::Denied => ConsentState::Denied,
121        }
122    }
123}
124
125impl From<ConsentState> for PersistedConsentState {
126    fn from(c: ConsentState) -> Self {
127        match c {
128            ConsentState::Unknown => PersistedConsentState::Unknown,
129            ConsentState::Granted(scope) => PersistedConsentState::Granted {
130                scope: scope.into(),
131            },
132            ConsentState::Denied => PersistedConsentState::Denied,
133        }
134    }
135}
136
137/// In-memory façade over [`ConsentFile`] with a `Signal<ConsentState>`
138/// for widget binding.
139///
140/// `Clone` is cheap: both the `SettingsFile` handle and the `Signal`
141/// are `Rc`-shared internally.
142///
143/// When constructed with an attached [`SettingsStore`] (via
144/// [`ConsentStore::with_settings_mirror`]), every write also updates
145/// the per-scope `SettingsKey<bool>` constants in
146/// [`crate::scopes`] so power users editing `general.toml` directly
147/// see the same state. The mirror is one-way (consent → settings); we
148/// don't watch the settings keys for changes because the consent file
149/// is always the source of truth.
150#[derive(Clone)]
151pub struct ConsentStore {
152    file: SettingsFile<ConsentFile>,
153    state: Signal<ConsentState>,
154    current_event_schema: u32,
155    settings_mirror: Option<SettingsStore>,
156}
157
158impl ConsentStore {
159    /// Open the consent file at `paths.config_file("telemetry-consent")`.
160    ///
161    /// Applies the event-schema re-prompt rule: if the user previously
162    /// consented to an older event-schema version (or to a different
163    /// endpoint), the persisted state is reset to `Unknown` and the
164    /// widget will re-prompt on first display.
165    pub fn open(
166        paths: &AppPaths,
167        delay: Duration,
168        current_event_schema: u32,
169        current_endpoint: &str,
170    ) -> Result<Self, SettingsFileError> {
171        // `delay` is vestigial: `SettingsFile`'s writes are now always a
172        // synchronous locked read-modify-write (consent is written rarely — a
173        // grant/deny, not a burst), so there is no debounce left to configure.
174        // Kept in the signature so callers don't churn.
175        let _ = delay;
176        let migrator = Migrator::<ConsentFile>::new();
177        let file = SettingsFile::load(paths.config_file("telemetry-consent"), migrator)?;
178
179        // Re-prompt rule. Schema bump or endpoint change resets state.
180        let snap = file.snapshot();
181        let needs_reprompt = snap.consented_to_event_schema < current_event_schema
182            || (!snap.endpoint_at_consent_time.is_empty()
183                && snap.endpoint_at_consent_time != current_endpoint);
184        if needs_reprompt {
185            file.mutate(|f| {
186                f.state = PersistedConsentState::Unknown;
187                f.decided_at = None;
188                f.consented_to_event_schema = current_event_schema;
189                f.endpoint_at_consent_time = current_endpoint.to_string();
190            })?;
191        }
192
193        let state = Signal::new(ConsentState::from(file.snapshot().state));
194        Ok(Self {
195            file,
196            state,
197            current_event_schema,
198            settings_mirror: None,
199        })
200    }
201
202    /// Attach a [`SettingsStore`] for one-way mirror of the per-scope
203    /// toggles into the app's `general.toml`. Called by
204    /// `TelemetryBundle::open` once the store is available. Idempotent
205    /// — passing `None` is a no-op; calling twice replaces the
206    /// previously-attached store.
207    pub fn with_settings_mirror(mut self, settings: SettingsStore) -> Self {
208        self.settings_mirror = Some(settings);
209        // Seed the mirror with the current state so the first read
210        // from the settings store reflects the consent file.
211        self.write_mirror(self.state.get().scope().copied());
212        self
213    }
214
215    fn write_mirror(&self, scope: Option<ConsentScope>) {
216        let Some(store) = &self.settings_mirror else {
217            return;
218        };
219        let resolved = scope.unwrap_or_default();
220        store
221            .signal_for(&TELEMETRY_ANONYMOUS_METRICS)
222            .set(resolved.anonymous_metrics);
223        store
224            .signal_for(&TELEMETRY_CRASH_REPORTS)
225            .set(resolved.crash_reports);
226        store
227            .signal_for(&TELEMETRY_FEATURE_FLAGS)
228            .set(resolved.feature_flags);
229    }
230
231    /// The reactive state. Bind directly to the consent widget.
232    pub fn state_signal(&self) -> Signal<ConsentState> {
233        self.state.clone()
234    }
235
236    /// `true` iff [`ConsentState::is_granted`] holds. Convenience for
237    /// the dispatch tap which gates emission.
238    pub fn is_granted(&self) -> bool {
239        self.state.get().is_granted()
240    }
241
242    /// User accepted, with the given scope. Persists `decided_at` and
243    /// the current event-schema version so future schema bumps will
244    /// re-prompt correctly.
245    pub fn grant(&self, scope: ConsentScope, endpoint: &str) -> Result<(), SettingsFileError> {
246        self.file.mutate(|f| {
247            f.state = PersistedConsentState::Granted {
248                scope: scope.into(),
249            };
250            f.decided_at = Some(SystemTime::now());
251            f.consented_to_event_schema = self.current_event_schema;
252            f.endpoint_at_consent_time = endpoint.to_string();
253        })?;
254        self.state.set(ConsentState::Granted(scope));
255        self.write_mirror(Some(scope));
256        Ok(())
257    }
258
259    /// User explicitly declined. No events emitted; queue is left
260    /// alone (caller's responsibility to discard if appropriate).
261    pub fn deny(&self) -> Result<(), SettingsFileError> {
262        self.file.mutate(|f| {
263            f.state = PersistedConsentState::Denied;
264            f.decided_at = Some(SystemTime::now());
265        })?;
266        self.state.set(ConsentState::Denied);
267        self.write_mirror(None);
268        Ok(())
269    }
270
271    /// User withdrew previously-given consent. Same persistence as
272    /// `deny`; semantically distinct in the widget UI.
273    pub fn withdraw(&self) -> Result<(), SettingsFileError> {
274        self.deny()
275    }
276
277    /// Reset to `Unknown` — used by the mode-switch flow before
278    /// re-prompting.
279    pub fn reset(&self) -> Result<(), SettingsFileError> {
280        self.file.mutate(|f| {
281            f.state = PersistedConsentState::Unknown;
282            f.decided_at = None;
283        })?;
284        self.state.set(ConsentState::Unknown);
285        self.write_mirror(None);
286        Ok(())
287    }
288
289    /// Mutate the granted scope in place. If the current state is
290    /// `Unknown`, transitions to `Granted` with the mutated default
291    /// scope (so flipping a single toggle on first run grants
292    /// consent for that one scope only).
293    ///
294    /// `Denied` is preserved — the user must explicitly withdraw the
295    /// "no" before scope toggles take effect. Returns `Ok(true)` if
296    /// the mutation was applied, `Ok(false)` if `Denied` blocked it.
297    ///
298    /// Used by the consent widget for individual scope toggles.
299    pub fn set_or_grant_scope(
300        &self,
301        endpoint: &str,
302        f: impl FnOnce(&mut ConsentScope),
303    ) -> Result<bool, SettingsFileError> {
304        let current = self.state.get();
305        match current {
306            ConsentState::Denied => Ok(false),
307            ConsentState::Granted(_) => self.set_scope(f),
308            ConsentState::Unknown => {
309                let mut scope = ConsentScope::none();
310                f(&mut scope);
311                self.grant(scope, endpoint)?;
312                Ok(true)
313            }
314        }
315    }
316
317    /// Mutate the granted scope in place (e.g. user toggles
318    /// `crash_reports` from the settings widget).
319    ///
320    /// **Returns `Ok(false)` and is a no-op when the state is
321    /// `Denied` or `Unknown`** — the widget must call `grant()`
322    /// first to install a base scope. Returns `Ok(true)` when the
323    /// closure was invoked and the new scope persisted.
324    pub fn set_scope(&self, f: impl FnOnce(&mut ConsentScope)) -> Result<bool, SettingsFileError> {
325        let mut current = self.state.get();
326        let ConsentState::Granted(ref mut scope) = current else {
327            return Ok(false);
328        };
329        f(scope);
330        let scope = *scope;
331        self.file.mutate(|file| {
332            file.state = PersistedConsentState::Granted {
333                scope: scope.into(),
334            };
335        })?;
336        self.state.set(ConsentState::Granted(scope));
337        self.write_mirror(Some(scope));
338        Ok(true)
339    }
340
341    /// Force a synchronous flush to disk.
342    pub fn flush_now(&self) -> Result<(), SettingsFileError> {
343        self.file.flush_now()
344    }
345
346    /// The on-disk path. Surfaced verbatim by the consent widget for
347    /// transparency (the user can inspect or delete the file by hand).
348    pub fn path(&self) -> &std::path::Path {
349        self.file.path()
350    }
351}
352
353impl std::fmt::Debug for ConsentStore {
354    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
355        f.debug_struct("ConsentStore")
356            .field("path", &self.file.path())
357            .field("state", &self.state.get())
358            .field("schema", &self.current_event_schema)
359            .finish()
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use tempfile::tempdir;
367
368    fn open(dir: &std::path::Path, schema: u32, endpoint: &str) -> ConsentStore {
369        let paths = AppPaths::for_testing(dir);
370        ConsentStore::open(&paths, Duration::ZERO, schema, endpoint).unwrap()
371    }
372
373    #[test]
374    fn fresh_store_starts_unknown() {
375        let dir = tempdir().unwrap();
376        let store = open(dir.path(), 1, "stub://");
377        assert!(matches!(store.state_signal().get(), ConsentState::Unknown));
378        assert!(!store.is_granted());
379    }
380
381    #[test]
382    fn grant_persists_and_round_trips() {
383        let dir = tempdir().unwrap();
384        {
385            let store = open(dir.path(), 1, "stub://");
386            store
387                .grant(ConsentScope::anonymous_metrics_only(), "stub://")
388                .unwrap();
389            store.flush_now().unwrap();
390        }
391        let store = open(dir.path(), 1, "stub://");
392        let s = store.state_signal().get();
393        assert!(matches!(s, ConsentState::Granted(_)));
394        if let ConsentState::Granted(scope) = s {
395            assert!(scope.anonymous_metrics);
396            assert!(!scope.crash_reports);
397        }
398    }
399
400    #[test]
401    fn schema_bump_resets_to_unknown() {
402        let dir = tempdir().unwrap();
403        {
404            let store = open(dir.path(), 1, "stub://");
405            store.grant(ConsentScope::all(), "stub://").unwrap();
406            store.flush_now().unwrap();
407        }
408        // Reopen with a higher event-schema version.
409        let store = open(dir.path(), 2, "stub://");
410        assert!(matches!(store.state_signal().get(), ConsentState::Unknown));
411    }
412
413    #[test]
414    fn endpoint_change_resets_to_unknown() {
415        let dir = tempdir().unwrap();
416        {
417            let store = open(dir.path(), 1, "https://eu.example.com/");
418            store
419                .grant(ConsentScope::all(), "https://eu.example.com/")
420                .unwrap();
421            store.flush_now().unwrap();
422        }
423        // Reopen with a different endpoint — recipient changed.
424        let store = open(dir.path(), 1, "https://us.example.com/");
425        assert!(matches!(store.state_signal().get(), ConsentState::Unknown));
426    }
427
428    #[test]
429    fn deny_then_grant_works() {
430        let dir = tempdir().unwrap();
431        let store = open(dir.path(), 1, "stub://");
432        store.deny().unwrap();
433        assert!(matches!(store.state_signal().get(), ConsentState::Denied));
434        store
435            .grant(ConsentScope::anonymous_metrics_only(), "stub://")
436            .unwrap();
437        assert!(store.is_granted());
438    }
439
440    #[test]
441    fn set_scope_no_op_when_not_granted() {
442        let dir = tempdir().unwrap();
443        let store = open(dir.path(), 1, "stub://");
444        // Unknown — should not panic, should not change state.
445        let applied = store.set_scope(|s| s.crash_reports = true).unwrap();
446        assert!(!applied, "set_scope must report skipped");
447        assert!(matches!(store.state_signal().get(), ConsentState::Unknown));
448    }
449
450    #[test]
451    fn settings_mirror_reflects_scope_changes() {
452        use teksilo_settings::SettingsStore;
453        let dir = tempdir().unwrap();
454        let paths = AppPaths::for_testing(dir.path());
455        let store =
456            SettingsStore::open_with_delay(paths.config_file("general"), Duration::ZERO).unwrap();
457        let consent = ConsentStore::open(&paths, Duration::ZERO, 1, "stub://")
458            .unwrap()
459            .with_settings_mirror(store.clone());
460
461        // Initial mirror is all-off (matches Unknown state).
462        assert!(!store.signal_for(&TELEMETRY_ANONYMOUS_METRICS).get());
463
464        consent.grant(ConsentScope::all(), "stub://").unwrap();
465
466        assert!(store.signal_for(&TELEMETRY_ANONYMOUS_METRICS).get());
467        assert!(store.signal_for(&TELEMETRY_CRASH_REPORTS).get());
468        assert!(store.signal_for(&TELEMETRY_FEATURE_FLAGS).get());
469
470        consent.set_scope(|s| s.crash_reports = false).unwrap();
471        assert!(!store.signal_for(&TELEMETRY_CRASH_REPORTS).get());
472
473        consent.deny().unwrap();
474        // Denied wipes the mirror back to all-off.
475        assert!(!store.signal_for(&TELEMETRY_ANONYMOUS_METRICS).get());
476        assert!(!store.signal_for(&TELEMETRY_CRASH_REPORTS).get());
477    }
478
479    #[test]
480    fn set_scope_updates_when_granted() {
481        let dir = tempdir().unwrap();
482        let store = open(dir.path(), 1, "stub://");
483        store
484            .grant(ConsentScope::anonymous_metrics_only(), "stub://")
485            .unwrap();
486        let applied = store.set_scope(|s| s.crash_reports = true).unwrap();
487        assert!(applied, "set_scope must report applied");
488        if let ConsentState::Granted(scope) = store.state_signal().get() {
489            assert!(scope.anonymous_metrics);
490            assert!(scope.crash_reports);
491        } else {
492            panic!("expected Granted");
493        }
494    }
495}