Skip to main content

rac_engine/
consent.rs

1//! Usage-sharing consent (`src/asdecided/consent.py`) — ADR-041, ADR-086.
2//!
3//! The record is JSON under `$XDG_CONFIG_HOME/decisions/telemetry.json` with the
4//! Explorer-preferences posture: a missing/corrupt/non-dict file means no
5//! consent, loading never raises, and saving tolerates filesystem trouble
6//! silently. The install id is random (`secrets.token_hex(16)`), minted at
7//! opt-in and preserved across off-and-on toggles; the enterprise hard-lock
8//! (ADR-086) forces the ping off and refuses opt-in until unlocked.
9//!
10//! Loading mirrors CPython's coercions field-by-field: `bool(value)` truth
11//! semantics for the flags and `str(value)` (including `str(None) == "None"`
12//! and container repr) for the id fields — a present-but-null install_id
13//! really does read back as the string `None`, exactly like the oracle.
14//!
15//! This module also carries the shared XDG path builder, the UTC timestamp
16//! formatters, and the `/dev/urandom` token minting that `usage.rs` reuses
17//! for the recorder — the Rust analogue of consent.py sitting outside
18//! `decided.mcp` so everything here stays SDK-free.
19
20use serde_json::{Map, Value};
21
22use crate::pycompat::py_float_repr;
23use crate::pyjson;
24use crate::walk::normalize_root;
25
26/// The PostHog public write key (ADR-041) — inert here; emptying it is the
27/// kill switch that makes `telemetry on`/`status` print the not-configured
28/// notes. The reference build embeds a non-empty key, so those lines are
29/// absent from every captured oracle run.
30pub const POSTHOG_API_KEY: &str = "phc_whK4Ndn7Pae3ZtgNRJWswiafYEyPc9d3eVoFihxzDysZ";
31
32const CONSENT_FILENAME: &str = "telemetry.json";
33
34/// The recorded sharing choice; the default is no consent.
35#[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
44/// What `decided telemetry status` reports.
45pub 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
54// ---------------------------------------------------------------------------
55// XDG paths — `os.environ.get(VAR) or str(Path.home() / …)`, then
56// `str(Path(base) / "decided" / name)`.
57// ---------------------------------------------------------------------------
58
59/// `str(Path(base) / "decided" / name)` where `base` is `$VAR` when set and
60/// NON-EMPTY (Python's `or` treats `""` as unset), else `$HOME/<fallback…>`.
61/// The base goes through PurePosixPath normalization, and a base that
62/// normalizes to `.` joins as a bare relative path (`Path(".") / "decided"` is
63/// `decided`, not `./decided`) — relative XDG values resolve against the process
64/// cwd on both engines, byte-for-byte.
65pub(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
90/// `consent_recorded()` — true once ANY answer (including a decline) has
91/// been persisted; the ask-at-most-once gate of the init/quickstart prompt.
92pub fn consent_recorded() -> bool {
93    std::path::Path::new(&consent_path()).is_file()
94}
95
96// ---------------------------------------------------------------------------
97// CPython value coercions (load_consent applies bool()/str() field-wise)
98// ---------------------------------------------------------------------------
99
100/// Python `bool(value)` over a JSON value.
101fn 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
112/// Python `repr(value)` over a JSON value (containers in `str()` position).
113fn 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
120/// Python `str(value)` over a JSON value: `None`/`True`/`False`, int
121/// digits, float repr, the string itself, and container repr.
122fn 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
153// ---------------------------------------------------------------------------
154// Load / save
155// ---------------------------------------------------------------------------
156
157/// Read the consent record; any problem means no consent (never raises).
158/// A non-UTF-8 file is a `UnicodeDecodeError` in the oracle — a `ValueError`
159/// subclass, so it lands in the same tolerant default (unlike the state
160/// LOGS, whose readers catch only `OSError` and crash).
161pub 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
192/// Persist the record: `json.dumps(asdict(consent), indent=2) + "\n"` in
193/// dataclass field order; tolerates filesystem trouble silently.
194pub 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
215/// Record consent, minting ids only where none exist yet; the enterprise
216/// lock is preserved, never cleared here (ADR-086).
217pub 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
238/// `decline()` — persist the default no-consent record, making ask-once
239/// true (unlike `opt_out`, nothing from an existing record is kept).
240pub fn decline() -> Consent {
241    let consent = Consent::default();
242    save_consent(&consent);
243    consent
244}
245
246/// Withdraw consent; the ids are kept so a later opt-in stays continuous.
247pub 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
257/// Force the ping off and hard-lock it (ADR-086); ids kept.
258pub 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
269/// Remove the enterprise hard-lock (ADR-086); sharing stays as recorded.
270pub 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        // `bool(POSTHOG_API_KEY)` — compile-time non-empty by construction;
288        // the const expression IS the oracle's semantics (the empty-key
289        // kill switch), so the lint is silenced rather than the check
290        // restructured.
291        #[allow(clippy::const_is_empty)]
292        endpoint_configured: !POSTHOG_API_KEY.is_empty(),
293        enterprise_locked: consent.enterprise_locked,
294    }
295}
296
297// ---------------------------------------------------------------------------
298// Shared seams: CSPRNG token minting and UTC timestamp formatting
299// (`secrets.token_hex`, `datetime.now(UTC).isoformat(...)`)
300// ---------------------------------------------------------------------------
301
302/// `secrets.token_hex(nbytes)` — lowercase hex over CSPRNG bytes. Falls
303/// back to a time/pid hash mix if `/dev/urandom` is unreadable (the oracle
304/// would fail hard there; this channel is never byte-refereed).
305pub(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
336/// Proleptic-Gregorian civil date from days since 1970-01-01 (Howard
337/// Hinnant's `civil_from_days`).
338fn 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; // [0, 146096]
342    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
343    let y = yoe + era * 400;
344    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
345    let mp = (5 * doy + 2) / 153; // [0, 11]
346    let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
347    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
348    (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
372/// `datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")`.
373fn 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
379/// `datetime.now(UTC).isoformat()` — microseconds included, but omitted
380/// entirely when the microsecond field is zero (CPython isoformat).
381pub(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"))); // bool("no") is True
398        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)); // leap year
421        assert_eq!(civil_from_days(19_782), (2024, 2, 29));
422        // datetime(2026, 7, 12, 22, 6, 59, tzinfo=UTC).timestamp()
423        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}