1use 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#[derive(Clone, Debug, Default, Serialize, Deserialize)]
36pub struct ConsentFile {
37 #[serde(default)]
39 pub version: u32,
40 #[serde(default)]
41 pub state: PersistedConsentState,
42 #[serde(default)]
43 pub decided_at: Option<SystemTime>,
44 #[serde(default)]
48 pub consented_to_event_schema: u32,
49 #[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#[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#[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 pub fn open(
166 paths: &AppPaths,
167 delay: Duration,
168 current_event_schema: u32,
169 current_endpoint: &str,
170 ) -> Result<Self, SettingsFileError> {
171 let _ = delay;
176 let migrator = Migrator::<ConsentFile>::new();
177 let file = SettingsFile::load(paths.config_file("telemetry-consent"), migrator)?;
178
179 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 pub fn with_settings_mirror(mut self, settings: SettingsStore) -> Self {
208 self.settings_mirror = Some(settings);
209 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 pub fn state_signal(&self) -> Signal<ConsentState> {
233 self.state.clone()
234 }
235
236 pub fn is_granted(&self) -> bool {
239 self.state.get().is_granted()
240 }
241
242 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 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 pub fn withdraw(&self) -> Result<(), SettingsFileError> {
274 self.deny()
275 }
276
277 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 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 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 pub fn flush_now(&self) -> Result<(), SettingsFileError> {
343 self.file.flush_now()
344 }
345
346 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 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 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 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 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 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}