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