返回 CodeWhale
telemetry_contract.rs
根目录 / crates / tui / tests / integration / telemetry_contract.rs
1 //! Process-level telemetry contract.
2 //!
3 //! Everything here drives the real `codewhale` binary inside a sealed
4 //! `HOME`/`CODEWHALE_HOME`, with a loopback recorder standing in for the
5 //! telemetry endpoint. The unit tests in `codewhale-telemetry` prove the
6 //! predicate; these prove that the *emitting process* consults it — which is
7 //! the thing v1 of this design got wrong, because `resolve_runtime_options`
8 //! had no non-test caller and so neither `telemetry = false` in the config file
9 //! nor `CODEWHALE_TELEMETRY=0` was ever read by a process that would have sent.
10 //!
11 //! Two disciplines make the zero-request assertions non-vacuous:
12 //!
13 //! 1. Short CLI tests require complete local sessions after an acknowledged
14 //! flush, or an explicit bounded-writer timeout. Disabled runs create no
15 //! telemetry state. Short commands do not wait for network delivery.
16 //! 2. Full `exec` tests prove the buffered events reach a live recorder while
17 //! respecting the same persistent and run-scoped opt-outs.
18 //!
19 //! The recorder is `http://127.0.0.1:<port>` — loopback, which is the one place
20 //! `validate_endpoint` permits plaintext, and a packet that never leaves the
21 //! machine.
22
23 #![cfg(unix)]
24
25 use std::io::Read;
26 use std::path::{Path, PathBuf};
27 use std::process::{Command, Output, Stdio};
28 use std::sync::{Mutex, MutexGuard, OnceLock};
29 use std::time::{Duration, Instant};
30
31 use codewhale_config::{SetupState, TELEMETRY_NOTICE_VERSION};
32 use futures_util::FutureExt;
33 use serde_json::{Value, json};
34 use tempfile::TempDir;
35 use wait_timeout::ChildExt;
36 use wiremock::matchers::{method, path as path_matcher};
37 use wiremock::{Mock, MockServer, ResponseTemplate};
38
39 /// Where the recorder listens for batches.
40 const TELEMETRY_PATH: &str = "/v1/telemetry";
41 /// Where the mock model listens.
42 const MODEL_PATH: &str = "/v1/chat/completions";
43 const TEST_MODEL: &str = "telemetry-contract-model";
44
45 /// Sentinels planted through real inputs. None of these may appear in a batch.
46 ///
47 /// Deliberately low-entropy: `crates/tui/src/fleet/ledger.rs` notes that
48 /// realistic-looking tokens trip secret scanning at push time.
49 const SENTINEL_PROMPT: &str = "tc-prompt-sentinel-do-not-collect";
50 const SENTINEL_FILENAME: &str = "tc-workspace-sentinel-file.txt";
51 const SENTINEL_PROVIDER_TABLE: &str = "tc_custom_provider_sentinel";
52 const SENTINEL_MCP_SERVER: &str = "tc-mcp-server-sentinel";
53 const SENTINEL_API_KEY: &str = "tc-api-key-sentinel-not-a-real-key";
54 /// Planted by writing to `buffer.jsonl` directly, which is what any other
55 /// process running as this user can do.
56 const SENTINEL_INJECTED: &str = "tc-injected-sentinel-/Users/victim/secret-repo";
57 /// The key lives only in the child's environment, never in a file, so the
58 /// "absent from every written file" assertion means something.
59 const SENTINEL_API_KEY_ENV: &str = "TC_SENTINEL_API_KEY";
60
61 const EXEC_TIMEOUT: Duration = Duration::from_secs(90);
62
63 // ── Fixture ──────────────────────────────────────────────────────────────
64
65 struct Fixture {
66 // The consolidated integration target runs tests in parallel. Each test in
67 // this module launches the full Codewhale binary, and low-resource CI
68 // runners can fail those children before they reach either loopback server.
69 // These tests exercise telemetry contracts, not launch concurrency, so one
70 // process fixture at a time keeps their non-vacuity assertions meaningful.
71 _process_test_guard: MutexGuard<'static, ()>,
72 _root: TempDir,
73 home: PathBuf,
74 codewhale_home: PathBuf,
75 workspace: PathBuf,
76 config_path: PathBuf,
77 endpoint: Option<String>,
78 }
79
80 impl Fixture {
81 fn new() -> Self {
82 let process_test_guard = telemetry_process_test_lock()
83 .lock()
84 .unwrap_or_else(|poisoned| poisoned.into_inner());
85 let root = TempDir::new().expect("fixture root");
86 let home = root.path().join("home");
87 let codewhale_home = root.path().join("codewhale-home");
88 let workspace = root.path().join("workspace");
89 for dir in [&home, &codewhale_home, &workspace] {
90 std::fs::create_dir_all(dir).expect("create fixture dir");
91 }
92 let config_path = root.path().join("config.toml");
93 std::fs::write(&config_path, "").expect("write config");
94 Self {
95 _process_test_guard: process_test_guard,
96 _root: root,
97 home,
98 codewhale_home,
99 workspace,
100 config_path,
101 endpoint: None,
102 }
103 }
104
105 /// Point this fixture at a loopback recorder.
106 fn with_endpoint(mut self, base_url: &str) -> Self {
107 self.endpoint = Some(format!("{base_url}{TELEMETRY_PATH}"));
108 self
109 }
110
111 fn write_config(&self, body: &str) {
112 std::fs::write(&self.config_path, body).expect("write config");
113 }
114
115 /// Record the answer a user would have given on a TTY.
116 ///
117 /// Explicit preferences are machine-scoped and preserve historical no
118 /// across TTY and headless launches. Presentation records no preference.
119 fn record_notice(&self, opt_in: bool) {
120 let mut state = SetupState::default();
121 state.record_telemetry_notice(TELEMETRY_NOTICE_VERSION, opt_in);
122 state
123 .save_to(&self.codewhale_home.join("setup_state.json"))
124 .expect("write setup state");
125 }
126
127 fn setup_state_path(&self) -> PathBuf {
128 self.codewhale_home.join("setup_state.json")
129 }
130
131 fn telemetry_root(&self) -> PathBuf {
132 self.codewhale_home.join("telemetry")
133 }
134
135 fn command(&self) -> Command {
136 let mut command = Command::new(crate::binary::codewhale());
137 command
138 .current_dir(&self.workspace)
139 .env_clear()
140 .env("PATH", std::env::var_os("PATH").expect("PATH"))
141 .env("HOME", &self.home)
142 .env("USERPROFILE", &self.home)
143 .env("XDG_CONFIG_HOME", self.home.join(".config"))
144 .env("XDG_DATA_HOME", self.home.join(".local").join("share"))
145 .env("XDG_CACHE_HOME", self.home.join(".cache"))
146 .env("CODEWHALE_HOME", &self.codewhale_home)
147 .env("CODEWHALE_SECRET_BACKEND", "file")
148 .env("CODEWHALE_MEMORY", "false")
149 // A pinned mirror version keeps the release crate from issuing a
150 // metadata request, so the only egress a test can observe is the
151 // one this file is about.
152 .env(
153 "CODEWHALE_RELEASE_BASE_URL",
154 "https://example.invalid/releases",
155 )
156 .env("DEEPSEEK_TUI_VERSION", env!("CARGO_PKG_VERSION"))
157 .env("RUST_LOG", "warn")
158 .stdin(Stdio::null());
159 if let Some(endpoint) = &self.endpoint {
160 command.env("CODEWHALE_TELEMETRY_ENDPOINT", endpoint);
161 }
162 command
163 }
164
165 /// The cheapest subcommand that still traverses the whole telemetry
166 /// lifecycle: arm, `session_start`, dispatch, `session_end`, bounded local
167 /// persistence. Verbose logging exposes the actual persistence outcome.
168 /// The read-only `config telemetry` query traverses the shared command
169 /// lifecycle without provider, MCP, or model execution. Structural Doctor
170 /// deliberately has no telemetry lifecycle or persistence side effects.
171 fn run_short_command(&self) -> Output {
172 let mut command = self.command();
173 command.args([
174 "--verbose",
175 "--config",
176 self.config_path.to_str().expect("config path"),
177 "config",
178 "telemetry",
179 ]);
180 let output = command.output().expect("run codewhale config telemetry");
181 assert!(
182 output.status.success(),
183 "config telemetry failed\nstdout:\n{}\nstderr:\n{}",
184 String::from_utf8_lossy(&output.stdout),
185 String::from_utf8_lossy(&output.stderr)
186 );
187 output
188 }
189
190 /// Every regular file under the sealed roots, for leak scanning.
191 fn written_files(&self) -> Vec<PathBuf> {
192 let mut out = Vec::new();
193 for base in [&self.home, &self.codewhale_home, &self.workspace] {
194 collect_files(base, &mut out);
195 }
196 out.push(self.config_path.clone());
197 out
198 }
199 }
200
201 fn telemetry_process_test_lock() -> &'static Mutex<()> {
202 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
203 LOCK.get_or_init(Mutex::default)
204 }
205
206 fn collect_files(dir: &Path, out: &mut Vec<PathBuf>) {
207 let Ok(entries) = std::fs::read_dir(dir) else {
208 return;
209 };
210 for entry in entries.flatten() {
211 let path = entry.path();
212 match entry.file_type() {
213 Ok(kind) if kind.is_dir() => collect_files(&path, out),
214 Ok(kind) if kind.is_file() => out.push(path),
215 _ => {}
216 }
217 }
218 }
219
220 // ── Recorder ─────────────────────────────────────────────────────────────
221
222 /// A loopback endpoint that accepts every batch and keeps the body.
223 ///
224 /// The body is the point. The recorder `crates/tui/tests/diagnostic_read_only.rs`
225 /// copies deliberately drops it; a telemetry contract that cannot read what was
226 /// sent can only assert "something happened".
227 async fn start_recorder() -> MockServer {
228 let server = MockServer::start().await;
229 Mock::given(method("POST"))
230 .and(path_matcher(TELEMETRY_PATH))
231 .respond_with(ResponseTemplate::new(200))
232 .mount(&server)
233 .await;
234 server
235 }
236
237 /// Every request the recorder saw, batch bodies included.
238 async fn recorded_batches(server: &MockServer) -> Vec<Value> {
239 let requests = server
240 .received_requests()
241 .await
242 .expect("the recorder must retain its request log");
243 requests
244 .iter()
245 .filter(|request| request.url.path() == TELEMETRY_PATH)
246 .map(|request| {
247 serde_json::from_slice::<Value>(&request.body).unwrap_or_else(|error| {
248 panic!(
249 "a telemetry batch must be JSON: {error}\nbody: {}",
250 String::from_utf8_lossy(&request.body)
251 )
252 })
253 })
254 .collect()
255 }
256
257 /// How many chat completions the mock model served.
258 ///
259 /// The sentinel test's whole claim is that a prompt which *did* reach a model
260 /// did not reach a batch, so a run where the turn never happened would be
261 /// vacuous.
262 async fn model_request_count(server: &MockServer) -> usize {
263 server
264 .received_requests()
265 .await
266 .expect("the recorder must retain its request log")
267 .iter()
268 .filter(|request| request.url.path() == MODEL_PATH)
269 .count()
270 }
271
272 async fn assert_no_batches(server: &MockServer, why: &str) {
273 let batches = recorded_batches(server).await;
274 assert!(
275 batches.is_empty(),
276 "{why}: expected zero telemetry requests, recorded {}:\n{}",
277 batches.len(),
278 serde_json::to_string_pretty(&batches).unwrap_or_default()
279 );
280 }
281
282 fn buffered_events(fixture: &Fixture) -> Vec<Value> {
283 let path = fixture.telemetry_root().join("buffer.jsonl");
284 let body = std::fs::read_to_string(&path).unwrap_or_else(|error| {
285 panic!(
286 "read locally buffered telemetry at {}: {error}",
287 path.display()
288 )
289 });
290 body.lines()
291 .filter(|line| !line.trim().is_empty())
292 .map(|line| {
293 serde_json::from_str::<Value>(line).unwrap_or_else(|error| {
294 panic!("buffered telemetry must be JSON: {error}\nline: {line}")
295 })
296 })
297 .collect()
298 }
299
300 async fn assert_short_cli_persistence_without_network(
301 fixture: &Fixture,
302 server: &MockServer,
303 output: &Output,
304 why: &str,
305 ) {
306 assert_no_batches(server, why).await;
307 let stderr = String::from_utf8_lossy(&output.stderr);
308 let outcomes: Vec<_> = stderr
309 .lines()
310 .filter_map(|line| {
311 line.split_once("telemetry local persistence outcome=")
312 .map(|(_, outcome)| outcome)
313 })
314 .collect();
315 assert_eq!(
316 outcomes.len(),
317 1,
318 "{why}: require exactly one persistence outcome, not an inference from exit time: {stderr}"
319 );
320 match outcomes[0] {
321 // The detached writer may still be queued when the bounded CLI exits.
322 // Only the real timeout receipt excuses an incomplete local session.
323 "TimedOut" => return,
324 "Buffered" => {}
325 outcome => panic!("{why}: unexpected local persistence outcome: {outcome}"),
326 }
327 let events = buffered_events(fixture);
328 assert!(
329 events.iter().any(|event| event["event"] == "session_start"),
330 "{why}: the local buffer must carry the session it describes: {events:?}"
331 );
332 assert!(
333 events.iter().any(|event| event["event"] == "session_end"),
334 "{why}: local persistence must carry session_end: {events:?}"
335 );
336 }
337
338 // ── Short CLI persistence is bounded and observable ──────────────────────
339
340 /// An acknowledged short CLI flush must preserve a complete session. A slow
341 /// local writer must report its deadline without waiting for the endpoint.
342 #[tokio::test(flavor = "current_thread")]
343 async fn current_explicit_consent_buffers_one_complete_session_without_network() {
344 let server = start_recorder().await;
345 let fixture = Fixture::new().with_endpoint(&server.uri());
346
347 fixture.write_config("telemetry = true\n");
348 fixture.record_notice(true);
349 let output = fixture.run_short_command();
350
351 assert_short_cli_persistence_without_network(
352 &fixture,
353 &server,
354 &output,
355 "current explicit consent",
356 )
357 .await;
358 }
359
360 #[tokio::test(flavor = "current_thread")]
361 async fn default_on_reports_local_persistence_without_network() {
362 let server = start_recorder().await;
363 let fixture = Fixture::new().with_endpoint(&server.uri());
364
365 let output = fixture.run_short_command();
366
367 assert_short_cli_persistence_without_network(
368 &fixture,
369 &server,
370 &output,
371 "the documented default",
372 )
373 .await;
374 }
375
376 #[tokio::test(flavor = "current_thread")]
377 async fn incomplete_short_cli_sessions_require_an_explicit_timeout() {
378 use std::os::unix::process::ExitStatusExt;
379
380 let server = start_recorder().await;
381 let fixture = Fixture::new().with_endpoint(&server.uri());
382 std::fs::create_dir_all(fixture.telemetry_root()).expect("create local buffer root");
383 std::fs::write(
384 fixture.telemetry_root().join("buffer.jsonl"),
385 "{\"event\":\"session_start\",\"source\":\"unknown\"}\n",
386 )
387 .expect("write incomplete session");
388
389 // A successful process exit, an acknowledged but incomplete append, or an
390 // unrecognized outcome must not be relabelled as an allowed deadline.
391 for diagnostic in [
392 "",
393 "info telemetry local persistence outcome=Buffered\n",
394 "info telemetry local persistence outcome=Dropped\n",
395 "info telemetry local persistence outcome=TimedOutLater\n",
396 "info telemetry local persistence outcome=TimedOut\ninfo telemetry local persistence outcome=Buffered\n",
397 ] {
398 let output = Output {
399 status: std::process::ExitStatus::from_raw(0),
400 stdout: Vec::new(),
401 stderr: diagnostic.as_bytes().to_vec(),
402 };
403 let rejected = std::panic::AssertUnwindSafe(assert_short_cli_persistence_without_network(
404 &fixture,
405 &server,
406 &output,
407 "unexplained missing session_end",
408 ))
409 .catch_unwind()
410 .await;
411 assert!(
412 rejected.is_err(),
413 "accepted invalid receipt: {diagnostic:?}"
414 );
415 }
416
417 let timed_out = Output {
418 status: std::process::ExitStatus::from_raw(0),
419 stdout: Vec::new(),
420 stderr: b"info telemetry local persistence outcome=TimedOut\n".to_vec(),
421 };
422 assert_short_cli_persistence_without_network(
423 &fixture,
424 &server,
425 &timed_out,
426 "explicit bounded writer timeout",
427 )
428 .await;
429 }
430
431 // ── Off is real ──────────────────────────────────────────────────────────
432
433 /// The only test that proves the emitting process reads the config *file*.
434 ///
435 /// No environment variable is set here on purpose. `CODEWHALE_TELEMETRY=0` and
436 /// the config key travel different paths, and v1 of this design shipped a
437 /// kill switch that only the env half ever reached.
438 #[tokio::test(flavor = "current_thread")]
439 async fn config_file_only_opt_out_sends_zero_requests() {
440 let server = start_recorder().await;
441 let fixture = Fixture::new().with_endpoint(&server.uri());
442 fixture.write_config("telemetry = false\n");
443 fixture.record_notice(true);
444
445 let output = fixture.run_short_command();
446 assert!(output.status.success());
447
448 assert_no_batches(&server, "`telemetry = false` in the config file").await;
449 assert!(
450 !fixture.telemetry_root().exists(),
451 "a fresh config-file opt-out must create no telemetry state"
452 );
453 }
454
455 #[tokio::test(flavor = "current_thread")]
456 async fn telemetry_disabled_by_env_sends_zero_requests() {
457 let server = start_recorder().await;
458 let fixture = Fixture::new().with_endpoint(&server.uri());
459 fixture.write_config("telemetry = true\n");
460 fixture.record_notice(true);
461
462 let mut command = fixture.command();
463 command
464 .env("CODEWHALE_TELEMETRY", "0")
465 .args([
466 "--config",
467 fixture.config_path.to_str().expect("config path"),
468 "doctor",
469 ])
470 .output()
471 .expect("run codewhale doctor");
472
473 assert_no_batches(&server, "`CODEWHALE_TELEMETRY=0`").await;
474 assert!(
475 !fixture.telemetry_root().exists(),
476 "a fresh run-scoped opt-out must create no telemetry state"
477 );
478 }
479
480 /// An unparseable env value fails **closed**, rather than falling through to
481 /// the config file's `true`.
482 #[tokio::test(flavor = "current_thread")]
483 async fn an_unparseable_telemetry_env_value_sends_zero_requests() {
484 let server = start_recorder().await;
485 let fixture = Fixture::new().with_endpoint(&server.uri());
486 fixture.write_config("telemetry = true\n");
487 fixture.record_notice(true);
488
489 fixture
490 .command()
491 .env("CODEWHALE_TELEMETRY", "maybe")
492 .args([
493 "--config",
494 fixture.config_path.to_str().expect("config path"),
495 "doctor",
496 ])
497 .output()
498 .expect("run codewhale doctor");
499
500 assert_no_batches(&server, "`CODEWHALE_TELEMETRY=maybe`").await;
501 assert!(
502 !fixture.telemetry_root().exists(),
503 "a fresh forced-off run must create no telemetry state"
504 );
505 }
506
507 /// A fresh headless run defaults on and records presentation, not acceptance.
508 #[tokio::test(flavor = "current_thread")]
509 async fn telemetry_defaults_on_without_notice_buffers_a_complete_session() {
510 let server = start_recorder().await;
511 let fixture = Fixture::new().with_endpoint(&server.uri());
512 // Deliberately no `record_notice`.
513
514 let output = fixture.run_short_command();
515
516 assert_short_cli_persistence_without_network(&fixture, &server, &output, "default-on usage")
517 .await;
518 let state = SetupState::load_from(&fixture.setup_state_path()).expect("shown state");
519 assert_eq!(
520 state.telemetry_notice_shown_for.as_deref(),
521 Some(TELEMETRY_NOTICE_VERSION)
522 );
523 assert!(!state.telemetry_accepted(TELEMETRY_NOTICE_VERSION));
524 }
525
526 /// A previous acceptance remains enabled under the default-on policy.
527 #[tokio::test(flavor = "current_thread")]
528 async fn a_stale_accepted_notice_remains_on_without_synthesizing_current_acceptance() {
529 let server = start_recorder().await;
530 let fixture = Fixture::new().with_endpoint(&server.uri());
531 fixture.write_config("telemetry = true\n");
532 let mut state = SetupState::default();
533 state.record_telemetry_notice("0", true);
534 state
535 .save_to(&fixture.setup_state_path())
536 .expect("write setup state");
537
538 let output = fixture.run_short_command();
539
540 assert_short_cli_persistence_without_network(&fixture, &server, &output, "default-on usage")
541 .await;
542 let state = SetupState::load_from(&fixture.setup_state_path()).expect("shown state");
543 assert_eq!(
544 state.telemetry_notice_shown_for.as_deref(),
545 Some(TELEMETRY_NOTICE_VERSION)
546 );
547 assert!(!state.telemetry_accepted(TELEMETRY_NOTICE_VERSION));
548 }
549
550 // ── Nothing survives a disable ───────────────────────────────────────────
551
552 /// A human's "off" wipes: tombstone first, data truncated, lock file left in
553 /// place, identity removed.
554 #[tokio::test(flavor = "current_thread")]
555 async fn disabling_after_buffering_wipes_and_sends_nothing() {
556 let server = start_recorder().await;
557 let fixture = Fixture::new().with_endpoint(&server.uri());
558 let root = fixture.telemetry_root();
559 seed_consenting_home(&root);
560 fixture.write_config("telemetry = false\n");
561 fixture.record_notice(true);
562
563 fixture.run_short_command();
564
565 assert_no_batches(&server, "an explicit opt-out with a populated buffer").await;
566 assert!(
567 root.join("disabled").exists(),
568 "the tombstone is written first and never removed"
569 );
570 assert_eq!(
571 std::fs::read(root.join("buffer.jsonl")).expect("buffer survives as an empty file"),
572 Vec::<u8>::new(),
573 "buffered events must be truncated, not sent"
574 );
575 assert!(
576 root.join("buffer.jsonl.lock").exists(),
577 "the lock file is never unlinked: replacing it would leave appenders \
578 and compactors holding different inodes"
579 );
580 assert!(
581 !root.join("install_id.json").exists(),
582 "the install identity must not survive an opt-out"
583 );
584 }
585
586 /// A non-persistent forced-off result must preserve an existing identity and
587 /// unflushed buffer.
588 #[tokio::test(flavor = "current_thread")]
589 async fn forced_off_run_preserves_a_consenting_users_state() {
590 let server = start_recorder().await;
591 let fixture = Fixture::new().with_endpoint(&server.uri());
592 let root = fixture.telemetry_root();
593 seed_consenting_home(&root);
594 let before = snapshot(&root);
595 let mut command = fixture.command();
596 command
597 .env("CODEWHALE_TELEMETRY", "not-a-bool")
598 .args([
599 "--config",
600 fixture.config_path.to_str().expect("config path"),
601 "doctor",
602 ])
603 .output()
604 .expect("run codewhale doctor");
605
606 assert_no_batches(&server, "a forced-off run").await;
607 assert_eq!(
608 snapshot(&root),
609 before,
610 "a forced-off run must leave a consenting user's telemetry state byte-identical"
611 );
612 }
613
614 /// The documented one-command kill switch stops collection and destroys
615 /// nothing.
616 ///
617 /// `CODEWHALE_TELEMETRY=0` used to resolve as an *answer*, so it took the
618 /// destructive opt-out branch: an agent harness that set it for one command
619 /// deleted the install id and truncated the dry-run records of the person who
620 /// owns the machine, and the "permanent" tombstone it left was cleared by the
621 /// user's very next ordinary run. Off for the run, and only for the run.
622 #[tokio::test(flavor = "current_thread")]
623 async fn a_run_scoped_kill_switch_preserves_a_consenting_users_state() {
624 let server = start_recorder().await;
625 let fixture = Fixture::new().with_endpoint(&server.uri());
626 let root = fixture.telemetry_root();
627 seed_consenting_home(&root);
628 let before = snapshot(&root);
629 fixture.write_config("telemetry = true\n");
630 fixture.record_notice(true);
631
632 for value in ["0", "off", "false"] {
633 fixture
634 .command()
635 .env("CODEWHALE_TELEMETRY", value)
636 .args([
637 "--config",
638 fixture.config_path.to_str().expect("config path"),
639 "doctor",
640 ])
641 .output()
642 .expect("run codewhale doctor");
643
644 assert_no_batches(&server, "a run-scoped kill switch").await;
645 assert!(
646 !root.join("disabled").exists(),
647 "`CODEWHALE_TELEMETRY={value}` tombstoned a machine nobody opted out"
648 );
649 assert_eq!(
650 snapshot(&root),
651 before,
652 "`CODEWHALE_TELEMETRY={value}` touched a consenting user's telemetry state"
653 );
654 }
655
656 // And the persistent switch still is the destructive one, on the same
657 // home, so the two are not merely both no-ops here.
658 fixture.write_config("telemetry = false\n");
659 fixture.run_short_command();
660 assert!(
661 root.join("disabled").exists(),
662 "the config-file opt-out must still wipe and tombstone"
663 );
664 assert!(!root.join("install_id.json").exists());
665 }
666
667 /// A run that was never permitted to collect creates no directory at all —
668 /// which is also what makes the process panic hook, installed before the
669 /// command line is even parsed, write nothing for a disabled user.
670 #[tokio::test(flavor = "current_thread")]
671 async fn a_disabled_run_creates_no_telemetry_directory() {
672 let server = start_recorder().await;
673 let fixture = Fixture::new().with_endpoint(&server.uri());
674 fixture.write_config("telemetry = false\n");
675 fixture.record_notice(false);
676
677 fixture.run_short_command();
678
679 assert_no_batches(&server, "a declined run").await;
680 assert!(
681 !fixture.telemetry_root().exists(),
682 "nothing may be created for a user who declined on a fresh home"
683 );
684 }
685
686 // ── The notice is never answered by silence ──────────────────────────────
687
688 /// Deferral is not a decision. `--skip-onboarding` records and prints no notice
689 /// decision, while the non-interactive run still follows the documented
690 /// default — unlike the constitution checkpoint, which persists a `Deferred`
691 /// completion.
692 #[tokio::test(flavor = "current_thread")]
693 async fn skip_onboarding_writes_no_telemetry_decision() {
694 let server = start_recorder().await;
695 let fixture = Fixture::new().with_endpoint(&server.uri());
696 fixture.write_config("telemetry = true\n");
697
698 let mut command = fixture.command();
699 let output = command
700 .args([
701 "--verbose",
702 "--config",
703 fixture.config_path.to_str().expect("config path"),
704 "--skip-onboarding",
705 "config",
706 "telemetry",
707 ])
708 .output()
709 .expect("run short telemetry config command");
710 assert!(output.status.success());
711
712 let stdout = String::from_utf8_lossy(&output.stdout);
713 let stderr = String::from_utf8_lossy(&output.stderr);
714 for stream in [&stdout, &stderr] {
715 assert!(
716 !stream.contains("keep telemetry off"),
717 "the notice must not be rendered on a path that cannot answer it"
718 );
719 }
720
721 if let Some(state) = SetupState::load_from(&fixture.setup_state_path()) {
722 assert_eq!(
723 state.telemetry_notice_decided_for, None,
724 "skip-onboarding must leave the telemetry decision unset"
725 );
726 }
727 assert_short_cli_persistence_without_network(
728 &fixture,
729 &server,
730 &output,
731 "`--skip-onboarding` follows the default",
732 )
733 .await;
734 let state = SetupState::load_from(&fixture.setup_state_path()).expect("shown state");
735 assert_eq!(
736 state.telemetry_notice_shown_for.as_deref(),
737 Some(TELEMETRY_NOTICE_VERSION)
738 );
739 assert!(!state.telemetry_accepted(TELEMETRY_NOTICE_VERSION));
740 }
741
742 // ── Payload red lines, through a real turn ───────────────────────────────
743
744 /// Five sentinel classes planted through real inputs — the prompt, a workspace
745 /// filename, a custom `[providers.<name>]` table key, an MCP server name, and
746 /// the API key — asserted absent from every recorded batch.
747 ///
748 /// The API key is held to the stricter standard the harness at
749 /// `crates/tui/tests/verifiers_harness_contract.rs` applies: absent from stdout,
750 /// stderr, and every file under the sealed roots as well. The other four
751 /// legitimately appear in files the product owns — a prompt is in the session
752 /// transcript, a provider table key is in the config the user wrote — so the
753 /// claim about them is precisely that they never reach a *batch*.
754 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
755 async fn batch_contains_no_planted_sentinel() {
756 let server = start_recorder().await;
757 mount_model(&server, Duration::ZERO).await;
758 let fixture = Fixture::new().with_endpoint(&server.uri());
759 plant_sentinels(&fixture, &server.uri());
760
761 let output = run_exec(&fixture, SENTINEL_PROMPT);
762 assert_exec_succeeded(&output, "sentinel payload run");
763
764 assert!(
765 model_request_count(&server).await > 0,
766 "the sentinel prompt must actually have reached a model, or this test \
767 proves nothing\nstdout:\n{}\nstderr:\n{}",
768 String::from_utf8_lossy(&output.stdout),
769 String::from_utf8_lossy(&output.stderr)
770 );
771 let batches = recorded_batches(&server).await;
772 assert!(
773 !batches.is_empty(),
774 "this test is only meaningful against a batch that was actually sent"
775 );
776 for batch in &batches {
777 assert_eq!(batch["schema_version"], 3);
778 assert_eq!(batch["notice_version"], 5);
779 assert!(batch.get("consent_version").is_none());
780 }
781 let shown = SetupState::load_from(&fixture.setup_state_path()).expect("disclosure marker");
782 assert_eq!(shown.telemetry_notice_decided_for, None);
783 assert!(!shown.telemetry_opt_in);
784 let serialized = serde_json::to_string(&batches).expect("serialize batches");
785 for sentinel in [
786 SENTINEL_PROMPT,
787 SENTINEL_FILENAME,
788 SENTINEL_PROVIDER_TABLE,
789 SENTINEL_MCP_SERVER,
790 SENTINEL_API_KEY,
791 ] {
792 assert!(
793 !serialized.contains(sentinel),
794 "sentinel `{sentinel}` reached a telemetry batch:\n{serialized}"
795 );
796 }
797
798 let stdout = String::from_utf8_lossy(&output.stdout);
799 let stderr = String::from_utf8_lossy(&output.stderr);
800 assert!(
801 !stdout.contains(SENTINEL_API_KEY),
802 "the API key leaked into stdout"
803 );
804 assert!(
805 !stderr.contains(SENTINEL_API_KEY),
806 "the API key leaked into stderr"
807 );
808 for file in fixture.written_files() {
809 let Ok(bytes) = std::fs::read(&file) else {
810 continue;
811 };
812 assert!(
813 !String::from_utf8_lossy(&bytes).contains(SENTINEL_API_KEY),
814 "the API key leaked into {}",
815 file.display()
816 );
817 }
818 }
819
820 /// The buffer file is an **untrusted input**, and this is the test that says so.
821 ///
822 /// Every bound in `codewhale-telemetry`'s schema is a property of how a payload
823 /// is *built*: closed enums, `u32`s, `ProviderKind::as_str()`,
824 /// `reduce_panic_site`. None of that survives the round trip, because `flush`
825 /// re-reads `buffer.jsonl` and deserializes it — and `$CODEWHALE_HOME` is a
826 /// predictable path that anything running as the user can append to. The
827 /// realistic writer is not an intruder: it is a `Bash` tool call this very
828 /// session made on the model's behalf, or an MCP server, or a hook. Without a
829 /// drain-path re-check, telemetry is a confused deputy that POSTs whatever that
830 /// writer chooses to the configured endpoint, under the user's install id, past
831 /// every egress control the user has on the provider route.
832 ///
833 /// The injection happens **after** the session has armed, on purpose: `init`
834 /// truncates the buffer, so a pre-arming plant proves nothing.
835 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
836 async fn a_hostile_buffer_line_never_reaches_a_batch() {
837 let server = start_recorder().await;
838 // A slow first token holds the session open long enough to append.
839 mount_model(&server, Duration::from_secs(5)).await;
840 let fixture = Fixture::new().with_endpoint(&server.uri());
841 plant_sentinels(&fixture, &server.uri());
842
843 let mut command = exec_command(&fixture, "hello");
844 let mut child = command.spawn().expect("spawn codewhale exec");
845 let stdout = read_in_background(child.stdout.take().expect("stdout pipe"));
846 let stderr = read_in_background(child.stderr.take().expect("stderr pipe"));
847
848 let buffer = fixture.telemetry_root().join("buffer.jsonl");
849 wait_until(Duration::from_secs(30), || buffer.exists());
850 append_lines(
851 &buffer,
852 &[
853 // The path-bearing field, carrying a path it was never meant to.
854 json!({"event": "panic", "site": SENTINEL_INJECTED}).to_string(),
855 // The one event field read back from `state.json`.
856 json!({"event": "install_or_upgrade", "kind": "upgrade",
857 "previous_version": SENTINEL_INJECTED})
858 .to_string(),
859 // The provider set, whose whole design is that it cannot carry a
860 // customer's `[providers.<name>]` table key.
861 json!({"event": "session_end", "duration_bucket": "lt_1m",
862 "exit_class": "clean", "cold_start_bucket": null,
863 "providers": [SENTINEL_INJECTED],
864 "counters": {"turns": 0, "tool_calls": 0, "fleet_dispatch": 0,
865 "workflow_run": 0, "subagent_spawn": 0,
866 "mcp_server_connected": 0, "memory_search": 0,
867 "approval_modal_shown": 0, "approval_auto_allowed": 0,
868 "command_palette_open": 0},
869 "errors": {"auth_preflight_failed": 0, "provider_http_4xx": 0,
870 "provider_http_5xx": 0, "tool_denied_by_policy": 0,
871 "tool_timeout": 0, "network_error": 0},
872 "turn_wall": {"lt_5s": 0, "5_30s": 0, "30_120s": 0, "gte_120s": 0}})
873 .to_string(),
874 ],
875 );
876
877 let status = child
878 .wait_timeout(EXEC_TIMEOUT)
879 .expect("wait for codewhale exec")
880 .expect("codewhale exec must exit");
881 let output = Output {
882 status,
883 stdout: stdout.join().expect("stdout reader"),
884 stderr: stderr.join().expect("stderr reader"),
885 };
886 assert_exec_succeeded(&output, "hostile-buffer payload run");
887
888 let batches = recorded_batches(&server).await;
889 assert!(
890 !batches.is_empty(),
891 "this test is only meaningful against a batch that was actually sent"
892 );
893 let serialized = serde_json::to_string(&batches).expect("serialize batches");
894 assert!(
895 !serialized.contains(SENTINEL_INJECTED),
896 "a line appended to buffer.jsonl was POSTed verbatim:\n{serialized}"
897 );
898 }
899
900 /// Append raw lines to a sink, the way any other process on the machine would.
901 fn append_lines(path: &Path, lines: &[String]) {
902 use std::io::Write as _;
903 let mut file = std::fs::OpenOptions::new()
904 .append(true)
905 .open(path)
906 .expect("open the telemetry buffer");
907 for line in lines {
908 writeln!(file, "{line}").expect("append to the telemetry buffer");
909 }
910 }
911
912 /// The documented mid-session opt-out — `codewhale config set telemetry false`,
913 /// written by another process — must be observed by a session that is already
914 /// running. The flush re-resolves from disk before it sends anything.
915 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
916 async fn mid_session_opt_out_stops_the_shutdown_flush() {
917 let server = start_recorder().await;
918 // A slow first token gives the second writer a window while the session is
919 // armed and buffering.
920 mount_model(&server, Duration::from_secs(4)).await;
921 let fixture = Fixture::new().with_endpoint(&server.uri());
922 plant_sentinels(&fixture, &server.uri());
923
924 let mut command = exec_command(&fixture, "hello");
925 let mut child = command.spawn().expect("spawn codewhale exec");
926 let stdout = read_in_background(child.stdout.take().expect("stdout pipe"));
927 let stderr = read_in_background(child.stderr.take().expect("stderr pipe"));
928
929 // Wait until this session's event is actually buffered, then leave enough
930 // time for any accidental background flush to reach the recorder. Nothing
931 // may be sent before shutdown.
932 let buffer = fixture.telemetry_root().join("buffer.jsonl");
933 wait_until(Duration::from_secs(30), || {
934 std::fs::read_to_string(&buffer)
935 .map(|body| body.contains("\"event\":\"session_start\""))
936 .unwrap_or(false)
937 });
938 tokio::time::sleep(Duration::from_millis(250)).await;
939 assert_no_batches(&server, "before the shutdown flush").await;
940
941 // Now take the documented way out from outside the process. The only
942 // flush, at shutdown, must re-resolve this write and suppress the batch.
943 fixture.write_config(&sentinel_config(&server.uri(), false));
944
945 let status = child
946 .wait_timeout(EXEC_TIMEOUT)
947 .expect("wait for codewhale exec")
948 .expect("codewhale exec must exit");
949 let output = Output {
950 status,
951 stdout: stdout.join().expect("stdout reader"),
952 stderr: stderr.join().expect("stderr reader"),
953 };
954 assert_exec_succeeded(&output, "mid-session opt-out run");
955
956 assert_no_batches(&server, "an opt-out written mid-session").await;
957 let root = fixture.telemetry_root();
958 assert!(
959 root.join("disabled").exists(),
960 "the opt-out wipe must leave a tombstone the next run also honours"
961 );
962 }
963
964 /// Ctrl-C must not wait on a lock a second Codewhale process is holding.
965 ///
966 /// This is why appends never take the compaction lock: `flock` is per-fd within
967 /// a process, so a blocking acquisition on the signal path would hang exit for
968 /// as long as any other holder lives.
969 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
970 async fn ctrl_c_exits_while_a_second_process_holds_the_lock() {
971 let server = start_recorder().await;
972 mount_model(&server, Duration::from_secs(30)).await;
973 let fixture = Fixture::new().with_endpoint(&server.uri());
974 plant_sentinels(&fixture, &server.uri());
975
976 let mut command = exec_command(&fixture, "hello");
977 let mut child = command.spawn().expect("spawn codewhale exec");
978
979 let root = fixture.telemetry_root();
980 let lock_path = root.join("buffer.jsonl.lock");
981 // Arming itself takes the lock, so the holder below must wait until the
982 // session is armed — otherwise this test would pin a deadlock it created.
983 wait_until(Duration::from_secs(30), || {
984 root.join("buffer.jsonl").exists()
985 });
986 let _holder = LockHolder::take(&lock_path);
987
988 // SIGINT to the child alone; the process group belongs to the test runner.
989 let pid = child.id() as libc::pid_t;
990 // SAFETY: `kill` with a pid this process spawned and has not reaped.
991 unsafe {
992 libc::kill(pid, libc::SIGINT);
993 }
994
995 let started = Instant::now();
996 let status = child
997 .wait_timeout(Duration::from_secs(10))
998 .expect("wait for codewhale exec");
999 let status = status.unwrap_or_else(|| {
1000 let _ = child.kill();
1001 panic!(
1002 "Ctrl-C blocked for {:?} while another process held the telemetry lock — \
1003 the signal path must append without taking it",
1004 started.elapsed()
1005 )
1006 });
1007 assert_eq!(
1008 status.code(),
1009 Some(130),
1010 "SIGINT must still exit 130 with the telemetry lock held elsewhere"
1011 );
1012 assert!(
1013 started.elapsed() < Duration::from_secs(1),
1014 "Ctrl-C took {:?} while the telemetry lock was held elsewhere",
1015 started.elapsed()
1016 );
1017 }
1018
1019 /// Holds the telemetry compaction lock for the lifetime of the value.
1020 struct LockHolder {
1021 file: std::fs::File,
1022 }
1023
1024 impl LockHolder {
1025 fn take(path: &Path) -> Self {
1026 let file = std::fs::OpenOptions::new()
1027 .create(true)
1028 .read(true)
1029 .write(true)
1030 .truncate(false)
1031 .open(path)
1032 .expect("open the telemetry lock");
1033 let fd = std::os::unix::io::AsRawFd::as_raw_fd(&file);
1034 let started = Instant::now();
1035 loop {
1036 // SAFETY: `fd` is owned by `file` and outlives the call.
1037 let taken = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
1038 if taken == 0 {
1039 break;
1040 }
1041
1042 let err = std::io::Error::last_os_error();
1043 let retryable = err
1044 .raw_os_error()
1045 .is_some_and(|code| code == libc::EWOULDBLOCK || code == libc::EAGAIN);
1046 assert!(retryable, "failed to take the telemetry lock: {err}");
1047 assert!(
1048 started.elapsed() < Duration::from_secs(5),
1049 "the telemetry arming lock remained held for {:?}",
1050 started.elapsed()
1051 );
1052 std::thread::sleep(Duration::from_millis(10));
1053 }
1054 Self { file }
1055 }
1056 }
1057
1058 impl Drop for LockHolder {
1059 fn drop(&mut self) {
1060 let fd = std::os::unix::io::AsRawFd::as_raw_fd(&self.file);
1061 // SAFETY: same fd, still owned by `self`.
1062 unsafe {
1063 libc::flock(fd, libc::LOCK_UN);
1064 }
1065 }
1066 }
1067
1068 // ── exec harness ─────────────────────────────────────────────────────────
1069
1070 fn sse_chunk(value: Value) -> String {
1071 format!(
1072 "data: {}\n\n",
1073 serde_json::to_string(&value).expect("SSE JSON")
1074 )
1075 }
1076
1077 fn text_sse(text: &str) -> String {
1078 [
1079 sse_chunk(json!({
1080 "id": "chatcmpl-tc",
1081 "object": "chat.completion.chunk",
1082 "model": TEST_MODEL,
1083 "choices": [{"index": 0, "delta": {"content": text}, "finish_reason": null}]
1084 })),
1085 sse_chunk(json!({
1086 "id": "chatcmpl-tc",
1087 "object": "chat.completion.chunk",
1088 "model": TEST_MODEL,
1089 "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
1090 "usage": {"prompt_tokens": 7, "completion_tokens": 2, "total_tokens": 9}
1091 })),
1092 "data: [DONE]\n\n".to_string(),
1093 ]
1094 .join("")
1095 }
1096
1097 async fn mount_model(server: &MockServer, delay: Duration) {
1098 Mock::given(method("GET"))
1099 .and(path_matcher("/v1/models"))
1100 .respond_with(
1101 ResponseTemplate::new(200)
1102 .insert_header("content-type", "application/json")
1103 .set_body_json(json!({
1104 "object": "list",
1105 "data": [{"id": TEST_MODEL, "object": "model"}]
1106 })),
1107 )
1108 .mount(server)
1109 .await;
1110 Mock::given(method("POST"))
1111 .and(path_matcher(MODEL_PATH))
1112 .respond_with(
1113 ResponseTemplate::new(200)
1114 .insert_header("content-type", "text/event-stream")
1115 .insert_header("cache-control", "no-cache")
1116 .set_body_string(text_sse("acknowledged"))
1117 .set_delay(delay),
1118 )
1119 .mount(server)
1120 .await;
1121 }
1122
1123 /// A config whose provider table key, MCP server name, and workspace file are
1124 /// all sentinels, so a leak has somewhere to come from.
1125 fn sentinel_config(base_url: &str, telemetry: bool) -> String {
1126 format!(
1127 "telemetry = {telemetry}\nprovider = \"{SENTINEL_PROVIDER_TABLE}\"\n\n\
1128 [providers.{SENTINEL_PROVIDER_TABLE}]\n\
1129 kind = \"openai-compatible\"\n\
1130 base_url = \"{base_url}/v1\"\n\
1131 model = \"{TEST_MODEL}\"\n\
1132 api_key_env = \"{SENTINEL_API_KEY_ENV}\"\n"
1133 )
1134 }
1135
1136 fn plant_sentinels(fixture: &Fixture, base_url: &str) {
1137 let config = sentinel_config(base_url, true);
1138 fixture.write_config(
1139 config
1140 .strip_prefix("telemetry = true\n")
1141 .expect("fixture preference"),
1142 );
1143 // No saved preference or acceptance: the real exec path must use default-on.
1144 std::fs::write(
1145 fixture.workspace.join(SENTINEL_FILENAME),
1146 "sentinel workspace file\n",
1147 )
1148 .expect("plant workspace file");
1149 std::fs::write(
1150 fixture.codewhale_home.join("mcp.json"),
1151 // Keep the MCP name in a real parsed config without starting a process
1152 // that exits before CodeWhale can write its initialize request. The
1153 // telemetry contract is about name redaction, not broken-pipe handling.
1154 json!({"mcpServers": {SENTINEL_MCP_SERVER: {
1155 "command": "/bin/true",
1156 "args": [],
1157 "disabled": true
1158 }}})
1159 .to_string(),
1160 )
1161 .expect("plant MCP config");
1162 }
1163
1164 fn exec_command(fixture: &Fixture, prompt: &str) -> Command {
1165 let mut command = fixture.command();
1166 command
1167 .env(
1168 "CODEWHALE_MCP_CONFIG",
1169 fixture.codewhale_home.join("mcp.json"),
1170 )
1171 .env(SENTINEL_API_KEY_ENV, SENTINEL_API_KEY)
1172 .args([
1173 "--config",
1174 fixture.config_path.to_str().expect("config path"),
1175 "--workspace",
1176 fixture.workspace.to_str().expect("workspace path"),
1177 "--no-project-config",
1178 "--skip-onboarding",
1179 "exec",
1180 "--auto",
1181 "--output-format",
1182 "stream-json",
1183 "--",
1184 prompt,
1185 ])
1186 .stdout(Stdio::piped())
1187 .stderr(Stdio::piped());
1188 command
1189 }
1190
1191 fn run_exec(fixture: &Fixture, prompt: &str) -> Output {
1192 let mut command = exec_command(fixture, prompt);
1193 let mut child = command.spawn().expect("spawn codewhale exec");
1194 let stdout = read_in_background(child.stdout.take().expect("stdout pipe"));
1195 let stderr = read_in_background(child.stderr.take().expect("stderr pipe"));
1196 let status = match child.wait_timeout(EXEC_TIMEOUT).expect("wait for exec") {
1197 Some(status) => status,
1198 None => {
1199 let _ = child.kill();
1200 panic!("codewhale exec did not exit within {EXEC_TIMEOUT:?}");
1201 }
1202 };
1203 Output {
1204 status,
1205 stdout: stdout.join().expect("stdout reader"),
1206 stderr: stderr.join().expect("stderr reader"),
1207 }
1208 }
1209
1210 fn assert_exec_succeeded(output: &Output, context: &str) {
1211 assert!(
1212 output.status.success(),
1213 "{context} exited with {}\nstdout:\n{}\nstderr:\n{}",
1214 output.status,
1215 String::from_utf8_lossy(&output.stdout),
1216 String::from_utf8_lossy(&output.stderr)
1217 );
1218 }
1219
1220 fn read_in_background(mut pipe: impl Read + Send + 'static) -> std::thread::JoinHandle<Vec<u8>> {
1221 std::thread::spawn(move || {
1222 let mut buffer = Vec::new();
1223 let _ = pipe.read_to_end(&mut buffer);
1224 buffer
1225 })
1226 }
1227
1228 fn wait_until(limit: Duration, mut ready: impl FnMut() -> bool) {
1229 let started = Instant::now();
1230 while started.elapsed() < limit {
1231 if ready() {
1232 return;
1233 }
1234 std::thread::sleep(Duration::from_millis(25));
1235 }
1236 panic!("condition was not reached within {limit:?}");
1237 }
1238
1239 // ── Seeded state ─────────────────────────────────────────────────────────
1240
1241 /// A home that already belongs to a consenting user: an identity, a populated
1242 /// buffer, a flush record, and the lock file.
1243 fn seed_consenting_home(root: &Path) {
1244 std::fs::create_dir_all(root).expect("create telemetry root");
1245 std::fs::write(
1246 root.join("install_id.json"),
1247 json!({
1248 "schema_version": 1,
1249 "install_id": "11111111-2222-3333-4444-555555555555",
1250 "rotated_at": "2026-01-01T00:00:00Z"
1251 })
1252 .to_string(),
1253 )
1254 .expect("seed install id");
1255 std::fs::write(
1256 root.join("state.json"),
1257 json!({"schema_version": 1, "last_version": "0.0.1"}).to_string(),
1258 )
1259 .expect("seed state");
1260 std::fs::write(
1261 root.join("buffer.jsonl"),
1262 format!(
1263 "{}\n",
1264 json!({"event": "session_start", "source": "unknown"})
1265 ),
1266 )
1267 .expect("seed buffer");
1268 std::fs::write(root.join("buffer.jsonl.lock"), b"").expect("seed lock file");
1269 }
1270
1271 fn snapshot(root: &Path) -> Vec<(String, Vec<u8>)> {
1272 let mut files = Vec::new();
1273 collect_files(root, &mut files);
1274 let mut out: Vec<(String, Vec<u8>)> = files
1275 .into_iter()
1276 .map(|path| {
1277 let name = path
1278 .strip_prefix(root)
1279 .unwrap_or(&path)
1280 .to_string_lossy()
1281 .into_owned();
1282 let bytes = std::fs::read(&path).unwrap_or_default();
1283 (name, bytes)
1284 })
1285 .collect();
1286 out.sort();
1287 out
1288 }
1289
1289 lines RUST