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