Skip to main content

tzcompile/
doctor.rs

1//! `doctor` (T16.6b) — a read-only environment probe for operators/packagers.
2//!
3//! Answers "is this environment fit to *verify* (and optionally cross-check) zic-rs output?" — like
4//! `brew doctor`. It **never compiles, never writes, never admits**: it inspects the host for the
5//! reference `zic`/`zdump` tools and an optional `tzdata.zi`, and reports the build identity. Every
6//! probe degrades to an explicit `absent`/`not_found` (never a silent gap), and `doctor` **always exits
7//! 0** — it is a diagnosis, not a gate.
8//!
9//! Non-claims: `doctor` reads the host env **for diagnosis only**; it admits/validates nothing, and
10//! **tool presence ≠ tool correctness ≠ an admitted reference** (admission stays the T16.3
11//! versioned-archive + integrity-pin rule). The production `compile` path requires none of these tools;
12//! only the conformance/`compare` path does (see `docs/platform-portability.md`).
13//!
14//! **T17.3 reliability hardening (schema `v1 → v2`):** a resolved tool's `--version` outcome is the typed
15//! [`ToolVersionStatus`] (a non-zero exit is `Unsupported` — old forks — never a fake version) and its
16//! hash is the typed [`HashReadStatus`] (an unreadable file is a typed status, never the literal
17//! `"unreadable"` inside a `sha256` field).
18
19use std::path::{Path, PathBuf};
20
21use crate::error::Result;
22use crate::json::escape;
23use crate::manifest::CompilerIdentity;
24
25/// The outcome of asking a resolved tool for its `--version` (T17.3 — was a bare `Option<String>` that
26/// silently turned an old fork's "unknown option" or a non-zero exit into a "version" string). The tool
27/// *ran* (or didn't) is now distinguished from the tool *has a usable version*: a `--version` that the
28/// tool rejected (old forks like OpenBSD/DragonFly `zic`) is `Unsupported`, never a fake version.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum ToolVersionStatus {
31    /// `--version` exited 0 and printed a usable first line.
32    Supported { version: String },
33    /// `--version` exited **non-zero** — the tool does not support the flag (typical of old `zic` forks,
34    /// which print usage to stderr and exit 1). The captured first line + exit status are recorded, but
35    /// it is **not** treated as a version.
36    Unsupported {
37        first_line: Option<String>,
38        exit_status: Option<i32>,
39    },
40    /// `--version` ran but neither cleanly succeeded with a version nor cleanly rejected the flag (e.g.
41    /// exited 0 with no usable output, or was terminated by a signal — `exit_status: None`).
42    CommandFailed {
43        first_line: Option<String>,
44        exit_status: Option<i32>,
45    },
46    /// The `--version` command could not be spawned at all (resolved file not executable, race, …).
47    NotRun { reason: String },
48}
49
50impl ToolVersionStatus {
51    fn to_json(&self) -> String {
52        let optline = |o: &Option<String>| {
53            o.as_ref()
54                .map(|s| escape(s))
55                .unwrap_or_else(|| "null".into())
56        };
57        let optcode = |o: &Option<i32>| o.map(|c| c.to_string()).unwrap_or_else(|| "null".into());
58        match self {
59            ToolVersionStatus::Supported { version } => {
60                format!(
61                    "{{ \"status\": \"supported\", \"version\": {} }}",
62                    escape(version)
63                )
64            }
65            ToolVersionStatus::Unsupported {
66                first_line,
67                exit_status,
68            } => format!(
69                "{{ \"status\": \"unsupported\", \"first_line\": {}, \"exit_status\": {} }}",
70                optline(first_line),
71                optcode(exit_status)
72            ),
73            ToolVersionStatus::CommandFailed {
74                first_line,
75                exit_status,
76            } => format!(
77                "{{ \"status\": \"command_failed\", \"first_line\": {}, \"exit_status\": {} }}",
78                optline(first_line),
79                optcode(exit_status)
80            ),
81            ToolVersionStatus::NotRun { reason } => {
82                format!(
83                    "{{ \"status\": \"not_run\", \"reason\": {} }}",
84                    escape(reason)
85                )
86            }
87        }
88    }
89
90    /// Short human label for the text report.
91    fn label(&self) -> String {
92        match self {
93            ToolVersionStatus::Supported { version } => version.clone(),
94            ToolVersionStatus::Unsupported { exit_status, .. } => {
95                format!(
96                    "(--version unsupported, exit {})",
97                    exit_status.unwrap_or(-1)
98                )
99            }
100            ToolVersionStatus::CommandFailed { .. } => "(--version failed)".into(),
101            ToolVersionStatus::NotRun { .. } => "(--version not run)".into(),
102        }
103    }
104}
105
106/// The outcome of reading a resolved tool's bytes for hashing (T17.3 — replaces the old habit of putting
107/// the non-hash literal `"unreadable"` into a field named `sha256`). The field is now honest by type.
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub enum HashReadStatus {
110    /// The bytes were read and hashed.
111    Read { sha256: String },
112    /// The resolved file exists but could not be read (permissions, race, …).
113    Unreadable { reason: String },
114    /// No hash was attempted (not reached in the current flow — a resolved tool is always hashed — but
115    /// kept so a future caller that defers hashing has an honest value rather than a fake hash).
116    NotAttempted { reason: String },
117}
118
119impl HashReadStatus {
120    fn to_json(&self) -> String {
121        match self {
122            HashReadStatus::Read { sha256 } => {
123                format!("{{ \"status\": \"read\", \"sha256\": {} }}", escape(sha256))
124            }
125            HashReadStatus::Unreadable { reason } => {
126                format!(
127                    "{{ \"status\": \"unreadable\", \"reason\": {} }}",
128                    escape(reason)
129                )
130            }
131            HashReadStatus::NotAttempted { reason } => {
132                format!(
133                    "{{ \"status\": \"not_attempted\", \"reason\": {} }}",
134                    escape(reason)
135                )
136            }
137        }
138    }
139
140    fn label(&self) -> String {
141        match self {
142            HashReadStatus::Read { sha256 } => {
143                // first 12 hex chars, like the lab's evidence shorthand
144                sha256.chars().take(12).collect::<String>()
145            }
146            HashReadStatus::Unreadable { .. } => "(unreadable)".into(),
147            HashReadStatus::NotAttempted { .. } => "(hash not attempted)".into(),
148        }
149    }
150}
151
152/// The presence/identity of a reference tool on the host.
153#[derive(Debug, Clone, PartialEq, Eq)]
154pub enum ToolStatus {
155    /// The tool was resolved on the host. Its `--version` outcome and hash-read outcome are **typed**
156    /// (T17.3): a rejected `--version` is `Unsupported`, never a fake version; an unreadable file is a
157    /// typed `HashReadStatus`, never the literal `"unreadable"` in a `sha256` field.
158    Present {
159        path: String,
160        version_status: ToolVersionStatus,
161        hash_status: HashReadStatus,
162    },
163    /// The tool could not be resolved (not on `PATH`, or the given path does not exist).
164    Absent,
165}
166
167impl ToolStatus {
168    fn to_json(&self) -> String {
169        match self {
170            ToolStatus::Present {
171                path,
172                version_status,
173                hash_status,
174            } => {
175                format!(
176                    "{{ \"status\": \"present\", \"path\": {}, \"version_status\": {}, \"hash_status\": {} }}",
177                    escape(path),
178                    version_status.to_json(),
179                    hash_status.to_json()
180                )
181            }
182            ToolStatus::Absent => "{ \"status\": \"absent\" }".to_string(),
183        }
184    }
185
186    fn label(&self) -> String {
187        match self {
188            ToolStatus::Present {
189                path,
190                version_status,
191                hash_status,
192            } => {
193                format!(
194                    "present — {path} [{}] ({})",
195                    version_status.label(),
196                    hash_status.label()
197                )
198            }
199            ToolStatus::Absent => "absent".into(),
200        }
201    }
202}
203
204/// The installed-`tzdata.zi` probe (explicit path only — `doctor` never reads the host tree implicitly).
205#[derive(Debug, Clone, PartialEq, Eq)]
206pub enum TzdataStatus {
207    NotProbed,
208    NotFound(String),
209    Found {
210        path: String,
211        detected_version: Option<String>,
212        sha256: String,
213    },
214}
215
216/// Options for the doctor probe.
217#[derive(Debug, Clone)]
218pub struct DoctorOptions {
219    pub reference_zic: String,
220    pub reference_zdump: String,
221    pub tzdata: Option<PathBuf>,
222}
223
224/// The doctor report.
225#[derive(Debug)]
226pub struct DoctorReport {
227    pub reference_zic: ToolStatus,
228    pub reference_zdump: ToolStatus,
229    pub tzdata: TzdataStatus,
230    pub compiler: CompilerIdentity,
231}
232
233/// The schema id. **Bumped `v1 → v2` at T17.3** (intentional, recorded): the `Present` tool object
234/// replaced the free `version: string|null` + `sha256: string` fields with the typed `version_status`
235/// and `hash_status` objects (so a rejected `--version` is `unsupported`, not a fake version, and an
236/// unreadable file is a typed status, not the literal `"unreadable"` in a `sha256` field). `doctor-v1`
237/// shipped in T16.6b earlier this cycle and has no external consumers; the bump is the honest record of
238/// the shape change rather than an in-place mutation of a frozen id.
239pub const SCHEMA: &str = "zic-rs-doctor-v2";
240
241/// Resolve a program name to a file path: an explicit path is taken as-is; a bare name is searched on
242/// `$PATH`. Returns `None` if nothing exists (→ `Absent`). `pub(crate)` so `release_diff` can reuse the
243/// one resolver to decide global-tool-unavailable vs per-identifier oracle failure (T17.3).
244pub(crate) fn resolve(program: &str) -> Option<PathBuf> {
245    let p = Path::new(program);
246    if p.is_absolute() || p.components().count() > 1 {
247        return if p.is_file() {
248            Some(p.to_path_buf())
249        } else {
250            None
251        };
252    }
253    let path = std::env::var_os("PATH")?;
254    for dir in std::env::split_paths(&path) {
255        let cand = dir.join(program);
256        if cand.is_file() {
257            return Some(cand);
258        }
259    }
260    None
261}
262
263/// Probe one reference tool (read-only): resolve it, hash it (typed [`HashReadStatus`]), and classify
264/// its `--version` outcome (typed [`ToolVersionStatus`]). T17.3: a non-zero `--version` exit is
265/// `Unsupported` (old forks), an unreadable file is a typed hash status — never a fake version or a
266/// non-hash string in a `sha256` field.
267fn probe_tool(program: &str) -> ToolStatus {
268    let Some(path) = resolve(program) else {
269        return ToolStatus::Absent;
270    };
271    let hash_status = match std::fs::read(&path) {
272        Ok(b) => HashReadStatus::Read {
273            sha256: crate::hash::sha256_hex(&b),
274        },
275        Err(e) => HashReadStatus::Unreadable {
276            reason: e.to_string(),
277        },
278    };
279    let version_status = match std::process::Command::new(&path).arg("--version").output() {
280        Err(e) => ToolVersionStatus::NotRun {
281            reason: e.to_string(),
282        },
283        Ok(o) => {
284            let pick = if o.stdout.is_empty() {
285                &o.stderr
286            } else {
287                &o.stdout
288            };
289            let first_line = String::from_utf8_lossy(pick)
290                .lines()
291                .next()
292                .map(|l| l.trim().to_string())
293                .filter(|l| !l.is_empty());
294            match o.status.code() {
295                // exited cleanly with a usable line → a real version.
296                Some(0) if first_line.is_some() => ToolVersionStatus::Supported {
297                    version: first_line.unwrap(),
298                },
299                // exited cleanly but produced nothing usable — ran, but no version.
300                Some(0) => ToolVersionStatus::CommandFailed {
301                    first_line,
302                    exit_status: Some(0),
303                },
304                // exited non-zero → the tool rejected `--version` (old fork). NOT a version.
305                Some(code) => ToolVersionStatus::Unsupported {
306                    first_line,
307                    exit_status: Some(code),
308                },
309                // terminated by a signal (no exit code).
310                None => ToolVersionStatus::CommandFailed {
311                    first_line,
312                    exit_status: None,
313                },
314            }
315        }
316    };
317    ToolStatus::Present {
318        path: path.to_string_lossy().into_owned(),
319        version_status,
320        hash_status,
321    }
322}
323
324/// Run the read-only environment probe. Never fails (a diagnosis, not a gate).
325pub fn run_doctor(opts: &DoctorOptions) -> Result<DoctorReport> {
326    let tzdata = match &opts.tzdata {
327        None => TzdataStatus::NotProbed,
328        Some(p) => match std::fs::read(p) {
329            Ok(bytes) => TzdataStatus::Found {
330                path: p.to_string_lossy().into_owned(),
331                detected_version: crate::report::sniff_tzdb_version(&bytes),
332                sha256: crate::hash::sha256_hex(&bytes),
333            },
334            Err(e) => TzdataStatus::NotFound(format!("{}: {e}", p.display())),
335        },
336    };
337    Ok(DoctorReport {
338        reference_zic: probe_tool(&opts.reference_zic),
339        reference_zdump: probe_tool(&opts.reference_zdump),
340        tzdata,
341        compiler: CompilerIdentity::capture(),
342    })
343}
344
345impl DoctorReport {
346    /// Render as deterministic JSON (`zic-rs-doctor-v2`).
347    pub fn to_json(&self) -> String {
348        let mut s = String::new();
349        s.push_str("{\n");
350        s.push_str(&format!("  \"schema\": {},\n", escape(SCHEMA)));
351        s.push_str(&crate::manifest::provenance_block_json());
352        s.push_str(
353            "  \"non_claim\": \"doctor reads the host environment for DIAGNOSIS ONLY; it admits/validates \
354             nothing. Tool presence ≠ tool correctness ≠ an admitted reference (admission stays the \
355             versioned-archive + integrity-pin rule). The production compile path needs none of these tools.\",\n",
356        );
357        s.push_str(&format!(
358            "  \"reference_zic\": {},\n",
359            self.reference_zic.to_json()
360        ));
361        s.push_str(&format!(
362            "  \"reference_zdump\": {},\n",
363            self.reference_zdump.to_json()
364        ));
365        s.push_str(&format!("  \"tzdata\": {},\n", self.tzdata_json()));
366        let c = &self.compiler;
367        let opt = |o: Option<&str>| o.map(escape).unwrap_or_else(|| "null".into());
368        s.push_str(&format!(
369            "  \"compiler_identity\": {{ \"zic_rs_version\": {}, \"rustc\": {}, \"target\": {}, \
370             \"profile\": {}, \"git_commit\": {} }}\n",
371            escape(c.zic_rs_version),
372            opt(c.rustc),
373            escape(&c.target),
374            escape(c.profile),
375            opt(c.git_commit),
376        ));
377        s.push_str("}\n");
378        s
379    }
380
381    fn tzdata_json(&self) -> String {
382        match &self.tzdata {
383            TzdataStatus::NotProbed => "{ \"status\": \"not_probed\" }".into(),
384            TzdataStatus::NotFound(reason) => {
385                format!(
386                    "{{ \"status\": \"not_found\", \"reason\": {} }}",
387                    escape(reason)
388                )
389            }
390            TzdataStatus::Found {
391                path,
392                detected_version,
393                sha256,
394            } => {
395                let v = detected_version
396                    .as_ref()
397                    .map(|s| escape(s))
398                    .unwrap_or_else(|| "null".into());
399                format!(
400                    "{{ \"status\": \"found\", \"path\": {}, \"detected_version\": {}, \"sha256\": {} }}",
401                    escape(path),
402                    v,
403                    escape(sha256)
404                )
405            }
406        }
407    }
408
409    /// Render a short human-readable summary.
410    pub fn to_text(&self) -> String {
411        let mut s = String::new();
412        s.push_str(
413            "zic-rs doctor (read-only environment probe — diagnosis only, admits nothing)\n",
414        );
415        s.push_str(&format!(
416            "  reference zic   : {}\n",
417            self.reference_zic.label()
418        ));
419        s.push_str(&format!(
420            "  reference zdump : {}\n",
421            self.reference_zdump.label()
422        ));
423        let tz = match &self.tzdata {
424            TzdataStatus::NotProbed => "not probed (pass --tzdata <path>)".to_string(),
425            TzdataStatus::NotFound(r) => format!("not found ({r})"),
426            TzdataStatus::Found {
427                path,
428                detected_version,
429                ..
430            } => format!(
431                "{path} (version {})",
432                detected_version.as_deref().unwrap_or("unknown")
433            ),
434        };
435        s.push_str(&format!("  tzdata.zi       : {tz}\n"));
436        s.push_str(&format!(
437            "  zic-rs          : {} ({} / {})\n",
438            self.compiler.zic_rs_version, self.compiler.target, self.compiler.profile
439        ));
440        s
441    }
442}