| 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 | |
| 11 | use std::path::{Path, PathBuf}; |
| 12 | |
| 13 | use codewhale_config::{ResolvedRuntimeOptions, SetupState, TelemetrySource}; |
| 14 | |
| 15 | use crate::buffer; |
| 16 | use crate::event::Surface; |
| 17 | |
| 18 | /// Directory name under `$CODEWHALE_HOME` that holds every telemetry file. |
| 19 | pub 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)] |
| 30 | pub 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 | |
| 44 | impl 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)] |
| 68 | pub 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 | |
| 76 | impl 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 | |
| 119 | enum 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)] |
| 131 | pub 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 | |
| 140 | impl 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. |
| 164 | pub 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`. |
| 186 | fn 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. |
| 200 | pub 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 fresh default. |
| 218 | #[must_use] |
| 219 | pub 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] |
| 227 | pub 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`, including an absent configuration preference. |
| 246 | /// |
| 247 | /// The disclosure is policy metadata, not a consent gate. Every armed surface |
| 248 | /// presents a startup disclosure; the TUI also has a localized notice. |
| 249 | pub fn decide_in_home( |
| 250 | home: Option<&Path>, |
| 251 | resolved: &ResolvedRuntimeOptions, |
| 252 | setup: &SetupState, |
| 253 | surface: Surface, |
| 254 | ) -> TelemetryDecision { |
| 255 | match evaluate_in_home(home, resolved, setup) { |
| 256 | TelemetryEvaluation::Enabled { |
| 257 | root, |
| 258 | endpoint, |
| 259 | tombstone_generation, |
| 260 | } => TelemetryDecision::Enabled(TelemetryConsent { |
| 261 | root, |
| 262 | endpoint, |
| 263 | surface, |
| 264 | config_path: None, |
| 265 | tombstone_generation, |
| 266 | }), |
| 267 | TelemetryEvaluation::OptedOut(root) => opted_out(root.as_deref()), |
| 268 | TelemetryEvaluation::ForcedOff => TelemetryDecision::ForcedOff, |
| 269 | } |
| 270 | } |
| 271 | |
| 272 | /// Evaluate the permission predicate without performing the opt-out wipe. |
| 273 | /// |
| 274 | /// Keeping the classification pure lets `init` re-check it while holding the |
| 275 | /// privacy lock. The public decision path maps `OptedOut` to the destructive |
| 276 | /// wipe exactly once, outside that already-held lock. |
| 277 | fn evaluate_in_home( |
| 278 | home: Option<&Path>, |
| 279 | resolved: &ResolvedRuntimeOptions, |
| 280 | setup: &SetupState, |
| 281 | ) -> TelemetryEvaluation { |
| 282 | let root = home.map(|home| home.join(TELEMETRY_DIR)); |
| 283 | |
| 284 | // 1. An explicit persistent "off" is an opt-out and wipes. Run-scoped or |
| 285 | // invalid-value false is only a kill switch and leaves disk alone. |
| 286 | if !resolved.telemetry { |
| 287 | if resolved.telemetry_explicit_off |
| 288 | || (resolved.telemetry_source == TelemetrySource::Default |
| 289 | && setup.telemetry_opted_out()) |
| 290 | { |
| 291 | // A run-scoped preference must not hide a durable decline in |
| 292 | // the sidecar when the config register was never written. |
| 293 | return TelemetryEvaluation::OptedOut(root); |
| 294 | } |
| 295 | return TelemetryEvaluation::ForcedOff; |
| 296 | } |
| 297 | |
| 298 | // 2. A historical or current decline remains a durable opt-out. Notice |
| 299 | // version bumps may update disclosure, never reverse a user's "no". |
| 300 | if setup.telemetry_opted_out() { |
| 301 | return TelemetryEvaluation::OptedOut(root); |
| 302 | } |
| 303 | |
| 304 | // Nowhere to keep an install id or a buffer. |
| 305 | let Some(root) = root else { |
| 306 | return TelemetryEvaluation::ForcedOff; |
| 307 | }; |
| 308 | |
| 309 | // 4. A refused endpoint is a configuration error, not a user answer. |
| 310 | let endpoint = match resolved.telemetry_endpoint.as_deref() { |
| 311 | Some(raw) if !raw.trim().is_empty() => match validate_endpoint(raw) { |
| 312 | Ok(endpoint) => Some(endpoint), |
| 313 | Err(error) => { |
| 314 | tracing::warn!( |
| 315 | "telemetry endpoint refused ({}); telemetry is off for this run", |
| 316 | error.label() |
| 317 | ); |
| 318 | return TelemetryEvaluation::ForcedOff; |
| 319 | } |
| 320 | }, |
| 321 | _ => None, |
| 322 | }; |
| 323 | |
| 324 | let Ok(tombstone_generation) = buffer::tombstone_generation(&root) else { |
| 325 | return TelemetryEvaluation::ForcedOff; |
| 326 | }; |
| 327 | TelemetryEvaluation::Enabled { |
| 328 | root, |
| 329 | endpoint, |
| 330 | tombstone_generation, |
| 331 | } |
| 332 | } |
| 333 | |
| 334 | /// Re-check the current durable permission without wiping or clearing state. |
| 335 | /// |
| 336 | /// Called only while `init` holds the telemetry privacy lock. A stale consent |
| 337 | /// token may arm only when the config, setup-state answer, home, and endpoint |
| 338 | /// still classify as enabled. |
| 339 | pub(crate) fn permission_still_enabled(config_path: Option<&Path>, expected_root: &Path) -> bool { |
| 340 | let Ok(setup_path) = SetupState::path() else { |
| 341 | return false; |
| 342 | }; |
| 343 | let home = codewhale_paths::codewhale_home().ok().flatten(); |
| 344 | permission_still_enabled_in_home(config_path, &setup_path, home.as_deref(), expected_root) |
| 345 | } |
| 346 | |
| 347 | pub(crate) fn permission_still_enabled_in_home( |
| 348 | config_path: Option<&Path>, |
| 349 | setup_path: &Path, |
| 350 | home: Option<&Path>, |
| 351 | expected_root: &Path, |
| 352 | ) -> bool { |
| 353 | let Ok(store) = codewhale_config::ConfigStore::load(config_path.map(Path::to_path_buf)) else { |
| 354 | return false; |
| 355 | }; |
| 356 | let resolved = store |
| 357 | .config |
| 358 | .resolve_runtime_options(&codewhale_config::CliRuntimeOverrides::default()); |
| 359 | let Some(setup) = load_setup_state_for_decision_at(setup_path) else { |
| 360 | return false; |
| 361 | }; |
| 362 | matches!( |
| 363 | evaluate_in_home(home, &resolved, &setup), |
| 364 | TelemetryEvaluation::Enabled { root, .. } if root == expected_root |
| 365 | ) |
| 366 | } |
| 367 | |
| 368 | /// Re-run the predicate from the filesystem, for the flush path. |
| 369 | /// |
| 370 | /// Loads the same config file the process was launched with and the current |
| 371 | /// setup state, so a `codewhale config set telemetry false` written by another |
| 372 | /// process between init and flush is honoured. Returns `ForcedOff` if either |
| 373 | /// load fails: a flush is never the right place to guess. |
| 374 | #[must_use] |
| 375 | pub fn re_decide(config_path: Option<&Path>, surface: Surface) -> TelemetryDecision { |
| 376 | let Ok(setup_path) = SetupState::path() else { |
| 377 | return TelemetryDecision::ForcedOff; |
| 378 | }; |
| 379 | re_decide_with_setup_path(config_path, &setup_path, surface) |
| 380 | } |
| 381 | |
| 382 | pub(crate) fn re_decide_with_setup_path( |
| 383 | config_path: Option<&Path>, |
| 384 | setup_path: &Path, |
| 385 | surface: Surface, |
| 386 | ) -> TelemetryDecision { |
| 387 | let Ok(store) = codewhale_config::ConfigStore::load(config_path.map(Path::to_path_buf)) else { |
| 388 | return TelemetryDecision::ForcedOff; |
| 389 | }; |
| 390 | let resolved = store |
| 391 | .config |
| 392 | .resolve_runtime_options(&codewhale_config::CliRuntimeOverrides::default()); |
| 393 | let Some(setup) = load_setup_state_for_decision_at(setup_path) else { |
| 394 | return TelemetryDecision::ForcedOff; |
| 395 | }; |
| 396 | decide(&resolved, &setup, surface) |
| 397 | } |
| 398 | |
| 399 | /// Perform the opt-out wipe, then report `OptedOut`. |
| 400 | /// |
| 401 | /// Nothing is created for a user who never opted in: if the telemetry directory |
| 402 | /// does not exist there is nothing to wipe and nothing to announce, so this |
| 403 | /// returns without touching the filesystem. |
| 404 | fn opted_out(root: Option<&Path>) -> TelemetryDecision { |
| 405 | if let Some(root) = root |
| 406 | && root.is_dir() |
| 407 | && let Err(error) = buffer::wipe(root) |
| 408 | { |
| 409 | // A failed wipe fails **closed**: the tombstone is written first and is |
| 410 | // never removed by the wipe, so even a partial failure leaves the |
| 411 | // buffer permanently undrainable. |
| 412 | tracing::warn!("telemetry opt-out wipe was incomplete: {error}"); |
| 413 | } |
| 414 | TelemetryDecision::OptedOut |
| 415 | } |
| 416 |