返回 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 the selectable `ProviderKind::ALL` catalog subset,
475 // but captured intrinsic kinds include 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 /// Audit R05-09: only a canonical v4 id with a past `rotated_at` survives a
516 /// read. A v1 (MAC + clock), nil, braced, uppercase, or padded id is replaced,
517 /// and so is a future timestamp that would otherwise never rotate.
518 #[test]
519 fn a_non_canonical_or_future_dated_install_id_is_rotated() {
520 let now = envelope::now_rfc3339();
521 let future = (chrono::Utc::now() + chrono::Duration::days(3650))
522 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
523 let canonical = "3f2a9c1e-0000-4000-8000-000000000001";
524 for (install_id, rotated_at) in [
525 ("6ba7b810-9dad-11d1-80b4-00c04fd430c8", now.as_str()),
526 ("00000000-0000-0000-0000-000000000000", now.as_str()),
527 ("{3f2a9c1e-0000-4000-8000-000000000001}", now.as_str()),
528 ("3F2A9C1E-0000-4000-8000-000000000001", now.as_str()),
529 (" 3f2a9c1e-0000-4000-8000-000000000001 ", now.as_str()),
530 (canonical, future.as_str()),
531 ] {
532 let home = temp_home();
533 let root = root_of(&home);
534 buffer::ensure_dir(&root).expect("create telemetry root");
535 std::fs::write(
536 buffer::install_id_path(&root),
537 serde_json::json!({
538 "schema_version": 1,
539 "install_id": install_id,
540 "rotated_at": rotated_at,
541 })
542 .to_string(),
543 )
544 .expect("plant install id");
545
546 let record = envelope::read_or_create_install_id(&root).expect("read install id");
547 assert_ne!(record.install_id, install_id, "kept {install_id:?}");
548 let minted = uuid::Uuid::parse_str(&record.install_id).expect("minted uuid");
549 assert_eq!(minted.get_version(), Some(uuid::Version::Random));
550 }
551
552 // The canonical, past-dated form is kept.
553 let home = temp_home();
554 let root = root_of(&home);
555 buffer::ensure_dir(&root).expect("create telemetry root");
556 let kept = envelope::InstallId {
557 schema_version: 1,
558 install_id: canonical.to_string(),
559 rotated_at: now.clone(),
560 };
561 std::fs::write(
562 buffer::install_id_path(&root),
563 serde_json::to_string(&kept).unwrap(),
564 )
565 .expect("plant canonical id");
566 let record = envelope::read_or_create_install_id(&root).expect("read install id");
567 assert_eq!(record.install_id, canonical);
568 }
569
570 // ------------------------------------------------------------- panic sites --
571
572 #[test]
573 fn panic_site_reduces_dependency_frames() {
574 assert_eq!(
575 envelope::reduce_panic_site("crates/tui/src/x.rs", 9, 1),
576 "crates/tui/src/x.rs:9:1"
577 );
578 assert_eq!(
579 envelope::reduce_panic_site(
580 "/Users/builder/.cargo/registry/src/index.crates.io-1949cf8c/ratatui-0.29.0/src/y.rs",
581 1,
582 1
583 ),
584 "<dep>"
585 );
586 assert_eq!(
587 envelope::reduce_panic_site("/rustc/deadbeef/library/core/src/panicking.rs", 1, 1),
588 "<dep>"
589 );
590 // A path that merely mentions `crates/` somewhere is not a `crates/` frame.
591 assert_eq!(
592 envelope::reduce_panic_site("../vendor/crates/foo/src/lib.rs", 1, 1),
593 "<dep>"
594 );
595 }
596
597 #[test]
598 fn git_sha_is_null_without_release_env() {
599 // The build script emits `CODEWHALE_RELEASE_BUILD_SHA` only when
600 // `CODEWHALE_BUILD_SHA` (or a build-only compatibility alias) was in the
601 // build environment, so on a developer machine this is `None` and on
602 // release CI it is twelve hex characters. Both are asserted, because the
603 // test has to pass in both places and neither shape may ever be a path, a
604 // version, or a full sha.
605 // The rule that produces it lives in `codewhale-build-support` and is
606 // tested there against an injected environment; what is asserted here is
607 // that whatever reaches the payload is `null` or twelve lowercase hex
608 // characters, and never a path, a version, or a full sha.
609 if let Some(sha) = envelope::release_build_sha() {
610 assert!(
611 is_short_sha(&sha),
612 "release sha has the wrong shape: {sha:?}"
613 );
614 }
615 assert_eq!(
616 envelope::short_hex_sha("ABCDEF0123456789abcdef0123456789abcdef01"),
617 Some("abcdef012345".to_string())
618 );
619 assert_eq!(envelope::short_hex_sha("not-a-sha"), None);
620 assert_eq!(envelope::short_hex_sha("abc123"), None);
621 }
622
623 // --------------------------------------------------------------- decisions --
624
625 #[test]
626 fn decision_matrix_is_exhaustive() {
627 let home = temp_home();
628 let path = home.path();
629
630 // Row: nobody has said anything. No acceptance is inferred.
631 assert!(matches!(
632 decide_in_home(
633 Some(path),
634 &resolved(true, false, None),
635 &SetupState::default(),
636 Surface::Tui
637 ),
638 TelemetryDecision::Enabled(_)
639 ));
640
641 // Row: a human said off. That is an answer.
642 assert!(matches!(
643 decide_in_home(
644 Some(path),
645 &resolved(false, true, None),
646 &accepted_setup(),
647 Surface::Tui
648 ),
649 TelemetryDecision::OptedOut
650 ));
651
652 // Row: config on with no notice record also uses the default-on policy.
653 assert!(matches!(
654 decide_in_home(
655 Some(path),
656 &resolved(true, false, None),
657 &SetupState::default(),
658 Surface::Tui
659 ),
660 TelemetryDecision::Enabled(_)
661 ));
662
663 // Row: on, asked, declined.
664 assert!(matches!(
665 decide_in_home(
666 Some(path),
667 &resolved(true, false, None),
668 &declined_setup(),
669 Surface::Tui
670 ),
671 TelemetryDecision::OptedOut
672 ));
673
674 // Row: old explicit yes remains on under the current default-on policy.
675 assert!(matches!(
676 decide_in_home(
677 Some(path),
678 &resolved(true, false, None),
679 &stale_setup(),
680 Surface::Tui
681 ),
682 TelemetryDecision::Enabled(_)
683 ));
684
685 // Row: on and accepted, no home to keep state in.
686 assert!(matches!(
687 decide_in_home(
688 None,
689 &resolved(true, false, None),
690 &accepted_setup(),
691 Surface::Tui
692 ),
693 TelemetryDecision::ForcedOff
694 ));
695
696 // Row: on and accepted, plaintext endpoint to a public host.
697 assert!(matches!(
698 decide_in_home(
699 Some(path),
700 &resolved(true, false, Some("http://example.com/t")),
701 &accepted_setup(),
702 Surface::Tui
703 ),
704 TelemetryDecision::ForcedOff
705 ));
706
707 // Row: on and accepted, no endpoint — the dry-run sink, which resolution
708 // reaches from an explicitly empty `telemetry_endpoint`. (The *shipped*
709 // default is `DEFAULT_TELEMETRY_ENDPOINT`; this predicate never sees it,
710 // because it reads an already-resolved value.)
711 let decision = decide_in_home(
712 Some(path),
713 &resolved(true, false, None),
714 &accepted_setup(),
715 Surface::Exec,
716 );
717 let TelemetryDecision::Enabled(consent) = decision else {
718 panic!("an accepted, endpoint-less machine must be Enabled");
719 };
720 assert_eq!(consent.endpoint(), None);
721 assert_eq!(consent.surface(), Surface::Exec);
722 assert_eq!(consent.root(), root_of(&home));
723
724 // Row: on and accepted, https endpoint.
725 assert!(
726 decide_in_home(
727 Some(path),
728 &resolved(true, false, Some("https://example.com/t")),
729 &accepted_setup(),
730 Surface::Tui
731 )
732 .is_enabled()
733 );
734
735 // Row: every headless surface uses the same documented default and kill
736 // switches.
737 for surface in Surface::ALL {
738 assert!(
739 decide_in_home(
740 Some(path),
741 &resolved(true, false, None),
742 &SetupState::default(),
743 *surface
744 )
745 .is_enabled(),
746 "{surface:?} uses default-on without inferring acceptance"
747 );
748 }
749 }
750
751 #[test]
752 fn an_unparseable_env_value_forces_off_and_does_not_wipe() {
753 // The floor in `codewhale-config` turns an unreadable `CODEWHALE_TELEMETRY`
754 // into `telemetry == false` *without* setting `telemetry_explicit_off`. A
755 // typo is not a user answer and must never destroy state.
756 let home = temp_home();
757 let root = root_of(&home);
758 buffer::ensure_dir(&root).expect("create root");
759 let buffer_path = buffer::buffer_path(&root);
760 buffer::append(
761 &root,
762 &buffer_path,
763 "{\"event\":\"session_start\",\"source\":\"api\"}",
764 )
765 .expect("seed");
766
767 let decision = decide_in_home(
768 Some(home.path()),
769 &resolved(false, false, None),
770 &accepted_setup(),
771 Surface::Tui,
772 );
773 assert!(matches!(decision, TelemetryDecision::ForcedOff));
774 assert!(!buffer::tombstone_present(&root));
775 assert_eq!(buffer::read_lines(&buffer_path).len(), 1);
776 }
777
778 #[test]
779 fn only_opt_out_touches_disk() {
780 // Every ForcedOff row against a seeded, consenting home must leave it
781 // byte-identical. This is the finding that a "wipe on resolved false" would
782 // have broken: `false` is the *default*, so it fired on every ordinary run.
783 let forced_off_rows: Vec<(ResolvedRuntimeOptions, SetupState)> = vec![
784 (resolved(false, false, None), accepted_setup()),
785 (
786 resolved(true, false, Some("http://example.com/t")),
787 accepted_setup(),
788 ),
789 ];
790
791 for (options, setup) in forced_off_rows {
792 let home = temp_home();
793 let root = root_of(&home);
794 buffer::ensure_dir(&root).expect("create root");
795 let before = seed_consenting_home(&root);
796
797 let decision = decide_in_home(Some(home.path()), &options, &setup, Surface::Tui);
798 assert!(
799 matches!(decision, TelemetryDecision::ForcedOff),
800 "expected ForcedOff, got {}",
801 decision.label()
802 );
803 assert_eq!(snapshot(&root), before, "a ForcedOff run touched disk");
804 }
805
806 // Every OptedOut row wipes: tombstone present, data truncated, lock file
807 // still present, identity gone.
808 let home = temp_home();
809 let root = root_of(&home);
810 buffer::ensure_dir(&root).expect("create root");
811 seed_consenting_home(&root);
812
813 let decision = decide_in_home(
814 Some(home.path()),
815 &resolved(false, true, None),
816 &accepted_setup(),
817 Surface::Tui,
818 );
819 assert!(matches!(decision, TelemetryDecision::OptedOut));
820 assert!(buffer::tombstone_present(&root));
821 assert!(buffer::buffer_path(&root).exists());
822 assert!(buffer::read_lines(&buffer::buffer_path(&root)).is_empty());
823 assert!(buffer::read_lines(&buffer::dryrun_path(&root)).is_empty());
824 assert!(
825 buffer::lock_path(&root).exists(),
826 "the lock file must survive a wipe: unlinking it leaves appenders on a dead inode"
827 );
828 assert!(!buffer::install_id_path(&root).exists());
829 assert!(!buffer::state_path(&root).exists());
830 }
831
832 #[test]
833 fn default_on_does_not_hide_a_durable_sidecar_decline() {
834 let home = temp_home();
835 let root = root_of(&home);
836 buffer::ensure_dir(&root).expect("create root");
837 let before = seed_consenting_home(&root);
838 let mut options = resolved(false, false, None);
839
840 // A run-scoped kill switch still preserves the preexisting ordering.
841 options.telemetry_source = codewhale_config::TelemetrySource::Env;
842 assert!(matches!(
843 decide_in_home(Some(home.path()), &options, &declined_setup(), Surface::Tui),
844 TelemetryDecision::ForcedOff
845 ));
846 assert_eq!(snapshot(&root), before);
847
848 // Once that temporary switch is gone, the durable decline must wipe even
849 // though the new shipped configuration preference is on.
850 options.telemetry = true;
851 options.telemetry_source = codewhale_config::TelemetrySource::Default;
852 assert!(matches!(
853 decide_in_home(Some(home.path()), &options, &declined_setup(), Surface::Tui),
854 TelemetryDecision::OptedOut
855 ));
856 assert!(buffer::tombstone_present(&root));
857 assert!(!buffer::install_id_path(&root).exists());
858 assert!(buffer::read_lines(&buffer::buffer_path(&root)).is_empty());
859 }
860
861 #[test]
862 fn the_tombstone_outlives_every_run_the_opt_out_covers() {
863 // `docs/TELEMETRY.md` says the opt-out's tombstone survives, and an
864 // adversary showed it did not: one ordinary run afterwards called
865 // `buffer::arm`, which removes the tombstone, and minted a fresh install
866 // id. Both halves of that are now impossible, and for the same reason —
867 // the opt-out is a *persisted* statement, so every later run re-reads it,
868 // takes the OptedOut branch again, and never reaches arming at all.
869 let home = temp_home();
870 let root = root_of(&home);
871 buffer::ensure_dir(&root).expect("create root");
872 seed_consenting_home(&root);
873
874 // The user writes `telemetry = false`.
875 let opted_out = resolved(false, true, None);
876 assert!(matches!(
877 decide_in_home(
878 Some(home.path()),
879 &opted_out,
880 &accepted_setup(),
881 Surface::Tui
882 ),
883 TelemetryDecision::OptedOut
884 ));
885 assert!(buffer::tombstone_present(&root));
886 let after_wipe = snapshot(&root);
887
888 // Three more launches of any surface, with the setting still in place.
889 for surface in [Surface::Tui, Surface::Exec, Surface::AppServer] {
890 let decision = decide_in_home(Some(home.path()), &opted_out, &accepted_setup(), surface);
891 assert!(
892 matches!(decision, TelemetryDecision::OptedOut),
893 "{surface:?} re-read the opt-out as {}",
894 decision.label()
895 );
896 assert!(
897 buffer::tombstone_present(&root),
898 "{surface:?} cleared the tombstone"
899 );
900 assert!(
901 !buffer::install_id_path(&root).exists(),
902 "{surface:?} minted a new identity for an opted-out machine"
903 );
904 assert!(!buffer::state_path(&root).exists());
905 assert!(buffer::read_lines(&buffer::buffer_path(&root)).is_empty());
906 assert!(buffer::read_lines(&buffer::dryrun_path(&root)).is_empty());
907 assert_eq!(snapshot(&root), after_wipe, "{surface:?} touched disk");
908 }
909
910 // Only writing the setting back turns collection on again, and that is the
911 // one path allowed to clear the tombstone.
912 let TelemetryDecision::Enabled(consent) = decide_in_home(
913 Some(home.path()),
914 &resolved(true, false, None),
915 &accepted_setup(),
916 Surface::Tui,
917 ) else {
918 panic!("an explicit re-enable must produce consent");
919 };
920 buffer::arm(&root, consent.tombstone_generation(), || true).expect("re-consent arms");
921 assert!(!buffer::tombstone_present(&root));
922 }
923
924 #[test]
925 fn a_run_scoped_kill_switch_costs_a_consenting_user_nothing() {
926 // The documented one-command recipe — `CODEWHALE_TELEMETRY=0 codewhale` —
927 // used to take the destructive opt-out branch, so it deleted the install
928 // id and truncated the user's own dry-run records every time it was used.
929 // The resolver now reports that as "off, but nobody revoked anything", and
930 // this is the half of that contract the telemetry crate owns.
931 let home = temp_home();
932 let root = root_of(&home);
933 buffer::ensure_dir(&root).expect("create root");
934 let before = seed_consenting_home(&root);
935 let identity = std::fs::read(buffer::install_id_path(&root)).expect("seeded install id");
936
937 for _ in 0..3 {
938 let decision = decide_in_home(
939 Some(home.path()),
940 // `telemetry == false`, `telemetry_explicit_off == false`: the
941 // shape a run-scoped kill switch resolves to.
942 &resolved(false, false, None),
943 &accepted_setup(),
944 Surface::Exec,
945 );
946 assert!(matches!(decision, TelemetryDecision::ForcedOff));
947 }
948
949 assert_eq!(snapshot(&root), before, "a kill-switch run touched disk");
950 assert!(!buffer::tombstone_present(&root));
951 assert_eq!(
952 std::fs::read(buffer::install_id_path(&root)).expect("install id"),
953 identity,
954 "the install id churned across a kill-switch run"
955 );
956 }
957
958 #[test]
959 fn an_opt_out_on_a_fresh_home_creates_nothing() {
960 let home = temp_home();
961 let root = root_of(&home);
962 let decision = decide_in_home(
963 Some(home.path()),
964 &resolved(false, true, None),
965 &SetupState::default(),
966 Surface::Tui,
967 );
968 assert!(matches!(decision, TelemetryDecision::OptedOut));
969 assert!(
970 !root.exists(),
971 "a fresh user who opts out must not get a telemetry directory"
972 );
973 }
974
975 fn seed_consenting_home(root: &Path) -> Vec<(String, Vec<u8>)> {
976 buffer::append(
977 root,
978 &buffer::buffer_path(root),
979 "{\"event\":\"session_start\",\"source\":\"interactive\"}",
980 )
981 .expect("seed buffer");
982 buffer::append_locked(root, &buffer::dryrun_path(root), "{\"schema_version\":1}")
983 .expect("seed dryrun");
984 envelope::read_or_create_install_id(root).expect("seed install id");
985 envelope::write_state(root, &envelope::TelemetryState::default()).expect("seed state");
986 snapshot(root)
987 }
988
989 fn snapshot(root: &Path) -> Vec<(String, Vec<u8>)> {
990 let Ok(entries) = std::fs::read_dir(root) else {
991 return Vec::new();
992 };
993 let mut out: Vec<(String, Vec<u8>)> = entries
994 .filter_map(Result::ok)
995 .map(|entry| {
996 let name = entry.file_name().to_string_lossy().to_string();
997 let body = std::fs::read(entry.path()).unwrap_or_default();
998 (name, body)
999 })
1000 .collect();
1001 out.sort();
1002 out
1003 }
1004
1005 #[test]
1006 fn failed_wipe_fails_closed() {
1007 let home = temp_home();
1008 let root = root_of(&home);
1009 buffer::ensure_dir(&root).expect("create root");
1010 seed_consenting_home(&root);
1011
1012 // Make the buffer un-truncatable. The tombstone is written first, so even
1013 // when the rest of the wipe fails the buffer is permanently undrainable.
1014 let buffer_path = buffer::buffer_path(&root);
1015 let readonly_worked = make_read_only(&buffer_path);
1016
1017 let result = buffer::wipe(&root);
1018 assert!(
1019 buffer::tombstone_present(&root),
1020 "the tombstone must survive"
1021 );
1022 if readonly_worked {
1023 assert!(result.is_err(), "a failed truncate must be reported");
1024 }
1025 assert!(
1026 buffer::drain(&root).is_empty(),
1027 "a tombstoned buffer must never drain, wipe failure or not"
1028 );
1029 assert!(
1030 buffer::append(
1031 &root,
1032 &buffer_path,
1033 "{\"event\":\"session_start\",\"source\":\"api\"}"
1034 )
1035 .is_none(),
1036 "a tombstoned buffer must never accept an append"
1037 );
1038 }
1039
1040 #[cfg(unix)]
1041 fn make_read_only(path: &Path) -> bool {
1042 use std::os::unix::fs::PermissionsExt as _;
1043 // Root ignores the mode bits, so this fixture cannot be relied on there.
1044 if geteuid_is_root() {
1045 return false;
1046 }
1047 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o400)).is_ok()
1048 }
1049
1050 #[cfg(unix)]
1051 fn geteuid_is_root() -> bool {
1052 unsafe extern "C" {
1053 fn geteuid() -> u32;
1054 }
1055 unsafe { geteuid() == 0 }
1056 }
1057
1058 #[cfg(not(unix))]
1059 fn make_read_only(_path: &Path) -> bool {
1060 false
1061 }
1062
1063 // --------------------------------------------------------------- endpoints --
1064
1065 #[test]
1066 fn plain_http_is_rejected_except_on_loopback() {
1067 assert!(validate_endpoint("https://example.com/t").is_ok());
1068 assert_eq!(
1069 validate_endpoint("http://example.com/t"),
1070 Err(EndpointError::InsecureScheme)
1071 );
1072 assert!(validate_endpoint("http://127.0.0.1:9/x").is_ok());
1073 assert!(validate_endpoint("http://localhost:9/x").is_ok());
1074 assert!(validate_endpoint("http://[::1]:9/x").is_ok());
1075 assert_eq!(
1076 validate_endpoint("ftp://example.com/t"),
1077 Err(EndpointError::UnsupportedScheme)
1078 );
1079 assert_eq!(
1080 validate_endpoint("example.com"),
1081 Err(EndpointError::Unparseable)
1082 );
1083 }
1084
1085 #[test]
1086 fn no_environment_variable_can_authorize_plaintext() {
1087 // `CODEWHALE_ALLOW_INSECURE_HTTP` is a *provider* trust decision — it
1088 // permits an insecure model base URL for harnesses that intercept model
1089 // traffic. Honouring it here would let that decision also authorize
1090 // telemetry POSTs to an arbitrary host. No override of any kind exists.
1091 unsafe { std::env::set_var("CODEWHALE_ALLOW_INSECURE_HTTP", "1") };
1092 let with_env = validate_endpoint("http://example.com/t");
1093 unsafe { std::env::remove_var("CODEWHALE_ALLOW_INSECURE_HTTP") };
1094 assert_eq!(with_env, Err(EndpointError::InsecureScheme));
1095 }
1096
1097 // -------------------------------------------------------- install identity --
1098
1099 #[test]
1100 fn install_id_is_random_and_rotates() {
1101 let first_home = temp_home();
1102 let second_home = temp_home();
1103 let first_root = root_of(&first_home);
1104 let second_root = root_of(&second_home);
1105
1106 let first = envelope::read_or_create_install_id(&first_root).expect("mint");
1107 let second = envelope::read_or_create_install_id(&second_root).expect("mint");
1108 assert_ne!(
1109 first.install_id, second.install_id,
1110 "two fresh homes must not share an id"
1111 );
1112 assert!(uuid::Uuid::parse_str(&first.install_id).is_ok());
1113
1114 // Stable across reads on the same home.
1115 let again = envelope::read_or_create_install_id(&first_root).expect("re-read");
1116 assert_eq!(first.install_id, again.install_id);
1117
1118 // Not a function of hostname, user, or path: nothing derivable appears in
1119 // the value, and two homes under the same user differ.
1120 for derived in [
1121 std::env::var("USER").unwrap_or_default(),
1122 std::env::var("HOME").unwrap_or_default(),
1123 first_root.display().to_string(),
1124 ] {
1125 if derived.trim().is_empty() {
1126 continue;
1127 }
1128 assert!(!first.install_id.contains(derived.trim()));
1129 }
1130
1131 // 91 days old rotates.
1132 let stale = envelope::InstallId {
1133 schema_version: 1,
1134 install_id: first.install_id.clone(),
1135 rotated_at: (chrono::Utc::now() - chrono::Duration::days(91))
1136 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1137 };
1138 codewhale_config::persistence::atomic_write_json(&buffer::install_id_path(&first_root), &stale)
1139 .expect("write stale id");
1140 let rotated = envelope::read_or_create_install_id(&first_root).expect("rotate");
1141 assert_ne!(rotated.install_id, first.install_id);
1142 assert_ne!(rotated.rotated_at, stale.rotated_at);
1143 }
1144
1145 // ------------------------------------------------------------------ buffer --
1146
1147 fn line(n: usize) -> String {
1148 serde_json::to_string(&Event::Panic {
1149 site: format!("crates/tui/src/x.rs:{n}:1"),
1150 })
1151 .expect("serialize")
1152 }
1153
1154 #[test]
1155 fn ring_buffer_drops_oldest_at_cap_for_both_sinks() {
1156 let home = temp_home();
1157 let root = root_of(&home);
1158 for path in [buffer::buffer_path(&root), buffer::dryrun_path(&root)] {
1159 for n in 0..600 {
1160 buffer::append(&root, &path, &line(n)).expect("append");
1161 }
1162 let kept = buffer::read_lines(&path);
1163 assert_eq!(kept.len(), buffer::MAX_EVENTS, "{}", path.display());
1164 assert_eq!(
1165 kept.first().expect("first"),
1166 &line(600 - buffer::MAX_EVENTS)
1167 );
1168 assert_eq!(kept.last().expect("last"), &line(599));
1169 }
1170 }
1171
1172 #[test]
1173 fn probe_threshold_cannot_hide_an_over_cap_buffer() {
1174 // The append path skips the count probe below a byte threshold. That is
1175 // only safe if `MAX_EVENTS` lines cannot fit under it.
1176 let shortest = serde_json::to_string(&Event::SessionStart {
1177 source: SessionSource::Api,
1178 })
1179 .expect("serialize");
1180 let floor = (shortest.len() as u64 + 1) * buffer::MAX_EVENTS as u64;
1181 assert!(
1182 floor > 4096,
1183 "the shortest event is now small enough that {} of them fit under the probe threshold",
1184 buffer::MAX_EVENTS
1185 );
1186 }
1187
1188 #[test]
1189 fn a_line_over_pipe_buf_is_dropped_not_split() {
1190 let home = temp_home();
1191 let root = root_of(&home);
1192 let path = buffer::buffer_path(&root);
1193 let huge = format!("{{\"pad\":\"{}\"}}", "x".repeat(buffer::MAX_LINE_BYTES));
1194 assert!(buffer::append(&root, &path, &huge).is_none());
1195 assert!(buffer::read_lines(&path).is_empty());
1196 }
1197
1198 #[test]
1199 fn drain_skips_a_torn_trailing_line() {
1200 let home = temp_home();
1201 let root = root_of(&home);
1202 let path = buffer::buffer_path(&root);
1203 buffer::append(&root, &path, &line(1)).expect("append");
1204 buffer::append(&root, &path, &line(2)).expect("append");
1205 // `std::process::exit` on the signal path can cut a concurrent write.
1206 {
1207 use std::io::Write as _;
1208 let mut file = std::fs::OpenOptions::new()
1209 .append(true)
1210 .open(&path)
1211 .expect("open");
1212 file.write_all(b"{\"event\":\"pan").expect("tear");
1213 }
1214 let drained = buffer::drain(&root);
1215 assert_eq!(drained.len(), 3, "the drain returns raw lines");
1216 let parsed: Vec<Event> = drained
1217 .iter()
1218 .filter_map(|l| serde_json::from_str(l).ok())
1219 .collect();
1220 assert_eq!(parsed.len(), 2, "the torn line must not reach a batch");
1221 assert!(buffer::read_lines(&path).is_empty(), "drain truncates");
1222 }
1223
1224 #[test]
1225 fn append_drops_without_blocking_on_a_held_privacy_lock() {
1226 let home = temp_home();
1227 let root = root_of(&home);
1228 buffer::ensure_dir(&root).expect("create root");
1229 let path = buffer::buffer_path(&root);
1230
1231 let held = std::sync::Arc::new(std::sync::Barrier::new(2));
1232 let release = std::sync::Arc::new(std::sync::Barrier::new(2));
1233 let holder_root = root.clone();
1234 let holder_held = held.clone();
1235 let holder_release = release.clone();
1236 let holder = std::thread::spawn(move || {
1237 buffer::with_lock(&holder_root, || {
1238 holder_held.wait();
1239 holder_release.wait();
1240 Ok(())
1241 })
1242 });
1243
1244 held.wait();
1245 let started = Instant::now();
1246 let outcome = buffer::append(&root, &path, &line(7));
1247 let elapsed = started.elapsed();
1248 release.wait();
1249 holder.join().expect("holder thread").expect("holder lock");
1250
1251 assert!(outcome.is_none(), "a contended append must be dropped");
1252 assert!(
1253 elapsed < Duration::from_millis(250),
1254 "an append waited {elapsed:?} on the privacy lock"
1255 );
1256 assert!(
1257 buffer::read_lines(&path).is_empty(),
1258 "the panic-safe path bypassed the privacy lock"
1259 );
1260 }
1261
1262 #[test]
1263 fn a_tombstoned_buffer_never_appends_or_drains() {
1264 let home = temp_home();
1265 let root = root_of(&home);
1266 buffer::ensure_dir(&root).expect("create root");
1267 buffer::append(&root, &buffer::buffer_path(&root), &line(1)).expect("append");
1268 buffer::wipe(&root).expect("wipe");
1269
1270 assert!(buffer::append(&root, &buffer::buffer_path(&root), &line(2)).is_none());
1271 assert!(buffer::append_locked(&root, &buffer::dryrun_path(&root), "{}").is_none());
1272 assert!(buffer::drain(&root).is_empty());
1273 }
1274
1275 #[test]
1276 fn arming_truncates_a_pre_consent_buffer() {
1277 let home = temp_home();
1278 let root = root_of(&home);
1279 buffer::ensure_dir(&root).expect("create root");
1280 buffer::append(&root, &buffer::buffer_path(&root), &line(1)).expect("append");
1281 buffer::wipe(&root).expect("wipe");
1282
1283 let generation = buffer::tombstone_generation(&root).expect("read wipe generation");
1284 buffer::arm(&root, generation.as_ref(), || true).expect("arm");
1285 assert!(!buffer::tombstone_present(&root));
1286 assert!(buffer::read_lines(&buffer::buffer_path(&root)).is_empty());
1287 }
1288
1289 #[test]
1290 fn stale_consent_cannot_clear_a_newer_opt_out_but_fresh_reenable_can() {
1291 let home = temp_home();
1292 let root = root_of(&home);
1293 let config_path = home.path().join("config.toml");
1294 let setup_path = home.path().join("setup_state.json");
1295 accepted_setup()
1296 .save_to(&setup_path)
1297 .expect("write accepted setup state");
1298
1299 // This process resolved the old enabled config before another process
1300 // persisted an opt-out and completed its wipe.
1301 let stale_resolved = resolved(true, false, None);
1302 let TelemetryDecision::Enabled(pre_wipe_consent) = decide_in_home(
1303 Some(home.path()),
1304 &stale_resolved,
1305 &accepted_setup(),
1306 Surface::Tui,
1307 ) else {
1308 panic!("pre-wipe enabled facts must produce consent");
1309 };
1310 std::fs::write(&config_path, "telemetry = false\n").expect("persist opt-out");
1311 buffer::ensure_dir(&root).expect("create telemetry root");
1312 buffer::wipe(&root).expect("complete newer wipe");
1313 assert!(
1314 buffer::arm(&root, pre_wipe_consent.tombstone_generation(), || true).is_err(),
1315 "an old consent token cleared a newer tombstone generation"
1316 );
1317 assert!(buffer::tombstone_present(&root));
1318
1319 // Even the difficult ordering — stale config facts combined with the new
1320 // tombstone generation — cannot arm, because arm re-reads the durable
1321 // predicate while holding the wipe lock.
1322 let TelemetryDecision::Enabled(stale_consent) = decide_in_home(
1323 Some(home.path()),
1324 &stale_resolved,
1325 &accepted_setup(),
1326 Surface::Tui,
1327 ) else {
1328 panic!("fixture must carry stale enabled facts");
1329 };
1330 assert!(
1331 buffer::arm(&root, stale_consent.tombstone_generation(), || {
1332 permission_still_enabled_in_home(
1333 Some(&config_path),
1334 &setup_path,
1335 Some(home.path()),
1336 &root,
1337 )
1338 })
1339 .is_err(),
1340 "stale consent cleared a completed opt-out"
1341 );
1342 assert!(buffer::tombstone_present(&root));
1343
1344 // The documented explicit re-enable updates the durable register first. A
1345 // fresh consent observes both that value and the current generation, so it
1346 // may clear exactly that tombstone.
1347 std::fs::write(&config_path, "telemetry = true\n").expect("persist re-enable");
1348 let TelemetryDecision::Enabled(fresh_consent) = decide_in_home(
1349 Some(home.path()),
1350 &resolved(true, false, None),
1351 &accepted_setup(),
1352 Surface::Tui,
1353 ) else {
1354 panic!("fresh re-enable must produce consent");
1355 };
1356 buffer::arm(&root, fresh_consent.tombstone_generation(), || {
1357 permission_still_enabled_in_home(Some(&config_path), &setup_path, Some(home.path()), &root)
1358 })
1359 .expect("fresh re-enable arms");
1360 assert!(!buffer::tombstone_present(&root));
1361 }
1362
1363 #[test]
1364 fn completed_wipe_blocks_identity_and_state_recreation() {
1365 let home = temp_home();
1366 let root = root_of(&home);
1367 buffer::ensure_dir(&root).expect("create telemetry root");
1368 envelope::read_or_create_install_id(&root).expect("seed install id");
1369 envelope::write_state(&root, &envelope::TelemetryState::default()).expect("seed state");
1370 buffer::wipe(&root).expect("wipe telemetry home");
1371
1372 assert!(
1373 envelope::read_or_create_install_id(&root).is_err(),
1374 "an in-flight flush recreated the deleted install id"
1375 );
1376 assert!(
1377 envelope::write_state(&root, &envelope::TelemetryState::default()).is_err(),
1378 "an in-flight flush recreated state after opt-out"
1379 );
1380 assert!(!buffer::install_id_path(&root).exists());
1381 assert!(!buffer::state_path(&root).exists());
1382 }
1383
1384 // ------------------------------------------------------------ unarmed gate --
1385
1386 #[test]
1387 fn record_blocking_is_a_noop_when_unarmed() {
1388 // The process panic hook is installed before the command line is parsed, so
1389 // this is the state the hook runs in for every user who never opted in.
1390 let home = temp_home();
1391 let root = root_of(&home);
1392 assert!(!crate::is_armed());
1393 crate::record_blocking(Event::Panic {
1394 site: "crates/tui/src/x.rs:1:1".to_string(),
1395 });
1396 crate::record(Event::SessionStart {
1397 source: SessionSource::Interactive,
1398 });
1399 crate::set_exit_class(ExitClass::Panic);
1400 assert_eq!(crate::exit_class(), ExitClass::Clean);
1401 assert!(
1402 !root.exists(),
1403 "an unarmed process must create no directory"
1404 );
1405 }
1406
1407 // ------------------------------------------------------------------ client --
1408
1409 #[test]
1410 fn endpoint_unset_writes_the_dry_run_sink() {
1411 let home = temp_home();
1412 let root = root_of(&home);
1413 let batch = every_field_batch();
1414 assert_eq!(
1415 crate::client::send(&root, None, &batch),
1416 crate::client::SendOutcome::DryRun
1417 );
1418 let written = buffer::read_lines(&buffer::dryrun_path(&root));
1419 assert_eq!(written.len(), 1);
1420 let round_tripped: Batch = serde_json::from_str(&written[0]).expect("parse dry-run batch");
1421 assert_eq!(round_tripped, batch);
1422 assert!(
1423 !buffer::buffer_path(&root).exists(),
1424 "the dry-run sink is a separate file from the pending buffer"
1425 );
1426 }
1427
1428 #[test]
1429 fn a_tombstoned_home_sends_nothing_even_with_an_endpoint() {
1430 let home = temp_home();
1431 let root = root_of(&home);
1432 buffer::ensure_dir(&root).expect("create root");
1433 buffer::wipe(&root).expect("wipe");
1434 // The tombstone check fires before any client is constructed, so this
1435 // asserts on the sink rather than on network timing.
1436 assert_eq!(
1437 crate::client::send(&root, Some("http://127.0.0.1:1/t"), &every_field_batch()),
1438 crate::client::SendOutcome::Dropped
1439 );
1440 assert!(buffer::read_lines(&buffer::dryrun_path(&root)).is_empty());
1441 }
1442
1443 #[test]
1444 fn wipe_and_delivery_share_one_ordering_boundary() {
1445 let home = temp_home();
1446 let root = root_of(&home);
1447 let entered = std::sync::Arc::new(std::sync::Barrier::new(2));
1448 let release = std::sync::Arc::new(std::sync::Barrier::new(2));
1449
1450 let send_root = root.clone();
1451 let send_entered = entered.clone();
1452 let send_release = release.clone();
1453 let send = std::thread::spawn(move || {
1454 crate::client::send_with_transport(
1455 &send_root,
1456 Some("https://telemetry.codewhale.ai/v1/batch"),
1457 &every_field_batch(),
1458 move |_, _, _| {
1459 send_entered.wait();
1460 send_release.wait();
1461 crate::client::SendOutcome::Accepted
1462 },
1463 )
1464 });
1465 entered.wait();
1466
1467 // The real send path is paused inside its transport callback. A
1468 // non-blocking probe must observe the same lock that wipe takes; this
1469 // deterministically pins the entire delivery inside the boundary without
1470 // depending on loopback networking in a restricted test sandbox.
1471 assert!(
1472 buffer::try_with_lock(&root, || Ok(()))
1473 .expect("probe privacy lock")
1474 .is_none(),
1475 "network delivery did not hold the wipe lock"
1476 );
1477
1478 // Start the real blocking wipe while the POST is still in flight. It can
1479 // only complete after the response releases the sender's privacy guard.
1480 let wipe_root = root.clone();
1481 let wipe = std::thread::spawn(move || buffer::wipe(&wipe_root));
1482 release.wait();
1483 assert_eq!(
1484 send.join().expect("send thread"),
1485 crate::client::SendOutcome::Accepted
1486 );
1487
1488 wipe.join()
1489 .expect("wipe thread")
1490 .expect("wipe after delivery");
1491 assert!(buffer::tombstone_present(&root));
1492 assert_eq!(
1493 crate::client::send(&root, Some("http://127.0.0.1:1/t"), &every_field_batch()),
1494 crate::client::SendOutcome::Dropped,
1495 "a send crossed the completed wipe boundary"
1496 );
1497 }
1498
1499 // ----------------------------------------------------------------- buckets --
1500
1501 #[test]
1502 fn buckets_are_half_open_at_every_boundary() {
1503 assert_eq!(DurationBucket::from_secs(0), DurationBucket::Lt1m);
1504 assert_eq!(DurationBucket::from_secs(59), DurationBucket::Lt1m);
1505 assert_eq!(DurationBucket::from_secs(60), DurationBucket::OneToTen);
1506 assert_eq!(DurationBucket::from_secs(599), DurationBucket::OneToTen);
1507 assert_eq!(DurationBucket::from_secs(600), DurationBucket::TenToSixty);
1508 assert_eq!(DurationBucket::from_secs(3599), DurationBucket::TenToSixty);
1509 assert_eq!(DurationBucket::from_secs(3600), DurationBucket::Gt60m);
1510
1511 assert_eq!(ColdStartBucket::from_millis(249), ColdStartBucket::Lt250);
1512 assert_eq!(ColdStartBucket::from_millis(250), ColdStartBucket::Mid);
1513 assert_eq!(ColdStartBucket::from_millis(999), ColdStartBucket::Mid);
1514 assert_eq!(ColdStartBucket::from_millis(1000), ColdStartBucket::Slow);
1515 assert_eq!(ColdStartBucket::from_millis(2999), ColdStartBucket::Slow);
1516 assert_eq!(ColdStartBucket::from_millis(3000), ColdStartBucket::Gte3000);
1517
1518 let mut wall = TurnWall::default();
1519 for secs in [0, 4, 5, 29, 30, 119, 120, 10_000] {
1520 wall.observe_secs(secs);
1521 }
1522 assert_eq!(wall.lt_5s, 2);
1523 assert_eq!(wall.five_to_thirty, 2);
1524 assert_eq!(wall.thirty_to_onetwenty, 2);
1525 assert_eq!(wall.gte_120s, 2);
1526 }
1527
1528 #[test]
1529 fn exit_class_round_trips_through_the_atomic_encoding() {
1530 for class in ExitClass::ALL {
1531 assert_eq!(ExitClass::from_u8(class.as_u8()), *class);
1532 }
1533 // An exit code is never the source: 130 is both a cancelled turn and SIGINT.
1534 assert_eq!(ExitClass::from_u8(130), ExitClass::Clean);
1535 }
1536
1537 // ---------------------------------------------------------------- counters --
1538
1539 #[test]
1540 fn custom_provider_emits_literal_custom() {
1541 let counters = crate::SessionCounters::default();
1542 counters.record_provider(codewhale_config::ProviderKind::Custom);
1543 counters.record_provider(codewhale_config::ProviderKind::Deepseek);
1544 counters.record_provider(codewhale_config::ProviderKind::Custom);
1545 let providers = counters.providers();
1546 assert_eq!(
1547 providers,
1548 vec!["custom".to_string(), "deepseek".to_string()]
1549 );
1550 }
1551
1552 #[test]
1553 fn counter_bumps_land_in_the_named_field() {
1554 let counters = crate::SessionCounters::default();
1555 counters.bump(crate::Counter::Turns);
1556 counters.bump(crate::Counter::Turns);
1557 counters.bump(crate::Counter::CommandPaletteOpen);
1558 counters.bump_error(crate::ErrorCounter::ProviderHttp5xx);
1559 counters.observe_turn_secs(3);
1560
1561 let snapshot = counters.counters();
1562 assert_eq!(snapshot.turns, 2);
1563 assert_eq!(snapshot.command_palette_open, 1);
1564 assert_eq!(snapshot.tool_calls, 0);
1565 assert_eq!(counters.errors().provider_http_5xx, 1);
1566 assert_eq!(counters.turn_wall().lt_5s, 1);
1567 }
1568
1569 #[test]
1570 fn http_status_maps_to_the_class_counter_and_nothing_else() {
1571 assert_eq!(
1572 crate::counters::http_status_counter(404),
1573 Some(crate::ErrorCounter::ProviderHttp4xx)
1574 );
1575 assert_eq!(
1576 crate::counters::http_status_counter(503),
1577 Some(crate::ErrorCounter::ProviderHttp5xx)
1578 );
1579 assert_eq!(crate::counters::http_status_counter(200), None);
1580 assert_eq!(crate::counters::http_status_counter(302), None);
1581 }
1582
1583 // --------------------------------------------------------------- API shape --
1584
1585 #[test]
1586 fn no_public_api_accepts_a_bare_bool() {
1587 // `init` takes a `TelemetryConsent` **by value**, and `TelemetryConsent` has
1588 // no public constructor other than `decide`. This is a shape assertion: it
1589 // stops compiling if the signature is ever widened.
1590 let init: fn(crate::TelemetryConsent) = crate::init;
1591 let _ = init;
1592
1593 // The only source of one is `decide`, which still applies every persistent
1594 // and run-scoped opt-out before constructing the capability.
1595 let home = temp_home();
1596 assert!(
1597 decide_in_home(
1598 Some(home.path()),
1599 &resolved(true, false, None),
1600 &accepted_setup(),
1601 Surface::Cli
1602 )
1603 .is_enabled()
1604 );
1605 }
1606
1607 // ------------------------------------------------- docs and code are welded --
1608
1609 const TELEMETRY_DOC: &str = include_str!("../../../docs/TELEMETRY.md");
1610 const GOLDEN_V3: &str = include_str!("../tests/golden/v3.json");
1611
1612 /// Extract the fenced ```jsonc blocks from the schema doc, in order.
1613 fn jsonc_blocks(doc: &str) -> Vec<String> {
1614 let mut blocks = Vec::new();
1615 let mut current: Option<String> = None;
1616 for raw in doc.lines() {
1617 let line = raw.trim_end();
1618 match current.as_mut() {
1619 None => {
1620 if line.trim() == "```jsonc" {
1621 current = Some(String::new());
1622 }
1623 }
1624 Some(body) => {
1625 if line.trim() == "```" {
1626 blocks.push(std::mem::take(body));
1627 current = None;
1628 } else {
1629 body.push_str(line);
1630 body.push('\n');
1631 }
1632 }
1633 }
1634 }
1635 blocks
1636 }
1637
1638 /// Every `"name":` key in a jsonc block, including nested objects. Values are
1639 /// never matched: a key is an identifier-shaped string followed by a colon, and
1640 /// no value in these blocks has that shape.
1641 fn documented_keys(block: &str) -> std::collections::BTreeSet<String> {
1642 let bytes: Vec<char> = block.chars().collect();
1643 let mut keys = std::collections::BTreeSet::new();
1644 let mut index = 0;
1645 while index < bytes.len() {
1646 if bytes[index] != '"' {
1647 index += 1;
1648 continue;
1649 }
1650 let start = index + 1;
1651 let mut end = start;
1652 while end < bytes.len() && bytes[end] != '"' {
1653 end += 1;
1654 }
1655 if end >= bytes.len() {
1656 break;
1657 }
1658 let candidate: String = bytes[start..end].iter().collect();
1659 let mut after = end + 1;
1660 while after < bytes.len() && bytes[after] == ' ' {
1661 after += 1;
1662 }
1663 let is_key = after < bytes.len() && bytes[after] == ':';
1664 let identifier_shaped = !candidate.is_empty()
1665 && candidate
1666 .chars()
1667 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
1668 if is_key && identifier_shaped {
1669 keys.insert(candidate);
1670 }
1671 index = end + 1;
1672 }
1673 keys
1674 }
1675
1676 /// Every key of a serialized value, including nested objects.
1677 fn serialized_keys(value: &Value) -> std::collections::BTreeSet<String> {
1678 let mut keys = std::collections::BTreeSet::new();
1679 fn walk(value: &Value, out: &mut std::collections::BTreeSet<String>) {
1680 match value {
1681 Value::Object(map) => {
1682 for (key, item) in map {
1683 out.insert(key.clone());
1684 walk(item, out);
1685 }
1686 }
1687 Value::Array(items) => {
1688 for item in items {
1689 walk(item, out);
1690 }
1691 }
1692 _ => {}
1693 }
1694 }
1695 walk(value, &mut keys);
1696 keys
1697 }
1698
1699 /// First-column entries of the markdown table that follows `heading`.
1700 fn table_first_column(doc: &str, heading: &str) -> Vec<String> {
1701 let mut lines = doc.lines().skip_while(|line| line.trim() != heading);
1702 let mut rows = Vec::new();
1703 let mut in_table = false;
1704 for line in lines.by_ref() {
1705 let trimmed = line.trim();
1706 if !trimmed.starts_with('|') {
1707 if in_table {
1708 break;
1709 }
1710 continue;
1711 }
1712 in_table = true;
1713 let first = trimmed
1714 .trim_matches('|')
1715 .split('|')
1716 .next()
1717 .unwrap_or("")
1718 .trim();
1719 if first.is_empty() || first.chars().all(|c| c == '-' || c == ':') {
1720 continue;
1721 }
1722 let name = first.trim_matches('`').to_string();
1723 if name.eq_ignore_ascii_case("field") || name.eq_ignore_ascii_case("file") {
1724 continue;
1725 }
1726 rows.push(name);
1727 }
1728 rows
1729 }
1730
1731 #[test]
1732 fn event_field_names_match_documented_schema() {
1733 let blocks = jsonc_blocks(TELEMETRY_DOC);
1734 assert_eq!(
1735 blocks.len(),
1736 7,
1737 "expected one jsonc block for the envelope and one per event variant; \
1738 a parse miss must fail rather than silently pass"
1739 );
1740
1741 // The envelope.
1742 let documented = documented_keys(&blocks[0]);
1743 let declared: std::collections::BTreeSet<String> =
1744 Batch::FIELDS.iter().map(|f| (*f).to_string()).collect();
1745 assert_eq!(documented.len(), Batch::FIELDS.len());
1746 assert_eq!(
1747 documented, declared,
1748 "the batch envelope drifted from the doc"
1749 );
1750
1751 // One block per event variant, in the order the doc presents them.
1752 let events = every_event();
1753 assert_eq!(events.len(), blocks.len() - 1);
1754 for (index, event) in events.iter().enumerate() {
1755 let block = &blocks[index + 1];
1756 let documented = documented_keys(block);
1757 let serialized = serialized_keys(&serde_json::to_value(event).expect("serialize"));
1758 assert!(
1759 !documented.is_empty(),
1760 "no keys parsed out of the {} block",
1761 event.name()
1762 );
1763 assert_eq!(
1764 documented,
1765 serialized,
1766 "the `{}` event drifted from the doc",
1767 event.name()
1768 );
1769 }
1770
1771 // The envelope table, row for row.
1772 let envelope_rows =
1773 table_first_column(TELEMETRY_DOC, "### Batch envelope — sent on every POST");
1774 assert_eq!(
1775 envelope_rows.len(),
1776 Batch::FIELDS.len(),
1777 "the envelope table lost or gained a row: {envelope_rows:?}"
1778 );
1779 assert_eq!(
1780 envelope_rows,
1781 Batch::FIELDS
1782 .iter()
1783 .map(|f| (*f).to_string())
1784 .collect::<Vec<_>>()
1785 );
1786
1787 // The counters and errors tables, which are the two closed field sets a
1788 // contributor is most likely to extend without touching the doc.
1789 let counter_rows = table_first_column(
1790 TELEMETRY_DOC,
1791 "**`counters`** — closed field set. Every bump happens at the **call site**, never inside a conditionally-entered handler:",
1792 );
1793 assert_eq!(
1794 counter_rows,
1795 Counters::FIELDS
1796 .iter()
1797 .map(|f| (*f).to_string())
1798 .collect::<Vec<_>>(),
1799 "the counters table drifted from `Counters`"
1800 );
1801 let error_rows = table_first_column(
1802 TELEMETRY_DOC,
1803 "**`errors`** — closed field set. Every value is a **variant discriminant**, never `err.to_string()`:",
1804 );
1805 assert_eq!(
1806 error_rows,
1807 Errors::FIELDS
1808 .iter()
1809 .map(|f| (*f).to_string())
1810 .collect::<Vec<_>>(),
1811 "the errors table drifted from `Errors`"
1812 );
1813 }
1814
1815 #[test]
1816 fn golden_payload_v3() {
1817 assert_eq!(NOTICE_VERSION.to_string(), TELEMETRY_NOTICE_VERSION);
1818 // `crates/telemetry/tests/golden/v3.json` is one fully-populated instance of
1819 // the envelope and every event. Any field add, remove, or retype fails here
1820 // until the developer re-blesses it under a bumped `SCHEMA_VERSION` — and it
1821 // is also the artifact a future receiver author reads to know exactly what
1822 // v1 was.
1823 //
1824 // Re-bless with: `CODEWHALE_BLESS_TELEMETRY_GOLDEN=1 cargo test -p codewhale-telemetry`
1825 let batch = every_field_batch();
1826 assert_eq!(
1827 batch.schema_version, SCHEMA_VERSION,
1828 "the fixture must be built at the current schema version"
1829 );
1830 let mut rendered = serde_json::to_string_pretty(&batch).expect("serialize");
1831 rendered.push('\n');
1832
1833 if std::env::var("CODEWHALE_BLESS_TELEMETRY_GOLDEN").is_ok() {
1834 let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/golden/v3.json");
1835 std::fs::create_dir_all(path.parent().expect("parent")).expect("create golden dir");
1836 std::fs::write(&path, &rendered).expect("write golden");
1837 return;
1838 }
1839
1840 assert_eq!(
1841 rendered, GOLDEN_V3,
1842 "the v3 payload changed; bump SCHEMA_VERSION and re-bless the golden file"
1843 );
1844 }
1845
1846 #[test]
1847 fn version_comparison_names_install_upgrade_and_downgrade() {
1848 assert!(crate::version_is_older("0.9.3", "0.9.4"));
1849 assert!(crate::version_is_older("0.9", "0.9.4"));
1850 assert!(crate::version_is_older("0.10.0", "1.0.0"));
1851 assert!(!crate::version_is_older("0.9.4", "0.9.4"));
1852 assert!(!crate::version_is_older("0.9.5", "0.9.4"));
1853 // A pre-release suffix is not part of the ordering question being asked.
1854 assert!(!crate::version_is_older("0.9.4-rc.1", "0.9.4"));
1855 // Unparseable segments read as zero, so an unknown version never invents an
1856 // upgrade that did not happen.
1857 assert!(!crate::version_is_older("nightly", "0.0.0"));
1858 }
1859
1860 #[test]
1861 fn an_install_or_upgrade_is_reported_once_per_version() {
1862 let home = temp_home();
1863 let root = root_of(&home);
1864 buffer::ensure_dir(&root).expect("create telemetry dir");
1865
1866 // No prior record on this machine.
1867 let mut state = envelope::read_state(&root);
1868 assert_eq!(state.last_version, None);
1869
1870 // The state file is written before the event is queued, so the second
1871 // launch at the same version has nothing left to report.
1872 state.last_version = Some(env!("CARGO_PKG_VERSION").to_string());
1873 envelope::write_state(&root, &state).expect("write state");
1874 assert_eq!(
1875 envelope::read_state(&root).last_version.as_deref(),
1876 Some(env!("CARGO_PKG_VERSION"))
1877 );
1878
1879 // The previous version is read from this file and from nowhere else —
1880 // never from session history or config mtimes, which answer the same
1881 // question under a different privacy contract.
1882 let entries: Vec<String> = std::fs::read_dir(&root)
1883 .expect("read dir")
1884 .filter_map(|entry| Some(entry.ok()?.file_name().to_string_lossy().into_owned()))
1885 .collect();
1886 assert!(
1887 entries.iter().any(|name| name == "state.json"),
1888 "expected state.json in {entries:?}"
1889 );
1890 }
1891
1892 #[test]
1893 fn the_notice_summarizes_what_the_schema_collects_and_states_every_red_line() {
1894 use crate::notice;
1895
1896 let body = notice::NOTICE_BODY;
1897
1898 // The modal names the useful product categories and links the exact
1899 // field-by-field schema. `install_id` is "a random ID stored on this
1900 // machine"; transport metadata remains in the linked document. The body
1901 // wraps at 72 columns, so multi-word claims are matched across the
1902 // reflowed whitespace.
1903 let flat: String = body.split_whitespace().collect();
1904 for claim in [
1905 "version",
1906 "OS and CPU family",
1907 "session duration and outcome",
1908 "aggregate feature and error counters",
1909 "random ID stored on this machine",
1910 "every 90 days",
1911 "on by default",
1912 "PostHog",
1913 ] {
1914 assert!(
1915 flat.contains(&claim.split_whitespace().collect::<String>()),
1916 "the notice does not describe: {claim}"
1917 );
1918 }
1919
1920 // And every red line has to be stated as *not collected*, not as
1921 // anonymized or sampled — two promises this client does not make.
1922 for red_line in [
1923 "conversations",
1924 "code",
1925 "prompts",
1926 "files",
1927 "repo or branch names",
1928 "model content",
1929 "credentials",
1930 "per-turn or per-tool timeline",
1931 ] {
1932 assert!(
1933 flat.contains(&red_line.split_whitespace().collect::<String>()),
1934 "the notice does not disclaim: {red_line}"
1935 );
1936 }
1937 assert!(!body.to_ascii_lowercase().contains("anonymized"));
1938
1939 // The modal names the persistent opt-out because that is the switch that
1940 // also fulfils its deletion promise. Run-only kill switches stay in the
1941 // linked schema document, which explains that they erase nothing.
1942 assert!(body.contains("codewhale config set telemetry false"));
1943 assert!(!body.contains("CODEWHALE_TELEMETRY=0"));
1944 assert!(body.contains("docs/TELEMETRY.md"));
1945 }
1946
1946 lines RUST