voirs_cli/telemetry/
privacy.rs1use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5
6use super::events::{EventMetadata, TelemetryEvent};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10pub enum AnonymizationLevel {
11 None,
13
14 Low,
16
17 Medium,
19
20 High,
22}
23
24impl AnonymizationLevel {
25 pub fn hash_user_ids(&self) -> bool {
27 !matches!(self, AnonymizationLevel::None)
28 }
29
30 pub fn sanitize_paths(&self) -> bool {
32 matches!(self, AnonymizationLevel::Medium | AnonymizationLevel::High)
33 }
34
35 pub fn filter_metadata(&self) -> bool {
37 matches!(self, AnonymizationLevel::High)
38 }
39
40 pub fn remove_text_content(&self) -> bool {
42 matches!(self, AnonymizationLevel::Medium | AnonymizationLevel::High)
43 }
44}
45
46impl std::fmt::Display for AnonymizationLevel {
47 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48 match self {
49 AnonymizationLevel::None => write!(f, "none"),
50 AnonymizationLevel::Low => write!(f, "low"),
51 AnonymizationLevel::Medium => write!(f, "medium"),
52 AnonymizationLevel::High => write!(f, "high"),
53 }
54 }
55}
56
57pub struct PrivacyControl {
59 level: AnonymizationLevel,
60 salt: String,
61}
62
63impl PrivacyControl {
64 pub fn new(level: AnonymizationLevel) -> Self {
66 Self {
67 level,
68 salt: uuid::Uuid::new_v4().to_string(),
69 }
70 }
71
72 pub fn anonymize_event(&self, mut event: TelemetryEvent) -> TelemetryEvent {
74 if self.level.hash_user_ids() {
76 if let Some(user_id) = event.user_id {
77 event.user_id = Some(self.hash_string(&user_id));
78 }
79 }
80
81 if self.level.sanitize_paths() {
83 self.sanitize_metadata_paths(&mut event.metadata);
84 }
85
86 if self.level.remove_text_content() {
88 self.remove_text_from_metadata(&mut event.metadata);
89 }
90
91 if self.level.filter_metadata() {
93 self.filter_sensitive_metadata(&mut event.metadata);
94 }
95
96 event
97 }
98
99 fn hash_string(&self, input: &str) -> String {
101 let mut hasher = Sha256::new();
102 hasher.update(input.as_bytes());
103 hasher.update(self.salt.as_bytes());
104 format!("{:x}", hasher.finalize())
105 }
106
107 fn sanitize_metadata_paths(&self, metadata: &mut EventMetadata) {
109 let sensitive_keys = ["path", "file", "directory", "output", "input"];
110
111 for key in sensitive_keys {
112 if let Some(value) = metadata.get(key) {
113 let sanitized = self.sanitize_path(value);
114 metadata.set(key, sanitized);
115 }
116 }
117 }
118
119 fn sanitize_path(&self, path: &str) -> String {
121 let mut sanitized = path.to_string();
123
124 if let Ok(home) = std::env::var("HOME") {
125 if !home.is_empty() {
126 sanitized = sanitized.replace(&home, "$HOME");
127 }
128 }
129
130 if let Ok(userprofile) = std::env::var("USERPROFILE") {
131 if !userprofile.is_empty() {
132 sanitized = sanitized.replace(&userprofile, "$HOME");
133 }
134 }
135
136 if let Some(filename) = std::path::Path::new(&sanitized)
138 .file_name()
139 .and_then(|s| s.to_str())
140 {
141 filename.to_string()
142 } else {
143 sanitized
144 }
145 }
146
147 fn remove_text_from_metadata(&self, metadata: &mut EventMetadata) {
149 let text_keys = ["text", "message", "content", "input_text"];
150
151 for key in text_keys {
152 if let Some(value) = metadata.get(key) {
153 metadata.set(key, format!("<redacted {} chars>", value.len()));
155 }
156 }
157 }
158
159 fn filter_sensitive_metadata(&self, metadata: &mut EventMetadata) {
161 let allowed_keys = [
162 "command",
163 "voice",
164 "duration_ms",
165 "success",
166 "error_type",
167 "severity",
168 "metric_name",
169 "value",
170 "unit",
171 "event_type",
172 ];
173
174 let current_keys: Vec<String> = metadata.keys().cloned().collect();
176 for key in current_keys {
177 if !allowed_keys.contains(&key.as_str()) {
178 metadata.remove(&key);
179 }
180 }
181 }
182
183 pub fn level(&self) -> AnonymizationLevel {
185 self.level
186 }
187
188 pub fn allows_data_type(&self, data_type: &str) -> bool {
190 match self.level {
191 AnonymizationLevel::None | AnonymizationLevel::Low => true,
192 AnonymizationLevel::Medium => {
193 !matches!(data_type, "text_content" | "file_path" | "user_name")
195 }
196 AnonymizationLevel::High => {
197 matches!(
199 data_type,
200 "command" | "duration" | "error_type" | "performance"
201 )
202 }
203 }
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210 use crate::telemetry::events::EventType;
211
212 #[test]
213 fn test_anonymization_level_flags() {
214 assert!(!AnonymizationLevel::None.hash_user_ids());
215 assert!(AnonymizationLevel::Low.hash_user_ids());
216 assert!(AnonymizationLevel::Medium.hash_user_ids());
217 assert!(AnonymizationLevel::High.hash_user_ids());
218
219 assert!(!AnonymizationLevel::None.sanitize_paths());
220 assert!(!AnonymizationLevel::Low.sanitize_paths());
221 assert!(AnonymizationLevel::Medium.sanitize_paths());
222 assert!(AnonymizationLevel::High.sanitize_paths());
223 }
224
225 #[test]
226 fn test_hash_string() {
227 let control = PrivacyControl::new(AnonymizationLevel::Medium);
228 let hash1 = control.hash_string("test");
229 let hash2 = control.hash_string("test");
230
231 assert_eq!(hash1, hash2); assert_ne!(hash1, "test"); assert_eq!(hash1.len(), 64); }
235
236 #[test]
237 fn test_user_id_hashing() {
238 let control = PrivacyControl::new(AnonymizationLevel::Low);
239 let mut event =
240 TelemetryEvent::new(EventType::CommandExecuted).with_user_id("user123".to_string());
241
242 let anonymized = control.anonymize_event(event.clone());
243 assert_ne!(anonymized.user_id.as_ref().unwrap(), "user123");
244 assert_eq!(anonymized.user_id.unwrap().len(), 64); }
246
247 #[test]
248 fn test_path_sanitization() {
249 let control = PrivacyControl::new(AnonymizationLevel::Medium);
250
251 let home = std::env::var("HOME").unwrap_or_else(|_| "/home/user".to_string());
253 let path = format!("{}/documents/file.txt", home);
254 let sanitized = control.sanitize_path(&path);
255
256 assert_eq!(sanitized, "file.txt");
258 }
259
260 #[test]
261 fn test_text_removal() {
262 let control = PrivacyControl::new(AnonymizationLevel::Medium);
263 let mut event = TelemetryEvent::new(EventType::SynthesisRequest);
264 event.metadata.set("text", "Hello, this is sensitive text");
265
266 let anonymized = control.anonymize_event(event);
267 let text_value = anonymized.metadata.get("text").unwrap();
268
269 assert!(text_value.contains("redacted"));
270 assert!(!text_value.contains("Hello"));
271 }
272
273 #[test]
274 fn test_metadata_filtering() {
275 let control = PrivacyControl::new(AnonymizationLevel::High);
276 let mut event = TelemetryEvent::new(EventType::CommandExecuted);
277 event.metadata.set("command", "synthesize");
278 event.metadata.set("user_name", "john_doe");
279 event.metadata.set("duration_ms", "1500");
280
281 let anonymized = control.anonymize_event(event);
282
283 assert!(anonymized.metadata.contains("command"));
284 assert!(anonymized.metadata.contains("duration_ms"));
285 assert!(!anonymized.metadata.contains("user_name"));
286 }
287
288 #[test]
289 fn test_allows_data_type() {
290 let none_control = PrivacyControl::new(AnonymizationLevel::None);
291 assert!(none_control.allows_data_type("text_content"));
292 assert!(none_control.allows_data_type("file_path"));
293
294 let high_control = PrivacyControl::new(AnonymizationLevel::High);
295 assert!(!high_control.allows_data_type("text_content"));
296 assert!(high_control.allows_data_type("command"));
297 assert!(high_control.allows_data_type("performance"));
298 }
299
300 #[test]
301 fn test_anonymization_level_display() {
302 assert_eq!(AnonymizationLevel::None.to_string(), "none");
303 assert_eq!(AnonymizationLevel::Low.to_string(), "low");
304 assert_eq!(AnonymizationLevel::Medium.to_string(), "medium");
305 assert_eq!(AnonymizationLevel::High.to_string(), "high");
306 }
307
308 #[test]
309 fn test_privacy_control_level() {
310 let control = PrivacyControl::new(AnonymizationLevel::Medium);
311 assert_eq!(control.level(), AnonymizationLevel::Medium);
312 }
313}