Skip to main content

codewhale_telemetry/
decision.rs

1//! The one place that decides whether anonymous usage counting may run, and the
2//! token that makes that decision unforgeable.
3//!
4//! Every emitting surface calls [`decide`] and then, if and only if it gets
5//! [`TelemetryDecision::Enabled`], hands the contained [`TelemetryConsent`] to
6//! [`crate::init`]. `TelemetryConsent` has no `Default`, no public constructor,
7//! and cannot be built from a `bool`; `init` takes it **by value**. That is what
8//! makes the permission decision enforceable by the type system rather than by six init sites
9//! each remembering to re-check the same five-part predicate.
10
11use std::path::{Path, PathBuf};
12
13use codewhale_config::{ResolvedRuntimeOptions, SetupState};
14
15use crate::buffer;
16use crate::event::Surface;
17
18/// Directory name under `$CODEWHALE_HOME` that holds every telemetry file.
19pub const TELEMETRY_DIR: &str = "telemetry";
20
21/// The outcome of the emit predicate.
22///
23/// The split between [`Self::OptedOut`] and [`Self::ForcedOff`] is load-bearing,
24/// not cosmetic. A run-scoped kill switch also resolves telemetry to false, so
25/// a wipe keyed on that value would delete a user's identity and unflushed
26/// buffer every time they ran one `codewhale exec` with a
27/// transient `CODEWHALE_TELEMETRY=0` — the recipe the runtime docs themselves
28/// prescribe.
29#[derive(Debug)]
30pub enum TelemetryDecision {
31    /// Anonymous usage counting is enabled and nothing forces it off.
32    Enabled(TelemetryConsent),
33    /// A human persistently said no — `telemetry = false` in durable config or
34    /// declining the notice. **The only variant that touches disk**: it wipes
35    /// and leaves a tombstone. CLI and environment false values are run-scoped
36    /// kill switches and produce [`Self::ForcedOff`] instead.
37    OptedOut,
38    /// Off for a run-scoped or environmental reason: an unparseable env value,
39    /// an unresolvable home, or a rejected endpoint. Touches nothing, ever.
40    /// Leaves identity and buffer exactly as they were.
41    ForcedOff,
42}
43
44impl TelemetryDecision {
45    /// Whether this decision permits emission.
46    #[must_use]
47    pub fn is_enabled(&self) -> bool {
48        matches!(self, Self::Enabled(_))
49    }
50
51    /// A stable label for logs and tests.
52    #[must_use]
53    pub fn label(&self) -> &'static str {
54        match self {
55            Self::Enabled(_) => "enabled",
56            Self::OptedOut => "opted_out",
57            Self::ForcedOff => "forced_off",
58        }
59    }
60}
61
62/// Proof that a specific machine, at a specific moment, was permitted to
63/// collect.
64///
65/// Constructed only by [`decide`]. Not `Default`, not constructible from a
66/// `bool`, and consumed by value.
67#[derive(Debug)]
68pub struct TelemetryConsent {
69    root: PathBuf,
70    endpoint: Option<String>,
71    surface: Surface,
72    config_path: Option<PathBuf>,
73    tombstone_generation: Option<buffer::TombstoneGeneration>,
74}
75
76impl TelemetryConsent {
77    /// Remember which config file this process was launched with, so the flush
78    /// path can re-resolve from it.
79    ///
80    /// Without this the documented mid-session opt-out —
81    /// `codewhale config set telemetry false`, an external write by another
82    /// process — would never be observed by a session that is already running.
83    #[must_use]
84    pub fn with_config_path(mut self, config_path: Option<PathBuf>) -> Self {
85        self.config_path = config_path;
86        self
87    }
88
89    /// The config file this process was launched with, if any.
90    #[must_use]
91    pub fn config_path(&self) -> Option<&Path> {
92        self.config_path.as_deref()
93    }
94
95    /// `$CODEWHALE_HOME/telemetry`.
96    #[must_use]
97    pub fn root(&self) -> &Path {
98        &self.root
99    }
100
101    /// The validated endpoint, or `None` for the dry-run sink.
102    #[must_use]
103    pub fn endpoint(&self) -> Option<&str> {
104        self.endpoint.as_deref()
105    }
106
107    /// The surface this consent was resolved for.
108    #[must_use]
109    pub fn surface(&self) -> Surface {
110        self.surface
111    }
112
113    /// Exact opt-out generation this decision observed.
114    pub(crate) fn tombstone_generation(&self) -> Option<&buffer::TombstoneGeneration> {
115        self.tombstone_generation.as_ref()
116    }
117}
118
119enum TelemetryEvaluation {
120    Enabled {
121        root: PathBuf,
122        endpoint: Option<String>,
123        tombstone_generation: Option<buffer::TombstoneGeneration>,
124    },
125    OptedOut(Option<PathBuf>),
126    ForcedOff,
127}
128
129/// Why an endpoint was refused.
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub enum EndpointError {
132    /// Not a URL we could parse at all.
133    Unparseable,
134    /// `http://` to something that is not loopback.
135    InsecureScheme,
136    /// A scheme that is neither `http` nor `https`.
137    UnsupportedScheme,
138}
139
140impl EndpointError {
141    /// A stable label for the single `warn` line.
142    #[must_use]
143    pub fn label(self) -> &'static str {
144        match self {
145            Self::Unparseable => "unparseable",
146            Self::InsecureScheme => "plaintext http to a non-loopback host",
147            Self::UnsupportedScheme => "scheme is neither https nor http",
148        }
149    }
150}
151
152/// Validate a configured endpoint.
153///
154/// `https://` is required. Plaintext is permitted **only** for loopback hosts,
155/// where a batch never reaches a wire — that is the staging and dogfood case.
156///
157/// There is deliberately **no environment variable that overrides this**.
158/// `CODEWHALE_ALLOW_INSECURE_HTTP` is not consulted: it authorizes an insecure
159/// *provider* base URL, for harnesses that legitimately intercept model traffic,
160/// and reusing it would let that interception decision also authorize plaintext
161/// telemetry POSTs to an arbitrary host. Two unrelated trust decisions must not
162/// share one switch, least of all in the subsystem whose whole promise is that
163/// the user knows what leaves the machine.
164pub fn validate_endpoint(raw: &str) -> Result<String, EndpointError> {
165    let trimmed = raw.trim();
166    let url = reqwest::Url::parse(trimmed).map_err(|_| EndpointError::Unparseable)?;
167    match url.scheme() {
168        "https" => Ok(trimmed.to_string()),
169        "http" => {
170            if is_loopback_host(url.host_str()) {
171                Ok(trimmed.to_string())
172            } else {
173                Err(EndpointError::InsecureScheme)
174            }
175        }
176        _ => Err(EndpointError::UnsupportedScheme),
177    }
178}
179
180/// Whether a host is one a packet can never leave the machine to reach.
181///
182/// `Url::host_str` returns an IPv6 literal in its bracketed form (`[::1]`), so
183/// the brackets come off before the address is parsed. Anything that parses as
184/// an IP is judged by `is_loopback` — 127.0.0.0/8 and `::1` — and the only
185/// accepted name is `localhost`.
186fn is_loopback_host(host: Option<&str>) -> bool {
187    let Some(host) = host else {
188        return false;
189    };
190    let bare = host.trim_start_matches('[').trim_end_matches(']');
191    match bare.parse::<std::net::IpAddr>() {
192        Ok(address) => address.is_loopback(),
193        Err(_) => bare.eq_ignore_ascii_case("localhost"),
194    }
195}
196
197/// Resolve the emit predicate, reading the Codewhale home from the environment.
198///
199/// See [`decide_in_home`] for the injectable form used by tests.
200pub fn decide(
201    resolved: &ResolvedRuntimeOptions,
202    setup: &SetupState,
203    surface: Surface,
204) -> TelemetryDecision {
205    // `codewhale_home()` returns `Ok(None)` when no home can be resolved, and
206    // an error when an explicit override was unusable. Both are "we have
207    // nowhere to keep state", which is `ForcedOff`, never a wipe.
208    let home = codewhale_paths::codewhale_home().ok().flatten();
209    decide_in_home(home.as_deref(), resolved, setup, surface)
210}
211
212/// Load the privacy-bearing setup record for a telemetry decision.
213///
214/// A genuinely missing record is a fresh installation and therefore uses the
215/// documented default. An existing record that cannot be read or parsed may
216/// contain a durable decline, so it fails closed instead of being replaced by
217/// a default-on value.
218#[must_use]
219pub fn load_setup_state_for_decision() -> Option<SetupState> {
220    let path = SetupState::path().ok()?;
221    load_setup_state_for_decision_at(&path)
222}
223
224/// Injectable form of [`load_setup_state_for_decision`] used by every surface
225/// and by regression tests.
226#[must_use]
227pub fn load_setup_state_for_decision_at(path: &Path) -> Option<SetupState> {
228    match path.try_exists() {
229        Ok(false) => Some(SetupState::default()),
230        Ok(true) => SetupState::load_from(path),
231        Err(_) => None,
232    }
233}
234
235/// Resolve the emit predicate against an explicit Codewhale home.
236///
237/// The predicate, in order:
238///
239/// 1. Telemetry resolved to `false` from persistent config → `OptedOut`;
240///    resolved `false` from a run-scoped or invalid-value floor → `ForcedOff`.
241/// 2. Any recorded notice decline → `OptedOut`, including a decline recorded
242///    by the former opt-in notice.
243/// 3. No resolvable home → `ForcedOff`.
244/// 4. Endpoint configured but refused by [`validate_endpoint`] → `ForcedOff`.
245/// 5. Otherwise `Enabled`.
246///
247/// The notice is only ever *rendered* on a TTY. The interactive TUI explains
248/// the default in a native startup modal before telemetry is armed; headless
249/// surfaces use the same documented default and kill switches.
250pub fn decide_in_home(
251    home: Option<&Path>,
252    resolved: &ResolvedRuntimeOptions,
253    setup: &SetupState,
254    surface: Surface,
255) -> TelemetryDecision {
256    match evaluate_in_home(home, resolved, setup) {
257        TelemetryEvaluation::Enabled {
258            root,
259            endpoint,
260            tombstone_generation,
261        } => TelemetryDecision::Enabled(TelemetryConsent {
262            root,
263            endpoint,
264            surface,
265            config_path: None,
266            tombstone_generation,
267        }),
268        TelemetryEvaluation::OptedOut(root) => opted_out(root.as_deref()),
269        TelemetryEvaluation::ForcedOff => TelemetryDecision::ForcedOff,
270    }
271}
272
273/// Evaluate the permission predicate without performing the opt-out wipe.
274///
275/// Keeping the classification pure lets `init` re-check it while holding the
276/// privacy lock. The public decision path maps `OptedOut` to the destructive
277/// wipe exactly once, outside that already-held lock.
278fn evaluate_in_home(
279    home: Option<&Path>,
280    resolved: &ResolvedRuntimeOptions,
281    setup: &SetupState,
282) -> TelemetryEvaluation {
283    let root = home.map(|home| home.join(TELEMETRY_DIR));
284
285    // 1. An explicit persistent "off" is an opt-out and wipes. Run-scoped or
286    //    invalid-value false is only a kill switch and leaves disk alone.
287    if !resolved.telemetry {
288        if resolved.telemetry_explicit_off {
289            return TelemetryEvaluation::OptedOut(root);
290        }
291        return TelemetryEvaluation::ForcedOff;
292    }
293
294    // 2. A historical or current decline remains a durable opt-out. Notice
295    //    version bumps may update disclosure, never reverse a user's "no".
296    if setup.telemetry_opted_out() {
297        return TelemetryEvaluation::OptedOut(root);
298    }
299
300    // 3. Nowhere to keep an install id or a buffer.
301    let Some(root) = root else {
302        return TelemetryEvaluation::ForcedOff;
303    };
304
305    // 4. A refused endpoint is a configuration error, not a user answer.
306    let endpoint = match resolved.telemetry_endpoint.as_deref() {
307        Some(raw) if !raw.trim().is_empty() => match validate_endpoint(raw) {
308            Ok(endpoint) => Some(endpoint),
309            Err(error) => {
310                tracing::warn!(
311                    "telemetry endpoint refused ({}); telemetry is off for this run",
312                    error.label()
313                );
314                return TelemetryEvaluation::ForcedOff;
315            }
316        },
317        _ => None,
318    };
319
320    let Ok(tombstone_generation) = buffer::tombstone_generation(&root) else {
321        return TelemetryEvaluation::ForcedOff;
322    };
323    TelemetryEvaluation::Enabled {
324        root,
325        endpoint,
326        tombstone_generation,
327    }
328}
329
330/// Re-check the current durable permission without wiping or clearing state.
331///
332/// Called only while `init` holds the telemetry privacy lock. A stale consent
333/// token may arm only when the config, setup-state answer, home, and endpoint
334/// still classify as enabled.
335pub(crate) fn permission_still_enabled(config_path: Option<&Path>, expected_root: &Path) -> bool {
336    let Ok(setup_path) = SetupState::path() else {
337        return false;
338    };
339    let home = codewhale_paths::codewhale_home().ok().flatten();
340    permission_still_enabled_in_home(config_path, &setup_path, home.as_deref(), expected_root)
341}
342
343pub(crate) fn permission_still_enabled_in_home(
344    config_path: Option<&Path>,
345    setup_path: &Path,
346    home: Option<&Path>,
347    expected_root: &Path,
348) -> bool {
349    let Ok(store) = codewhale_config::ConfigStore::load(config_path.map(Path::to_path_buf)) else {
350        return false;
351    };
352    let resolved = store
353        .config
354        .resolve_runtime_options(&codewhale_config::CliRuntimeOverrides::default());
355    let Some(setup) = load_setup_state_for_decision_at(setup_path) else {
356        return false;
357    };
358    matches!(
359        evaluate_in_home(home, &resolved, &setup),
360        TelemetryEvaluation::Enabled { root, .. } if root == expected_root
361    )
362}
363
364/// Re-run the predicate from the filesystem, for the flush path.
365///
366/// Loads the same config file the process was launched with and the current
367/// setup state, so a `codewhale config set telemetry false` written by another
368/// process between init and flush is honoured. Returns `ForcedOff` if either
369/// load fails: a flush is never the right place to guess.
370#[must_use]
371pub fn re_decide(config_path: Option<&Path>, surface: Surface) -> TelemetryDecision {
372    let Ok(setup_path) = SetupState::path() else {
373        return TelemetryDecision::ForcedOff;
374    };
375    re_decide_with_setup_path(config_path, &setup_path, surface)
376}
377
378pub(crate) fn re_decide_with_setup_path(
379    config_path: Option<&Path>,
380    setup_path: &Path,
381    surface: Surface,
382) -> TelemetryDecision {
383    let Ok(store) = codewhale_config::ConfigStore::load(config_path.map(Path::to_path_buf)) else {
384        return TelemetryDecision::ForcedOff;
385    };
386    let resolved = store
387        .config
388        .resolve_runtime_options(&codewhale_config::CliRuntimeOverrides::default());
389    let Some(setup) = load_setup_state_for_decision_at(setup_path) else {
390        return TelemetryDecision::ForcedOff;
391    };
392    decide(&resolved, &setup, surface)
393}
394
395/// Perform the opt-out wipe, then report `OptedOut`.
396///
397/// Nothing is created for a user who never opted in: if the telemetry directory
398/// does not exist there is nothing to wipe and nothing to announce, so this
399/// returns without touching the filesystem.
400fn opted_out(root: Option<&Path>) -> TelemetryDecision {
401    if let Some(root) = root
402        && root.is_dir()
403        && let Err(error) = buffer::wipe(root)
404    {
405        // A failed wipe fails **closed**: the tombstone is written first and is
406        // never removed by the wipe, so even a partial failure leaves the
407        // buffer permanently undrainable.
408        tracing::warn!("telemetry opt-out wipe was incomplete: {error}");
409    }
410    TelemetryDecision::OptedOut
411}