返回 CodeWhale
tests.rs
根目录 / crates / telemetry / src / tests.rs
1 //! The tests are the contract.
2 //!
3 //! Two of them are load-bearing beyond ordinary coverage:
4 //! `every_payload_field_is_bounded` walks a fully-populated batch and asserts
5 //! that every string leaf is a member of a declared enum set or one of exactly
6 //! three regexed strings, and `every_string_leaf_survives_redaction_unchanged`
7 //! runs the workflow crate's disclosure redactor over each leaf **individually**
8 //! — never over the serialized document, which would be one whitespace-free
9 //! token and would report clean no matter what it contained.
10
11 use std::path::{Path, PathBuf};
12 use std::time::{Duration, Instant};
13
14 use codewhale_config::{
15 CliRuntimeOverrides, ConfigToml, ResolvedRuntimeOptions, SetupState, TELEMETRY_NOTICE_VERSION,
16 };
17 use serde_json::Value;
18
19 use crate::buffer;
20 use crate::decision::{
21 EndpointError, TelemetryDecision, decide_in_home, load_setup_state_for_decision_at,
22 permission_still_enabled_in_home, re_decide_with_setup_path, validate_endpoint,
23 };
24 use crate::envelope;
25 use crate::event::*;
26
27 // ---------------------------------------------------------------- fixtures --
28
29 fn temp_home() -> tempfile::TempDir {
30 tempfile::tempdir().expect("temp home")
31 }
32
33 fn root_of(home: &tempfile::TempDir) -> PathBuf {
34 home.path().join(crate::TELEMETRY_DIR)
35 }
36
37 /// A resolved-options value with everything but telemetry left at its default.
38 fn resolved(telemetry: bool, explicit_off: bool, endpoint: Option<&str>) -> ResolvedRuntimeOptions {
39 let mut options =
40 ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
41 options.telemetry = telemetry;
42 options.telemetry_explicit_off = explicit_off;
43 options.telemetry_endpoint = endpoint.map(str::to_string);
44 options
45 }
46
47 fn accepted_setup() -> SetupState {
48 let mut setup = SetupState::default();
49 setup.record_telemetry_notice(TELEMETRY_NOTICE_VERSION, true);
50 setup
51 }
52
53 fn declined_setup() -> SetupState {
54 let mut setup = SetupState::default();
55 setup.record_telemetry_notice(TELEMETRY_NOTICE_VERSION, false);
56 setup
57 }
58
59 fn stale_setup() -> SetupState {
60 let mut setup = SetupState::default();
61 setup.record_telemetry_notice("0", true);
62 setup
63 }
64
65 #[test]
66 fn setup_state_loader_defaults_only_when_the_privacy_record_is_absent() {
67 let home = temp_home();
68 let path = home.path().join("setup_state.json");
69
70 assert!(
71 load_setup_state_for_decision_at(&path).is_some(),
72 "a genuinely fresh install uses the documented default"
73 );
74
75 std::fs::write(&path, b"{not-json").expect("write corrupt setup state");
76 assert!(
77 load_setup_state_for_decision_at(&path).is_none(),
78 "an existing unreadable privacy record must fail closed"
79 );
80
81 accepted_setup()
82 .save_to(&path)
83 .expect("write valid setup state");
84 assert!(
85 load_setup_state_for_decision_at(&path)
86 .is_some_and(|setup| setup.telemetry_accepted(TELEMETRY_NOTICE_VERSION)),
87 "a valid setup state remains usable"
88 );
89 }
90
91 #[test]
92 fn flush_redecision_fails_closed_on_a_corrupt_setup_state() {
93 let home = temp_home();
94 let config_path = home.path().join("config.toml");
95 let setup_path = home.path().join("setup_state.json");
96 std::fs::write(&config_path, "telemetry = true\n").expect("write config");
97 std::fs::write(&setup_path, b"{not-json").expect("write corrupt setup state");
98
99 assert!(matches!(
100 re_decide_with_setup_path(Some(&config_path), &setup_path, Surface::Exec),
101 TelemetryDecision::ForcedOff
102 ));
103 }
104
105 /// One instance of every event variant, populated with the most adversarial
106 /// values the schema permits.
107 ///
108 /// **This list is hand-written, and the compiler cannot make you extend it.**
109 /// A new `Event` variant carrying a free-form `String` would be walked by none
110 /// of the red-line tests below — they all start here — and `golden_payload_v1`
111 /// would still pass, because it serializes this same fixture. Nothing closes
112 /// that hole from inside this file; enumerating an enum's variants needs
113 /// reflection this workspace deliberately does not depend on. What does bite is
114 /// [`Event::is_bounded`], whose `match self` is exhaustive: adding a variant
115 /// fails the build until its author states a bound. If you are that author,
116 /// add the variant here too.
117 fn every_event() -> Vec<Event> {
118 vec![
119 Event::InstallOrUpgrade {
120 kind: InstallKind::Upgrade,
121 previous_version: Some("0.9.3-rc.1".to_string()),
122 },
123 Event::SessionStart {
124 source: SessionSource::Resume,
125 },
126 Event::SessionEnd {
127 duration_bucket: DurationBucket::OneToTen,
128 exit_class: ExitClass::Panic,
129 cold_start_bucket: Some(ColdStartBucket::Mid),
130 providers: vec!["custom".to_string(), "deepseek".to_string()],
131 counters: Counters {
132 turns: 14,
133 tool_calls: 61,
134 fleet_dispatch: 0,
135 workflow_run: 0,
136 subagent_spawn: 2,
137 mcp_server_connected: 0,
138 memory_search: 0,
139 approval_modal_shown: 0,
140 approval_auto_allowed: 0,
141 command_palette_open: 3,
142 },
143 errors: Errors {
144 auth_preflight_failed: 0,
145 provider_http_4xx: 0,
146 provider_http_5xx: 1,
147 tool_denied_by_policy: 0,
148 tool_timeout: 0,
149 network_error: 0,
150 },
151 turn_wall: TurnWall {
152 lt_5s: 9,
153 five_to_thirty: 4,
154 thirty_to_onetwenty: 1,
155 gte_120s: 0,
156 },
157 },
158 Event::Panic {
159 site: "crates/tui/src/tui/ui.rs:8801:17".to_string(),
160 },
161 Event::ProductUsage {
162 counters: ProductCounters {
163 page_view: 1,
164 ..ProductCounters::default()
165 },
166 },
167 Event::OperationsSummary {
168 requests: 10,
169 errors: 1,
170 duration_ms_total: 1500,
171 duration_ms_max: 300,
172 probes: 2,
173 probes_failed: 0,
174 },
175 ]
176 }
177
178 /// A fully-populated batch. This is the artifact the red-line tests walk.
179 pub(crate) fn every_field_batch() -> Batch {
180 Batch {
181 schema_version: SCHEMA_VERSION,
182 notice_version: NOTICE_VERSION,
183 sent_at: "2026-08-03T18:04:11Z".to_string(),
184 install_id: "3f2a9c1e-0000-4000-8000-000000000001".to_string(),
185 app_version: "0.9.4".to_string(),
186 git_sha: Some("abcdef012345".to_string()),
187 surface: Surface::ControlPlane,
188 os: Os::Macos,
189 arch: Arch::Aarch64,
190 libc: Libc::None,
191 tty: true,
192 events: every_event(),
193 }
194 }
195
196 // --------------------------------------------------------- schema red lines --
197
198 fn walk_strings(value: &Value, path: &str, out: &mut Vec<(String, String)>) {
199 match value {
200 Value::String(text) => out.push((path.to_string(), text.clone())),
201 Value::Array(items) => {
202 for (index, item) in items.iter().enumerate() {
203 walk_strings(item, &format!("{path}[{index}]"), out);
204 }
205 }
206 Value::Object(map) => {
207 for (key, item) in map {
208 let child = if path.is_empty() {
209 key.clone()
210 } else {
211 format!("{path}.{key}")
212 };
213 walk_strings(item, &child, out);
214 }
215 }
216 _ => {}
217 }
218 }
219
220 pub(crate) fn string_leaves(value: &Value) -> Vec<(String, String)> {
221 let mut out = Vec::new();
222 walk_strings(value, "", &mut out);
223 out
224 }
225
226 // The version and panic-site rules are the shipped predicates, not local
227 // copies. A test that re-implements the rule it is checking passes against a
228 // binary that enforces nothing — which is exactly what
229 // `a_hostile_buffer_line_never_reaches_a_batch` found the first time.
230 use crate::event::{is_reduced_panic_site, is_release_version_string as is_app_version};
231
232 fn is_short_sha(value: &str) -> bool {
233 value.len() == 12
234 && value
235 .bytes()
236 .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
237 }
238
239 /// Every closed-enum string this schema may ever emit.
240 fn closed_enum_values() -> Vec<String> {
241 let mut values: Vec<String> = Vec::new();
242 values.extend(Surface::ALL.iter().map(|v| v.as_str().to_string()));
243 values.extend(Os::ALL.iter().map(|v| v.as_str().to_string()));
244 values.extend(Arch::ALL.iter().map(|v| v.as_str().to_string()));
245 values.extend(Libc::ALL.iter().map(|v| v.as_str().to_string()));
246 values.extend(InstallKind::ALL.iter().map(|v| v.as_str().to_string()));
247 values.extend(SessionSource::ALL.iter().map(|v| v.as_str().to_string()));
248 values.extend(DurationBucket::ALL.iter().map(|v| v.as_str().to_string()));
249 values.extend(ExitClass::ALL.iter().map(|v| v.as_str().to_string()));
250 values.extend(ColdStartBucket::ALL.iter().map(|v| v.as_str().to_string()));
251 // The `event` tag values.
252 values.extend(every_event().iter().map(|e| e.name().to_string()));
253 // `providers` entries: `ProviderKind::as_str()` is a `&'static str` from a
254 // closed enum, and `Custom` yields the literal "custom".
255 values.extend(
256 codewhale_config::ProviderKind::all()
257 .iter()
258 .map(|kind| kind.as_str().to_string()),
259 );
260 values
261 }
262
263 #[test]
264 fn every_payload_field_is_bounded() {
265 let batch = every_field_batch();
266 let json = serde_json::to_value(&batch).expect("serialize batch");
267 let enums = closed_enum_values();
268 let leaves = string_leaves(&json);
269 assert!(
270 leaves.len() >= 10,
271 "the walk found suspiciously few string leaves: {leaves:?}"
272 );
273
274 for (path, value) in leaves {
275 let ok = match path.as_str() {
276 "app_version" => is_app_version(&value),
277 "git_sha" => is_short_sha(&value),
278 "sent_at" => value.ends_with('Z') && value.len() == 20,
279 "install_id" => uuid::Uuid::parse_str(&value).is_ok(),
280 p if p.ends_with(".site") => is_reduced_panic_site(&value),
281 p if p.ends_with(".previous_version") => is_app_version(&value),
282 _ => enums.contains(&value),
283 };
284 assert!(ok, "unbounded string leaf at {path}: {value:?}");
285 }
286 }
287
288 #[test]
289 fn counters_and_errors_serialize_every_field_including_zeros() {
290 let json = serde_json::to_value(Counters::default()).expect("serialize counters");
291 let object = json.as_object().expect("counters is an object");
292 assert_eq!(object.len(), Counters::FIELDS.len());
293 for field in Counters::FIELDS {
294 assert_eq!(object.get(*field), Some(&Value::from(0u32)), "{field}");
295 }
296
297 let json = serde_json::to_value(Errors::default()).expect("serialize errors");
298 let object = json.as_object().expect("errors is an object");
299 assert_eq!(object.len(), Errors::FIELDS.len());
300 for field in Errors::FIELDS {
301 assert_eq!(object.get(*field), Some(&Value::from(0u32)), "{field}");
302 }
303
304 let json = serde_json::to_value(TurnWall::default()).expect("serialize turn_wall");
305 let object = json.as_object().expect("turn_wall is an object");
306 assert_eq!(object.len(), TurnWall::FIELDS.len());
307 for field in TurnWall::FIELDS {
308 assert_eq!(object.get(*field), Some(&Value::from(0u32)), "{field}");
309 }
310 }
311
312 #[test]
313 fn no_event_field_is_ever_omitted() {
314 // Options serialize as `null` rather than being skipped, so the key set on
315 // the wire is closed and the doc-match test can be exact.
316 let event = Event::SessionEnd {
317 duration_bucket: DurationBucket::Lt1m,
318 exit_class: ExitClass::Clean,
319 cold_start_bucket: None,
320 providers: Vec::new(),
321 counters: Counters::default(),
322 errors: Errors::default(),
323 turn_wall: TurnWall::default(),
324 };
325 let json = serde_json::to_value(&event).expect("serialize");
326 assert_eq!(json.get("cold_start_bucket"), Some(&Value::Null));
327
328 let event = Event::InstallOrUpgrade {
329 kind: InstallKind::Install,
330 previous_version: None,
331 };
332 let json = serde_json::to_value(&event).expect("serialize");
333 assert_eq!(json.get("previous_version"), Some(&Value::Null));
334 }
335
336 // ------------------------------------------------------ scrubber assertions --
337
338 /// Run the workflow crate's disclosure redactor over **each string leaf**.
339 ///
340 /// Never over the serialized document: `redact_for_disclosure` tokenizes with
341 /// `input.split(' ')`, and a compact `serde_json` batch has no spaces, so the
342 /// whole document would be one token and every classifier would fail on it. A
343 /// batch containing an absolute path, a live-looking key, and a whole prompt
344 /// would report clean. The gate would detect nothing while appearing to pass.
345 fn redaction_kinds_over_leaves(json: &Value) -> Vec<String> {
346 let mut kinds = Vec::new();
347 for (_, value) in string_leaves(json) {
348 let redaction = codewhale_workflow::redaction::redact_for_disclosure(&value);
349 if redaction.redacted() {
350 kinds.extend(redaction.kinds());
351 }
352 }
353 kinds
354 }
355
356 #[test]
357 fn every_string_leaf_survives_redaction_unchanged() {
358 let json = serde_json::to_value(every_field_batch()).expect("serialize batch");
359 let kinds = redaction_kinds_over_leaves(&json);
360 assert!(
361 kinds.is_empty(),
362 "a real payload tripped the disclosure redactor: {kinds:?}"
363 );
364 }
365
366 #[test]
367 fn redaction_catches_a_planted_absolute_path() {
368 let mut json = serde_json::to_value(every_field_batch()).expect("serialize batch");
369 json["app_version"] = Value::from("/Users/hunter/src/app/main.rs");
370 let kinds = redaction_kinds_over_leaves(&json);
371 assert!(
372 kinds.iter().any(|k| k == "absolute_path"),
373 "the negative control did not fire: {kinds:?}"
374 );
375 }
376
377 #[test]
378 fn redaction_catches_a_planted_secret() {
379 let mut json = serde_json::to_value(every_field_batch()).expect("serialize batch");
380 // Deliberately low-entropy: a realistic token in a fixture trips secret
381 // scanners at push time.
382 json["app_version"] = Value::from("api_key=sk-live-abcdef0123456789abcdef");
383 let kinds = redaction_kinds_over_leaves(&json);
384 assert!(
385 kinds.iter().any(|k| k == "secret"),
386 "the negative control did not fire: {kinds:?}"
387 );
388 }
389
390 #[test]
391 fn panic_site_is_the_only_field_that_may_carry_a_path() {
392 // `panic_site` is a repo-relative path by design, so it is the one
393 // documented exemption. Prove the redactor would flag such a value, and
394 // that no other leaf in a real payload carries one.
395 let planted = codewhale_workflow::redaction::redact_for_disclosure("crates/tui/src/main.rs");
396 assert!(
397 planted.kinds().iter().any(|k| k == "relative_path"),
398 "the redactor no longer classifies a repo-relative path: {:?}",
399 planted.kinds()
400 );
401
402 let json = serde_json::to_value(every_field_batch()).expect("serialize batch");
403 for (path, value) in string_leaves(&json) {
404 if path.ends_with(".site") {
405 continue;
406 }
407 let redaction = codewhale_workflow::redaction::redact_for_disclosure(&value);
408 assert!(
409 !redaction.kinds().iter().any(|k| k.ends_with("path")),
410 "a non-exempt leaf carries a path: {path} = {value:?}"
411 );
412 }
413 }
414
415 // ------------------------------------------- the drain path is a boundary --
416
417 /// The buffer is a **deserializer input**, not an internal channel.
418 ///
419 /// Every bound above is a property of how this process *builds* an event.
420 /// `flush` re-reads `buffer.jsonl` and hands the lines to `serde`, and any
421 /// process running as the user can append to that file — including a `Bash`
422 /// tool call the session made on the model's behalf, since `$CODEWHALE_HOME`
423 /// is a predictable path. Before `Event::is_bounded` existed, an appended
424 /// `{"event":"panic","site":"…/Users/victim/secret-repo"}` was POSTed verbatim
425 /// to the configured endpoint under the user's install id; the process-level
426 /// proof of that is `a_hostile_buffer_line_never_reaches_a_batch` in
427 /// `crates/tui/tests/telemetry_contract.rs`.
428 #[test]
429 fn hostile_buffer_lines_are_dropped_before_they_reach_a_batch() {
430 let hostile = [
431 // A path, which is the class `panic_site` is the sole exemption for.
432 r#"{"event":"panic","site":"/Users/victim/src/secret-repo/main.rs"}"#,
433 // A whole prompt in the one field that is allowed to look like text.
434 r#"{"event":"panic","site":"rewrite the auth module for acme-corp"}"#,
435 // A frame outside the `crates/` allowlist, spelled to look inside it.
436 r#"{"event":"panic","site":"../vendor/crates/foo/src/lib.rs:1:1"}"#,
437 // `previous_version` is read back from `state.json`, never validated
438 // at the point it is written.
439 r#"{"event":"install_or_upgrade","kind":"upgrade","previous_version":"/Users/victim/.ssh/id_ed25519"}"#,
440 // A customer's `[providers.<name>]` table key — the exact string
441 // `record_provider` takes a `ProviderKind` by value to avoid.
442 r#"{"event":"session_end","duration_bucket":"lt_1m","exit_class":"clean","cold_start_bucket":null,"providers":["acme_internal_gateway"],"counters":{"turns":0,"tool_calls":0,"fleet_dispatch":0,"workflow_run":0,"subagent_spawn":0,"mcp_server_connected":0,"memory_search":0,"approval_modal_shown":0,"approval_auto_allowed":0,"command_palette_open":0},"errors":{"auth_preflight_failed":0,"provider_http_4xx":0,"provider_http_5xx":0,"tool_denied_by_policy":0,"tool_timeout":0,"network_error":0},"turn_wall":{"lt_5s":0,"5_30s":0,"30_120s":0,"gte_120s":0}}"#,
443 ];
444 for line in hostile {
445 let event = serde_json::from_str::<Event>(line)
446 .unwrap_or_else(|error| panic!("the fixture must be parseable: {error}\n{line}"));
447 assert!(
448 !event.is_bounded(),
449 "an out-of-bounds event passed the drain check: {line}"
450 );
451 let parsed = crate::actor::parse_events(&[line.to_string()]);
452 assert!(
453 parsed.is_empty(),
454 "a hostile buffer line survived the drain: {line}"
455 );
456 }
457 }
458
459 /// The drain check must not delete real telemetry. Everything this process
460 /// legitimately records has to survive a round trip through the buffer.
461 #[test]
462 fn every_legitimately_recorded_event_survives_the_drain() {
463 let lines: Vec<String> = every_event()
464 .iter()
465 .map(|event| serde_json::to_string(event).expect("serialize"))
466 .collect();
467 assert_eq!(
468 crate::actor::parse_events(&lines).len(),
469 lines.len(),
470 "the drain check dropped an event this process builds itself"
471 );
472
473 // Dialect kinds (`deepseek-anthropic`, the Model Studio plan variants) are
474 // absent from `ProviderKind::ALL`, which is the 37-row *catalog* subset,
475 // but `ApiProvider::kind()` yields them for real routes. Narrowing the
476 // provider bound to the catalog would drop those users' `session_end`.
477 for kind in [
478 codewhale_config::ProviderKind::DeepseekAnthropic,
479 codewhale_config::ProviderKind::MinimaxAnthropic,
480 codewhale_config::ProviderKind::Custom,
481 ] {
482 assert!(
483 crate::event::is_known_provider_id(kind.as_str()),
484 "a real routed provider is not a legal `providers` entry: {}",
485 kind.as_str()
486 );
487 }
488 }
489
490 /// `install_id` is the one envelope field read verbatim off disk into a batch.
491 #[test]
492 fn a_non_uuid_install_id_on_disk_is_replaced_rather_than_sent() {
493 let home = temp_home();
494 let root = root_of(&home);
495 buffer::ensure_dir(&root).expect("create telemetry root");
496 std::fs::write(
497 buffer::install_id_path(&root),
498 serde_json::json!({
499 "schema_version": 1,
500 "install_id": "/Users/victim/src/secret-repo",
501 "rotated_at": envelope::now_rfc3339(),
502 })
503 .to_string(),
504 )
505 .expect("plant a hostile install id");
506
507 let record = envelope::read_or_create_install_id(&root).expect("read install id");
508 assert!(
509 uuid::Uuid::parse_str(&record.install_id).is_ok(),
510 "a non-UUID install id was carried onto the wire: {:?}",
511 record.install_id
512 );
513 }
514
515 // ------------------------------------------------------------- panic sites --
516
517 #[test]
518 fn panic_site_reduces_dependency_frames() {
519 assert_eq!(
520 envelope::reduce_panic_site("crates/tui/src/x.rs", 9, 1),
521 "crates/tui/src/x.rs:9:1"
522 );
523 assert_eq!(
524 envelope::reduce_panic_site(
525 "/Users/builder/.cargo/registry/src/index.crates.io-1949cf8c/ratatui-0.29.0/src/y.rs",
526 1,
527 1
528 ),
529 "<dep>"
530 );
531 assert_eq!(
532 envelope::reduce_panic_site("/rustc/deadbeef/library/core/src/panicking.rs", 1, 1),
533 "<dep>"
534 );
535 // A path that merely mentions `crates/` somewhere is not a `crates/` frame.
536 assert_eq!(
537 envelope::reduce_panic_site("../vendor/crates/foo/src/lib.rs", 1, 1),
538 "<dep>"
539 );
540 }
541
542 #[test]
543 fn git_sha_is_null_without_release_env() {
544 // The build script emits `CODEWHALE_RELEASE_BUILD_SHA` only when
545 // `CODEWHALE_BUILD_SHA` (or a build-only compatibility alias) was in the
546 // build environment, so on a developer machine this is `None` and on
547 // release CI it is twelve hex characters. Both are asserted, because the
548 // test has to pass in both places and neither shape may ever be a path, a
549 // version, or a full sha.
550 // The rule that produces it lives in `codewhale-build-support` and is
551 // tested there against an injected environment; what is asserted here is
552 // that whatever reaches the payload is `null` or twelve lowercase hex
553 // characters, and never a path, a version, or a full sha.
554 if let Some(sha) = envelope::release_build_sha() {
555 assert!(
556 is_short_sha(&sha),
557 "release sha has the wrong shape: {sha:?}"
558 );
559 }
560 assert_eq!(
561 envelope::short_hex_sha("ABCDEF0123456789abcdef0123456789abcdef01"),
562 Some("abcdef012345".to_string())
563 );
564 assert_eq!(envelope::short_hex_sha("not-a-sha"), None);
565 assert_eq!(envelope::short_hex_sha("abc123"), None);
566 }
567
568 // --------------------------------------------------------------- decisions --
569
570 #[test]
571 fn decision_matrix_is_exhaustive() {
572 let home = temp_home();
573 let path = home.path();
574
575 // Row: nobody has said anything. No acceptance is inferred.
576 assert!(matches!(
577 decide_in_home(
578 Some(path),
579 &resolved(true, false, None),
580 &SetupState::default(),
581 Surface::Tui
582 ),
583 TelemetryDecision::Enabled(_)
584 ));
585
586 // Row: a human said off. That is an answer.
587 assert!(matches!(
588 decide_in_home(
589 Some(path),
590 &resolved(false, true, None),
591 &accepted_setup(),
592 Surface::Tui
593 ),
594 TelemetryDecision::OptedOut
595 ));
596
597 // Row: config on with no notice record also uses the default-on policy.
598 assert!(matches!(
599 decide_in_home(
600 Some(path),
601 &resolved(true, false, None),
602 &SetupState::default(),
603 Surface::Tui
604 ),
605 TelemetryDecision::Enabled(_)
606 ));
607
608 // Row: on, asked, declined.
609 assert!(matches!(
610 decide_in_home(
611 Some(path),
612 &resolved(true, false, None),
613 &declined_setup(),
614 Surface::Tui
615 ),
616 TelemetryDecision::OptedOut
617 ));
618
619 // Row: old explicit yes remains on under the current default-on policy.
620 assert!(matches!(
621 decide_in_home(
622 Some(path),
623 &resolved(true, false, None),
624 &stale_setup(),
625 Surface::Tui
626 ),
627 TelemetryDecision::Enabled(_)
628 ));
629
630 // Row: on and accepted, no home to keep state in.
631 assert!(matches!(
632 decide_in_home(
633 None,
634 &resolved(true, false, None),
635 &accepted_setup(),
636 Surface::Tui
637 ),
638 TelemetryDecision::ForcedOff
639 ));
640
641 // Row: on and accepted, plaintext endpoint to a public host.
642 assert!(matches!(
643 decide_in_home(
644 Some(path),
645 &resolved(true, false, Some("http://example.com/t")),
646 &accepted_setup(),
647 Surface::Tui
648 ),
649 TelemetryDecision::ForcedOff
650 ));
651
652 // Row: on and accepted, no endpoint — the dry-run sink, which resolution
653 // reaches from an explicitly empty `telemetry_endpoint`. (The *shipped*
654 // default is `DEFAULT_TELEMETRY_ENDPOINT`; this predicate never sees it,
655 // because it reads an already-resolved value.)
656 let decision = decide_in_home(
657 Some(path),
658 &resolved(true, false, None),
659 &accepted_setup(),
660 Surface::Exec,
661 );
662 let TelemetryDecision::Enabled(consent) = decision else {
663 panic!("an accepted, endpoint-less machine must be Enabled");
664 };
665 assert_eq!(consent.endpoint(), None);
666 assert_eq!(consent.surface(), Surface::Exec);
667 assert_eq!(consent.root(), root_of(&home));
668
669 // Row: on and accepted, https endpoint.
670 assert!(
671 decide_in_home(
672 Some(path),
673 &resolved(true, false, Some("https://example.com/t")),
674 &accepted_setup(),
675 Surface::Tui
676 )
677 .is_enabled()
678 );
679
680 // Row: every headless surface uses the same documented default and kill
681 // switches.
682 for surface in Surface::ALL {
683 assert!(
684 decide_in_home(
685 Some(path),
686 &resolved(true, false, None),
687 &SetupState::default(),
688 *surface
689 )
690 .is_enabled(),
691 "{surface:?} uses default-on without inferring acceptance"
692 );
693 }
694 }
695
696 #[test]
697 fn an_unparseable_env_value_forces_off_and_does_not_wipe() {
698 // The floor in `codewhale-config` turns an unreadable `CODEWHALE_TELEMETRY`
699 // into `telemetry == false` *without* setting `telemetry_explicit_off`. A
700 // typo is not a user answer and must never destroy state.
701 let home = temp_home();
702 let root = root_of(&home);
703 buffer::ensure_dir(&root).expect("create root");
704 let buffer_path = buffer::buffer_path(&root);
705 buffer::append(
706 &root,
707 &buffer_path,
708 "{\"event\":\"session_start\",\"source\":\"api\"}",
709 )
710 .expect("seed");
711
712 let decision = decide_in_home(
713 Some(home.path()),
714 &resolved(false, false, None),
715 &accepted_setup(),
716 Surface::Tui,
717 );
718 assert!(matches!(decision, TelemetryDecision::ForcedOff));
719 assert!(!buffer::tombstone_present(&root));
720 assert_eq!(buffer::read_lines(&buffer_path).len(), 1);
721 }
722
723 #[test]
724 fn only_opt_out_touches_disk() {
725 // Every ForcedOff row against a seeded, consenting home must leave it
726 // byte-identical. This is the finding that a "wipe on resolved false" would
727 // have broken: `false` is the *default*, so it fired on every ordinary run.
728 let forced_off_rows: Vec<(ResolvedRuntimeOptions, SetupState)> = vec![
729 (resolved(false, false, None), accepted_setup()),
730 (
731 resolved(true, false, Some("http://example.com/t")),
732 accepted_setup(),
733 ),
734 ];
735
736 for (options, setup) in forced_off_rows {
737 let home = temp_home();
738 let root = root_of(&home);
739 buffer::ensure_dir(&root).expect("create root");
740 let before = seed_consenting_home(&root);
741
742 let decision = decide_in_home(Some(home.path()), &options, &setup, Surface::Tui);
743 assert!(
744 matches!(decision, TelemetryDecision::ForcedOff),
745 "expected ForcedOff, got {}",
746 decision.label()
747 );
748 assert_eq!(snapshot(&root), before, "a ForcedOff run touched disk");
749 }
750
751 // Every OptedOut row wipes: tombstone present, data truncated, lock file
752 // still present, identity gone.
753 let home = temp_home();
754 let root = root_of(&home);
755 buffer::ensure_dir(&root).expect("create root");
756 seed_consenting_home(&root);
757
758 let decision = decide_in_home(
759 Some(home.path()),
760 &resolved(false, true, None),
761 &accepted_setup(),
762 Surface::Tui,
763 );
764 assert!(matches!(decision, TelemetryDecision::OptedOut));
765 assert!(buffer::tombstone_present(&root));
766 assert!(buffer::buffer_path(&root).exists());
767 assert!(buffer::read_lines(&buffer::buffer_path(&root)).is_empty());
768 assert!(buffer::read_lines(&buffer::dryrun_path(&root)).is_empty());
769 assert!(
770 buffer::lock_path(&root).exists(),
771 "the lock file must survive a wipe: unlinking it leaves appenders on a dead inode"
772 );
773 assert!(!buffer::install_id_path(&root).exists());
774 assert!(!buffer::state_path(&root).exists());
775 }
776
777 #[test]
778 fn default_on_does_not_hide_a_durable_sidecar_decline() {
779 let home = temp_home();
780 let root = root_of(&home);
781 buffer::ensure_dir(&root).expect("create root");
782 let before = seed_consenting_home(&root);
783 let mut options = resolved(false, false, None);
784
785 // A run-scoped kill switch still preserves the preexisting ordering.
786 options.telemetry_source = codewhale_config::TelemetrySource::Env;
787 assert!(matches!(
788 decide_in_home(Some(home.path()), &options, &declined_setup(), Surface::Tui),
789 TelemetryDecision::ForcedOff
790 ));
791 assert_eq!(snapshot(&root), before);
792
793 // Once that temporary switch is gone, the durable decline must wipe even
794 // though the new shipped configuration preference is on.
795 options.telemetry = true;
796 options.telemetry_source = codewhale_config::TelemetrySource::Default;
797 assert!(matches!(
798 decide_in_home(Some(home.path()), &options, &declined_setup(), Surface::Tui),
799 TelemetryDecision::OptedOut
800 ));
801 assert!(buffer::tombstone_present(&root));
802 assert!(!buffer::install_id_path(&root).exists());
803 assert!(buffer::read_lines(&buffer::buffer_path(&root)).is_empty());
804 }
805
806 #[test]
807 fn the_tombstone_outlives_every_run_the_opt_out_covers() {
808 // `docs/TELEMETRY.md` says the opt-out's tombstone survives, and an
809 // adversary showed it did not: one ordinary run afterwards called
810 // `buffer::arm`, which removes the tombstone, and minted a fresh install
811 // id. Both halves of that are now impossible, and for the same reason —
812 // the opt-out is a *persisted* statement, so every later run re-reads it,
813 // takes the OptedOut branch again, and never reaches arming at all.
814 let home = temp_home();
815 let root = root_of(&home);
816 buffer::ensure_dir(&root).expect("create root");
817 seed_consenting_home(&root);
818
819 // The user writes `telemetry = false`.
820 let opted_out = resolved(false, true, None);
821 assert!(matches!(
822 decide_in_home(
823 Some(home.path()),
824 &opted_out,
825 &accepted_setup(),
826 Surface::Tui
827 ),
828 TelemetryDecision::OptedOut
829 ));
830 assert!(buffer::tombstone_present(&root));
831 let after_wipe = snapshot(&root);
832
833 // Three more launches of any surface, with the setting still in place.
834 for surface in [Surface::Tui, Surface::Exec, Surface::AppServer] {
835 let decision = decide_in_home(Some(home.path()), &opted_out, &accepted_setup(), surface);
836 assert!(
837 matches!(decision, TelemetryDecision::OptedOut),
838 "{surface:?} re-read the opt-out as {}",
839 decision.label()
840 );
841 assert!(
842 buffer::tombstone_present(&root),
843 "{surface:?} cleared the tombstone"
844 );
845 assert!(
846 !buffer::install_id_path(&root).exists(),
847 "{surface:?} minted a new identity for an opted-out machine"
848 );
849 assert!(!buffer::state_path(&root).exists());
850 assert!(buffer::read_lines(&buffer::buffer_path(&root)).is_empty());
851 assert!(buffer::read_lines(&buffer::dryrun_path(&root)).is_empty());
852 assert_eq!(snapshot(&root), after_wipe, "{surface:?} touched disk");
853 }
854
855 // Only writing the setting back turns collection on again, and that is the
856 // one path allowed to clear the tombstone.
857 let TelemetryDecision::Enabled(consent) = decide_in_home(
858 Some(home.path()),
859 &resolved(true, false, None),
860 &accepted_setup(),
861 Surface::Tui,
862 ) else {
863 panic!("an explicit re-enable must produce consent");
864 };
865 buffer::arm(&root, consent.tombstone_generation(), || true).expect("re-consent arms");
866 assert!(!buffer::tombstone_present(&root));
867 }
868
869 #[test]
870 fn a_run_scoped_kill_switch_costs_a_consenting_user_nothing() {
871 // The documented one-command recipe — `CODEWHALE_TELEMETRY=0 codewhale` —
872 // used to take the destructive opt-out branch, so it deleted the install
873 // id and truncated the user's own dry-run records every time it was used.
874 // The resolver now reports that as "off, but nobody revoked anything", and
875 // this is the half of that contract the telemetry crate owns.
876 let home = temp_home();
877 let root = root_of(&home);
878 buffer::ensure_dir(&root).expect("create root");
879 let before = seed_consenting_home(&root);
880 let identity = std::fs::read(buffer::install_id_path(&root)).expect("seeded install id");
881
882 for _ in 0..3 {
883 let decision = decide_in_home(
884 Some(home.path()),
885 // `telemetry == false`, `telemetry_explicit_off == false`: the
886 // shape a run-scoped kill switch resolves to.
887 &resolved(false, false, None),
888 &accepted_setup(),
889 Surface::Exec,
890 );
891 assert!(matches!(decision, TelemetryDecision::ForcedOff));
892 }
893
894 assert_eq!(snapshot(&root), before, "a kill-switch run touched disk");
895 assert!(!buffer::tombstone_present(&root));
896 assert_eq!(
897 std::fs::read(buffer::install_id_path(&root)).expect("install id"),
898 identity,
899 "the install id churned across a kill-switch run"
900 );
901 }
902
903 #[test]
904 fn an_opt_out_on_a_fresh_home_creates_nothing() {
905 let home = temp_home();
906 let root = root_of(&home);
907 let decision = decide_in_home(
908 Some(home.path()),
909 &resolved(false, true, None),
910 &SetupState::default(),
911 Surface::Tui,
912 );
913 assert!(matches!(decision, TelemetryDecision::OptedOut));
914 assert!(
915 !root.exists(),
916 "a fresh user who opts out must not get a telemetry directory"
917 );
918 }
919
920 fn seed_consenting_home(root: &Path) -> Vec<(String, Vec<u8>)> {
921 buffer::append(
922 root,
923 &buffer::buffer_path(root),
924 "{\"event\":\"session_start\",\"source\":\"interactive\"}",
925 )
926 .expect("seed buffer");
927 buffer::append_locked(root, &buffer::dryrun_path(root), "{\"schema_version\":1}")
928 .expect("seed dryrun");
929 envelope::read_or_create_install_id(root).expect("seed install id");
930 envelope::write_state(root, &envelope::TelemetryState::default()).expect("seed state");
931 snapshot(root)
932 }
933
934 fn snapshot(root: &Path) -> Vec<(String, Vec<u8>)> {
935 let Ok(entries) = std::fs::read_dir(root) else {
936 return Vec::new();
937 };
938 let mut out: Vec<(String, Vec<u8>)> = entries
939 .filter_map(Result::ok)
940 .map(|entry| {
941 let name = entry.file_name().to_string_lossy().to_string();
942 let body = std::fs::read(entry.path()).unwrap_or_default();
943 (name, body)
944 })
945 .collect();
946 out.sort();
947 out
948 }
949
950 #[test]
951 fn failed_wipe_fails_closed() {
952 let home = temp_home();
953 let root = root_of(&home);
954 buffer::ensure_dir(&root).expect("create root");
955 seed_consenting_home(&root);
956
957 // Make the buffer un-truncatable. The tombstone is written first, so even
958 // when the rest of the wipe fails the buffer is permanently undrainable.
959 let buffer_path = buffer::buffer_path(&root);
960 let readonly_worked = make_read_only(&buffer_path);
961
962 let result = buffer::wipe(&root);
963 assert!(
964 buffer::tombstone_present(&root),
965 "the tombstone must survive"
966 );
967 if readonly_worked {
968 assert!(result.is_err(), "a failed truncate must be reported");
969 }
970 assert!(
971 buffer::drain(&root).is_empty(),
972 "a tombstoned buffer must never drain, wipe failure or not"
973 );
974 assert!(
975 buffer::append(
976 &root,
977 &buffer_path,
978 "{\"event\":\"session_start\",\"source\":\"api\"}"
979 )
980 .is_none(),
981 "a tombstoned buffer must never accept an append"
982 );
983 }
984
985 #[cfg(unix)]
986 fn make_read_only(path: &Path) -> bool {
987 use std::os::unix::fs::PermissionsExt as _;
988 // Root ignores the mode bits, so this fixture cannot be relied on there.
989 if geteuid_is_root() {
990 return false;
991 }
992 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o400)).is_ok()
993 }
994
995 #[cfg(unix)]
996 fn geteuid_is_root() -> bool {
997 unsafe extern "C" {
998 fn geteuid() -> u32;
999 }
1000 unsafe { geteuid() == 0 }
1001 }
1002
1003 #[cfg(not(unix))]
1004 fn make_read_only(_path: &Path) -> bool {
1005 false
1006 }
1007
1008 // --------------------------------------------------------------- endpoints --
1009
1010 #[test]
1011 fn plain_http_is_rejected_except_on_loopback() {
1012 assert!(validate_endpoint("https://example.com/t").is_ok());
1013 assert_eq!(
1014 validate_endpoint("http://example.com/t"),
1015 Err(EndpointError::InsecureScheme)
1016 );
1017 assert!(validate_endpoint("http://127.0.0.1:9/x").is_ok());
1018 assert!(validate_endpoint("http://localhost:9/x").is_ok());
1019 assert!(validate_endpoint("http://[::1]:9/x").is_ok());
1020 assert_eq!(
1021 validate_endpoint("ftp://example.com/t"),
1022 Err(EndpointError::UnsupportedScheme)
1023 );
1024 assert_eq!(
1025 validate_endpoint("example.com"),
1026 Err(EndpointError::Unparseable)
1027 );
1028 }
1029
1030 #[test]
1031 fn no_environment_variable_can_authorize_plaintext() {
1032 // `CODEWHALE_ALLOW_INSECURE_HTTP` is a *provider* trust decision — it
1033 // permits an insecure model base URL for harnesses that intercept model
1034 // traffic. Honouring it here would let that decision also authorize
1035 // telemetry POSTs to an arbitrary host. No override of any kind exists.
1036 unsafe { std::env::set_var("CODEWHALE_ALLOW_INSECURE_HTTP", "1") };
1037 let with_env = validate_endpoint("http://example.com/t");
1038 unsafe { std::env::remove_var("CODEWHALE_ALLOW_INSECURE_HTTP") };
1039 assert_eq!(with_env, Err(EndpointError::InsecureScheme));
1040 }
1041
1042 // -------------------------------------------------------- install identity --
1043
1044 #[test]
1045 fn install_id_is_random_and_rotates() {
1046 let first_home = temp_home();
1047 let second_home = temp_home();
1048 let first_root = root_of(&first_home);
1049 let second_root = root_of(&second_home);
1050
1051 let first = envelope::read_or_create_install_id(&first_root).expect("mint");
1052 let second = envelope::read_or_create_install_id(&second_root).expect("mint");
1053 assert_ne!(
1054 first.install_id, second.install_id,
1055 "two fresh homes must not share an id"
1056 );
1057 assert!(uuid::Uuid::parse_str(&first.install_id).is_ok());
1058
1059 // Stable across reads on the same home.
1060 let again = envelope::read_or_create_install_id(&first_root).expect("re-read");
1061 assert_eq!(first.install_id, again.install_id);
1062
1063 // Not a function of hostname, user, or path: nothing derivable appears in
1064 // the value, and two homes under the same user differ.
1065 for derived in [
1066 std::env::var("USER").unwrap_or_default(),
1067 std::env::var("HOME").unwrap_or_default(),
1068 first_root.display().to_string(),
1069 ] {
1070 if derived.trim().is_empty() {
1071 continue;
1072 }
1073 assert!(!first.install_id.contains(derived.trim()));
1074 }
1075
1076 // 91 days old rotates.
1077 let stale = envelope::InstallId {
1078 schema_version: 1,
1079 install_id: first.install_id.clone(),
1080 rotated_at: (chrono::Utc::now() - chrono::Duration::days(91))
1081 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1082 };
1083 codewhale_config::persistence::atomic_write_json(&buffer::install_id_path(&first_root), &stale)
1084 .expect("write stale id");
1085 let rotated = envelope::read_or_create_install_id(&first_root).expect("rotate");
1086 assert_ne!(rotated.install_id, first.install_id);
1087 assert_ne!(rotated.rotated_at, stale.rotated_at);
1088 }
1089
1090 // ------------------------------------------------------------------ buffer --
1091
1092 fn line(n: usize) -> String {
1093 serde_json::to_string(&Event::Panic {
1094 site: format!("crates/tui/src/x.rs:{n}:1"),
1095 })
1096 .expect("serialize")
1097 }
1098
1099 #[test]
1100 fn ring_buffer_drops_oldest_at_cap_for_both_sinks() {
1101 let home = temp_home();
1102 let root = root_of(&home);
1103 for path in [buffer::buffer_path(&root), buffer::dryrun_path(&root)] {
1104 for n in 0..600 {
1105 buffer::append(&root, &path, &line(n)).expect("append");
1106 }
1107 let kept = buffer::read_lines(&path);
1108 assert_eq!(kept.len(), buffer::MAX_EVENTS, "{}", path.display());
1109 assert_eq!(
1110 kept.first().expect("first"),
1111 &line(600 - buffer::MAX_EVENTS)
1112 );
1113 assert_eq!(kept.last().expect("last"), &line(599));
1114 }
1115 }
1116
1117 #[test]
1118 fn probe_threshold_cannot_hide_an_over_cap_buffer() {
1119 // The append path skips the count probe below a byte threshold. That is
1120 // only safe if `MAX_EVENTS` lines cannot fit under it.
1121 let shortest = serde_json::to_string(&Event::SessionStart {
1122 source: SessionSource::Api,
1123 })
1124 .expect("serialize");
1125 let floor = (shortest.len() as u64 + 1) * buffer::MAX_EVENTS as u64;
1126 assert!(
1127 floor > 4096,
1128 "the shortest event is now small enough that {} of them fit under the probe threshold",
1129 buffer::MAX_EVENTS
1130 );
1131 }
1132
1133 #[test]
1134 fn a_line_over_pipe_buf_is_dropped_not_split() {
1135 let home = temp_home();
1136 let root = root_of(&home);
1137 let path = buffer::buffer_path(&root);
1138 let huge = format!("{{\"pad\":\"{}\"}}", "x".repeat(buffer::MAX_LINE_BYTES));
1139 assert!(buffer::append(&root, &path, &huge).is_none());
1140 assert!(buffer::read_lines(&path).is_empty());
1141 }
1142
1143 #[test]
1144 fn drain_skips_a_torn_trailing_line() {
1145 let home = temp_home();
1146 let root = root_of(&home);
1147 let path = buffer::buffer_path(&root);
1148 buffer::append(&root, &path, &line(1)).expect("append");
1149 buffer::append(&root, &path, &line(2)).expect("append");
1150 // `std::process::exit` on the signal path can cut a concurrent write.
1151 {
1152 use std::io::Write as _;
1153 let mut file = std::fs::OpenOptions::new()
1154 .append(true)
1155 .open(&path)
1156 .expect("open");
1157 file.write_all(b"{\"event\":\"pan").expect("tear");
1158 }
1159 let drained = buffer::drain(&root);
1160 assert_eq!(drained.len(), 3, "the drain returns raw lines");
1161 let parsed: Vec<Event> = drained
1162 .iter()
1163 .filter_map(|l| serde_json::from_str(l).ok())
1164 .collect();
1165 assert_eq!(parsed.len(), 2, "the torn line must not reach a batch");
1166 assert!(buffer::read_lines(&path).is_empty(), "drain truncates");
1167 }
1168
1169 #[test]
1170 fn append_drops_without_blocking_on_a_held_privacy_lock() {
1171 let home = temp_home();
1172 let root = root_of(&home);
1173 buffer::ensure_dir(&root).expect("create root");
1174 let path = buffer::buffer_path(&root);
1175
1176 let held = std::sync::Arc::new(std::sync::Barrier::new(2));
1177 let release = std::sync::Arc::new(std::sync::Barrier::new(2));
1178 let holder_root = root.clone();
1179 let holder_held = held.clone();
1180 let holder_release = release.clone();
1181 let holder = std::thread::spawn(move || {
1182 buffer::with_lock(&holder_root, || {
1183 holder_held.wait();
1184 holder_release.wait();
1185 Ok(())
1186 })
1187 });
1188
1189 held.wait();
1190 let started = Instant::now();
1191 let outcome = buffer::append(&root, &path, &line(7));
1192 let elapsed = started.elapsed();
1193 release.wait();
1194 holder.join().expect("holder thread").expect("holder lock");
1195
1196 assert!(outcome.is_none(), "a contended append must be dropped");
1197 assert!(
1198 elapsed < Duration::from_millis(250),
1199 "an append waited {elapsed:?} on the privacy lock"
1200 );
1201 assert!(
1202 buffer::read_lines(&path).is_empty(),
1203 "the panic-safe path bypassed the privacy lock"
1204 );
1205 }
1206
1207 #[test]
1208 fn a_tombstoned_buffer_never_appends_or_drains() {
1209 let home = temp_home();
1210 let root = root_of(&home);
1211 buffer::ensure_dir(&root).expect("create root");
1212 buffer::append(&root, &buffer::buffer_path(&root), &line(1)).expect("append");
1213 buffer::wipe(&root).expect("wipe");
1214
1215 assert!(buffer::append(&root, &buffer::buffer_path(&root), &line(2)).is_none());
1216 assert!(buffer::append_locked(&root, &buffer::dryrun_path(&root), "{}").is_none());
1217 assert!(buffer::drain(&root).is_empty());
1218 }
1219
1220 #[test]
1221 fn arming_truncates_a_pre_consent_buffer() {
1222 let home = temp_home();
1223 let root = root_of(&home);
1224 buffer::ensure_dir(&root).expect("create root");
1225 buffer::append(&root, &buffer::buffer_path(&root), &line(1)).expect("append");
1226 buffer::wipe(&root).expect("wipe");
1227
1228 let generation = buffer::tombstone_generation(&root).expect("read wipe generation");
1229 buffer::arm(&root, generation.as_ref(), || true).expect("arm");
1230 assert!(!buffer::tombstone_present(&root));
1231 assert!(buffer::read_lines(&buffer::buffer_path(&root)).is_empty());
1232 }
1233
1234 #[test]
1235 fn stale_consent_cannot_clear_a_newer_opt_out_but_fresh_reenable_can() {
1236 let home = temp_home();
1237 let root = root_of(&home);
1238 let config_path = home.path().join("config.toml");
1239 let setup_path = home.path().join("setup_state.json");
1240 accepted_setup()
1241 .save_to(&setup_path)
1242 .expect("write accepted setup state");
1243
1244 // This process resolved the old enabled config before another process
1245 // persisted an opt-out and completed its wipe.
1246 let stale_resolved = resolved(true, false, None);
1247 let TelemetryDecision::Enabled(pre_wipe_consent) = decide_in_home(
1248 Some(home.path()),
1249 &stale_resolved,
1250 &accepted_setup(),
1251 Surface::Tui,
1252 ) else {
1253 panic!("pre-wipe enabled facts must produce consent");
1254 };
1255 std::fs::write(&config_path, "telemetry = false\n").expect("persist opt-out");
1256 buffer::ensure_dir(&root).expect("create telemetry root");
1257 buffer::wipe(&root).expect("complete newer wipe");
1258 assert!(
1259 buffer::arm(&root, pre_wipe_consent.tombstone_generation(), || true).is_err(),
1260 "an old consent token cleared a newer tombstone generation"
1261 );
1262 assert!(buffer::tombstone_present(&root));
1263
1264 // Even the difficult ordering — stale config facts combined with the new
1265 // tombstone generation — cannot arm, because arm re-reads the durable
1266 // predicate while holding the wipe lock.
1267 let TelemetryDecision::Enabled(stale_consent) = decide_in_home(
1268 Some(home.path()),
1269 &stale_resolved,
1270 &accepted_setup(),
1271 Surface::Tui,
1272 ) else {
1273 panic!("fixture must carry stale enabled facts");
1274 };
1275 assert!(
1276 buffer::arm(&root, stale_consent.tombstone_generation(), || {
1277 permission_still_enabled_in_home(
1278 Some(&config_path),
1279 &setup_path,
1280 Some(home.path()),
1281 &root,
1282 )
1283 })
1284 .is_err(),
1285 "stale consent cleared a completed opt-out"
1286 );
1287 assert!(buffer::tombstone_present(&root));
1288
1289 // The documented explicit re-enable updates the durable register first. A
1290 // fresh consent observes both that value and the current generation, so it
1291 // may clear exactly that tombstone.
1292 std::fs::write(&config_path, "telemetry = true\n").expect("persist re-enable");
1293 let TelemetryDecision::Enabled(fresh_consent) = decide_in_home(
1294 Some(home.path()),
1295 &resolved(true, false, None),
1296 &accepted_setup(),
1297 Surface::Tui,
1298 ) else {
1299 panic!("fresh re-enable must produce consent");
1300 };
1301 buffer::arm(&root, fresh_consent.tombstone_generation(), || {
1302 permission_still_enabled_in_home(Some(&config_path), &setup_path, Some(home.path()), &root)
1303 })
1304 .expect("fresh re-enable arms");
1305 assert!(!buffer::tombstone_present(&root));
1306 }
1307
1308 #[test]
1309 fn completed_wipe_blocks_identity_and_state_recreation() {
1310 let home = temp_home();
1311 let root = root_of(&home);
1312 buffer::ensure_dir(&root).expect("create telemetry root");
1313 envelope::read_or_create_install_id(&root).expect("seed install id");
1314 envelope::write_state(&root, &envelope::TelemetryState::default()).expect("seed state");
1315 buffer::wipe(&root).expect("wipe telemetry home");
1316
1317 assert!(
1318 envelope::read_or_create_install_id(&root).is_err(),
1319 "an in-flight flush recreated the deleted install id"
1320 );
1321 assert!(
1322 envelope::write_state(&root, &envelope::TelemetryState::default()).is_err(),
1323 "an in-flight flush recreated state after opt-out"
1324 );
1325 assert!(!buffer::install_id_path(&root).exists());
1326 assert!(!buffer::state_path(&root).exists());
1327 }
1328
1329 // ------------------------------------------------------------ unarmed gate --
1330
1331 #[test]
1332 fn record_blocking_is_a_noop_when_unarmed() {
1333 // The process panic hook is installed before the command line is parsed, so
1334 // this is the state the hook runs in for every user who never opted in.
1335 let home = temp_home();
1336 let root = root_of(&home);
1337 assert!(!crate::is_armed());
1338 crate::record_blocking(Event::Panic {
1339 site: "crates/tui/src/x.rs:1:1".to_string(),
1340 });
1341 crate::record(Event::SessionStart {
1342 source: SessionSource::Interactive,
1343 });
1344 crate::set_exit_class(ExitClass::Panic);
1345 assert_eq!(crate::exit_class(), ExitClass::Clean);
1346 assert!(
1347 !root.exists(),
1348 "an unarmed process must create no directory"
1349 );
1350 }
1351
1352 // ------------------------------------------------------------------ client --
1353
1354 #[test]
1355 fn endpoint_unset_writes_the_dry_run_sink() {
1356 let home = temp_home();
1357 let root = root_of(&home);
1358 let batch = every_field_batch();
1359 assert_eq!(
1360 crate::client::send(&root, None, &batch),
1361 crate::client::SendOutcome::DryRun
1362 );
1363 let written = buffer::read_lines(&buffer::dryrun_path(&root));
1364 assert_eq!(written.len(), 1);
1365 let round_tripped: Batch = serde_json::from_str(&written[0]).expect("parse dry-run batch");
1366 assert_eq!(round_tripped, batch);
1367 assert!(
1368 !buffer::buffer_path(&root).exists(),
1369 "the dry-run sink is a separate file from the pending buffer"
1370 );
1371 }
1372
1373 #[test]
1374 fn a_tombstoned_home_sends_nothing_even_with_an_endpoint() {
1375 let home = temp_home();
1376 let root = root_of(&home);
1377 buffer::ensure_dir(&root).expect("create root");
1378 buffer::wipe(&root).expect("wipe");
1379 // The tombstone check fires before any client is constructed, so this
1380 // asserts on the sink rather than on network timing.
1381 assert_eq!(
1382 crate::client::send(&root, Some("http://127.0.0.1:1/t"), &every_field_batch()),
1383 crate::client::SendOutcome::Dropped
1384 );
1385 assert!(buffer::read_lines(&buffer::dryrun_path(&root)).is_empty());
1386 }
1387
1388 #[test]
1389 fn wipe_and_delivery_share_one_ordering_boundary() {
1390 let home = temp_home();
1391 let root = root_of(&home);
1392 let entered = std::sync::Arc::new(std::sync::Barrier::new(2));
1393 let release = std::sync::Arc::new(std::sync::Barrier::new(2));
1394
1395 let send_root = root.clone();
1396 let send_entered = entered.clone();
1397 let send_release = release.clone();
1398 let send = std::thread::spawn(move || {
1399 crate::client::send_with_transport(
1400 &send_root,
1401 Some("https://telemetry.codewhale.ai/v1/batch"),
1402 &every_field_batch(),
1403 move |_, _, _| {
1404 send_entered.wait();
1405 send_release.wait();
1406 crate::client::SendOutcome::Accepted
1407 },
1408 )
1409 });
1410 entered.wait();
1411
1412 // The real send path is paused inside its transport callback. A
1413 // non-blocking probe must observe the same lock that wipe takes; this
1414 // deterministically pins the entire delivery inside the boundary without
1415 // depending on loopback networking in a restricted test sandbox.
1416 assert!(
1417 buffer::try_with_lock(&root, || Ok(()))
1418 .expect("probe privacy lock")
1419 .is_none(),
1420 "network delivery did not hold the wipe lock"
1421 );
1422
1423 // Start the real blocking wipe while the POST is still in flight. It can
1424 // only complete after the response releases the sender's privacy guard.
1425 let wipe_root = root.clone();
1426 let wipe = std::thread::spawn(move || buffer::wipe(&wipe_root));
1427 release.wait();
1428 assert_eq!(
1429 send.join().expect("send thread"),
1430 crate::client::SendOutcome::Accepted
1431 );
1432
1433 wipe.join()
1434 .expect("wipe thread")
1435 .expect("wipe after delivery");
1436 assert!(buffer::tombstone_present(&root));
1437 assert_eq!(
1438 crate::client::send(&root, Some("http://127.0.0.1:1/t"), &every_field_batch()),
1439 crate::client::SendOutcome::Dropped,
1440 "a send crossed the completed wipe boundary"
1441 );
1442 }
1443
1444 // ----------------------------------------------------------------- buckets --
1445
1446 #[test]
1447 fn buckets_are_half_open_at_every_boundary() {
1448 assert_eq!(DurationBucket::from_secs(0), DurationBucket::Lt1m);
1449 assert_eq!(DurationBucket::from_secs(59), DurationBucket::Lt1m);
1450 assert_eq!(DurationBucket::from_secs(60), DurationBucket::OneToTen);
1451 assert_eq!(DurationBucket::from_secs(599), DurationBucket::OneToTen);
1452 assert_eq!(DurationBucket::from_secs(600), DurationBucket::TenToSixty);
1453 assert_eq!(DurationBucket::from_secs(3599), DurationBucket::TenToSixty);
1454 assert_eq!(DurationBucket::from_secs(3600), DurationBucket::Gt60m);
1455
1456 assert_eq!(ColdStartBucket::from_millis(249), ColdStartBucket::Lt250);
1457 assert_eq!(ColdStartBucket::from_millis(250), ColdStartBucket::Mid);
1458 assert_eq!(ColdStartBucket::from_millis(999), ColdStartBucket::Mid);
1459 assert_eq!(ColdStartBucket::from_millis(1000), ColdStartBucket::Slow);
1460 assert_eq!(ColdStartBucket::from_millis(2999), ColdStartBucket::Slow);
1461 assert_eq!(ColdStartBucket::from_millis(3000), ColdStartBucket::Gte3000);
1462
1463 let mut wall = TurnWall::default();
1464 for secs in [0, 4, 5, 29, 30, 119, 120, 10_000] {
1465 wall.observe_secs(secs);
1466 }
1467 assert_eq!(wall.lt_5s, 2);
1468 assert_eq!(wall.five_to_thirty, 2);
1469 assert_eq!(wall.thirty_to_onetwenty, 2);
1470 assert_eq!(wall.gte_120s, 2);
1471 }
1472
1473 #[test]
1474 fn exit_class_round_trips_through_the_atomic_encoding() {
1475 for class in ExitClass::ALL {
1476 assert_eq!(ExitClass::from_u8(class.as_u8()), *class);
1477 }
1478 // An exit code is never the source: 130 is both a cancelled turn and SIGINT.
1479 assert_eq!(ExitClass::from_u8(130), ExitClass::Clean);
1480 }
1481
1482 // ---------------------------------------------------------------- counters --
1483
1484 #[test]
1485 fn custom_provider_emits_literal_custom() {
1486 let counters = crate::SessionCounters::default();
1487 counters.record_provider(codewhale_config::ProviderKind::Custom);
1488 counters.record_provider(codewhale_config::ProviderKind::Deepseek);
1489 counters.record_provider(codewhale_config::ProviderKind::Custom);
1490 let providers = counters.providers();
1491 assert_eq!(
1492 providers,
1493 vec!["custom".to_string(), "deepseek".to_string()]
1494 );
1495 }
1496
1497 #[test]
1498 fn counter_bumps_land_in_the_named_field() {
1499 let counters = crate::SessionCounters::default();
1500 counters.bump(crate::Counter::Turns);
1501 counters.bump(crate::Counter::Turns);
1502 counters.bump(crate::Counter::CommandPaletteOpen);
1503 counters.bump_error(crate::ErrorCounter::ProviderHttp5xx);
1504 counters.observe_turn_secs(3);
1505
1506 let snapshot = counters.counters();
1507 assert_eq!(snapshot.turns, 2);
1508 assert_eq!(snapshot.command_palette_open, 1);
1509 assert_eq!(snapshot.tool_calls, 0);
1510 assert_eq!(counters.errors().provider_http_5xx, 1);
1511 assert_eq!(counters.turn_wall().lt_5s, 1);
1512 }
1513
1514 #[test]
1515 fn http_status_maps_to_the_class_counter_and_nothing_else() {
1516 assert_eq!(
1517 crate::counters::http_status_counter(404),
1518 Some(crate::ErrorCounter::ProviderHttp4xx)
1519 );
1520 assert_eq!(
1521 crate::counters::http_status_counter(503),
1522 Some(crate::ErrorCounter::ProviderHttp5xx)
1523 );
1524 assert_eq!(crate::counters::http_status_counter(200), None);
1525 assert_eq!(crate::counters::http_status_counter(302), None);
1526 }
1527
1528 // --------------------------------------------------------------- API shape --
1529
1530 #[test]
1531 fn no_public_api_accepts_a_bare_bool() {
1532 // `init` takes a `TelemetryConsent` **by value**, and `TelemetryConsent` has
1533 // no public constructor other than `decide`. This is a shape assertion: it
1534 // stops compiling if the signature is ever widened.
1535 let init: fn(crate::TelemetryConsent) = crate::init;
1536 let _ = init;
1537
1538 // The only source of one is `decide`, which still applies every persistent
1539 // and run-scoped opt-out before constructing the capability.
1540 let home = temp_home();
1541 assert!(
1542 decide_in_home(
1543 Some(home.path()),
1544 &resolved(true, false, None),
1545 &accepted_setup(),
1546 Surface::Cli
1547 )
1548 .is_enabled()
1549 );
1550 }
1551
1552 // ------------------------------------------------- docs and code are welded --
1553
1554 const TELEMETRY_DOC: &str = include_str!("../../../docs/TELEMETRY.md");
1555 const GOLDEN_V3: &str = include_str!("../tests/golden/v3.json");
1556
1557 /// Extract the fenced ```jsonc blocks from the schema doc, in order.
1558 fn jsonc_blocks(doc: &str) -> Vec<String> {
1559 let mut blocks = Vec::new();
1560 let mut current: Option<String> = None;
1561 for raw in doc.lines() {
1562 let line = raw.trim_end();
1563 match current.as_mut() {
1564 None => {
1565 if line.trim() == "```jsonc" {
1566 current = Some(String::new());
1567 }
1568 }
1569 Some(body) => {
1570 if line.trim() == "```" {
1571 blocks.push(std::mem::take(body));
1572 current = None;
1573 } else {
1574 body.push_str(line);
1575 body.push('\n');
1576 }
1577 }
1578 }
1579 }
1580 blocks
1581 }
1582
1583 /// Every `"name":` key in a jsonc block, including nested objects. Values are
1584 /// never matched: a key is an identifier-shaped string followed by a colon, and
1585 /// no value in these blocks has that shape.
1586 fn documented_keys(block: &str) -> std::collections::BTreeSet<String> {
1587 let bytes: Vec<char> = block.chars().collect();
1588 let mut keys = std::collections::BTreeSet::new();
1589 let mut index = 0;
1590 while index < bytes.len() {
1591 if bytes[index] != '"' {
1592 index += 1;
1593 continue;
1594 }
1595 let start = index + 1;
1596 let mut end = start;
1597 while end < bytes.len() && bytes[end] != '"' {
1598 end += 1;
1599 }
1600 if end >= bytes.len() {
1601 break;
1602 }
1603 let candidate: String = bytes[start..end].iter().collect();
1604 let mut after = end + 1;
1605 while after < bytes.len() && bytes[after] == ' ' {
1606 after += 1;
1607 }
1608 let is_key = after < bytes.len() && bytes[after] == ':';
1609 let identifier_shaped = !candidate.is_empty()
1610 && candidate
1611 .chars()
1612 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
1613 if is_key && identifier_shaped {
1614 keys.insert(candidate);
1615 }
1616 index = end + 1;
1617 }
1618 keys
1619 }
1620
1621 /// Every key of a serialized value, including nested objects.
1622 fn serialized_keys(value: &Value) -> std::collections::BTreeSet<String> {
1623 let mut keys = std::collections::BTreeSet::new();
1624 fn walk(value: &Value, out: &mut std::collections::BTreeSet<String>) {
1625 match value {
1626 Value::Object(map) => {
1627 for (key, item) in map {
1628 out.insert(key.clone());
1629 walk(item, out);
1630 }
1631 }
1632 Value::Array(items) => {
1633 for item in items {
1634 walk(item, out);
1635 }
1636 }
1637 _ => {}
1638 }
1639 }
1640 walk(value, &mut keys);
1641 keys
1642 }
1643
1644 /// First-column entries of the markdown table that follows `heading`.
1645 fn table_first_column(doc: &str, heading: &str) -> Vec<String> {
1646 let mut lines = doc.lines().skip_while(|line| line.trim() != heading);
1647 let mut rows = Vec::new();
1648 let mut in_table = false;
1649 for line in lines.by_ref() {
1650 let trimmed = line.trim();
1651 if !trimmed.starts_with('|') {
1652 if in_table {
1653 break;
1654 }
1655 continue;
1656 }
1657 in_table = true;
1658 let first = trimmed
1659 .trim_matches('|')
1660 .split('|')
1661 .next()
1662 .unwrap_or("")
1663 .trim();
1664 if first.is_empty() || first.chars().all(|c| c == '-' || c == ':') {
1665 continue;
1666 }
1667 let name = first.trim_matches('`').to_string();
1668 if name.eq_ignore_ascii_case("field") || name.eq_ignore_ascii_case("file") {
1669 continue;
1670 }
1671 rows.push(name);
1672 }
1673 rows
1674 }
1675
1676 #[test]
1677 fn event_field_names_match_documented_schema() {
1678 let blocks = jsonc_blocks(TELEMETRY_DOC);
1679 assert_eq!(
1680 blocks.len(),
1681 7,
1682 "expected one jsonc block for the envelope and one per event variant; \
1683 a parse miss must fail rather than silently pass"
1684 );
1685
1686 // The envelope.
1687 let documented = documented_keys(&blocks[0]);
1688 let declared: std::collections::BTreeSet<String> =
1689 Batch::FIELDS.iter().map(|f| (*f).to_string()).collect();
1690 assert_eq!(documented.len(), Batch::FIELDS.len());
1691 assert_eq!(
1692 documented, declared,
1693 "the batch envelope drifted from the doc"
1694 );
1695
1696 // One block per event variant, in the order the doc presents them.
1697 let events = every_event();
1698 assert_eq!(events.len(), blocks.len() - 1);
1699 for (index, event) in events.iter().enumerate() {
1700 let block = &blocks[index + 1];
1701 let documented = documented_keys(block);
1702 let serialized = serialized_keys(&serde_json::to_value(event).expect("serialize"));
1703 assert!(
1704 !documented.is_empty(),
1705 "no keys parsed out of the {} block",
1706 event.name()
1707 );
1708 assert_eq!(
1709 documented,
1710 serialized,
1711 "the `{}` event drifted from the doc",
1712 event.name()
1713 );
1714 }
1715
1716 // The envelope table, row for row.
1717 let envelope_rows =
1718 table_first_column(TELEMETRY_DOC, "### Batch envelope — sent on every POST");
1719 assert_eq!(
1720 envelope_rows.len(),
1721 Batch::FIELDS.len(),
1722 "the envelope table lost or gained a row: {envelope_rows:?}"
1723 );
1724 assert_eq!(
1725 envelope_rows,
1726 Batch::FIELDS
1727 .iter()
1728 .map(|f| (*f).to_string())
1729 .collect::<Vec<_>>()
1730 );
1731
1732 // The counters and errors tables, which are the two closed field sets a
1733 // contributor is most likely to extend without touching the doc.
1734 let counter_rows = table_first_column(
1735 TELEMETRY_DOC,
1736 "**`counters`** — closed field set. Every bump happens at the **call site**, never inside a conditionally-entered handler:",
1737 );
1738 assert_eq!(
1739 counter_rows,
1740 Counters::FIELDS
1741 .iter()
1742 .map(|f| (*f).to_string())
1743 .collect::<Vec<_>>(),
1744 "the counters table drifted from `Counters`"
1745 );
1746 let error_rows = table_first_column(
1747 TELEMETRY_DOC,
1748 "**`errors`** — closed field set. Every value is a **variant discriminant**, never `err.to_string()`:",
1749 );
1750 assert_eq!(
1751 error_rows,
1752 Errors::FIELDS
1753 .iter()
1754 .map(|f| (*f).to_string())
1755 .collect::<Vec<_>>(),
1756 "the errors table drifted from `Errors`"
1757 );
1758 }
1759
1760 #[test]
1761 fn golden_payload_v3() {
1762 assert_eq!(NOTICE_VERSION.to_string(), TELEMETRY_NOTICE_VERSION);
1763 // `crates/telemetry/tests/golden/v3.json` is one fully-populated instance of
1764 // the envelope and every event. Any field add, remove, or retype fails here
1765 // until the developer re-blesses it under a bumped `SCHEMA_VERSION` — and it
1766 // is also the artifact a future receiver author reads to know exactly what
1767 // v1 was.
1768 //
1769 // Re-bless with: `CODEWHALE_BLESS_TELEMETRY_GOLDEN=1 cargo test -p codewhale-telemetry`
1770 let batch = every_field_batch();
1771 assert_eq!(
1772 batch.schema_version, SCHEMA_VERSION,
1773 "the fixture must be built at the current schema version"
1774 );
1775 let mut rendered = serde_json::to_string_pretty(&batch).expect("serialize");
1776 rendered.push('\n');
1777
1778 if std::env::var("CODEWHALE_BLESS_TELEMETRY_GOLDEN").is_ok() {
1779 let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/golden/v3.json");
1780 std::fs::create_dir_all(path.parent().expect("parent")).expect("create golden dir");
1781 std::fs::write(&path, &rendered).expect("write golden");
1782 return;
1783 }
1784
1785 assert_eq!(
1786 rendered, GOLDEN_V3,
1787 "the v3 payload changed; bump SCHEMA_VERSION and re-bless the golden file"
1788 );
1789 }
1790
1791 #[test]
1792 fn version_comparison_names_install_upgrade_and_downgrade() {
1793 assert!(crate::version_is_older("0.9.3", "0.9.4"));
1794 assert!(crate::version_is_older("0.9", "0.9.4"));
1795 assert!(crate::version_is_older("0.10.0", "1.0.0"));
1796 assert!(!crate::version_is_older("0.9.4", "0.9.4"));
1797 assert!(!crate::version_is_older("0.9.5", "0.9.4"));
1798 // A pre-release suffix is not part of the ordering question being asked.
1799 assert!(!crate::version_is_older("0.9.4-rc.1", "0.9.4"));
1800 // Unparseable segments read as zero, so an unknown version never invents an
1801 // upgrade that did not happen.
1802 assert!(!crate::version_is_older("nightly", "0.0.0"));
1803 }
1804
1805 #[test]
1806 fn an_install_or_upgrade_is_reported_once_per_version() {
1807 let home = temp_home();
1808 let root = root_of(&home);
1809 buffer::ensure_dir(&root).expect("create telemetry dir");
1810
1811 // No prior record on this machine.
1812 let mut state = envelope::read_state(&root);
1813 assert_eq!(state.last_version, None);
1814
1815 // The state file is written before the event is queued, so the second
1816 // launch at the same version has nothing left to report.
1817 state.last_version = Some(env!("CARGO_PKG_VERSION").to_string());
1818 envelope::write_state(&root, &state).expect("write state");
1819 assert_eq!(
1820 envelope::read_state(&root).last_version.as_deref(),
1821 Some(env!("CARGO_PKG_VERSION"))
1822 );
1823
1824 // The previous version is read from this file and from nowhere else —
1825 // never from session history or config mtimes, which answer the same
1826 // question under a different privacy contract.
1827 let entries: Vec<String> = std::fs::read_dir(&root)
1828 .expect("read dir")
1829 .filter_map(|entry| Some(entry.ok()?.file_name().to_string_lossy().into_owned()))
1830 .collect();
1831 assert!(
1832 entries.iter().any(|name| name == "state.json"),
1833 "expected state.json in {entries:?}"
1834 );
1835 }
1836
1837 #[test]
1838 fn the_notice_summarizes_what_the_schema_collects_and_states_every_red_line() {
1839 use crate::notice;
1840
1841 let body = notice::NOTICE_BODY;
1842
1843 // The modal names the useful product categories and links the exact
1844 // field-by-field schema. `install_id` is "a random ID stored on this
1845 // machine"; transport metadata remains in the linked document. The body
1846 // wraps at 72 columns, so multi-word claims are matched across the
1847 // reflowed whitespace.
1848 let flat: String = body.split_whitespace().collect();
1849 for claim in [
1850 "version",
1851 "OS and CPU family",
1852 "session duration and outcome",
1853 "aggregate feature and error counters",
1854 "random ID stored on this machine",
1855 "every 90 days",
1856 "on by default",
1857 "PostHog",
1858 ] {
1859 assert!(
1860 flat.contains(&claim.split_whitespace().collect::<String>()),
1861 "the notice does not describe: {claim}"
1862 );
1863 }
1864
1865 // And every red line has to be stated as *not collected*, not as
1866 // anonymized or sampled — two promises this client does not make.
1867 for red_line in [
1868 "conversations",
1869 "code",
1870 "prompts",
1871 "files",
1872 "repo or branch names",
1873 "model content",
1874 "credentials",
1875 "per-turn or per-tool timeline",
1876 ] {
1877 assert!(
1878 flat.contains(&red_line.split_whitespace().collect::<String>()),
1879 "the notice does not disclaim: {red_line}"
1880 );
1881 }
1882 assert!(!body.to_ascii_lowercase().contains("anonymized"));
1883
1884 // The modal names the persistent opt-out because that is the switch that
1885 // also fulfils its deletion promise. Run-only kill switches stay in the
1886 // linked schema document, which explains that they erase nothing.
1887 assert!(body.contains("codewhale config set telemetry false"));
1888 assert!(!body.contains("CODEWHALE_TELEMETRY=0"));
1889 assert!(body.contains("docs/TELEMETRY.md"));
1890 }
1891
1891 lines RUST