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}