1use crate::error::{Result, TelemetryError};
10use serde::{Deserialize, Serialize};
11use std::path::PathBuf;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct PrivacyConfig {
16 pub consent_required: bool,
18
19 pub respect_do_not_track: bool,
21
22 pub sanitize_paths: bool,
24
25 pub sanitize_emails: bool,
27
28 pub data_retention_days: u32,
30
31 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, sanitize_paths: true,
41 sanitize_emails: true,
42 data_retention_days: 90,
43 anonymize_ips: true,
44 }
45 }
46}
47
48impl PrivacyConfig {
49 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 pub fn minimal() -> Self {
63 Self {
64 consent_required: false,
65 respect_do_not_track: true, sanitize_paths: false,
67 sanitize_emails: false,
68 data_retention_days: 0, anonymize_ips: false,
70 }
71 }
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
76pub enum ConsentStatus {
77 Unknown,
79 Granted,
81 Denied,
83 OptedOut,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct ConsentInfo {
90 pub status: ConsentStatus,
92 pub timestamp: chrono::DateTime<chrono::Utc>,
94 pub service_name: String,
96}
97
98pub struct PrivacyManager {
100 config: PrivacyConfig,
101 consent_file: PathBuf,
102}
103
104impl PrivacyManager {
105 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 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 pub fn should_track(&self) -> Result<bool> {
127 if self.config.respect_do_not_track && Self::is_do_not_track_enabled() {
129 return Ok(false);
130 }
131
132 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), }
140 } else {
141 Ok(true)
142 }
143 }
144
145 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 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 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 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 pub fn grant_consent(&self, service_name: &str) -> Result<()> {
191 self.save_consent(ConsentStatus::Granted, service_name)
192 }
193
194 pub fn deny_consent(&self, service_name: &str) -> Result<()> {
196 self.save_consent(ConsentStatus::Denied, service_name)
197 }
198
199 pub fn opt_out(&self, service_name: &str) -> Result<()> {
201 self.save_consent(ConsentStatus::OptedOut, service_name)
202 }
203
204 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 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 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 if self.config.sanitize_paths && (s.contains('/') || s.contains('\\')) {
232 *s = Self::sanitize_path(s);
233 }
234 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 #[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 let current_consent = self.load_consent()?;
276 if current_consent.status != ConsentStatus::Unknown {
277 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) .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 #[cfg(feature = "cli")]
332 pub fn prompt_minimal(&self, service_name: &str) -> Result<bool> {
333 use dialoguer::{theme::ColorfulTheme, Confirm};
334
335 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); assert!(!config.sanitize_paths);
389 assert_eq!(config.data_retention_days, 0);
390 }
391
392 #[test]
393 fn test_do_not_track_detection() {
394 let original = std::env::var("DO_NOT_TRACK").ok();
396
397 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 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 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 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 let other_path = "/tmp/some/path";
436 let sanitized = PrivacyManager::sanitize_path(other_path);
437 assert_eq!(sanitized, other_path); }
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 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 let original_dnt = std::env::var("DO_NOT_TRACK").ok();
466
467 std::env::remove_var("DO_NOT_TRACK");
469 assert!(manager.should_track().unwrap());
470
471 std::env::set_var("DO_NOT_TRACK", "1");
473 assert!(!manager.should_track().unwrap());
474
475 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 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 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 #[cfg(test)]
522 mod proptests {
523 use super::*;
524 use proptest::prelude::*;
525
526 proptest! {
527 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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, 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 #[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 #[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 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}