telemetry_kit/
privacy.rs

1//! Privacy controls and consent management for telemetry-kit
2//!
3//! This module provides GDPR-compliant privacy controls including:
4//! - User consent management
5//! - DO_NOT_TRACK support
6//! - Data sanitization
7//! - Retention policies
8
9use crate::error::{Result, TelemetryError};
10use serde::{Deserialize, Serialize};
11use std::path::PathBuf;
12
13/// Privacy configuration
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct PrivacyConfig {
16    /// Whether user consent is required before tracking
17    pub consent_required: bool,
18
19    /// Whether to respect DO_NOT_TRACK environment variable
20    pub respect_do_not_track: bool,
21
22    /// Whether to sanitize paths (remove usernames)
23    pub sanitize_paths: bool,
24
25    /// Whether to sanitize email addresses
26    pub sanitize_emails: bool,
27
28    /// Data retention period in days (0 = forever)
29    pub data_retention_days: u32,
30
31    /// Whether to anonymize IP addresses (for future use)
32    pub anonymize_ips: bool,
33}
34
35impl Default for PrivacyConfig {
36    fn default() -> Self {
37        Self {
38            consent_required: false,
39            respect_do_not_track: true, // Always respect DNT by default
40            sanitize_paths: true,
41            sanitize_emails: true,
42            data_retention_days: 90,
43            anonymize_ips: true,
44        }
45    }
46}
47
48impl PrivacyConfig {
49    /// Create a new privacy configuration with strictest settings
50    pub fn strict() -> Self {
51        Self {
52            consent_required: true,
53            respect_do_not_track: true,
54            sanitize_paths: true,
55            sanitize_emails: true,
56            data_retention_days: 30,
57            anonymize_ips: true,
58        }
59    }
60
61    /// Create a minimal privacy configuration
62    pub fn minimal() -> Self {
63        Self {
64            consent_required: false,
65            respect_do_not_track: true, // Still respect DNT
66            sanitize_paths: false,
67            sanitize_emails: false,
68            data_retention_days: 0, // No automatic cleanup
69            anonymize_ips: false,
70        }
71    }
72}
73
74/// User consent status
75#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
76pub enum ConsentStatus {
77    /// User has not been asked yet
78    Unknown,
79    /// User has granted consent
80    Granted,
81    /// User has denied consent
82    Denied,
83    /// User has opted out (DO_NOT_TRACK)
84    OptedOut,
85}
86
87/// Consent information stored on disk
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct ConsentInfo {
90    /// Consent status
91    pub status: ConsentStatus,
92    /// When consent was given/denied
93    pub timestamp: chrono::DateTime<chrono::Utc>,
94    /// Service name this consent applies to
95    pub service_name: String,
96}
97
98/// Privacy manager handles consent and privacy settings
99pub struct PrivacyManager {
100    config: PrivacyConfig,
101    consent_file: PathBuf,
102}
103
104impl PrivacyManager {
105    /// Create a new privacy manager
106    pub fn new(config: PrivacyConfig, service_name: &str) -> Result<Self> {
107        let consent_file = Self::consent_file_path(service_name)?;
108
109        Ok(Self {
110            config,
111            consent_file,
112        })
113    }
114
115    /// Get the path to the consent file
116    fn consent_file_path(service_name: &str) -> Result<PathBuf> {
117        let mut path = dirs::home_dir().ok_or_else(|| {
118            TelemetryError::InvalidConfig("Cannot determine home directory".to_string())
119        })?;
120        path.push(".telemetry-kit");
121        path.push(format!("{}-consent.json", service_name));
122        Ok(path)
123    }
124
125    /// Check if tracking should proceed
126    pub fn should_track(&self) -> Result<bool> {
127        // First: Check DO_NOT_TRACK environment variable
128        if self.config.respect_do_not_track && Self::is_do_not_track_enabled() {
129            return Ok(false);
130        }
131
132        // Second: Check consent if required
133        if self.config.consent_required {
134            let consent = self.load_consent()?;
135            match consent.status {
136                ConsentStatus::Granted => Ok(true),
137                ConsentStatus::Denied | ConsentStatus::OptedOut => Ok(false),
138                ConsentStatus::Unknown => Ok(false), // Default deny if consent required
139            }
140        } else {
141            Ok(true)
142        }
143    }
144
145    /// Check if DO_NOT_TRACK environment variable is set
146    pub fn is_do_not_track_enabled() -> bool {
147        std::env::var("DO_NOT_TRACK")
148            .map(|v| !v.is_empty() && v != "0" && v.to_lowercase() != "false")
149            .unwrap_or(false)
150    }
151
152    /// Load consent information from disk
153    pub fn load_consent(&self) -> Result<ConsentInfo> {
154        if !self.consent_file.exists() {
155            return Ok(ConsentInfo {
156                status: ConsentStatus::Unknown,
157                timestamp: chrono::Utc::now(),
158                service_name: String::new(),
159            });
160        }
161
162        let content = std::fs::read_to_string(&self.consent_file)?;
163
164        let consent: ConsentInfo = serde_json::from_str(&content)?;
165
166        Ok(consent)
167    }
168
169    /// Save consent information to disk
170    pub fn save_consent(&self, status: ConsentStatus, service_name: &str) -> Result<()> {
171        let consent = ConsentInfo {
172            status,
173            timestamp: chrono::Utc::now(),
174            service_name: service_name.to_string(),
175        };
176
177        // Ensure directory exists
178        if let Some(parent) = self.consent_file.parent() {
179            std::fs::create_dir_all(parent)?;
180        }
181
182        let content = serde_json::to_string_pretty(&consent)?;
183
184        std::fs::write(&self.consent_file, content)?;
185
186        Ok(())
187    }
188
189    /// Grant consent for tracking
190    pub fn grant_consent(&self, service_name: &str) -> Result<()> {
191        self.save_consent(ConsentStatus::Granted, service_name)
192    }
193
194    /// Deny consent for tracking
195    pub fn deny_consent(&self, service_name: &str) -> Result<()> {
196        self.save_consent(ConsentStatus::Denied, service_name)
197    }
198
199    /// Opt out of tracking (DO_NOT_TRACK equivalent)
200    pub fn opt_out(&self, service_name: &str) -> Result<()> {
201        self.save_consent(ConsentStatus::OptedOut, service_name)
202    }
203
204    /// Sanitize a path by removing username components
205    pub fn sanitize_path(path: &str) -> String {
206        if let Some(home) = dirs::home_dir() {
207            if let Some(home_str) = home.to_str() {
208                return path.replace(home_str, "~");
209            }
210        }
211        path.to_string()
212    }
213
214    /// Sanitize an email address by hashing it
215    pub fn sanitize_email(email: &str) -> String {
216        use sha2::{Digest, Sha256};
217
218        let mut hasher = Sha256::new();
219        hasher.update(email.as_bytes());
220        let result = hasher.finalize();
221        format!("email_{}", hex::encode(&result[..8]))
222    }
223
224    /// Apply sanitization to data based on config
225    pub fn sanitize_data(&self, data: &mut serde_json::Value) {
226        if let serde_json::Value::Object(map) = data {
227            for (_key, value) in map.iter_mut() {
228                match value {
229                    serde_json::Value::String(s) => {
230                        // Sanitize paths
231                        if self.config.sanitize_paths && (s.contains('/') || s.contains('\\')) {
232                            *s = Self::sanitize_path(s);
233                        }
234                        // Sanitize emails
235                        if self.config.sanitize_emails && s.contains('@') {
236                            *s = Self::sanitize_email(s);
237                        }
238                    }
239                    serde_json::Value::Object(_) => {
240                        self.sanitize_data(value);
241                    }
242                    serde_json::Value::Array(arr) => {
243                        for item in arr {
244                            self.sanitize_data(item);
245                        }
246                    }
247                    _ => {}
248                }
249            }
250        }
251    }
252
253    /// Prompt user for consent interactively
254    ///
255    /// This displays a user-friendly prompt explaining what telemetry is collected
256    /// and asks for the user's consent. Returns true if consent was granted.
257    ///
258    /// # Example
259    ///
260    /// ```no_run
261    /// use telemetry_kit::privacy::{PrivacyConfig, PrivacyManager};
262    ///
263    /// let config = PrivacyConfig::default();
264    /// let manager = PrivacyManager::new(config, "my-app").unwrap();
265    ///
266    /// if manager.prompt_for_consent("my-app", "1.0.0").unwrap() {
267    ///     println!("User granted consent");
268    /// }
269    /// ```
270    #[cfg(feature = "cli")]
271    pub fn prompt_for_consent(&self, service_name: &str, version: &str) -> Result<bool> {
272        use dialoguer::{theme::ColorfulTheme, Confirm};
273
274        // Check if consent was already given
275        let current_consent = self.load_consent()?;
276        if current_consent.status != ConsentStatus::Unknown {
277            // Already have a consent decision
278            return Ok(current_consent.status == ConsentStatus::Granted);
279        }
280
281        println!("\n{} Privacy & Telemetry Consent", "📊".to_string());
282        println!("{}", "─".repeat(50));
283        println!();
284        println!("{} {} v{}", "Application:", service_name, version);
285        println!();
286        println!("This application collects anonymous usage telemetry to help");
287        println!("improve the software. The following information is collected:");
288        println!();
289        println!("  • Command usage (which features you use)");
290        println!("  • Success/failure of operations");
291        println!("  • Performance metrics (duration, not content)");
292        println!("  • Operating system and architecture");
293        println!();
294        println!("{}", "Privacy Guarantees:".to_string());
295        println!("  ✓ No personal information (PII) is collected");
296        println!("  ✓ User IDs are anonymized (SHA-256 hashed)");
297        println!("  ✓ File paths are sanitized (usernames removed)");
298        println!("  ✓ Email addresses are hashed if detected");
299        println!("  ✓ You can opt out anytime with DO_NOT_TRACK=1");
300        println!();
301        println!("You can manage consent later with:");
302        println!("  telemetry-kit consent grant   # Enable telemetry");
303        println!("  telemetry-kit consent deny    # Disable telemetry");
304        println!("  telemetry-kit consent status  # Check current status");
305        println!();
306
307        let consent = Confirm::with_theme(&ColorfulTheme::default())
308            .with_prompt("Allow anonymous telemetry collection?")
309            .default(false) // Default to NO for privacy
310            .interact()
311            .map_err(|e| TelemetryError::Other(format!("Failed to get user input: {}", e)))?;
312
313        if consent {
314            self.grant_consent(service_name)?;
315            println!();
316            println!("{} Thank you! Telemetry enabled.", "✓".to_string());
317        } else {
318            self.deny_consent(service_name)?;
319            println!();
320            println!("{} Telemetry disabled. You can change this anytime.", "✓".to_string());
321        }
322        println!();
323
324        Ok(consent)
325    }
326
327    /// Prompt for consent with a minimal message (one-liner)
328    ///
329    /// This is a shorter version of `prompt_for_consent` for applications
330    /// that want a less verbose consent prompt.
331    #[cfg(feature = "cli")]
332    pub fn prompt_minimal(&self, service_name: &str) -> Result<bool> {
333        use dialoguer::{theme::ColorfulTheme, Confirm};
334
335        // Check if consent was already given
336        let current_consent = self.load_consent()?;
337        if current_consent.status != ConsentStatus::Unknown {
338            return Ok(current_consent.status == ConsentStatus::Granted);
339        }
340
341        println!();
342        let consent = Confirm::with_theme(&ColorfulTheme::default())
343            .with_prompt(format!(
344                "{} Enable anonymous telemetry? (improves {}, respects privacy)",
345                "📊", service_name
346            ))
347            .default(false)
348            .interact()
349            .map_err(|e| TelemetryError::Other(format!("Failed to get user input: {}", e)))?;
350
351        if consent {
352            self.grant_consent(service_name)?;
353        } else {
354            self.deny_consent(service_name)?;
355        }
356
357        Ok(consent)
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    #[test]
366    fn test_default_config() {
367        let config = PrivacyConfig::default();
368        assert!(!config.consent_required);
369        assert!(config.respect_do_not_track);
370        assert!(config.sanitize_paths);
371        assert!(config.sanitize_emails);
372        assert_eq!(config.data_retention_days, 90);
373    }
374
375    #[test]
376    fn test_strict_config() {
377        let config = PrivacyConfig::strict();
378        assert!(config.consent_required);
379        assert!(config.respect_do_not_track);
380        assert_eq!(config.data_retention_days, 30);
381    }
382
383    #[test]
384    fn test_minimal_config() {
385        let config = PrivacyConfig::minimal();
386        assert!(!config.consent_required);
387        assert!(config.respect_do_not_track); // Still respects DNT
388        assert!(!config.sanitize_paths);
389        assert_eq!(config.data_retention_days, 0);
390    }
391
392    #[test]
393    fn test_do_not_track_detection() {
394        // Save original value
395        let original = std::env::var("DO_NOT_TRACK").ok();
396
397        // Test enabled
398        std::env::set_var("DO_NOT_TRACK", "1");
399        assert!(PrivacyManager::is_do_not_track_enabled());
400
401        std::env::set_var("DO_NOT_TRACK", "true");
402        assert!(PrivacyManager::is_do_not_track_enabled());
403
404        // Test disabled
405        std::env::set_var("DO_NOT_TRACK", "0");
406        assert!(!PrivacyManager::is_do_not_track_enabled());
407
408        std::env::set_var("DO_NOT_TRACK", "false");
409        assert!(!PrivacyManager::is_do_not_track_enabled());
410
411        std::env::remove_var("DO_NOT_TRACK");
412        assert!(!PrivacyManager::is_do_not_track_enabled());
413
414        // Restore original
415        if let Some(val) = original {
416            std::env::set_var("DO_NOT_TRACK", val);
417        } else {
418            std::env::remove_var("DO_NOT_TRACK");
419        }
420    }
421
422    #[test]
423    fn test_sanitize_path() {
424        // Use actual home directory for testing
425        if let Some(home) = dirs::home_dir() {
426            if let Some(home_str) = home.to_str() {
427                let path = format!("{}/Documents/project", home_str);
428                let sanitized = PrivacyManager::sanitize_path(&path);
429                assert!(sanitized.starts_with('~'));
430                assert!(!sanitized.contains(home_str));
431            }
432        }
433
434        // Test path that's not in home directory
435        let other_path = "/tmp/some/path";
436        let sanitized = PrivacyManager::sanitize_path(other_path);
437        assert_eq!(sanitized, other_path); // Should remain unchanged
438    }
439
440    #[test]
441    fn test_sanitize_email() {
442        let email = "user@example.com";
443        let sanitized = PrivacyManager::sanitize_email(email);
444        assert!(sanitized.starts_with("email_"));
445        assert!(!sanitized.contains('@'));
446        assert!(!sanitized.contains("example.com"));
447
448        // Same email should produce same hash
449        let sanitized2 = PrivacyManager::sanitize_email(email);
450        assert_eq!(sanitized, sanitized2);
451    }
452
453    #[test]
454    fn test_consent_status() {
455        assert_eq!(ConsentStatus::Unknown, ConsentStatus::Unknown);
456        assert_ne!(ConsentStatus::Granted, ConsentStatus::Denied);
457    }
458
459    #[tokio::test]
460    async fn test_privacy_manager_should_track() {
461        let config = PrivacyConfig::default();
462        let manager = PrivacyManager::new(config, "test-service").unwrap();
463
464        // Save original DNT value
465        let original_dnt = std::env::var("DO_NOT_TRACK").ok();
466
467        // Without DNT, should track (consent not required by default)
468        std::env::remove_var("DO_NOT_TRACK");
469        assert!(manager.should_track().unwrap());
470
471        // With DNT, should not track
472        std::env::set_var("DO_NOT_TRACK", "1");
473        assert!(!manager.should_track().unwrap());
474
475        // Restore original
476        if let Some(val) = original_dnt {
477            std::env::set_var("DO_NOT_TRACK", val);
478        } else {
479            std::env::remove_var("DO_NOT_TRACK");
480        }
481    }
482
483    #[test]
484    fn test_sanitize_data() {
485        let config = PrivacyConfig::default();
486        let manager = PrivacyManager::new(config, "test").unwrap();
487
488        // Use actual home directory
489        let home_path = if let Some(home) = dirs::home_dir() {
490            if let Some(home_str) = home.to_str() {
491                format!("{}/file.txt", home_str)
492            } else {
493                "/tmp/file.txt".to_string()
494            }
495        } else {
496            "/tmp/file.txt".to_string()
497        };
498
499        let mut data = serde_json::json!({
500            "email": "test@example.com",
501            "path": home_path.clone(),
502            "normal": "just text"
503        });
504
505        manager.sanitize_data(&mut data);
506
507        let email = data["email"].as_str().unwrap();
508        assert!(email.starts_with("email_"));
509        assert!(!email.contains('@'));
510
511        let path = data["path"].as_str().unwrap();
512        // If it was a home path, should be sanitized to ~
513        if home_path.starts_with("/Users/") || home_path.starts_with("/home/") {
514            assert!(path.starts_with('~'));
515        }
516
517        assert_eq!(data["normal"].as_str().unwrap(), "just text");
518    }
519
520    // Property-based tests
521    #[cfg(test)]
522    mod proptests {
523        use super::*;
524        use proptest::prelude::*;
525
526        proptest! {
527            /// Property: Sanitized emails always start with "email_" and never contain "@"
528            #[test]
529            fn sanitize_email_always_valid(email in "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}") {
530                let sanitized = PrivacyManager::sanitize_email(&email);
531                prop_assert!(sanitized.starts_with("email_"));
532                prop_assert!(!sanitized.contains('@'));
533                prop_assert!(!sanitized.contains(&email));
534            }
535
536            /// Property: Same email always produces same hash (determinism)
537            #[test]
538            fn sanitize_email_deterministic(email in "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}") {
539                let sanitized1 = PrivacyManager::sanitize_email(&email);
540                let sanitized2 = PrivacyManager::sanitize_email(&email);
541                prop_assert_eq!(sanitized1, sanitized2);
542            }
543
544            /// Property: Different emails produce different hashes
545            #[test]
546            fn sanitize_email_unique(
547                email1 in "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}",
548                email2 in "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}"
549            ) {
550                prop_assume!(email1 != email2);
551                let sanitized1 = PrivacyManager::sanitize_email(&email1);
552                let sanitized2 = PrivacyManager::sanitize_email(&email2);
553                prop_assert_ne!(sanitized1, sanitized2);
554            }
555
556            /// Property: Path sanitization never exposes home directory
557            #[test]
558            fn sanitize_path_hides_home(suffix in "[a-zA-Z0-9/_.-]+") {
559                if let Some(home) = dirs::home_dir() {
560                    if let Some(home_str) = home.to_str() {
561                        let path = format!("{}/{}", home_str, suffix);
562                        let sanitized = PrivacyManager::sanitize_path(&path);
563                        prop_assert!(sanitized.starts_with('~'));
564                        prop_assert!(!sanitized.contains(home_str));
565                    }
566                }
567            }
568
569            /// Property: Path sanitization is idempotent
570            #[test]
571            fn sanitize_path_idempotent(suffix in "[a-zA-Z0-9/_.-]+") {
572                if let Some(home) = dirs::home_dir() {
573                    if let Some(home_str) = home.to_str() {
574                        let path = format!("{}/{}", home_str, suffix);
575                        let sanitized1 = PrivacyManager::sanitize_path(&path);
576                        let sanitized2 = PrivacyManager::sanitize_path(&sanitized1);
577                        prop_assert_eq!(sanitized1, sanitized2);
578                    }
579                }
580            }
581
582            /// Property: Non-home paths remain unchanged
583            #[test]
584            fn sanitize_path_preserves_non_home(path in "/tmp/[a-zA-Z0-9/_.-]+") {
585                if let Some(home) = dirs::home_dir() {
586                    if let Some(home_str) = home.to_str() {
587                        prop_assume!(!path.starts_with(home_str));
588                        let sanitized = PrivacyManager::sanitize_path(&path);
589                        prop_assert_eq!(sanitized, path);
590                    }
591                }
592            }
593
594            /// Property: Sanitized data never contains raw emails
595            #[test]
596            fn sanitize_data_removes_emails(email in "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}") {
597                let config = PrivacyConfig::default();
598                let manager = PrivacyManager::new(config, "test").unwrap();
599
600                let mut data = serde_json::json!({
601                    "email": email.clone(),
602                    "nested": {
603                        "email": email.clone()
604                    }
605                });
606
607                manager.sanitize_data(&mut data);
608
609                let data_str = serde_json::to_string(&data).unwrap();
610                prop_assert!(!data_str.contains(&email));
611                prop_assert!(data_str.contains("email_"));
612            }
613
614            /// Property: ConsentStatus serialization roundtrip
615            #[test]
616            fn consent_status_serde_roundtrip(
617                status in prop_oneof![
618                    Just(ConsentStatus::Unknown),
619                    Just(ConsentStatus::Granted),
620                    Just(ConsentStatus::Denied),
621                    Just(ConsentStatus::OptedOut),
622                ]
623            ) {
624                let json = serde_json::to_string(&status).unwrap();
625                let deserialized: ConsentStatus = serde_json::from_str(&json).unwrap();
626                prop_assert_eq!(status, deserialized);
627            }
628
629            /// Property: PrivacyConfig cloning preserves all fields
630            #[test]
631            fn privacy_config_clone_preserves(
632                consent_required in proptest::bool::ANY,
633                sanitize_paths in proptest::bool::ANY,
634                sanitize_emails in proptest::bool::ANY,
635                data_retention_days in 0u32..=3650u32,
636                anonymize_ips in proptest::bool::ANY
637            ) {
638                let config = PrivacyConfig {
639                    consent_required,
640                    respect_do_not_track: true, // Always true
641                    sanitize_paths,
642                    sanitize_emails,
643                    data_retention_days,
644                    anonymize_ips,
645                };
646
647                let cloned = config.clone();
648
649                prop_assert_eq!(config.consent_required, cloned.consent_required);
650                prop_assert_eq!(config.respect_do_not_track, cloned.respect_do_not_track);
651                prop_assert_eq!(config.sanitize_paths, cloned.sanitize_paths);
652                prop_assert_eq!(config.sanitize_emails, cloned.sanitize_emails);
653                prop_assert_eq!(config.data_retention_days, cloned.data_retention_days);
654                prop_assert_eq!(config.anonymize_ips, cloned.anonymize_ips);
655            }
656
657            /// Property: Email hash is always 16 hex characters
658            #[test]
659            fn sanitize_email_hash_format(email in "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}") {
660                let sanitized = PrivacyManager::sanitize_email(&email);
661                let hash_part = sanitized.strip_prefix("email_").unwrap();
662                prop_assert_eq!(hash_part.len(), 16);
663                prop_assert!(hash_part.chars().all(|c| c.is_ascii_hexdigit()));
664            }
665
666            /// Property: Recursive data sanitization handles nested structures
667            #[test]
668            fn sanitize_data_handles_nesting(
669                email in "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}",
670                depth in 1usize..=5usize
671            ) {
672                let config = PrivacyConfig::default();
673                let manager = PrivacyManager::new(config, "test").unwrap();
674
675                // Build nested JSON
676                let mut data = serde_json::json!({"email": email.clone()});
677                for _ in 0..depth {
678                    data = serde_json::json!({"nested": data, "email": email.clone()});
679                }
680
681                manager.sanitize_data(&mut data);
682
683                let data_str = serde_json::to_string(&data).unwrap();
684                prop_assert!(!data_str.contains(&email));
685            }
686        }
687    }
688}