1use serde_json::{Map, Value};
21
22use crate::pycompat::py_float_repr;
23use crate::pyjson;
24use crate::walk::normalize_root;
25
26pub const POSTHOG_API_KEY: &str = "phc_whK4Ndn7Pae3ZtgNRJWswiafYEyPc9d3eVoFihxzDysZ";
31
32const CONSENT_FILENAME: &str = "telemetry.json";
33
34#[derive(Debug, Clone, Default)]
36pub struct Consent {
37 pub share_usage: bool,
38 pub install_id: String,
39 pub salt: String,
40 pub consented_at: String,
41 pub enterprise_locked: bool,
42}
43
44pub struct ConsentStatus {
46 pub sharing: bool,
47 pub install_id: String,
48 pub consented_at: String,
49 pub path: String,
50 pub endpoint_configured: bool,
51 pub enterprise_locked: bool,
52}
53
54pub(crate) fn xdg_rac_file(var: &str, home_fallback: &[&str], name: &str) -> String {
66 let base = match std::env::var(var) {
67 Ok(v) if !v.is_empty() => v,
68 _ => {
69 let mut p = std::env::var("HOME").unwrap_or_default();
70 for seg in home_fallback {
71 p.push('/');
72 p.push_str(seg);
73 }
74 p
75 }
76 };
77 let norm = normalize_root(&base);
78 match norm.as_str() {
79 "." => format!("decisions/{name}"),
80 "/" => format!("/decisions/{name}"),
81 "//" => format!("//decisions/{name}"),
82 _ => format!("{norm}/decisions/{name}"),
83 }
84}
85
86pub fn consent_path() -> String {
87 xdg_rac_file("XDG_CONFIG_HOME", &[".config"], CONSENT_FILENAME)
88}
89
90pub fn consent_recorded() -> bool {
93 std::path::Path::new(&consent_path()).is_file()
94}
95
96fn py_truthy(v: &Value) -> bool {
102 match v {
103 Value::Null => false,
104 Value::Bool(b) => *b,
105 Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(true),
106 Value::String(s) => !s.is_empty(),
107 Value::Array(a) => !a.is_empty(),
108 Value::Object(o) => !o.is_empty(),
109 }
110}
111
112fn py_repr_json(v: &Value) -> String {
114 match v {
115 Value::String(s) => crate::pycompat::py_repr_str(s),
116 other => py_str_json(other),
117 }
118}
119
120fn py_str_json(v: &Value) -> String {
123 match v {
124 Value::Null => "None".to_string(),
125 Value::Bool(true) => "True".to_string(),
126 Value::Bool(false) => "False".to_string(),
127 Value::Number(n) => {
128 if let Some(i) = n.as_i64() {
129 i.to_string()
130 } else if let Some(u) = n.as_u64() {
131 u.to_string()
132 } else {
133 py_float_repr(n.as_f64().unwrap_or(0.0))
134 }
135 }
136 Value::String(s) => s.clone(),
137 Value::Array(items) => {
138 let inner: Vec<String> = items.iter().map(py_repr_json).collect();
139 format!("[{}]", inner.join(", "))
140 }
141 Value::Object(map) => {
142 let inner: Vec<String> = map
143 .iter()
144 .map(|(k, v)| {
145 format!("{}: {}", crate::pycompat::py_repr_str(k), py_repr_json(v))
146 })
147 .collect();
148 format!("{{{}}}", inner.join(", "))
149 }
150 }
151}
152
153pub fn load_consent() -> Consent {
162 let Ok(bytes) = std::fs::read(consent_path()) else {
163 return Consent::default();
164 };
165 let Ok(text) = String::from_utf8(bytes) else {
166 return Consent::default();
167 };
168 let Ok(value) = serde_json::from_str::<Value>(&text) else {
169 return Consent::default();
170 };
171 let Value::Object(map) = value else {
172 return Consent::default();
173 };
174 Consent {
175 share_usage: map.get("share_usage").map(py_truthy).unwrap_or(false),
176 install_id: map
177 .get("install_id")
178 .map(py_str_json)
179 .unwrap_or_default(),
180 salt: map.get("salt").map(py_str_json).unwrap_or_default(),
181 consented_at: map
182 .get("consented_at")
183 .map(py_str_json)
184 .unwrap_or_default(),
185 enterprise_locked: map
186 .get("enterprise_locked")
187 .map(py_truthy)
188 .unwrap_or(false),
189 }
190}
191
192pub fn save_consent(consent: &Consent) {
195 let mut m = Map::new();
196 m.insert("share_usage".into(), Value::Bool(consent.share_usage));
197 m.insert("install_id".into(), Value::String(consent.install_id.clone()));
198 m.insert("salt".into(), Value::String(consent.salt.clone()));
199 m.insert(
200 "consented_at".into(),
201 Value::String(consent.consented_at.clone()),
202 );
203 m.insert(
204 "enterprise_locked".into(),
205 Value::Bool(consent.enterprise_locked),
206 );
207 let text = pyjson::dumps_indent2(&Value::Object(m)) + "\n";
208 let path = consent_path();
209 if let Some(parent) = std::path::Path::new(&path).parent() {
210 let _ = std::fs::create_dir_all(parent);
211 }
212 let _ = std::fs::write(&path, text);
213}
214
215pub fn opt_in() -> Consent {
218 let existing = load_consent();
219 let consent = Consent {
220 share_usage: true,
221 install_id: if existing.install_id.is_empty() {
222 token_hex(16)
223 } else {
224 existing.install_id
225 },
226 salt: if existing.salt.is_empty() {
227 token_hex(16)
228 } else {
229 existing.salt
230 },
231 consented_at: utc_now_seconds_z(),
232 enterprise_locked: existing.enterprise_locked,
233 };
234 save_consent(&consent);
235 consent
236}
237
238pub fn decline() -> Consent {
241 let consent = Consent::default();
242 save_consent(&consent);
243 consent
244}
245
246pub fn opt_out() -> Consent {
248 let existing = load_consent();
249 let consent = Consent {
250 share_usage: false,
251 ..existing
252 };
253 save_consent(&consent);
254 consent
255}
256
257pub fn enterprise_lock() -> Consent {
259 let existing = load_consent();
260 let consent = Consent {
261 share_usage: false,
262 enterprise_locked: true,
263 ..existing
264 };
265 save_consent(&consent);
266 consent
267}
268
269pub fn enterprise_unlock() -> Consent {
271 let existing = load_consent();
272 let consent = Consent {
273 enterprise_locked: false,
274 ..existing
275 };
276 save_consent(&consent);
277 consent
278}
279
280pub fn consent_status() -> ConsentStatus {
281 let consent = load_consent();
282 ConsentStatus {
283 sharing: consent.share_usage,
284 install_id: consent.install_id,
285 consented_at: consent.consented_at,
286 path: consent_path(),
287 #[allow(clippy::const_is_empty)]
292 endpoint_configured: !POSTHOG_API_KEY.is_empty(),
293 enterprise_locked: consent.enterprise_locked,
294 }
295}
296
297pub(crate) fn token_hex(nbytes: usize) -> String {
306 let mut buf = vec![0u8; nbytes];
307 let read_ok = (|| -> std::io::Result<()> {
308 use std::io::Read;
309 std::fs::File::open("/dev/urandom")?.read_exact(&mut buf)
310 })()
311 .is_ok();
312 if !read_ok {
313 use std::hash::{Hash, Hasher};
314 let mut seed = std::collections::hash_map::DefaultHasher::new();
315 std::process::id().hash(&mut seed);
316 if let Ok(d) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
317 d.subsec_nanos().hash(&mut seed);
318 d.as_secs().hash(&mut seed);
319 }
320 let mut state = seed.finish();
321 for chunk in buf.chunks_mut(8) {
322 state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
323 for (i, b) in chunk.iter_mut().enumerate() {
324 *b = (state >> (8 * i)) as u8;
325 }
326 }
327 }
328 let mut out = String::with_capacity(nbytes * 2);
329 for b in buf {
330 use std::fmt::Write as _;
331 let _ = write!(out, "{b:02x}");
332 }
333 out
334}
335
336fn civil_from_days(days: i64) -> (i64, u32, u32) {
339 let z = days + 719_468;
340 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
341 let doe = z - era * 146_097; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400;
344 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = (doy - (153 * mp + 2) / 5 + 1) as u32; let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; (y + i64::from(m <= 2), m, d)
349}
350
351fn utc_fields(secs: i64) -> (i64, u32, u32, u32, u32, u32) {
352 let days = secs.div_euclid(86_400);
353 let sod = secs.rem_euclid(86_400);
354 let (y, m, d) = civil_from_days(days);
355 (
356 y,
357 m,
358 d,
359 (sod / 3600) as u32,
360 ((sod % 3600) / 60) as u32,
361 (sod % 60) as u32,
362 )
363}
364
365pub(crate) fn now_epoch() -> (i64, u32) {
366 match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
367 Ok(d) => (d.as_secs() as i64, d.subsec_micros()),
368 Err(_) => (0, 0),
369 }
370}
371
372fn utc_now_seconds_z() -> String {
374 let (secs, _) = now_epoch();
375 let (y, mo, d, h, mi, s) = utc_fields(secs);
376 format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
377}
378
379pub(crate) fn utc_isoformat_micros(secs: i64, micros: u32) -> String {
382 let (y, mo, d, h, mi, s) = utc_fields(secs);
383 if micros == 0 {
384 format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}+00:00")
385 } else {
386 format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}.{micros:06}+00:00")
387 }
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393 use serde_json::json;
394
395 #[test]
396 fn truthy_matches_python_bool() {
397 assert!(py_truthy(&json!("no"))); assert!(!py_truthy(&json!("")));
399 assert!(!py_truthy(&json!(0)));
400 assert!(!py_truthy(&json!(0.0)));
401 assert!(py_truthy(&json!(2)));
402 assert!(!py_truthy(&json!(null)));
403 assert!(!py_truthy(&json!([])));
404 assert!(py_truthy(&json!([0])));
405 }
406
407 #[test]
408 fn str_matches_python_str() {
409 assert_eq!(py_str_json(&json!(null)), "None");
410 assert_eq!(py_str_json(&json!(42)), "42");
411 assert_eq!(py_str_json(&json!(true)), "True");
412 assert_eq!(py_str_json(&json!(3.5)), "3.5");
413 assert_eq!(py_str_json(&json!([1, "a"])), "[1, 'a']");
414 assert_eq!(py_str_json(&json!({"a": 1})), "{'a': 1}");
415 }
416
417 #[test]
418 fn civil_dates_round_trip() {
419 assert_eq!(civil_from_days(0), (1970, 1, 1));
420 assert_eq!(civil_from_days(19_723), (2024, 1, 1)); assert_eq!(civil_from_days(19_782), (2024, 2, 29));
422 assert_eq!(utc_fields(1_783_894_019), (2026, 7, 12, 22, 6, 59));
424 }
425
426 #[test]
427 fn isoformat_micro_omission() {
428 assert_eq!(
429 utc_isoformat_micros(1_783_894_019, 0),
430 "2026-07-12T22:06:59+00:00"
431 );
432 assert_eq!(
433 utc_isoformat_micros(1_783_894_019, 547_399),
434 "2026-07-12T22:06:59.547399+00:00"
435 );
436 }
437}