1use std::path::{Path, PathBuf};
42
43use serde::{Deserialize, Serialize};
44
45use crate::download::{
46 cache_file_usable, purge_corrupt_cache_files, verify_cache_file, CacheIntegrity,
47};
48
49const KNOWN_STATE_FILES: &[&str] = &[
54 "messaging.json",
55 "models.json",
56 "connectors.json",
57 "car-connectors.json",
58 "agents.json",
59 "routing.json",
60 "declagents.json",
61 "lane-defaults.json",
62 "update-prefs.json",
63 "upgrade-cache.json",
64 "catalog-cache.json",
65 "discovered_models.json",
66 "a2a-peers.json",
67 "external-agents.jsonl",
68 "nudge-state.json",
69 "benchmark_priors.json",
70 "key_pool_stats.json",
71 "model_profiles.json",
72 "agent-permissions.json",
73 "version.json",
74 "secret_index.json",
82 "gateway-state.json",
89 "parslee-credential-state.json",
96 "model-resource-policy.json",
98 "parslee-auth-authority.json",
100];
101
102const KNOWN_NON_JSON_FILES: &[&str] = &[
107 "env",
108 "peer-identity.key",
110];
111
112const KNOWN_DIRS: &[&str] = &[
115 "models",
116 "journals",
117 "logs",
118 "agents",
119 "runs",
120 "run",
121 "workflow-runs",
122 "workflows",
123 "tasks",
124 "trajectories",
125 "registry",
126 "meetings",
127 "speech-runtime",
128 "visual-runtime",
129 "coder",
130 "projects",
131 "memory",
132 "reason",
137 "doctor",
138 "bin",
139 "voiceprints",
140 "sync",
141 "proposal-completed-index",
143 "selfheal",
145];
146
147const TOLERATED_SUFFIXES: &[&str] = &[".lock", ".tmp", ".bak", ".bin", ".jsonl"];
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct VersionStamp {
156 pub car_version: String,
158 pub state_schema_version: u32,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub previous_car_version: Option<String>,
176 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub previous_state_schema_version: Option<u32>,
179}
180
181pub const STATE_SCHEMA_VERSION: u32 = 1;
184
185impl VersionStamp {
186 pub fn current() -> Self {
188 VersionStamp {
189 car_version: env!("CARGO_PKG_VERSION").to_string(),
190 state_schema_version: STATE_SCHEMA_VERSION,
191 previous_car_version: None,
192 previous_state_schema_version: None,
193 }
194 }
195
196 pub fn succeeding(existing: Option<&VersionStamp>) -> Self {
202 let mut next = VersionStamp::current();
203 if let Some(prior) = existing {
204 if prior.car_version != next.car_version
205 || prior.state_schema_version != next.state_schema_version
206 {
207 next.previous_car_version = Some(prior.car_version.clone());
208 next.previous_state_schema_version = Some(prior.state_schema_version);
209 } else {
210 next.previous_car_version = prior.previous_car_version.clone();
211 next.previous_state_schema_version = prior.previous_state_schema_version;
212 }
213 }
214 next
215 }
216}
217
218#[derive(Debug, Clone)]
221pub struct StampTransition {
222 pub previous: Option<VersionStamp>,
227 pub current: VersionStamp,
229}
230
231impl StampTransition {
232 pub fn upgraded(&self) -> bool {
234 self.previous
235 .as_ref()
236 .is_some_and(|p| p.car_version != self.current.car_version)
237 }
238
239 pub fn schema_from_the_future(&self) -> bool {
247 self.previous
248 .as_ref()
249 .is_some_and(|p| p.state_schema_version > self.current.state_schema_version)
250 }
251}
252
253pub fn car_home() -> PathBuf {
274 ::car_home::root_or_relative()
276}
277
278pub fn write_version_stamp(car_home: &Path) -> std::io::Result<()> {
282 stamp_version(car_home).map(|_| ())
283}
284
285pub fn stamp_version(car_home: &Path) -> std::io::Result<StampTransition> {
293 std::fs::create_dir_all(car_home)?;
294 let previous = read_version_stamp(car_home);
295 let stamp = VersionStamp::succeeding(previous.as_ref());
296 let json = serde_json::to_string_pretty(&stamp)
297 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
298 let tmp = car_home.join(format!("version.json.{}.tmp", std::process::id()));
302 std::fs::write(&tmp, json)?;
303 std::fs::rename(&tmp, car_home.join("version.json"))?;
304 Ok(StampTransition {
305 previous,
306 current: stamp,
307 })
308}
309
310const DOCTOR_DIR: &str = "doctor";
313
314const DAEMON_TMPDIR_MARKER_FILE: &str = "daemon-tmpdir.json";
316
317const TMPDIR_PROBE_BYTES: &[u8] = b"car tmpdir probe\n";
321
322#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
332pub struct DaemonTmpdirMarker {
333 pub pid: u32,
335 pub booted_at_unix: u64,
337 pub car_version: String,
339 pub checked_path: String,
343 #[serde(default, skip_serializing_if = "Option::is_none")]
347 pub checked_path_absolute: Option<String>,
348 pub ok: bool,
352 #[serde(default, skip_serializing_if = "Option::is_none")]
354 pub error: Option<String>,
355 #[serde(default, skip_serializing_if = "Option::is_none")]
358 pub note: Option<String>,
359}
360
361#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
363#[serde(rename_all = "snake_case", tag = "status")]
364pub enum DaemonTmpdirStatus {
365 Current { marker: DaemonTmpdirMarker },
368 CurrentPathGone { marker: DaemonTmpdirMarker },
372 CurrentPathRecheckFailed {
376 marker: DaemonTmpdirMarker,
377 error: String,
378 },
379 Stale { marker: DaemonTmpdirMarker },
382 Unverified { marker: DaemonTmpdirMarker },
385 Unreadable { error: String },
388}
389
390#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
392pub struct DaemonTmpdirCheck {
393 pub marker_path: String,
395 #[serde(flatten)]
396 pub status: DaemonTmpdirStatus,
397}
398
399impl DaemonTmpdirCheck {
400 pub fn is_failing(&self) -> bool {
403 matches!(
404 &self.status,
405 DaemonTmpdirStatus::Current { marker } if !marker.ok
406 ) || matches!(
407 &self.status,
408 DaemonTmpdirStatus::CurrentPathGone { .. }
409 | DaemonTmpdirStatus::CurrentPathRecheckFailed { .. }
410 )
411 }
412}
413
414pub fn daemon_tmpdir_marker_path(car_home: &Path) -> PathBuf {
416 car_home.join(DOCTOR_DIR).join(DAEMON_TMPDIR_MARKER_FILE)
417}
418
419pub fn record_daemon_tmpdir_probe(
436 car_home: &Path,
437 tmpdir: &Path,
438) -> std::io::Result<DaemonTmpdirMarker> {
439 record_daemon_tmpdir_probe_result(car_home, tmpdir, probe_daemon_tmpdir(tmpdir))
440}
441
442pub fn probe_daemon_tmpdir(tmpdir: &Path) -> Result<Option<String>, String> {
449 probe_tmpdir(tmpdir, std::process::id())
450}
451
452pub fn record_daemon_tmpdir_probe_result(
460 car_home: &Path,
461 tmpdir: &Path,
462 result: Result<Option<String>, String>,
463) -> std::io::Result<DaemonTmpdirMarker> {
464 record_daemon_tmpdir_probe_result_with_cwd(car_home, tmpdir, result, std::env::current_dir())
465}
466
467fn record_daemon_tmpdir_probe_result_with_cwd(
468 car_home: &Path,
469 tmpdir: &Path,
470 result: Result<Option<String>, String>,
471 current_dir: std::io::Result<PathBuf>,
472) -> std::io::Result<DaemonTmpdirMarker> {
473 let (error, mut note) = match result {
474 Ok(note) => (None, note),
475 Err(error) => (Some(error), None),
476 };
477 let (checked_path_absolute, path_note) = exact_absolute_tmpdir_path(tmpdir, current_dir);
478 append_note(&mut note, path_note);
479 let marker = DaemonTmpdirMarker {
480 pid: std::process::id(),
481 booted_at_unix: std::time::SystemTime::now()
482 .duration_since(std::time::UNIX_EPOCH)
483 .map(|d| d.as_secs())
484 .unwrap_or(0),
485 car_version: env!("CARGO_PKG_VERSION").to_string(),
486 checked_path: tmpdir.display().to_string(),
487 checked_path_absolute,
488 ok: error.is_none(),
489 error,
490 note,
491 };
492 write_daemon_tmpdir_marker(car_home, &marker)?;
493 Ok(marker)
494}
495
496fn exact_absolute_tmpdir_path(
497 tmpdir: &Path,
498 current_dir: std::io::Result<PathBuf>,
499) -> (Option<String>, Option<String>) {
500 let absolute = if tmpdir.is_absolute() {
501 tmpdir.to_path_buf()
502 } else {
503 let current_dir = match current_dir {
504 Ok(current_dir) => current_dir,
505 Err(error) => {
506 return (
507 None,
508 Some(format!(
509 "TMPDIR recheck skipped: could not capture the daemon working directory: {error}"
510 )),
511 );
512 }
513 };
514 current_dir.join(tmpdir)
515 };
516 match absolute.to_str() {
517 Some(path) => (Some(path.to_string()), None),
518 None => (
519 None,
520 Some(
521 "TMPDIR recheck skipped: the exact absolute probe path is not valid UTF-8"
522 .to_string(),
523 ),
524 ),
525 }
526}
527
528fn append_note(note: &mut Option<String>, additional: Option<String>) {
529 let Some(additional) = additional else {
530 return;
531 };
532 match note {
533 Some(note) => {
534 note.push_str("; ");
535 note.push_str(&additional);
536 }
537 None => *note = Some(additional),
538 }
539}
540
541pub fn read_daemon_tmpdir_marker(car_home: &Path) -> Option<Result<DaemonTmpdirMarker, String>> {
544 let text = match std::fs::read_to_string(daemon_tmpdir_marker_path(car_home)) {
545 Ok(text) => text,
546 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
547 Err(e) => return Some(Err(e.to_string())),
548 };
549 Some(serde_json::from_str(&text).map_err(|e| e.to_string()))
550}
551
552fn unique_probe_suffix(pid: u32) -> String {
555 let nanos = std::time::SystemTime::now()
556 .duration_since(std::time::UNIX_EPOCH)
557 .map(|d| d.as_nanos())
558 .unwrap_or(0);
559 format!("{pid}.{nanos}")
560}
561
562fn probe_tmpdir(tmpdir: &Path, pid: u32) -> Result<Option<String>, String> {
565 use std::io::Write;
566
567 let path = tmpdir.join(format!(".car-tmpdir-probe.{}", unique_probe_suffix(pid)));
568 let mut options = std::fs::OpenOptions::new();
573 options.write(true).create_new(true);
574 #[cfg(windows)]
575 {
576 use std::os::windows::fs::OpenOptionsExt;
577 use windows::Win32::Storage::FileSystem::FILE_FLAG_DELETE_ON_CLOSE;
578
579 options.custom_flags(FILE_FLAG_DELETE_ON_CLOSE.0);
582 }
583 let mut file = options
584 .open(&path)
585 .map_err(|e| format!("create failed: {e}"))?;
586 let created = file.metadata().map_err(|e| format!("stat failed: {e}"));
587 let wrote = file
588 .write_all(TMPDIR_PROBE_BYTES)
589 .map_err(|e| format!("write failed: {e}"));
590 drop(file);
591
592 let removed = cleanup_probe_file(&path, &created);
595 combine_probe_results(created.map(|_| ()), wrote, removed)
596}
597
598fn cleanup_probe_file(
599 path: &Path,
600 created: &Result<std::fs::Metadata, String>,
601) -> Result<(), String> {
602 match created {
603 Ok(created) => remove_probe_file(path, created),
604 #[cfg(windows)]
605 Err(_) => Ok(()),
606 #[cfg(not(windows))]
607 Err(_) => Err(format!(
608 "cleanup skipped: probe file identity could not be proven; left {}",
609 path.display()
610 )),
611 }
612}
613
614fn combine_probe_results(
615 stat: Result<(), String>,
616 write: Result<(), String>,
617 cleanup: Result<(), String>,
618) -> Result<Option<String>, String> {
619 match (stat, write, cleanup) {
620 (Ok(()), Ok(()), Ok(())) => Ok(None),
621 (Err(stat), Ok(()), Ok(())) => Ok(Some(stat)),
622 (Err(stat), Ok(()), Err(cleanup)) => Ok(Some(format!("{stat}; {cleanup}"))),
623 (Ok(()), Ok(()), Err(cleanup)) => Err(cleanup),
624 (stat, Err(write), cleanup) => {
625 let mut errors = Vec::new();
626 if let Err(stat) = stat {
627 errors.push(stat);
628 }
629 errors.push(write);
630 if let Err(cleanup) = cleanup {
631 errors.push(cleanup);
632 }
633 Err(errors.join("; "))
634 }
635 }
636}
637
638#[cfg(unix)]
641fn remove_probe_file(path: &Path, created: &std::fs::Metadata) -> Result<(), String> {
642 use std::os::unix::fs::MetadataExt;
643
644 let on_disk = std::fs::symlink_metadata(path).map_err(|e| format!("remove failed: {e}"))?;
647 if !on_disk.file_type().is_file()
648 || created.dev() != on_disk.dev()
649 || created.ino() != on_disk.ino()
650 {
651 return Err(
652 "remove skipped: the probe path no longer names the file the probe created".to_string(),
653 );
654 }
655 std::fs::remove_file(path).map_err(|e| format!("remove failed: {e}"))
656}
657
658#[cfg(not(unix))]
659fn remove_probe_file(_path: &Path, _created: &std::fs::Metadata) -> Result<(), String> {
660 Ok(())
665}
666
667fn write_daemon_tmpdir_marker(car_home: &Path, marker: &DaemonTmpdirMarker) -> std::io::Result<()> {
670 use std::io::Write;
671
672 let dir = car_home.join(DOCTOR_DIR);
673 car_secrets::ensure_private_dir(&dir)?;
677 let mut json = serde_json::to_vec_pretty(marker)
678 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
679 json.push(b'\n');
680 let tmp = dir.join(format!(
681 "{DAEMON_TMPDIR_MARKER_FILE}.{}.tmp",
682 unique_probe_suffix(marker.pid)
683 ));
684 let mut file = car_secrets::create_private_file(&tmp)?;
689 file.write_all(&json)?;
690 car_secrets::atomic_replace_private_file(&tmp, &dir.join(DAEMON_TMPDIR_MARKER_FILE))
691}
692
693fn classify_daemon_tmpdir_marker(
696 marker: DaemonTmpdirMarker,
697 writer_alive: Option<bool>,
698) -> DaemonTmpdirStatus {
699 match writer_alive {
700 Some(true) => DaemonTmpdirStatus::Current { marker },
701 Some(false) => DaemonTmpdirStatus::Stale { marker },
702 None => DaemonTmpdirStatus::Unverified { marker },
703 }
704}
705
706fn daemon_writer_alive(pid: u32) -> Option<bool> {
714 #[cfg(unix)]
715 {
716 Some(unix_pid_alive(pid))
717 }
718 #[cfg(not(unix))]
719 {
720 let _ = pid;
721 None
722 }
723}
724
725#[cfg(unix)]
736fn unix_pid_alive(pid: u32) -> bool {
737 let Ok(pid) = libc::pid_t::try_from(pid) else {
739 return false;
740 };
741 if pid <= 0 {
742 return false;
743 }
744 let rc = unsafe { libc::kill(pid, 0) };
746 rc == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
747}
748
749fn recheck_current_tmpdir_with<F>(status: DaemonTmpdirStatus, stat: F) -> DaemonTmpdirStatus
750where
751 F: FnOnce(&Path) -> std::io::Result<()>,
752{
753 let mut marker = match status {
754 DaemonTmpdirStatus::Current { marker } => marker,
755 status => return status,
756 };
757 if !marker.ok {
758 return DaemonTmpdirStatus::Current { marker };
759 }
760 let Some(exact_path) = marker.checked_path_absolute.clone() else {
761 append_note(
762 &mut marker.note,
763 Some(
764 "TMPDIR recheck skipped: the startup record has no exact absolute probe path"
765 .to_string(),
766 ),
767 );
768 return DaemonTmpdirStatus::Current { marker };
769 };
770 match stat(Path::new(&exact_path)) {
771 Ok(()) => DaemonTmpdirStatus::Current { marker },
772 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
773 DaemonTmpdirStatus::CurrentPathGone { marker }
774 }
775 Err(error) => DaemonTmpdirStatus::CurrentPathRecheckFailed {
776 marker,
777 error: error.to_string(),
778 },
779 }
780}
781
782fn check_daemon_tmpdir(home: &Path) -> Option<DaemonTmpdirCheck> {
784 let status = match read_daemon_tmpdir_marker(home)? {
785 Ok(marker) => {
786 let writer_alive = daemon_writer_alive(marker.pid);
787 let status = classify_daemon_tmpdir_marker(marker, writer_alive);
788 recheck_current_tmpdir_with(status, |path| std::fs::metadata(path).map(|_| ()))
789 }
790 Err(error) => DaemonTmpdirStatus::Unreadable { error },
791 };
792 Some(DaemonTmpdirCheck {
793 marker_path: daemon_tmpdir_marker_path(home).display().to_string(),
794 status,
795 })
796}
797
798#[derive(Debug, Clone, Default)]
800pub struct DoctorOptions {
801 pub deep: bool,
805 pub repair: bool,
808}
809
810#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
812#[serde(rename_all = "snake_case", tag = "status")]
813pub enum StateFileStatus {
814 Absent,
816 Ok { schema_version: Option<u32> },
818 Unparseable {
821 error: String,
822 backed_up_to: Option<String>,
823 },
824}
825
826#[derive(Debug, Clone, Serialize, Deserialize)]
828pub struct StateFileCheck {
829 pub name: String,
830 #[serde(flatten)]
831 pub status: StateFileStatus,
832}
833
834#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
836#[serde(rename_all = "snake_case", tag = "status")]
837pub enum ModelStatus {
838 Healthy,
840 Corrupt {
843 bad_files: Vec<String>,
844 purged: usize,
845 },
846 Incomplete { detail: String },
857}
858
859#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
863pub struct Leftover {
864 pub path: String,
865 pub bytes: u64,
866}
867
868#[derive(Debug, Clone, Serialize, Deserialize)]
870pub struct ModelCheck {
871 pub name: String,
872 #[serde(flatten)]
873 pub status: ModelStatus,
874}
875
876#[derive(Debug, Clone, Serialize, Deserialize)]
886pub struct RuntimeCheck {
887 pub name: String,
889 pub root: String,
890 pub present: bool,
893 pub interpreter_ok: bool,
895}
896
897impl RuntimeCheck {
898 pub fn is_broken(&self) -> bool {
901 self.present && !self.interpreter_ok
902 }
903}
904
905#[derive(Debug, Clone, Serialize, Deserialize)]
907pub struct DoctorReport {
908 pub car_home: String,
909 pub binary_version: String,
911 pub on_disk_stamp: Option<VersionStamp>,
913 pub version_skew: bool,
919 #[serde(default, skip_serializing_if = "Option::is_none")]
926 pub carried_from_version: Option<String>,
927 #[serde(default)]
931 pub schema_from_the_future: bool,
932 pub state_files: Vec<StateFileCheck>,
933 pub models: Vec<ModelCheck>,
934 #[serde(default)]
937 pub installed_models: usize,
938 #[serde(default)]
942 pub leftovers: Vec<Leftover>,
943 #[serde(default)]
946 pub runtimes: Vec<RuntimeCheck>,
947 pub unrecognized: Vec<String>,
949 pub repairs: Vec<String>,
951 #[serde(default, skip_serializing_if = "Option::is_none")]
956 pub daemon_tmpdir: Option<DaemonTmpdirCheck>,
957}
958
959impl DoctorReport {
960 pub fn is_healthy(&self) -> bool {
963 !self.schema_from_the_future
968 && !self.version_skew
969 && self
970 .state_files
971 .iter()
972 .all(|f| !matches!(f.status, StateFileStatus::Unparseable { .. }))
973 && self
974 .models
975 .iter()
976 .all(|m| matches!(m.status, ModelStatus::Healthy))
977 && self.runtimes.iter().all(|r| !r.is_broken())
978 && !self
981 .daemon_tmpdir
982 .as_ref()
983 .is_some_and(DaemonTmpdirCheck::is_failing)
984 }
985}
986
987pub fn diagnose(opts: &DoctorOptions) -> DoctorReport {
996 diagnose_at(&car_home(), &crate::default_models_dir(), opts)
997}
998
999pub fn diagnose_in(home: &Path, opts: &DoctorOptions) -> DoctorReport {
1003 diagnose_at(home, &home.join("models"), opts)
1004}
1005
1006pub fn diagnose_at(home: &Path, models_dir: &Path, opts: &DoctorOptions) -> DoctorReport {
1008 let catalog_public_key = std::env::var("CAR_CATALOG_PUBKEY").ok();
1009 let registry = crate::registry::UnifiedRegistry::new_with_session(
1010 home.to_path_buf(),
1011 models_dir.to_path_buf(),
1012 catalog_public_key.as_deref(),
1013 crate::registry::SessionProbe::Inert,
1014 );
1015 diagnose_at_with_registry(home, models_dir, opts, ®istry, None)
1016}
1017
1018#[doc(hidden)]
1027pub fn diagnose_at_isolated(
1028 home: &Path,
1029 models_dir: &Path,
1030 huggingface_hub_root: &Path,
1031 opts: &DoctorOptions,
1032) -> DoctorReport {
1033 let registry = crate::registry::UnifiedRegistry::new_isolated_for_diagnosis(
1034 home.to_path_buf(),
1035 models_dir.to_path_buf(),
1036 );
1037 diagnose_at_with_registry(
1038 home,
1039 models_dir,
1040 opts,
1041 ®istry,
1042 Some(huggingface_hub_root),
1043 )
1044}
1045
1046fn diagnose_at_with_registry(
1047 home: &Path,
1048 models_dir: &Path,
1049 opts: &DoctorOptions,
1050 registry: &crate::registry::UnifiedRegistry,
1051 huggingface_hub_root: Option<&Path>,
1052) -> DoctorReport {
1053 let mut repairs = Vec::new();
1054
1055 let on_disk_stamp = read_version_stamp(home);
1057 let binary = VersionStamp::current();
1058 let version_skew = on_disk_stamp
1059 .as_ref()
1060 .map(|s| {
1061 s.car_version != binary.car_version
1062 || s.state_schema_version != binary.state_schema_version
1063 })
1064 .unwrap_or(false);
1065 let carried_from_version = on_disk_stamp
1067 .as_ref()
1068 .and_then(|s| s.previous_car_version.clone());
1069 let schema_from_the_future = on_disk_stamp
1070 .as_ref()
1071 .is_some_and(|s| s.state_schema_version > binary.state_schema_version);
1072
1073 let mut state_files = Vec::new();
1075 for name in KNOWN_STATE_FILES {
1076 state_files.push(check_state_file(home, name, opts, &mut repairs));
1077 }
1078
1079 let models = check_models(models_dir, opts, &mut repairs);
1081 let installed_models = registry
1084 .list()
1085 .into_iter()
1086 .filter(|schema| {
1087 schema.downloads_weights()
1088 && crate::registry::physical_weights_ready_with_huggingface_hub(
1089 schema,
1090 models_dir,
1091 huggingface_hub_root,
1092 )
1093 })
1094 .count();
1095
1096 let leftovers = match huggingface_hub_root {
1098 Some(hub) => find_leftovers_in(hub),
1099 None => find_leftovers(),
1100 };
1101 let unrecognized = find_unrecognized(home);
1102
1103 if opts.repair {
1105 let already_current = on_disk_stamp
1112 .as_ref()
1113 .map(|s| {
1114 s.car_version == binary.car_version
1115 && s.state_schema_version == binary.state_schema_version
1116 })
1117 .unwrap_or(false);
1118 match write_version_stamp(home) {
1119 Ok(()) if !already_current => repairs.push(format!(
1120 "refreshed version stamp to {} (schema v{})",
1121 binary.car_version, binary.state_schema_version
1122 )),
1123 Ok(()) => {}
1124 Err(e) => repairs.push(format!("failed to refresh version stamp: {e}")),
1125 }
1126 }
1127
1128 let empty_journals = find_empty_journals(home);
1130 if opts.repair && !empty_journals.is_empty() {
1131 let mut removed = 0usize;
1132 for p in &empty_journals {
1133 if std::fs::remove_file(p).is_ok() {
1134 removed += 1;
1135 }
1136 }
1137 if removed > 0 {
1138 repairs.push(format!(
1139 "removed {removed} empty event journal(s) from journals/"
1140 ));
1141 }
1142 }
1143
1144 let runtimes = check_runtimes(home);
1146
1147 let daemon_tmpdir = check_daemon_tmpdir(home);
1149
1150 DoctorReport {
1151 car_home: home.display().to_string(),
1152 binary_version: binary.car_version,
1153 on_disk_stamp,
1154 version_skew,
1155 carried_from_version,
1156 schema_from_the_future,
1157 state_files,
1158 models,
1159 installed_models,
1160 leftovers,
1161 runtimes,
1162 unrecognized,
1163 repairs,
1164 daemon_tmpdir,
1165 }
1166}
1167
1168const MANAGED_RUNTIMES: &[&str] = &["speech-runtime", "visual-runtime"];
1170
1171fn check_runtimes(home: &Path) -> Vec<RuntimeCheck> {
1177 MANAGED_RUNTIMES
1178 .iter()
1179 .filter_map(|name| {
1180 let root = home.join(name);
1181 if !root.exists() {
1182 return None;
1183 }
1184 Some(RuntimeCheck {
1185 name: (*name).to_string(),
1186 root: root.display().to_string(),
1187 present: true,
1188 interpreter_ok: crate::managed_venv::interpreter_healthy(&root),
1189 })
1190 })
1191 .collect()
1192}
1193
1194fn read_version_stamp(home: &Path) -> Option<VersionStamp> {
1195 let text = std::fs::read_to_string(home.join("version.json")).ok()?;
1196 serde_json::from_str(&text).ok()
1197}
1198
1199fn check_state_file(
1200 home: &Path,
1201 name: &str,
1202 opts: &DoctorOptions,
1203 repairs: &mut Vec<String>,
1204) -> StateFileCheck {
1205 let path = home.join(name);
1206 let is_jsonl = name.ends_with(".jsonl");
1207 let status = match std::fs::read_to_string(&path) {
1208 Err(_) => StateFileStatus::Absent,
1209 Ok(text) if text.trim().is_empty() => StateFileStatus::Ok {
1210 schema_version: None,
1211 },
1212 Ok(text) if is_jsonl => match jsonl_first_bad_line(&text) {
1215 None => StateFileStatus::Ok {
1216 schema_version: None,
1217 },
1218 Some(e) => unparseable(&path, name, e, opts, repairs),
1219 },
1220 Ok(text) => match serde_json::from_str::<serde_json::Value>(&text) {
1221 Ok(value) => StateFileStatus::Ok {
1222 schema_version: value
1223 .get("schema_version")
1224 .and_then(serde_json::Value::as_u64)
1225 .map(|v| v as u32),
1226 },
1227 Err(e) => unparseable(&path, name, e.to_string(), opts, repairs),
1228 },
1229 };
1230 StateFileCheck {
1231 name: name.to_string(),
1232 status,
1233 }
1234}
1235
1236fn jsonl_first_bad_line(text: &str) -> Option<String> {
1239 for (i, line) in text.lines().enumerate() {
1240 if line.trim().is_empty() {
1241 continue;
1242 }
1243 if let Err(e) = serde_json::from_str::<serde_json::Value>(line) {
1244 return Some(format!("line {}: {e}", i + 1));
1245 }
1246 }
1247 None
1248}
1249
1250fn unparseable(
1254 path: &Path,
1255 name: &str,
1256 error: String,
1257 opts: &DoctorOptions,
1258 repairs: &mut Vec<String>,
1259) -> StateFileStatus {
1260 let backed_up_to = if opts.repair {
1261 let plain = path.with_file_name(format!("{name}.corrupt.bak"));
1265 let bak = if plain.exists() {
1266 let epoch = std::time::SystemTime::now()
1267 .duration_since(std::time::UNIX_EPOCH)
1268 .map(|d| d.as_secs())
1269 .unwrap_or(0);
1270 path.with_file_name(format!("{name}.corrupt.{epoch}.bak"))
1271 } else {
1272 plain
1273 };
1274 match std::fs::rename(path, &bak) {
1275 Ok(()) => {
1276 repairs.push(format!("backed up unparseable {name} → {}", bak.display()));
1277 Some(bak.display().to_string())
1278 }
1279 Err(err) => {
1280 repairs.push(format!("failed to back up {name}: {err}"));
1281 None
1282 }
1283 }
1284 } else {
1285 None
1286 };
1287 StateFileStatus::Unparseable {
1288 error,
1289 backed_up_to,
1290 }
1291}
1292
1293fn check_models(
1294 models_dir: &Path,
1295 opts: &DoctorOptions,
1296 repairs: &mut Vec<String>,
1297) -> Vec<ModelCheck> {
1298 let Ok(entries) = std::fs::read_dir(models_dir) else {
1299 return Vec::new();
1300 };
1301 let mut out = Vec::new();
1302 for entry in entries.filter_map(Result::ok) {
1303 let dir = entry.path();
1304 if !dir.is_dir() {
1305 continue;
1306 }
1307 let name = entry.file_name().to_string_lossy().to_string();
1308 if let Some(status) = check_one_model(&dir, opts, &name, repairs) {
1313 out.push(ModelCheck { name, status });
1314 }
1315 }
1316 out.sort_by(|a, b| a.name.cmp(&b.name));
1317 out
1318}
1319
1320fn check_one_model(
1321 dir: &Path,
1322 opts: &DoctorOptions,
1323 name: &str,
1324 repairs: &mut Vec<String>,
1325) -> Option<ModelStatus> {
1326 let weights = weight_files(dir);
1327 if weights.is_empty() {
1328 if is_interrupted_install(dir) {
1344 return Some(ModelStatus::Incomplete {
1345 detail: format!(
1346 "manifest linked into the HuggingFace cache but no weights resolve — \
1347 re-pull with `car models pull {name}`"
1348 ),
1349 });
1350 }
1351 return None;
1352 }
1353 let mut bad_files = Vec::new();
1354 for w in &weights {
1355 let corrupt = if opts.deep {
1356 verify_cache_file(w) == CacheIntegrity::Corrupt
1357 } else {
1358 !cache_file_usable(w)
1359 };
1360 if corrupt {
1361 bad_files.push(
1362 w.file_name()
1363 .unwrap_or_default()
1364 .to_string_lossy()
1365 .to_string(),
1366 );
1367 }
1368 }
1369 if bad_files.is_empty() {
1370 return Some(ModelStatus::Healthy);
1371 }
1372 let purged = if opts.repair {
1373 let n = purge_corrupt_cache_files(dir);
1374 if n > 0 {
1375 repairs.push(format!(
1376 "purged {n} corrupt file(s) from model '{name}' — re-pull with `car models pull {name}`"
1377 ));
1378 }
1379 n
1380 } else {
1381 0
1382 };
1383 Some(ModelStatus::Corrupt { bad_files, purged })
1384}
1385
1386fn is_interrupted_install(dir: &Path) -> bool {
1414 const MANIFESTS: &[&str] = &[
1415 "config.json",
1416 "model_index.json",
1417 "tokenizer.json",
1418 "tokenizer_config.json",
1419 "model.safetensors.index.json",
1420 ];
1421 let has_symlinked_manifest = MANIFESTS.iter().any(|m| {
1422 let p = dir.join(m);
1423 std::fs::symlink_metadata(&p)
1424 .map(|meta| meta.file_type().is_symlink())
1425 .unwrap_or(false)
1426 });
1427 has_symlinked_manifest && !crate::registry::mlx_dir_has_weights(dir)
1428}
1429
1430fn find_empty_journals(home: &Path) -> Vec<PathBuf> {
1443 let dir = home.join("journals");
1444 let Ok(entries) = std::fs::read_dir(&dir) else {
1445 return Vec::new();
1446 };
1447 let mut out: Vec<PathBuf> = entries
1448 .filter_map(Result::ok)
1449 .filter(|e| {
1450 e.path().extension().and_then(|x| x.to_str()) == Some("jsonl")
1451 && e.metadata()
1452 .map(|m| m.is_file() && m.len() == 0)
1453 .unwrap_or(false)
1454 })
1455 .map(|e| e.path())
1456 .collect();
1457 out.sort();
1458 out
1459}
1460
1461fn find_leftovers() -> Vec<Leftover> {
1475 find_leftovers_in(&crate::registry::huggingface_cache_root())
1476}
1477
1478fn find_leftovers_in(hub: &Path) -> Vec<Leftover> {
1479 let mut out = Vec::new();
1480 let Ok(repos) = std::fs::read_dir(hub) else {
1481 return out;
1482 };
1483 for repo in repos.filter_map(Result::ok) {
1484 let blobs = repo.path().join("blobs");
1485 let Ok(entries) = std::fs::read_dir(&blobs) else {
1486 continue;
1487 };
1488 for e in entries.filter_map(Result::ok) {
1489 let p = e.path();
1490 let is_partial = p
1491 .file_name()
1492 .and_then(|n| n.to_str())
1493 .map(|n| n.ends_with(".sync.part") || n.ends_with(".incomplete"))
1494 .unwrap_or(false);
1495 if !is_partial {
1496 continue;
1497 }
1498 let bytes = e.metadata().map(|m| m.len()).unwrap_or(0);
1501 out.push(Leftover {
1502 path: p.display().to_string(),
1503 bytes,
1504 });
1505 }
1506 }
1507 out.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.path.cmp(&b.path)));
1508 out
1509}
1510
1511fn weight_files(dir: &Path) -> Vec<PathBuf> {
1512 fn is_weight(p: &Path) -> bool {
1513 matches!(
1514 p.extension().and_then(|e| e.to_str()),
1515 Some("safetensors") | Some("gguf")
1516 )
1517 }
1518 let mut out = Vec::new();
1519 let Ok(entries) = std::fs::read_dir(dir) else {
1520 return out;
1521 };
1522 for entry in entries.filter_map(Result::ok) {
1523 let p = entry.path();
1524 if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
1527 out.extend(weight_files(&p));
1528 } else if is_weight(&p) {
1529 out.push(p);
1530 }
1531 }
1532 out
1533}
1534
1535fn find_unrecognized(home: &Path) -> Vec<String> {
1536 let Ok(entries) = std::fs::read_dir(home) else {
1537 return Vec::new();
1538 };
1539 let mut out: Vec<String> = entries
1540 .filter_map(Result::ok)
1541 .filter_map(|e| {
1542 let name = e.file_name().to_string_lossy().to_string();
1543 let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
1544 let known = if is_dir {
1545 KNOWN_DIRS.contains(&name.as_str())
1546 } else {
1547 KNOWN_STATE_FILES.contains(&name.as_str())
1548 || KNOWN_NON_JSON_FILES.contains(&name.as_str())
1549 || TOLERATED_SUFFIXES.iter().any(|s| name.ends_with(s))
1552 || name.starts_with('.')
1554 };
1555 if known {
1556 None
1557 } else {
1558 Some(name)
1559 }
1560 })
1561 .collect();
1562 out.sort();
1563 out
1564}
1565
1566#[cfg(test)]
1567mod tests {
1568 use super::*;
1569 use tempfile::TempDir;
1570
1571 fn opts(deep: bool, repair: bool) -> DoctorOptions {
1572 DoctorOptions { deep, repair }
1573 }
1574
1575 fn diagnose_repair_with_isolated_model_roots(home: &Path) -> DoctorReport {
1576 let models_dir = TempDir::new().unwrap();
1577 let huggingface_hub = TempDir::new().unwrap();
1578 let blobs = huggingface_hub.path().join("models--fixture/blobs");
1579 std::fs::create_dir_all(&blobs).unwrap();
1580 std::fs::write(blobs.join("isolated.sync.part"), b"partial").unwrap();
1581
1582 let report = diagnose_at_isolated(
1583 home,
1584 models_dir.path(),
1585 huggingface_hub.path(),
1586 &opts(false, true),
1587 );
1588 assert_eq!(
1589 report.leftovers.len(),
1590 1,
1591 "the isolated seam must scan its injected Hugging Face root, not skip leftovers"
1592 );
1593 assert!(
1594 Path::new(&report.leftovers[0].path).starts_with(huggingface_hub.path()),
1595 "the leftover must come from the test-owned Hugging Face root"
1596 );
1597 report
1598 }
1599
1600 #[test]
1601 fn clean_home_is_healthy() {
1602 let tmp = TempDir::new().unwrap();
1603 std::fs::write(tmp.path().join("models.json"), "{}").unwrap();
1604 std::fs::create_dir_all(tmp.path().join("models")).unwrap();
1605 let r = diagnose_in(tmp.path(), &opts(false, false));
1606 assert!(r.is_healthy(), "clean home should be healthy: {r:?}");
1607 assert!(r.unrecognized.is_empty());
1608 }
1609
1610 #[test]
1611 fn secret_index_is_recognized_not_a_leftover() {
1612 let tmp = TempDir::new().unwrap();
1618 std::fs::write(tmp.path().join("models.json"), "{}").unwrap();
1619 std::fs::create_dir_all(tmp.path().join("models")).unwrap();
1620 std::fs::write(tmp.path().join("secret_index.json"), r#"{"entries":[]}"#).unwrap();
1621 let r = diagnose_in(tmp.path(), &opts(false, false));
1622 assert!(
1623 !r.unrecognized.contains(&"secret_index.json".to_string()),
1624 "secret_index.json must not be flagged as unrecognized: {:?}",
1625 r.unrecognized
1626 );
1627 assert!(
1628 r.is_healthy(),
1629 "home with a secret index should be healthy: {r:?}"
1630 );
1631 }
1632
1633 #[test]
1634 fn unparseable_state_file_is_flagged_and_backed_up_on_repair() {
1635 let tmp = TempDir::new().unwrap();
1636 std::fs::write(tmp.path().join("connectors.json"), "{not json").unwrap();
1637
1638 let r = diagnose_in(tmp.path(), &opts(false, false));
1640 let c = r
1641 .state_files
1642 .iter()
1643 .find(|f| f.name == "connectors.json")
1644 .unwrap();
1645 assert!(matches!(
1646 c.status,
1647 StateFileStatus::Unparseable {
1648 backed_up_to: None,
1649 ..
1650 }
1651 ));
1652 assert!(!r.is_healthy());
1653 assert!(
1654 tmp.path().join("connectors.json").exists(),
1655 "untouched without --repair"
1656 );
1657
1658 let r = diagnose_in(tmp.path(), &opts(false, true));
1660 let c = r
1661 .state_files
1662 .iter()
1663 .find(|f| f.name == "connectors.json")
1664 .unwrap();
1665 assert!(matches!(
1666 c.status,
1667 StateFileStatus::Unparseable {
1668 backed_up_to: Some(_),
1669 ..
1670 }
1671 ));
1672 assert!(!tmp.path().join("connectors.json").exists());
1673 assert!(tmp.path().join("connectors.json.corrupt.bak").exists());
1674 }
1675
1676 #[test]
1677 fn dotenv_env_file_is_never_parsed_or_moved() {
1678 let tmp = TempDir::new().unwrap();
1682 std::fs::write(
1683 tmp.path().join("env"),
1684 "ANTHROPIC_API_KEY=sk-secret\nFOO=bar\n",
1685 )
1686 .unwrap();
1687
1688 let r = diagnose_in(tmp.path(), &opts(false, true));
1689 assert!(
1690 r.is_healthy(),
1691 "dotenv env must not make the install unhealthy"
1692 );
1693 assert!(
1694 !r.unrecognized.contains(&"env".to_string()),
1695 "env is recognized"
1696 );
1697 assert!(
1698 r.state_files.iter().all(|f| f.name != "env"),
1699 "env is never JSON-checked"
1700 );
1701 assert!(
1702 tmp.path().join("env").exists(),
1703 "repair must not move the secrets file"
1704 );
1705 assert!(!tmp.path().join("env.corrupt.bak").exists());
1706 }
1707
1708 #[test]
1709 fn empty_state_file_is_ok() {
1710 let tmp = TempDir::new().unwrap();
1711 std::fs::write(tmp.path().join("messaging.json"), "").unwrap();
1712 let r = diagnose_in(tmp.path(), &opts(false, false));
1713 let c = r
1714 .state_files
1715 .iter()
1716 .find(|f| f.name == "messaging.json")
1717 .unwrap();
1718 assert!(matches!(c.status, StateFileStatus::Ok { .. }));
1719 }
1720
1721 #[test]
1727 #[cfg(unix)]
1728 fn interrupted_pull_is_reported_not_skipped() {
1729 let tmp = TempDir::new().unwrap();
1730 let snap = tmp.path().join("hfsnap");
1732 std::fs::create_dir_all(&snap).unwrap();
1733 std::fs::write(snap.join("config.json"), "{}").unwrap();
1734 std::fs::write(snap.join("tokenizer.json"), "{}").unwrap();
1735
1736 let m = tmp.path().join("models").join("Qwen3-4B-MLX");
1737 std::fs::create_dir_all(&m).unwrap();
1738 std::os::unix::fs::symlink(snap.join("config.json"), m.join("config.json")).unwrap();
1739 std::os::unix::fs::symlink(snap.join("tokenizer.json"), m.join("tokenizer.json")).unwrap();
1740 let r = diagnose_in(tmp.path(), &opts(false, false));
1743 let check = r
1744 .models
1745 .iter()
1746 .find(|c| c.name == "Qwen3-4B-MLX")
1747 .expect("an interrupted install must appear in the report, not be dropped");
1748 assert!(
1749 matches!(check.status, ModelStatus::Incomplete { .. }),
1750 "expected Incomplete, got {:?}",
1751 check.status
1752 );
1753 assert!(
1754 !r.is_healthy(),
1755 "a half-installed model must not read as healthy"
1756 );
1757 }
1758
1759 #[test]
1763 fn abandoned_partial_downloads_are_reported_never_deleted() {
1764 let hf = TempDir::new().unwrap();
1765 let blobs = hf.path().join("hub").join("models--x--y").join("blobs");
1766 std::fs::create_dir_all(&blobs).unwrap();
1767 let part = blobs.join("deadbeef.sync.part");
1768 std::fs::write(&part, vec![0u8; 4096]).unwrap();
1769 std::fs::write(blobs.join("finished"), b"whole").unwrap();
1770
1771 let home = TempDir::new().unwrap();
1772 let prev = std::env::var_os("HF_HOME");
1774 std::env::set_var("HF_HOME", hf.path());
1775 let r = diagnose_in(home.path(), &opts(false, true));
1776 match prev {
1777 Some(v) => std::env::set_var("HF_HOME", v),
1778 None => std::env::remove_var("HF_HOME"),
1779 }
1780
1781 assert_eq!(
1782 r.leftovers.len(),
1783 1,
1784 "expected one partial: {:?}",
1785 r.leftovers
1786 );
1787 assert_eq!(r.leftovers[0].bytes, 4096);
1788 assert!(r.leftovers[0].path.ends_with("deadbeef.sync.part"));
1789 assert!(
1790 part.exists(),
1791 "--repair must NOT delete a partial: the HF cache is shared and the \
1792 transfer may still be running"
1793 );
1794 assert!(r.is_healthy());
1796 }
1797
1798 #[test]
1801 fn repair_does_not_report_an_unchanged_version_stamp() {
1802 let tmp = TempDir::new().unwrap();
1803 let first = diagnose_in(tmp.path(), &opts(false, true));
1805 assert!(
1806 first.repairs.iter().any(|r| r.contains("version stamp")),
1807 "writing a missing stamp IS a repair: {:?}",
1808 first.repairs
1809 );
1810 let second = diagnose_in(tmp.path(), &opts(false, true));
1812 assert!(
1813 !second.repairs.iter().any(|r| r.contains("version stamp")),
1814 "an unchanged stamp is not a repair: {:?}",
1815 second.repairs
1816 );
1817 }
1818
1819 #[test]
1823 fn empty_journals_are_reaped_on_repair_only() {
1824 let tmp = TempDir::new().unwrap();
1825 let journals = tmp.path().join("journals");
1826 std::fs::create_dir_all(&journals).unwrap();
1827 let empty = journals.join("aaaaaaaaaaaa.jsonl");
1828 let full = journals.join("bbbbbbbbbbbb.jsonl");
1829 let other = journals.join("notes.txt");
1830 std::fs::write(&empty, b"").unwrap();
1831 std::fs::write(&full, b"{\"kind\":\"proposal_received\"}\n").unwrap();
1832 std::fs::write(&other, b"").unwrap();
1833
1834 let _ = diagnose_in(tmp.path(), &opts(false, false));
1836 assert!(empty.exists(), "no --repair, no deletion");
1837
1838 let r = diagnose_in(tmp.path(), &opts(false, true));
1839 assert!(!empty.exists(), "empty journal should be reaped");
1840 assert!(full.exists(), "a journal with events must be kept");
1841 assert!(other.exists(), "non-.jsonl files are not ours to remove");
1842 assert!(
1843 r.repairs.iter().any(|x| x.contains("empty event journal")),
1844 "the reap should be reported: {:?}",
1845 r.repairs
1846 );
1847 }
1848
1849 #[test]
1850 fn config_only_stub_is_skipped_not_flagged() {
1851 let tmp = TempDir::new().unwrap();
1854 let m = tmp.path().join("models").join("Stub");
1855 std::fs::create_dir_all(&m).unwrap();
1856 std::fs::write(m.join("config.json"), "{}").unwrap();
1857 let r = diagnose_in(tmp.path(), &opts(false, false));
1858 assert!(
1859 r.models.iter().all(|m| m.name != "Stub"),
1860 "stub should be skipped"
1861 );
1862 assert!(r.is_healthy());
1863 }
1864
1865 #[cfg(unix)]
1866 #[test]
1867 fn corrupt_model_weight_is_purged_on_repair() {
1868 let tmp = TempDir::new().unwrap();
1869 let m = tmp.path().join("models").join("Qwen3-Test");
1870 std::fs::create_dir_all(&m).unwrap();
1871 std::os::unix::fs::symlink(m.join("gone"), m.join("model.safetensors")).unwrap();
1873
1874 let r = diagnose_in(tmp.path(), &opts(false, false));
1875 let mc = r.models.iter().find(|m| m.name == "Qwen3-Test").unwrap();
1876 assert!(matches!(mc.status, ModelStatus::Corrupt { purged: 0, .. }));
1877
1878 let r = diagnose_in(tmp.path(), &opts(false, true));
1879 let mc = r.models.iter().find(|m| m.name == "Qwen3-Test").unwrap();
1880 match &mc.status {
1881 ModelStatus::Corrupt { purged, .. } => assert_eq!(*purged, 1),
1882 other => panic!("expected Corrupt, got {other:?}"),
1883 }
1884 assert!(std::fs::symlink_metadata(m.join("model.safetensors")).is_err());
1885 }
1886
1887 #[test]
1888 fn shared_huggingface_weights_count_as_installed() {
1889 let tmp = TempDir::new().unwrap();
1890 let models_dir = tmp.path().join("models");
1891 std::fs::create_dir(&models_dir).unwrap();
1892 let huggingface_hub = tmp.path().join("huggingface-hub");
1893 let snapshot = huggingface_hub
1894 .join("models--example--doctor-only")
1895 .join("snapshots")
1896 .join("revision");
1897 std::fs::create_dir_all(&snapshot).unwrap();
1898 std::fs::write(snapshot.join("config.json"), b"{}").unwrap();
1899 std::fs::write(snapshot.join("model.safetensors"), b"weights").unwrap();
1900
1901 let schema: crate::schema::ModelSchema = serde_json::from_value(serde_json::json!({
1902 "id": "example/doctor-only:4bit",
1903 "name": "doctor-only",
1904 "provider": "example",
1905 "family": "doctor-test",
1906 "capabilities": ["generate"],
1907 "context_length": 4096,
1908 "source": {
1909 "type": "mlx",
1910 "hf_repo": "example/doctor-only",
1911 "hf_weight_file": null
1912 }
1913 }))
1914 .unwrap();
1915 let mut registry = crate::registry::UnifiedRegistry::new_empty(models_dir.clone());
1916 registry.register(schema);
1917
1918 let report = diagnose_at_with_registry(
1919 tmp.path(),
1920 &models_dir,
1921 &opts(false, false),
1922 ®istry,
1923 Some(&huggingface_hub),
1924 );
1925 assert!(
1926 report.models.is_empty(),
1927 "shared-cache weights do not create a managed model directory check"
1928 );
1929 assert_eq!(
1930 report.installed_models, 1,
1931 "registry physical readiness must count shared-cache-only weights"
1932 );
1933 }
1934
1935 #[test]
1936 fn current_version_state_entries_are_recognized() {
1937 let tmp = TempDir::new().unwrap();
1938 for name in ["model-resource-policy.json", "parslee-auth-authority.json"] {
1939 std::fs::write(tmp.path().join(name), b"{}").unwrap();
1940 }
1941 for name in ["proposal-completed-index", "selfheal"] {
1942 std::fs::create_dir(tmp.path().join(name)).unwrap();
1943 }
1944
1945 let unrecognized = find_unrecognized(tmp.path());
1946 assert!(
1947 unrecognized.is_empty(),
1948 "current-version state must not be reported as older-install debris: {unrecognized:?}"
1949 );
1950 }
1951
1952 #[test]
1953 fn repair_never_json_validates_or_moves_the_binary_peer_identity_key() {
1954 let tmp = TempDir::new().unwrap();
1955 let key_path = tmp.path().join("peer-identity.key");
1956 let key_bytes = b"0123456789abcdef0123456789abcdef";
1957 std::fs::write(&key_path, key_bytes).unwrap();
1958
1959 let report = diagnose_repair_with_isolated_model_roots(tmp.path());
1960
1961 assert_eq!(
1962 std::fs::read(&key_path).unwrap(),
1963 key_bytes,
1964 "repair must preserve the binary peer identity byte-for-byte"
1965 );
1966 assert!(
1967 !tmp.path().join("peer-identity.key.corrupt.bak").exists(),
1968 "the non-JSON identity key must never be classified as corrupt JSON"
1969 );
1970 assert!(
1971 report
1972 .state_files
1973 .iter()
1974 .all(|file| file.name != "peer-identity.key"),
1975 "the peer identity belongs to the non-JSON allowlist"
1976 );
1977 assert!(
1978 !report
1979 .unrecognized
1980 .contains(&"peer-identity.key".to_string()),
1981 "the live identity key must be recognized"
1982 );
1983 }
1984
1985 #[test]
1989 fn repair_preserves_a_non_utf8_peer_identity_key() {
1990 let tmp = TempDir::new().unwrap();
1991 let key_path = tmp.path().join("peer-identity.key");
1992 let key_bytes = [0xff, 0xfe, 0xfd, 0x00, 0x80, 0x81, 0x82, 0x83];
1993 std::fs::write(&key_path, key_bytes).unwrap();
1994
1995 let report = diagnose_repair_with_isolated_model_roots(tmp.path());
1996
1997 assert_eq!(
1998 std::fs::read(&key_path).unwrap(),
1999 key_bytes,
2000 "repair must preserve genuinely non-UTF-8 identity bytes"
2001 );
2002 assert!(
2003 !tmp.path().join("peer-identity.key.corrupt.bak").exists(),
2004 "repair must not create a corrupt-JSON backup for a binary key"
2005 );
2006 assert!(
2007 report
2008 .state_files
2009 .iter()
2010 .all(|file| file.name != "peer-identity.key"),
2011 "the binary key must bypass the JSON state-file checker"
2012 );
2013 assert!(
2014 !report
2015 .unrecognized
2016 .contains(&"peer-identity.key".to_string()),
2017 "the binary key must take the recognized non-JSON branch"
2018 );
2019 }
2020
2021 #[test]
2022 fn unrecognized_entries_are_reported_not_removed() {
2023 let tmp = TempDir::new().unwrap();
2024 std::fs::write(tmp.path().join("mystery-leftover.json"), "{}").unwrap();
2025 std::fs::create_dir_all(tmp.path().join("old_install_dir")).unwrap();
2026 let r = diagnose_in(tmp.path(), &opts(false, true));
2027 assert!(r
2028 .unrecognized
2029 .contains(&"mystery-leftover.json".to_string()));
2030 assert!(r.unrecognized.contains(&"old_install_dir".to_string()));
2031 assert!(tmp.path().join("mystery-leftover.json").exists());
2033 assert!(tmp.path().join("old_install_dir").exists());
2034 }
2035
2036 #[test]
2037 fn version_skew_detected() {
2038 let tmp = TempDir::new().unwrap();
2039 let stale = VersionStamp {
2040 car_version: "0.0.1-ancient".to_string(),
2041 state_schema_version: 1,
2042 previous_car_version: None,
2043 previous_state_schema_version: None,
2044 };
2045 std::fs::write(
2046 tmp.path().join("version.json"),
2047 serde_json::to_string(&stale).unwrap(),
2048 )
2049 .unwrap();
2050 let r = diagnose_in(tmp.path(), &opts(false, false));
2051 assert!(r.version_skew);
2052 assert!(!r.is_healthy());
2053
2054 let _ = diagnose_in(tmp.path(), &opts(false, true));
2056 let r = diagnose_in(tmp.path(), &opts(false, false));
2057 assert!(!r.version_skew, "stamp refreshed, skew cleared");
2058 }
2059}
2060
2061#[cfg(test)]
2062mod version_stamp_continuity_tests {
2063 use super::*;
2064
2065 fn write(home: &Path, stamp: &VersionStamp) {
2066 std::fs::create_dir_all(home).unwrap();
2067 std::fs::write(
2068 home.join("version.json"),
2069 serde_json::to_string_pretty(stamp).unwrap(),
2070 )
2071 .unwrap();
2072 }
2073
2074 fn stamp(version: &str, schema: u32) -> VersionStamp {
2075 VersionStamp {
2076 car_version: version.to_string(),
2077 state_schema_version: schema,
2078 previous_car_version: None,
2079 previous_state_schema_version: None,
2080 }
2081 }
2082
2083 #[test]
2087 fn stamping_after_an_upgrade_preserves_what_it_replaced() {
2088 let dir = tempfile::tempdir().unwrap();
2089 write(dir.path(), &stamp("0.39.0", 1));
2090
2091 let t = stamp_version(dir.path()).unwrap();
2092 assert!(t.upgraded(), "0.39.0 -> current is an upgrade");
2093 assert_eq!(
2094 t.current.previous_car_version.as_deref(),
2095 Some("0.39.0"),
2096 "the predecessor must survive the stamp that replaced it"
2097 );
2098
2099 let report = diagnose_in(dir.path(), &DoctorOptions::default());
2100 assert_eq!(
2101 report.carried_from_version.as_deref(),
2102 Some("0.39.0"),
2103 "doctor must still see it AFTER the daemon stamped"
2104 );
2105 }
2106
2107 #[test]
2110 fn rebooting_on_the_same_version_keeps_the_original_predecessor() {
2111 let dir = tempfile::tempdir().unwrap();
2112 write(dir.path(), &stamp("0.39.0", 1));
2113
2114 stamp_version(dir.path()).unwrap(); for _ in 0..5 {
2116 stamp_version(dir.path()).unwrap(); }
2118
2119 let report = diagnose_in(dir.path(), &DoctorOptions::default());
2120 assert_eq!(
2121 report.carried_from_version.as_deref(),
2122 Some("0.39.0"),
2123 "five reboots must not rewrite the predecessor to the current version"
2124 );
2125 }
2126
2127 #[test]
2128 fn a_fresh_install_records_no_predecessor() {
2129 let dir = tempfile::tempdir().unwrap();
2130 let t = stamp_version(dir.path()).unwrap();
2131 assert!(t.previous.is_none());
2132 assert!(!t.upgraded());
2133 assert!(t.current.previous_car_version.is_none());
2134 assert!(!diagnose_in(dir.path(), &DoctorOptions::default()).schema_from_the_future);
2135 }
2136
2137 #[test]
2140 fn state_from_a_newer_schema_is_flagged() {
2141 let dir = tempfile::tempdir().unwrap();
2142 write(dir.path(), &stamp("99.0.0", STATE_SCHEMA_VERSION + 7));
2143
2144 let t = stamp_version(dir.path()).unwrap();
2145 assert!(
2146 t.schema_from_the_future(),
2147 "newer on-disk schema must be flagged"
2148 );
2149
2150 write(dir.path(), &stamp("99.0.0", STATE_SCHEMA_VERSION + 7));
2152 let report = diagnose_in(dir.path(), &DoctorOptions::default());
2153 assert!(report.schema_from_the_future);
2154 assert!(!report.is_healthy());
2155 }
2156
2157 #[test]
2161 fn carrying_state_across_an_upgrade_is_not_unhealthy() {
2162 let dir = tempfile::tempdir().unwrap();
2163 write(dir.path(), &stamp("0.39.0", STATE_SCHEMA_VERSION));
2164 stamp_version(dir.path()).unwrap();
2165
2166 let report = diagnose_in(dir.path(), &DoctorOptions::default());
2167 assert!(report.carried_from_version.is_some());
2168 assert!(report.is_healthy(), "an ordinary upgrade is not a defect");
2169 }
2170
2171 #[test]
2174 fn a_pre_existing_stamp_without_the_new_fields_still_loads() {
2175 let dir = tempfile::tempdir().unwrap();
2176 std::fs::create_dir_all(dir.path()).unwrap();
2177 std::fs::write(
2178 dir.path().join("version.json"),
2179 r#"{"car_version":"0.39.0","state_schema_version":1}"#,
2180 )
2181 .unwrap();
2182
2183 let t = stamp_version(dir.path()).unwrap();
2184 assert_eq!(
2185 t.previous.as_ref().map(|p| p.car_version.as_str()),
2186 Some("0.39.0")
2187 );
2188 assert_eq!(t.current.previous_car_version.as_deref(), Some("0.39.0"));
2189 }
2190
2191 #[test]
2195 fn a_corrupt_stamp_is_treated_as_absent_and_replaced() {
2196 let dir = tempfile::tempdir().unwrap();
2197 std::fs::create_dir_all(dir.path()).unwrap();
2198 std::fs::write(dir.path().join("version.json"), "{ not json").unwrap();
2199
2200 let t = stamp_version(dir.path()).unwrap();
2201 assert!(t.previous.is_none(), "unreadable == no usable predecessor");
2202 assert_eq!(t.current.car_version, VersionStamp::current().car_version);
2203 }
2204}
2205
2206#[cfg(test)]
2207mod runtime_check_tests {
2208 use super::*;
2209
2210 #[test]
2213 fn absent_runtimes_are_not_reported() {
2214 let dir = tempfile::tempdir().unwrap();
2215 assert!(check_runtimes(dir.path()).is_empty());
2216 }
2217
2218 #[cfg(unix)]
2223 #[test]
2224 fn rotated_away_interpreter_is_reported_broken() {
2225 let dir = tempfile::tempdir().unwrap();
2226 let bin = dir.path().join("visual-runtime").join("bin");
2227 std::fs::create_dir_all(&bin).unwrap();
2228 std::os::unix::fs::symlink(
2229 "/opt/homebrew/opt/python@0.0/bin/python0.0",
2230 bin.join("python"),
2231 )
2232 .unwrap();
2233
2234 let checks = check_runtimes(dir.path());
2235 assert_eq!(checks.len(), 1);
2236 assert_eq!(checks[0].name, "visual-runtime");
2237 assert!(checks[0].present);
2238 assert!(!checks[0].interpreter_ok);
2239 assert!(checks[0].is_broken());
2240 }
2241
2242 #[test]
2245 fn broken_runtime_makes_report_unhealthy() {
2246 let mut report = DoctorReport {
2247 car_home: "/tmp/x".into(),
2248 binary_version: VersionStamp::current().car_version,
2249 on_disk_stamp: None,
2250 version_skew: false,
2251 carried_from_version: None,
2252 schema_from_the_future: false,
2253 state_files: Vec::new(),
2254 models: Vec::new(),
2255 installed_models: 0,
2256 leftovers: Vec::new(),
2257 runtimes: Vec::new(),
2258 unrecognized: Vec::new(),
2259 repairs: Vec::new(),
2260 daemon_tmpdir: None,
2261 };
2262 assert!(report.is_healthy());
2263
2264 report.runtimes.push(RuntimeCheck {
2265 name: "speech-runtime".into(),
2266 root: "/tmp/x/speech-runtime".into(),
2267 present: true,
2268 interpreter_ok: false,
2269 });
2270 assert!(!report.is_healthy());
2271 }
2272}
2273
2274#[cfg(test)]
2278mod daemon_tmpdir_tests {
2279 use super::*;
2280
2281 const NOT_A_DIRECTORY: &[u8] = b"a regular file where TMPDIR should be";
2282
2283 fn marker(pid: u32, ok: bool) -> DaemonTmpdirMarker {
2284 let checked_path = "/private/tmp/PKInstallSandbox.test/tmp".to_string();
2285 DaemonTmpdirMarker {
2286 pid,
2287 booted_at_unix: 1_789_000_000,
2288 car_version: VersionStamp::current().car_version,
2289 checked_path: checked_path.clone(),
2290 checked_path_absolute: Some(checked_path),
2291 ok,
2292 error: (!ok)
2293 .then(|| "create failed: No such file or directory (os error 2)".to_string()),
2294 note: None,
2295 }
2296 }
2297
2298 fn write_marker(home: &Path, marker: &DaemonTmpdirMarker) {
2299 let path = daemon_tmpdir_marker_path(home);
2300 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2301 std::fs::write(path, serde_json::to_string_pretty(marker).unwrap()).unwrap();
2302 }
2303
2304 fn diagnose_home(home: &Path) -> DoctorReport {
2307 let models = tempfile::tempdir().unwrap();
2308 let hub = tempfile::tempdir().unwrap();
2309 diagnose_at_isolated(home, models.path(), hub.path(), &DoctorOptions::default())
2310 }
2311
2312 fn status_of(report: &DoctorReport) -> DaemonTmpdirStatus {
2313 report
2314 .daemon_tmpdir
2315 .clone()
2316 .expect("the record must be reported")
2317 .status
2318 }
2319
2320 #[cfg(unix)]
2323 fn dead_pid() -> u32 {
2324 let mut child = std::process::Command::new("true")
2325 .spawn()
2326 .expect("spawn `true`");
2327 let pid = child.id();
2328 child.wait().expect("reap `true`");
2329 assert!(
2330 !unix_pid_alive(pid),
2331 "a reaped child's pid must read as gone"
2332 );
2333 pid
2334 }
2335
2336 fn healthy_tmpdir(parent: &Path) -> PathBuf {
2337 let dir = parent.join("healthy-tmpdir");
2338 std::fs::create_dir(&dir).unwrap();
2339 dir
2340 }
2341
2342 #[test]
2343 fn probing_a_file_tmpdir_records_a_failure() {
2344 let tmp = tempfile::tempdir().unwrap();
2345 let home = tmp.path().join("car-home");
2346 let tmpdir = tmp.path().join("tmpdir-is-a-file");
2347 std::fs::write(&tmpdir, NOT_A_DIRECTORY).unwrap();
2348
2349 let recorded = record_daemon_tmpdir_probe(&home, &tmpdir)
2350 .expect("a failed probe is still a successful record");
2351
2352 assert!(!recorded.ok, "{recorded:?}");
2353 assert_eq!(recorded.pid, std::process::id());
2354 assert_eq!(recorded.checked_path, tmpdir.display().to_string());
2355 let error = recorded.error.clone().expect("a failure names its error");
2356 assert!(error.starts_with("create failed"), "{error}");
2357 #[cfg(unix)]
2358 assert!(
2359 error.contains(&format!("os error {}", libc::ENOTDIR)),
2360 "a file where the directory should be is ENOTDIR: {error}"
2361 );
2362 assert_eq!(read_daemon_tmpdir_marker(&home), Some(Ok(recorded)));
2363 assert_eq!(std::fs::read(&tmpdir).unwrap(), NOT_A_DIRECTORY);
2364 }
2365
2366 #[test]
2367 fn a_healthy_probe_replaces_a_prior_failure_and_leaves_nothing_behind() {
2368 let tmp = tempfile::tempdir().unwrap();
2369 let home = tmp.path().join("car-home");
2370 let broken = tmp.path().join("tmpdir-is-a-file");
2371 std::fs::write(&broken, NOT_A_DIRECTORY).unwrap();
2372 let healthy = healthy_tmpdir(tmp.path());
2373
2374 assert!(!record_daemon_tmpdir_probe(&home, &broken).unwrap().ok);
2375 let recorded = record_daemon_tmpdir_probe(&home, &healthy).unwrap();
2376
2377 assert!(recorded.ok, "{recorded:?}");
2378 assert_eq!(recorded.error, None);
2379 assert_eq!(recorded.checked_path_absolute.as_deref(), healthy.to_str());
2380 assert_eq!(read_daemon_tmpdir_marker(&home), Some(Ok(recorded)));
2381 #[cfg(any(unix, windows))]
2382 assert!(
2383 std::fs::read_dir(&healthy).unwrap().next().is_none(),
2384 "Unix unlinks the identity-checked probe and Windows deletes it on close"
2385 );
2386 #[cfg(all(not(unix), not(windows)))]
2387 assert_eq!(
2388 std::fs::read_dir(&healthy).unwrap().count(),
2389 1,
2390 "without portable file identity the tiny probe file is left safely behind"
2391 );
2392 let names: Vec<_> = std::fs::read_dir(home.join(DOCTOR_DIR))
2393 .unwrap()
2394 .map(|entry| entry.unwrap().file_name())
2395 .collect();
2396 assert_eq!(
2397 names,
2398 vec![std::ffi::OsString::from(DAEMON_TMPDIR_MARKER_FILE)],
2399 "no temporary record may be left behind"
2400 );
2401 }
2402
2403 #[cfg(unix)]
2404 #[test]
2405 fn a_symlinked_tmpdir_is_followed_and_its_probe_file_is_removed() {
2406 let tmp = tempfile::tempdir().unwrap();
2407 let home = tmp.path().join("car-home");
2408 let target = healthy_tmpdir(tmp.path());
2409 let link = tmp.path().join("tmpdir-link");
2410 std::os::unix::fs::symlink(&target, &link).unwrap();
2411
2412 let recorded = record_daemon_tmpdir_probe(&home, &link).unwrap();
2413
2414 assert!(
2415 recorded.ok,
2416 "a directory symlink is valid TMPDIR: {recorded:?}"
2417 );
2418 assert_eq!(recorded.checked_path, link.display().to_string());
2419 assert!(
2420 std::fs::read_dir(&target).unwrap().next().is_none(),
2421 "the identity-checked probe file is removed from the resolved directory"
2422 );
2423 }
2424
2425 #[cfg(unix)]
2426 #[test]
2427 fn cleanup_does_not_remove_a_different_file_at_the_probe_path() {
2428 let tmp = tempfile::tempdir().unwrap();
2429 let created_path = tmp.path().join("created");
2430 let probe_path = tmp.path().join("substitute");
2431 std::fs::write(&created_path, b"created").unwrap();
2432 std::fs::write(&probe_path, b"substitute").unwrap();
2433 let created = std::fs::metadata(&created_path).unwrap();
2434
2435 let error = remove_probe_file(&probe_path, &created).unwrap_err();
2436
2437 assert!(error.starts_with("remove skipped"), "{error}");
2438 assert_eq!(std::fs::read(&probe_path).unwrap(), b"substitute");
2439 }
2440
2441 #[test]
2442 fn a_stat_failure_after_create_and_write_records_healthy_with_a_note() {
2443 let tmp = tempfile::tempdir().unwrap();
2444 let home = tmp.path().join("car-home");
2445 let tmpdir = healthy_tmpdir(tmp.path());
2446 let result = combine_probe_results(
2447 Err("stat failed: injected failure".to_string()),
2448 Ok(()),
2449 Err("cleanup skipped: identity could not be proven; file left".to_string()),
2450 );
2451 let recorded = record_daemon_tmpdir_probe_result(&home, &tmpdir, result).unwrap();
2452
2453 assert!(
2454 recorded.ok,
2455 "create and write proved TMPDIR works: {recorded:?}"
2456 );
2457 assert_eq!(recorded.error, None);
2458 let note = recorded.note.as_deref().expect("stat failure is retained");
2459 assert!(note.contains("stat failed: injected failure"), "{note}");
2460 assert!(note.contains("identity could not be proven"), "{note}");
2461 assert!(note.contains("left"), "{note}");
2462
2463 let report = diagnose_home(&home);
2464 assert!(!report.daemon_tmpdir.as_ref().unwrap().is_failing());
2465 assert!(
2466 report.is_healthy(),
2467 "a note must keep doctor at exit 0: {report:?}"
2468 );
2469 }
2470
2471 #[cfg(windows)]
2472 #[test]
2473 fn windows_deletes_the_probe_file_when_its_handle_closes() {
2474 let tmp = tempfile::tempdir().unwrap();
2475 let home = tmp.path().join("car-home");
2476 let healthy = healthy_tmpdir(tmp.path());
2477
2478 let recorded = record_daemon_tmpdir_probe(&home, &healthy).unwrap();
2479
2480 assert!(recorded.ok, "{recorded:?}");
2481 assert!(
2482 std::fs::read_dir(&healthy).unwrap().next().is_none(),
2483 "FILE_FLAG_DELETE_ON_CLOSE must remove the probe file"
2484 );
2485 }
2486
2487 #[test]
2488 fn the_record_does_not_trip_find_unrecognized() {
2489 let tmp = tempfile::tempdir().unwrap();
2490 let home = tmp.path().join("car-home");
2491 let healthy = healthy_tmpdir(tmp.path());
2492
2493 record_daemon_tmpdir_probe(&home, &healthy).unwrap();
2494
2495 assert!(daemon_tmpdir_marker_path(&home).is_file());
2496 assert!(KNOWN_DIRS.contains(&DOCTOR_DIR));
2497 assert!(
2498 find_unrecognized(&home).is_empty(),
2499 "{:?}",
2500 find_unrecognized(&home)
2501 );
2502 assert!(diagnose_home(&home).unrecognized.is_empty());
2503 }
2504
2505 #[test]
2506 fn an_absent_record_is_not_an_error_and_adds_no_json_key() {
2507 let home = tempfile::tempdir().unwrap();
2508 let report = diagnose_home(home.path());
2509 assert!(report.daemon_tmpdir.is_none());
2510 assert!(report.is_healthy(), "{report:?}");
2511 let json = serde_json::to_value(&report).unwrap();
2512 assert!(
2513 json.get("daemon_tmpdir").is_none(),
2514 "additive: a report without a record serializes as before"
2515 );
2516 }
2517
2518 #[cfg(unix)]
2519 #[test]
2520 fn doctor_reads_failure_healthy_stale_and_absent_records() {
2521 let home = tempfile::tempdir().unwrap();
2522 let live = std::process::id();
2523
2524 let absent = diagnose_home(home.path());
2525 assert!(absent.daemon_tmpdir.is_none() && absent.is_healthy());
2526
2527 let failed = marker(live, false);
2528 write_marker(home.path(), &failed);
2529 let report = diagnose_home(home.path());
2530 assert_eq!(
2531 status_of(&report),
2532 DaemonTmpdirStatus::Current { marker: failed }
2533 );
2534 assert!(!report.is_healthy(), "failure from the running daemon");
2535
2536 let mut healthy = marker(live, true);
2537 healthy.checked_path = healthy_tmpdir(home.path()).display().to_string();
2538 healthy.checked_path_absolute = Some(healthy.checked_path.clone());
2539 write_marker(home.path(), &healthy);
2540 let report = diagnose_home(home.path());
2541 assert_eq!(
2542 status_of(&report),
2543 DaemonTmpdirStatus::Current { marker: healthy }
2544 );
2545 assert!(
2546 report.is_healthy(),
2547 "healthy record from the running daemon"
2548 );
2549
2550 let stale = marker(dead_pid(), false);
2551 write_marker(home.path(), &stale);
2552 let report = diagnose_home(home.path());
2553 assert_eq!(
2554 status_of(&report),
2555 DaemonTmpdirStatus::Stale { marker: stale }
2556 );
2557 assert!(report.is_healthy(), "a stale failure is not failing");
2558 }
2559
2560 #[cfg(unix)]
2561 #[test]
2562 fn a_running_daemons_failed_probe_is_unhealthy() {
2563 let home = tempfile::tempdir().unwrap();
2564 let recorded = marker(std::process::id(), false);
2565 write_marker(home.path(), &recorded);
2566
2567 let report = diagnose_home(home.path());
2568
2569 let check = report
2570 .daemon_tmpdir
2571 .clone()
2572 .expect("the record is reported");
2573 assert_eq!(
2574 check.marker_path,
2575 daemon_tmpdir_marker_path(home.path()).display().to_string()
2576 );
2577 assert_eq!(
2578 check.status,
2579 DaemonTmpdirStatus::Current { marker: recorded }
2580 );
2581 assert!(check.is_failing());
2582 assert!(!report.is_healthy(), "{report:?}");
2583 }
2584
2585 #[cfg(unix)]
2586 #[test]
2587 fn a_record_from_a_daemon_that_is_gone_is_stale_not_failing() {
2588 let home = tempfile::tempdir().unwrap();
2589 let recorded = marker(dead_pid(), false);
2590 write_marker(home.path(), &recorded);
2591
2592 let report = diagnose_home(home.path());
2593
2594 let check = report
2595 .daemon_tmpdir
2596 .clone()
2597 .expect("the record is reported");
2598 assert_eq!(check.status, DaemonTmpdirStatus::Stale { marker: recorded });
2599 assert!(!check.is_failing());
2600 assert!(report.is_healthy(), "{report:?}");
2601 }
2602
2603 #[cfg(unix)]
2604 #[test]
2605 fn a_current_ok_record_whose_checked_path_disappeared_is_failing() {
2606 let home = tempfile::tempdir().unwrap();
2607 let checked_path = healthy_tmpdir(home.path());
2608 let mut recorded = marker(std::process::id(), true);
2609 recorded.checked_path = checked_path.display().to_string();
2610 recorded.checked_path_absolute = Some(recorded.checked_path.clone());
2611 write_marker(home.path(), &recorded);
2612 std::fs::remove_dir(&checked_path).unwrap();
2613
2614 let report = diagnose_home(home.path());
2615
2616 assert_eq!(
2617 status_of(&report),
2618 DaemonTmpdirStatus::CurrentPathGone {
2619 marker: recorded.clone()
2620 }
2621 );
2622 assert!(report.daemon_tmpdir.as_ref().unwrap().is_failing());
2623 assert!(
2624 !report.is_healthy(),
2625 "the running daemon's vanished TMPDIR must produce exit 1"
2626 );
2627 }
2628
2629 #[test]
2630 fn a_non_not_found_recheck_error_is_distinct_and_failing() {
2631 let recorded = marker(std::process::id(), true);
2632 let status = recheck_current_tmpdir_with(
2633 DaemonTmpdirStatus::Current {
2634 marker: recorded.clone(),
2635 },
2636 |_| {
2637 Err(std::io::Error::new(
2638 std::io::ErrorKind::PermissionDenied,
2639 "injected permission denied",
2640 ))
2641 },
2642 );
2643
2644 assert_eq!(
2645 status,
2646 DaemonTmpdirStatus::CurrentPathRecheckFailed {
2647 marker: recorded,
2648 error: "injected permission denied".to_string(),
2649 }
2650 );
2651 let check = DaemonTmpdirCheck {
2652 marker_path: "doctor/daemon-tmpdir.json".to_string(),
2653 status,
2654 };
2655 assert!(check.is_failing());
2656 }
2657
2658 #[cfg(unix)]
2659 #[test]
2660 fn a_relative_probe_path_survives_doctor_running_from_a_different_cwd() {
2661 let runner_temp_root = std::env::temp_dir();
2662 let fixture_root = if runner_temp_root.to_str().is_some() {
2663 runner_temp_root.as_path()
2664 } else {
2665 Path::new(env!("CARGO_MANIFEST_DIR"))
2666 };
2667 assert!(
2668 fixture_root.to_str().is_some(),
2669 "the fixture root must be valid UTF-8"
2670 );
2671 let daemon_cwd = tempfile::Builder::new()
2672 .prefix("car-daemon-cwd-")
2673 .tempdir_in(fixture_root)
2674 .unwrap();
2675 let doctor_cwd = tempfile::Builder::new()
2676 .prefix("car-doctor-cwd-")
2677 .tempdir_in(fixture_root)
2678 .unwrap();
2679 assert!(daemon_cwd.path().to_str().is_some());
2680 assert!(doctor_cwd.path().to_str().is_some());
2681
2682 let relative = Path::new("relative-tmpdir");
2683 let actual = daemon_cwd.path().join(relative);
2684 std::fs::create_dir(&actual).unwrap();
2685 assert!(
2686 matches!(
2687 std::fs::metadata(doctor_cwd.path().join(relative)),
2688 Err(error) if error.kind() == std::io::ErrorKind::NotFound
2689 ),
2690 "the old display-path recheck would report this path as gone"
2691 );
2692
2693 let (exact, note) =
2694 exact_absolute_tmpdir_path(relative, Ok(daemon_cwd.path().to_path_buf()));
2695 assert_eq!(note, None);
2696 assert_eq!(exact.as_deref(), actual.to_str());
2697 let mut recorded = marker(std::process::id(), true);
2698 recorded.checked_path = relative.display().to_string();
2699 recorded.checked_path_absolute = exact;
2700
2701 let mut received_path = None;
2702 let status = recheck_current_tmpdir_with(
2703 DaemonTmpdirStatus::Current {
2704 marker: recorded.clone(),
2705 },
2706 |path| {
2707 received_path = Some(path.to_path_buf());
2708 std::fs::metadata(path).map(|_| ())
2709 },
2710 );
2711
2712 assert_eq!(received_path.as_deref(), Some(actual.as_path()));
2713 assert!(received_path
2714 .as_ref()
2715 .is_some_and(|path| path.is_absolute()));
2716 assert_eq!(status, DaemonTmpdirStatus::Current { marker: recorded });
2717 }
2718
2719 #[cfg(unix)]
2720 #[test]
2721 fn an_old_record_without_an_exact_path_skips_recheck_with_a_note() {
2722 let home = tempfile::tempdir().unwrap();
2723 let mut recorded = marker(std::process::id(), true);
2724 recorded.checked_path_absolute = None;
2725 write_marker(home.path(), &recorded);
2726
2727 let report = diagnose_home(home.path());
2728 let DaemonTmpdirStatus::Current { marker } = status_of(&report) else {
2729 panic!("old record must remain current: {:?}", report.daemon_tmpdir);
2730 };
2731
2732 assert!(marker
2733 .note
2734 .as_deref()
2735 .is_some_and(|note| note.contains("startup record has no exact absolute probe path")));
2736 assert!(
2737 report.is_healthy(),
2738 "skipping an unsafe guess is informational"
2739 );
2740 }
2741
2742 #[cfg(unix)]
2743 #[test]
2744 fn a_non_utf8_probe_path_skips_future_rechecks_with_a_note() {
2745 use std::os::unix::ffi::OsStringExt;
2746
2747 let path = PathBuf::from(std::ffi::OsString::from_vec(vec![
2748 b'/', b't', b'm', b'p', b'/', 0xff,
2749 ]));
2750 let (exact, note) = exact_absolute_tmpdir_path(&path, Ok(PathBuf::from("/ignored")));
2751
2752 assert_eq!(exact, None);
2753 assert!(note
2754 .as_deref()
2755 .is_some_and(|note| note.contains("not valid UTF-8")));
2756 }
2757
2758 #[test]
2759 fn an_ok_record_is_healthy() {
2760 let home = tempfile::tempdir().unwrap();
2761 let checked_path = healthy_tmpdir(home.path());
2762 let mut recorded = marker(std::process::id(), true);
2763 recorded.checked_path = checked_path.display().to_string();
2764 recorded.checked_path_absolute = Some(recorded.checked_path.clone());
2765 write_marker(home.path(), &recorded);
2766
2767 let report = diagnose_home(home.path());
2768
2769 #[cfg(unix)]
2770 assert_eq!(
2771 status_of(&report),
2772 DaemonTmpdirStatus::Current { marker: recorded }
2773 );
2774 #[cfg(not(unix))]
2775 assert_eq!(
2776 status_of(&report),
2777 DaemonTmpdirStatus::Unverified { marker: recorded }
2778 );
2779 assert!(report.is_healthy(), "{report:?}");
2780 }
2781
2782 #[test]
2785 fn without_a_liveness_check_a_failed_record_is_unverified_not_failing() {
2786 let recorded = marker(std::process::id(), false);
2787 let status = classify_daemon_tmpdir_marker(recorded.clone(), None);
2788 assert_eq!(status, DaemonTmpdirStatus::Unverified { marker: recorded });
2789 let check = DaemonTmpdirCheck {
2790 marker_path: "doctor/daemon-tmpdir.json".to_string(),
2791 status,
2792 };
2793 assert!(!check.is_failing());
2794 }
2795
2796 #[test]
2797 fn an_unreadable_record_is_reported_not_failing() {
2798 let home = tempfile::tempdir().unwrap();
2799 let path = daemon_tmpdir_marker_path(home.path());
2800 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2801 std::fs::write(&path, "{ not json").unwrap();
2802
2803 let report = diagnose_home(home.path());
2804
2805 assert!(
2806 matches!(status_of(&report), DaemonTmpdirStatus::Unreadable { .. }),
2807 "{:?}",
2808 report.daemon_tmpdir
2809 );
2810 assert!(report.is_healthy(), "{report:?}");
2811 }
2812
2813 #[test]
2814 fn a_record_serializes_additively_and_round_trips() {
2815 let home = tempfile::tempdir().unwrap();
2816 write_marker(home.path(), &marker(std::process::id(), true));
2817
2818 let json = serde_json::to_value(diagnose_home(home.path())).unwrap();
2819
2820 let record = json
2821 .get("daemon_tmpdir")
2822 .expect("the record serializes under `daemon_tmpdir`");
2823 assert!(
2824 record.get("status").is_some() && record.get("marker_path").is_some(),
2825 "{record}"
2826 );
2827 assert_eq!(record["marker"]["ok"], serde_json::Value::Bool(true));
2828 let back: DoctorReport = serde_json::from_value(json).unwrap();
2829 assert!(back.daemon_tmpdir.is_some());
2830 }
2831
2832 #[cfg(unix)]
2833 #[test]
2834 fn the_record_is_never_written_through_a_symlinked_doctor_dir() {
2835 let tmp = tempfile::tempdir().unwrap();
2836 let home = tmp.path().join("car-home");
2837 std::fs::create_dir(&home).unwrap();
2838 let elsewhere = tmp.path().join("elsewhere");
2839 std::fs::create_dir(&elsewhere).unwrap();
2840 std::os::unix::fs::symlink(&elsewhere, home.join(DOCTOR_DIR)).unwrap();
2841 let healthy = healthy_tmpdir(tmp.path());
2842
2843 assert!(record_daemon_tmpdir_probe(&home, &healthy).is_err());
2844 assert!(
2845 std::fs::read_dir(&elsewhere).unwrap().next().is_none(),
2846 "nothing may land where the symlink points"
2847 );
2848 }
2849
2850 #[cfg(unix)]
2851 #[test]
2852 fn a_symlink_at_the_record_path_is_replaced_not_followed() {
2853 let tmp = tempfile::tempdir().unwrap();
2854 let home = tmp.path().join("car-home");
2855 std::fs::create_dir_all(home.join(DOCTOR_DIR)).unwrap();
2856 let victim = tmp.path().join("victim");
2857 std::fs::write(&victim, b"victim").unwrap();
2858 std::os::unix::fs::symlink(&victim, daemon_tmpdir_marker_path(&home)).unwrap();
2859 let healthy = healthy_tmpdir(tmp.path());
2860
2861 let recorded = record_daemon_tmpdir_probe(&home, &healthy).unwrap();
2862
2863 assert_eq!(std::fs::read(&victim).unwrap(), b"victim");
2864 assert!(std::fs::symlink_metadata(daemon_tmpdir_marker_path(&home))
2865 .unwrap()
2866 .file_type()
2867 .is_file());
2868 assert_eq!(read_daemon_tmpdir_marker(&home), Some(Ok(recorded)));
2869 }
2870}