diff --git a/apps/desktop/Cargo.lock b/apps/desktop/Cargo.lock index 7074abb5e..f72f07722 100644 --- a/apps/desktop/Cargo.lock +++ b/apps/desktop/Cargo.lock @@ -3191,6 +3191,7 @@ dependencies = [ "libc", "serde", "serde_json", + "sha2 0.10.9", ] [[package]] diff --git a/apps/desktop/DOCTOR_TOOLS.md b/apps/desktop/DOCTOR_TOOLS.md new file mode 100644 index 000000000..00e499979 --- /dev/null +++ b/apps/desktop/DOCTOR_TOOLS.md @@ -0,0 +1,55 @@ +# Doctor tool coverage audit + +The fixed table in `core/src/doctor_tools.rs` attributes filtered PATH tools to +read-only CLI checks. It is not inferred from UI area names, repair recipes or +what might normally be installed together. This audit follows `doctor.ts` and +its imported read helpers at this PR's CLI base. Update it when those calls +change. A listed transitive dependency can be conditional; it does not prove +that every branch executed the helper. The coverage card always identifies the +unchecked tool; only listed dependent check IDs can be downgraded. + +| PATH tool | Actual read call and affected check IDs | +| --- | --- | +| bun, docker, brew, dnsmasq, mkcert, tmux, go, caddy, asdf | `doctor.ts::checkTool` looks up the named executable. Only its own tool-presence row (including optional/sessions suffixes) follows from this lookup. Docker's additional dependencies are below. | +| gum, fzf | `checkOptionalGum` / `checkOptionalFzf` call the UI path resolvers (override, bundled path, then PATH); only their optional presence rows. No Doctor read launches these UI tools. | +| mutagen | `checkMutagenBinary` resolves override/managed/PATH selection and runs `mutagen version`; only `mutagen`. Absolute candidates have a separate desktop preflight. | +| docker | `checkDockerRunning` runs `info`; `checkDockerNetwork` / `checkIngressSubnet` run network inspect (`network:*`); `checkProxyNetwork` -> `inspectProxyNetwork` runs ps/inspect (`proxy network`); `checkCoreDnsForwarding` -> `resolveCoreDnsServer` runs ps/inspect (`coredns forwarding`); `checkCaddyLocalCa` and `inspectProxyLoopback` -> `inspectCaddyCaIdentity` run ps/inspect/cp (`caddy local ca`, `proxy ports`); `checkCaddyHostMapping` -> `resolveGlobalCaddyIp` runs ps/inspect (`caddy hosts`). | +| docker, conditional | `checkHackLocalDns` calls `resolveGlobalCaddyIp` only for a nonstandard DNS answer (`dns:hack.local` and custom `dns:*`, excluding the separate lookup-only `dns:hack` / `dns:hack.gy` checks). `checkMacDnsmasqConfigForDomain` falls back to `resolveGlobalCaddyIp` for a dynamic address (`dnsmasq.conf:*`). | +| docker, runtime reads | `checkProjectRuntimeHygiene`, `checkWorktreeInstanceCollisions`, and lifecycle hygiene's active-Compose-project read call `readRuntimeProjects` -> docker ps/inspect (`runtime hygiene`, `worktree instances`, `lifecycle hygiene`). | +| git | `checkEnvKeyAtRoot` -> `doctor-env-key` runs ls-files/check-ignore (`env key`); `inspectWorktreeSecretKeys` -> `listGitWorktrees` (`worktree keys`); `doctor-generated-files` runs ls-files/check-ignore/rev-parse (`generated files`, `hack gitignore`); `inspectSecretsHygiene` runs git tracking/ignore queries (`secrets hygiene`); secret integrity and env materialization read shared/primary checkout inputs via `project-env-config`/`git-worktree`, and file-store key tracking via `secret-store` (`secret integrity`, `env materialization`). | +| security | `checkMacHostTlsTrust`, `checkSupersededCaddyRoots` and native `inspectNativeCaTrust` call `mac-ca-trust` -> security find-certificate/verify-cert (`host tls trust`, `superseded caddy roots`, `native root trust`, `native https owner`, `native https route`). Native owner/route results depend on the combined owner/trust observation. | +| brew | `checkMacDnsmasqConfigForDomain` runs `brew --prefix` (`dnsmasq.conf:*`); `createNativeHackLocalRouteDependencies` resolves the prefix the same way (`native hack.local route`). | +| systemctl | `resolveSystemdSummary` -> `readSystemdServiceStatus` probes/shows the user service (`daemon service`); Linux DNS host probes resolved status/version/unit reload (`linux dns route`). | +| loginctl | `readSystemdServiceStatus` -> `readLingerStatus` (`daemon service`). | +| ip, resolvectl | `inspectLinuxGlobalDns` -> `inspectLinuxDns` -> Linux DNS host reads link addresses and per-link resolver settings (`linux dns route`). | +| tmux, zellij | Lifecycle hygiene enumerates mux sessions; runtime project liveness reads tmux windows or zellij sessions (`lifecycle hygiene`, `runtime hygiene`, `worktree instances`). `sessions mux` separately looks up tmux only. | +| ps | Lifecycle hygiene -> `readProcessIdentities` spawns ps for process identity (`lifecycle hygiene`). Native DNS observation uses absolute `/bin/ps`, not this PATH selection. | +| lsof, ss | `inspectProxyLoopback` calls `listHostTcpListeners` only after a failed authenticated port probe, for extra listener detail. Keep the verified `proxy ports` warning and guidance; record unavailable auxiliary coverage on the one tool card, without downgrading the result. | + +## Explicit independent checks + +- `compose networks`: `checkComposeNetworkHygiene` reads/parses Compose YAML and + calls `analyzeComposeNetworkHygiene`. It does not invoke Docker. +- `compose backend features`: `checkComposeBackendFeatures` reads Compose and + adaptation files through `readComposeBackendReading`; pure feature decisions. +- `compose env at rest`: reads override/adoption files and reports stored literals; + no Docker read. `grafana` reads route configuration and uses Bun fetch. +- `native source size` invokes the selected native runtime metadata measurement; + it does not invoke PATH Git. Native owner/route runtime calls are not PATH ps, + lsof or caddy executions. Repairs (`sudo`, native CA repair, cache flushing) + are outside the read-only Doctor probe and never justify table entries. +- `dnsmasq:53` uses `queryDnsARecord` (UDP), not dnsmasq/dig/lsof. `resolver:*` + reads files. Native host DNS uses Node lookup. No read path invokes dig, + scutil, mkcert or openssl for these checks; they have no inferred dependency. +- `local ca trust` uses Node certificate parsing/files plus its own fixed vetted + helper PATH / absolute NSS resolver; filtering the desktop PATH does not + establish anything about those helpers. The separate NSS guard is an open + prerequisite; this table is not a mitigation for it. +- Project selection, env format/overlay file diagnostics, generated static + guidance and certificate parsing remain independent unless the exact read + helper above names a tool call. Shared/primary env inspection can query Git; + this does not make the entire Env or Project files area dependent on Git. + +No entry widens ownership, mode, ACL or symlink trust. No NSS profile discovery, +credential configuration copy, environment switch or runtime side effect is +introduced by this table. diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 7cd78c2d6..6d59b70cc 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -85,6 +85,140 @@ engine-down, error / cli-missing, many-projects, stopped / starting, running, degraded / stopping, failed-start, needs-attention): [light](screenshots/fixtures-light.png), [dark](screenshots/fixtures-dark.png). +## Doctor + +The sidebar Doctor row reads `hack doctor --json` once outside all projects and +once per registered checkout. Machine checks are deduplicated by check id; +project findings link from the project header. Unknown statuses, failed reads +and projects not reached within the 90-second refresh budget remain unchecked. +Checks refresh on opening the pane and every ten minutes while it is visible. +Stale results cannot show an all-clear message or green Doctor indicator. +Missing registry rows remain unchecked and are never probed. Compose coverage +requires a report identifying that exact checkout's Hack directory; native +coverage requires exact root selection and unchanged marker identity before and +after the read, plus a known project-only check. A machine-only or ancestor-project +report cannot establish Compose project coverage. +Machine diagnostics remain visible even when a checkout cannot be verified. + +Cards group findings by area and offer one copied command or documentation link. +Project commands include the checkout path. The app never runs a Doctor fix: +one-click fixes remain blocked on the CLI's explicit safe-fix metadata (R7a/R7b). +The probes do not use `hack global status`, which can start Docker. +Offscreen Doctor evidence: [light](screenshots/doctor-light.png) and +[dark](screenshots/doctor-dark.png). + +For integrations, `Card.authored_remedy` exposes an `AuthoredRemedy`: `argv()` +returns authored arguments without an executable or environment, including each +project root as one lossless argument. Bind these arguments to the integration's +selected, checked CLI context and retain its action gates. Do not parse or execute +the copy display string. Standalone Doctor's copy display names `hack` from the +user's terminal PATH; it does not select a candidate CLI or home. A recipe grants +no permission to run a fix, and Doctor has no dependency on Setup's context types. + +Doctor accepts owner-writable tool directories, including a normal user-owned +`/opt/homebrew/bin` with mode `0755`. A PATH directory, each selected executable, +every intermediate symlink target and canonical ancestor must be owned by this user +or root, with no group/other-write bits. A root-owned sticky ancestor such as +`/tmp` is allowed above a private entry; a shared writable PATH entry itself is +not. An unsafe executable is excluded individually, so it does not hide safe +siblings such as Git. Foreign-owned executable targets are excluded too. A +script tool's `#!` interpreter must pass the same rules (recursively); a script +whose interpreter lives in a shared writable directory is excluded and its +checks become Unknown. Only two `#!` forms are accepted: a trusted interpreter +with no arguments, or exactly `#!/usr/bin/env ` (env identified by its +real path), whose name resolves only through the private wrapper PATH. Any +other form, including shell flags such as `-e`, makes that tool Unknown. A tool +this user cannot read is not used either: its `#!` line cannot be checked, yet +the kernel would still follow it. macOS ACL mutation grants +(write/append/delete, child deletion, attributes, security or owner changes) are +refused for any principal; deny/read-only entries remain supported. Linux access +ACLs are conservatively refused. Unreadable ACL metadata is untrusted. The same +ACL policy covers Docker discovery/config paths and their link targets. This +may refuse owner-only ACL grants; it never attempts to infer group membership +or effective ACL ordering. Native ACL fixtures need hosted macOS qualification. Darwin ACL inspection does not require executable read access. NULL/ENOENT +counts as absent ACL only when pre/post link and target metadata show the path +still exists unchanged; missing, changed or unreadable metadata remains +untrusted. These observations cannot lock out same-user/root ABA changes. +Doctor checks each tool's executable and its `#!` interpreter chain, not the +shared libraries a compiled tool loads. Library search paths embedded in a binary +(ELF `RUNPATH`/`RPATH`, Mach-O `LC_RPATH` and `@rpath`/`@loader_path` load +commands), the system loader configuration and the dyld shared cache are not +validated; inherited loader variables are removed, but a library in another +user's writable directory named by the binary itself would still load. + +Each refresh creates one private temporary PATH of wrappers that exec the checked +absolute tool paths, preserving normal shim script locations and nested lookup. +Wrappers contain checked tool paths only, not credentials or injected context +values, and their temporary files are removed afterward. +Scans deduplicate absolute directories (including canonical aliases) before the +64-directory bound and inspect at most 8,192 entries. Reaching either bound +keeps already checked tools and runs Doctor with explicit incomplete coverage; +only checks dependent on filtered or unsearched Doctor tools become Unknown, +so those tools cannot look missing or healthy. One coverage card names the +unchecked tools; independently established checks keep their result. The fixed +[Doctor tool/dependency audit](DOCTOR_TOOLS.md) excludes unrelated executables +and documents actual read calls and file-only exclusions. For a scan cutoff +or unreadable directory, tools already checked remain available and only +unsearched Doctor tools receive incomplete coverage. +A root:admin group-writable `/Applications` or app-bundle target is refused too; +this preview does not widen trust for Docker Desktop or OrbStack. The lead's +Mac pass must qualify these layouts; a skip is not proof Docker is missing. This is an ownership/permission boundary, +not a sandbox or content/signature check: trusted tools, their plugins and +same-user/root updates remain trusted. Real macOS/Homebrew behavior still needs +the combined Mac pass; the layout and nested shims are tested with owned fixtures. +Those fixtures use only a temporary Homebrew-style tree and an owned symlink +alias; expected shim paths come from the fixture's canonical root, never from +the runner's real Homebrew installation or filesystem modes. + +The fixed absolute Mutagen override (`HACK_MUTAGEN_PATH`) or managed +`HACK_HOME/bin/mutagen` candidate, an absolute `HACK_NATIVE_BINARY` (or, when it +is unset, the `hack-native` the CLI would take from beside its own executable) +and every discovered Docker plugin are checked before Doctor too, including the same `#!` +interpreter rule (the native runtime does not get the `env` form); Doctor's +runs also remove inherited interpreter startup and preload variables +(`BASH_ENV`, `ENV`, `SHELLOPTS`, `PS4`, exported Bash functions, Perl, Python, +Ruby and Node options and paths, the Bun runtime's `BUN_OPTIONS`, `BUN_BE_BUN`, +`BUN_CONFIG_*` and `BUN_INSPECT*` (the CLI is a Bun-compiled binary), and every +`LD_*` and `DYLD_*` loader variable), so a checked tool script cannot be made to load code from the +environment; missing candidates +require protected ancestry, and relative/unprovable selections are refused. +For a compiled CLI, the `hack-lifecycle-supervisor` companion beside it (which +Doctor's project checks run by absolute path for projects using the supervisor +runner, ignoring any selector) must pass the same rule; an unsafe one refuses +Doctor. When the CLI is a `#!` launcher, the supervisor it would finally run cannot +be located or checked by the app. +Doctor's runs pin the checked native runtime through `HACK_NATIVE_BINARY`. When the +CLI is a `#!` launcher, the executable it finally runs (and so its fallback +directory) cannot be known, so Doctor pins an absent path inside its private +wrapper directory and native checks report the runtime as unavailable; set +`HACK_NATIVE_BINARY` to check a bundled runtime in that layout. +Docker plugin preflight preserves HOME, DOCKER_CONFIG and DOCKER_CONTEXT. Doctor's +automatic checks only use a local Docker socket: a non-`unix://` `DOCKER_HOST`, +or a selected context whose endpoint is remote (for example `ssh://`) or cannot +be found, refuses the probe, because Docker would start programs such as `ssh` +and its configured helpers that Doctor does not inspect. It +checks the selected config/plugin directories, configured `cliPluginsExtraDirs`, +the four standard Unix system plugin directories and plugin executable/symlink +targets with the same owner/mode/ancestor rule. It streams only the public extra +directory list from config JSON; credentials and context contents are ignored, +never retained, logged, copied or rewritten. Config parsing is bounded to 1 MiB; +plugin inspection to 64 directories and 8,192 entries. Unsafe or unprovable +discovery produces CouldNotCheck with a reason before the Doctor CLI/Docker probe. + +This supports the documented standard Unix Docker CLI discovery model, not +opaque/custom discovery. A nonempty `DOCKER_CLI_PLUGIN_EXTRA_DIRS`, relative +discovery path or unsupported/unreadable config is conservatively refused, +without switching to another context or home. Custom/bundled discovery and real +Mac behavior still require reviewer assessment and the combined Mac pass; this +is not a general Docker sandbox. Native pre/post marker witnesses are read-only +observations, not locks against concurrent same-user changes. + +## What v0 does not do yet + +The Activity tab, CPU and memory, onboarding, always-on, daemon actions and +upgrades come in later units. The design and the CLI/daemon requests they need +are in `DESIGN.md` (PR #800). + ## Logs tab `hack logs --json` for the selected project, following by default: @@ -108,12 +242,6 @@ reads for observed stopped projects include that state alongside the stream stat The paused fixture also demonstrates the overflow count. Pause has no timeout; following resumes through Resume, not automatically when scrolling to the bottom. -## Not yet - -The Activity tab, CPU and memory, onboarding, always-on, daemon actions and -upgrades come in later units. The design and the CLI/daemon requests they -need are in `DESIGN.md`. - ## Env tab (names only) Env reads `hack env list --json --path ` on tab open, project selection, diff --git a/apps/desktop/app/src/doctor_view.rs b/apps/desktop/app/src/doctor_view.rs new file mode 100644 index 000000000..9656ba83c --- /dev/null +++ b/apps/desktop/app/src/doctor_view.rs @@ -0,0 +1,276 @@ +//! The Doctor pane (DESIGN.md section 3): findings as cards grouped by area, +//! each with severity, what it affects and exactly one action. +//! +//! Actions only copy a command or open a doc. A safe one-click "Run" waits +//! for the CLI to report fix kinds (design request R7a). + +use gpui_kit::component::{ + ActiveTheme, Sizable as _, + button::{Button, ButtonVariants as _}, + h_flex, + tag::Tag, + v_flex, +}; +use gpui_kit::{ + AnyElement, ClipboardItem, Context, FontWeight, IntoElement, ParentElement, SharedString, + Styled, div, prelude::*, px, +}; +use std::time::Instant; + +use hack_desktop_core::doctor::{Card, CardAction, DoctorPane, DoctorState, DoctorView, Severity}; + +use crate::view::HackApp; + +/// Which pane the right side shows. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Pane { + #[default] + Project, + Doctor, +} + +pub fn render(pane: &DoctorPane, cx: &mut Context) -> AnyElement { + let t = cx.theme().clone(); + let loading = pane.is_loading(); + let header = h_flex() + .px_5() + .pt_4() + .pb_3() + .gap_3() + .border_b_1() + .border_color(t.border) + .child( + div() + .text_xl() + .font_weight(FontWeight::BOLD) + .child("Doctor"), + ) + .child(div().flex_1()) + .children( + pane.age_label(Instant::now()) + .map(|a| div().text_sm().text_color(t.muted_foreground).child(a)), + ) + .child( + Button::new("doctor-recheck") + .label(if loading { + "Checking…" + } else { + "Check again" + }) + .small() + .outline() + .loading(loading) + .on_click(cx.listener(|this, _, _, cx| this.load_doctor(cx))), + ); + let body: AnyElement = match (&pane.result, loading) { + (DoctorState::NeverRun, true) => message( + "Running doctor…", + "Checks the machine and each project; this takes up to 90 seconds.", + cx, + ), + (DoctorState::NeverRun, false) => message( + "Doctor has not run yet", + "Checks the engine, routing, certificates, the daemon and each project.", + cx, + ), + (DoctorState::Error(why), _) => message("Doctor could not run", why, cx), + (DoctorState::Loaded(v), _) => cards(v, pane, cx), + }; + v_flex() + .flex_1() + .min_w_0() + .h_full() + .child(header) + .children(pane.stale.then(|| { + div() + .px_5() + .py_1() + .text_xs() + .bg(t.warning.opacity(0.15)) + .child("Projects changed while checking; this result may be out of date. Checking again.") + })) + .child(body) + .into_any_element() +} + +fn message(title: &str, body: &str, cx: &Context) -> AnyElement { + let t = cx.theme(); + v_flex() + .flex_1() + .items_center() + .justify_center() + .gap_2() + .child( + div() + .text_lg() + .font_weight(FontWeight::SEMIBOLD) + .child(title.to_owned()), + ) + .child( + div() + .text_sm() + .text_color(t.muted_foreground) + .child(body.to_owned()), + ) + .into_any_element() +} + +fn cards(v: &DoctorView, pane: &DoctorPane, cx: &mut Context) -> AnyElement { + let t = cx.theme().clone(); + let mut col = v_flex() + .id("doctor-cards") + .flex_1() + .min_h_0() + .overflow_y_scroll() + .px_5() + .py_3() + .gap_4(); + if let Some(p) = &pane.filter { + col = col.child( + h_flex() + .gap_2() + .text_sm() + .child(format!("Showing findings for {p}")) + .child( + Button::new("doctor-show-all") + .label("Show all") + .xsmall() + .ghost() + .on_click(cx.listener(|this, _, _, cx| this.open_doctor(None, cx))), + ), + ); + } + if let Some(line) = v.coverage_line() { + col = col.child(div().text_sm().text_color(t.warning).child(line)); + } + let shown = |c: &&Card| { + pane.filter + .as_deref() + .is_none_or(|p| c.affected.includes(p)) + }; + let any_shown = v.cards().any(|c| shown(&c)); + if !any_shown { + let (dot_color, text) = if pane.stale { + (t.warning, "Results are out of date; check again") + } else if v.is_clean() { + (t.success, "No problems found") + } else if pane.filter.is_some() { + (t.muted_foreground, "No findings for this project") + } else { + (t.warning, "No problems found in the checks that ran") + }; + col = col.child( + h_flex() + .gap_2() + .text_sm() + .child(div().size(px(8.)).rounded_full().bg(dot_color)) + .child(text), + ); + } + for (area, list) in &v.groups { + let visible: Vec<&Card> = list.iter().filter(|c| shown(c)).collect(); + if visible.is_empty() { + continue; + } + let mut group = v_flex().gap_2().child( + div() + .text_xs() + .font_weight(FontWeight::SEMIBOLD) + .text_color(t.muted_foreground) + .child(area.label().to_uppercase()), + ); + for c in visible { + group = group.child(card(c, cx)); + } + col = col.child(group); + } + for (who, why) in &v.failed_runs { + col = col.child( + div() + .text_sm() + .text_color(t.danger) + .child(format!("Could not check {who}: {why}")), + ); + } + col = col.child( + div() + .text_sm() + .text_color(t.muted_foreground) + .child(format!("{} checks passed", v.passed)), + ); + col.into_any_element() +} + +fn card(c: &Card, cx: &mut Context) -> AnyElement { + let t = cx.theme().clone(); + let tag = match c.severity { + Severity::Error => Tag::danger().small().rounded_full().child("Error"), + Severity::Warn => Tag::warning().small().rounded_full().child("Warning"), + Severity::CouldNotCheck => Tag::secondary() + .small() + .rounded_full() + .child("Could not check"), + }; + let id = SharedString::from(format!("doctor-action-{}-{}", c.id, c.affected.label())); + let action = match &c.action { + CardAction::Copy(cmd) => { + let cmd = cmd.clone(); + Button::new(id) + .label("Copy command") + .small() + .outline() + .on_click(cx.listener(move |_, _, _, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(cmd.clone())); + })) + } + CardAction::OpenDoc(url) => { + let url = url.clone(); + Button::new(id) + .label("Open doc") + .small() + .ghost() + .on_click(cx.listener(move |_, _, _, cx| cx.open_url(&url))) + } + }; + let command = match &c.action { + CardAction::Copy(cmd) => Some( + div() + .font_family(t.mono_font_family.clone()) + .text_xs() + .px_2() + .py_0p5() + .rounded_sm() + .bg(t.muted) + .child(cmd.clone()), + ), + CardAction::OpenDoc(_) => None, + }; + v_flex() + .gap_1() + .p_3() + .rounded_md() + .border_1() + .border_color(t.border) + .child( + h_flex() + .gap_2() + .child(tag) + .child(div().font_weight(FontWeight::SEMIBOLD).child(c.id.clone())) + .child(div().flex_1()) + .child(action), + ) + .when(!c.detail.is_empty(), |d| { + d.child(div().text_sm().child(c.detail.clone())) + }) + .child( + h_flex() + .gap_3() + .text_xs() + .text_color(t.muted_foreground) + .child(c.affected.label()) + .children(command) + .child(div().flex_1()) + .child("Copy only: no one-click fix yet"), + ) + .into_any_element() +} diff --git a/apps/desktop/app/src/main.rs b/apps/desktop/app/src/main.rs index 84553a28b..27740b634 100644 --- a/apps/desktop/app/src/main.rs +++ b/apps/desktop/app/src/main.rs @@ -9,6 +9,7 @@ //! fixture, light and dark, offscreen //! ``` +mod doctor_view; mod logs_view; #[cfg(feature = "screenshots")] mod screenshots; @@ -136,8 +137,15 @@ fn main() { } cx.new(|cx| { let mut app = view::HackApp::new(source, fixture.is_none(), window, cx); + app.watch_window(window, cx); if let Some(f) = fixture { app.select(fixtures::selected(f)); + if fixtures::loads_doctor(f) { + app.show_doctor_now(); + if !fixtures::opens_doctor(f) { + app.show_project(); + } + } if f.name().starts_with("env-") { app.show_env_fixture(); } diff --git a/apps/desktop/app/src/screenshots.rs b/apps/desktop/app/src/screenshots.rs index 88f81675a..686a92ca2 100644 --- a/apps/desktop/app/src/screenshots.rs +++ b/apps/desktop/app/src/screenshots.rs @@ -52,6 +52,12 @@ pub fn run(dir: &Path, fixture: Option) { let mut app = HackApp::new(crate::source_for(Some(f)), false, window, cx); app.select(fixtures::selected(f)); + if fixtures::loads_doctor(f) { + app.show_doctor_now(); + if !fixtures::opens_doctor(f) { + app.show_project(); + } + } if f.name().starts_with("env-") { app.show_env_fixture(); } diff --git a/apps/desktop/app/src/view.rs b/apps/desktop/app/src/view.rs index 72032cc3a..422425f08 100644 --- a/apps/desktop/app/src/view.rs +++ b/apps/desktop/app/src/view.rs @@ -21,8 +21,16 @@ use gpui_kit::{ AnyElement, Context, FontWeight, Hsla, IntoElement, ParentElement, Render, SharedString, Styled, Window, div, prelude::*, px, }; +use hack_desktop_core::doctor::{DoctorPane, Finish, TargetKey, should_auto_refresh}; use hack_desktop_core::models::LogEntry; -use hack_desktop_core::source::{ActionOutcome, DaemonRecovery, Source}; +use hack_desktop_core::source::{ + ActionOutcome, DaemonRecovery, DoctorTarget, Snapshot, Source, SourceError, +}; + +use crate::doctor_view::Pane; +use hack_desktop_core::doctor::DoctorState as DoctorStateView; + +use hack_desktop_core::source::target_keys; use hack_desktop_core::state::{Action, ProjectState, ServiceView, Tone}; use hack_desktop_core::viewmodel::{ AppModel, DetailView, Event, ListView, Phase, RefreshToken, Row, @@ -39,6 +47,12 @@ pub struct HackApp { tail: Result, String>, follow: bool, live: bool, + pane: Pane, + doctor: DoctorPane, + /// Is the window visible (not minimised or hidden)? Automatic doctor + /// runs wait for it. + window_visible: bool, + _visibility: Option, recovery_busy: bool, recovery_error: Option, /// When each project's running action started, for the slow-action @@ -60,6 +74,10 @@ impl HackApp { source, logs, tab: 0, + pane: Pane::Project, + doctor: DoctorPane::default(), + window_visible: true, + _visibility: None, env: Default::default(), tail: Ok(Vec::new()), follow: true, @@ -70,6 +88,7 @@ impl HackApp { }; if live { this.start_polling(cx); + this.start_doctor_refresh(cx); } else { this.refresh_now(); } @@ -102,12 +121,149 @@ impl HackApp { ); } + /// Fixtures and screenshots: open the Doctor pane with its data loaded. + pub fn show_doctor_now(&mut self) { + self.pane = Pane::Doctor; + let targets = self.doctor_targets(); + let names = self.doctor_target_keys(); + let generation = self.doctor.begin(names.as_deref()); + let runs = self + .source + .doctor_runs(targets.as_deref().unwrap_or_default()); + self.doctor.finish( + generation, + &runs, + names.as_deref(), + std::time::Instant::now(), + ); + } + + /// `None` when the project list has not been read: doctor then runs + /// machine checks only and reports the projects as not checked. + fn doctor_targets(&self) -> Option> { + if !self.model.project_list_readable() { + return None; + } + self.model + .snapshot() + .map(|s| DoctorTarget::from_projects(&s.projects)) + } + + fn doctor_target_keys(&self) -> Option> { + self.doctor_targets().map(|t| target_keys(&t)) + } + + /// Run doctor (machine run plus one per project) off the UI thread. + /// A newer run supersedes an older one; a result whose project set no + /// longer matches is shown as stale and checked again. + pub fn load_doctor(&mut self, cx: &mut Context) { + if !self.live { + self.show_doctor_now(); + cx.notify(); + return; + } + let targets = self.doctor_targets().unwrap_or_default(); + let generation = self.doctor.begin(self.doctor_target_keys().as_deref()); + cx.notify(); + let source = self.source.clone(); + cx.spawn(async move |this, cx| { + let runs = cx + .background_executor() + .spawn(async move { source.doctor_runs(&targets) }) + .await; + let _ = this.update(cx, |this, cx| { + let current = this.doctor_target_keys(); + let outcome = this.doctor.finish( + generation, + &runs, + current.as_deref(), + std::time::Instant::now(), + ); + if outcome == Finish::AppliedStale { + // Re-run now only if someone can see it; otherwise the + // stale result re-runs when the window is shown again. + this.maybe_auto_refresh(cx); + } + cx.notify(); + }); + }) + .detach(); + } + + /// Open the Doctor pane, optionally filtered to one project, and run + /// doctor when it never ran or the result is older than 10 minutes. + pub fn open_doctor(&mut self, filter: Option, cx: &mut Context) { + self.pane = Pane::Doctor; + self.doctor.filter = filter; + if self.doctor.needs_refresh(std::time::Instant::now()) { + self.load_doctor(cx); + } + cx.notify(); + } + + /// Start a doctor run if the pane is shown in a visible window and the + /// result is missing, old or stale. Returns whether it started one. + fn maybe_auto_refresh(&mut self, cx: &mut Context) -> bool { + let start = should_auto_refresh( + self.pane == Pane::Doctor, + self.window_visible, + &self.doctor, + std::time::Instant::now(), + ); + if start { + self.load_doctor(cx); + } + start + } + + /// Track window visibility; when it becomes visible again, refresh a + /// missing, old or stale doctor result. + pub fn watch_window(&mut self, window: &mut Window, cx: &mut Context) { + self.window_visible = window.is_visible(); + self._visibility = Some(cx.observe_window_visibility( + window, + |this, visibility, _window, cx| { + this.window_visible = visibility.is_visible(); + this.maybe_auto_refresh(cx); + }, + )); + } + + /// While the Doctor pane is visible, refresh every 10 minutes. + fn start_doctor_refresh(&mut self, cx: &mut Context) { + cx.spawn(async move |this, cx| { + loop { + cx.background_executor() + .timer(std::time::Duration::from_secs(60)) + .await; + let alive = this.update(cx, |this, cx| { + if !this.maybe_auto_refresh(cx) + && this.pane == Pane::Doctor + && this.window_visible + { + // Keep "Checked N min ago" current. + cx.notify(); + } + }); + if alive.is_err() { + return; + } + } + }) + .detach(); + } + + /// Fixtures: show the selected project (after loading doctor). + pub fn show_project(&mut self) { + self.pane = Pane::Project; + } + /// Apply a fresh snapshot. If it changed the selected project (the old /// one disappeared), the Logs tab's stream follows the new selection. fn apply_refresh( &mut self, token: RefreshToken, - snap: Result, + snap: Result, cx: &mut Context, ) -> bool { let before = self.model.selected_row().map(|r| r.name.clone()); @@ -137,6 +293,12 @@ impl HackApp { self.open_logs(cx); } } + // A new, removed or moved project (or a project list that becomes + // readable) makes a shown Doctor result stale, so a visible Doctor + // pane checks again. + let current = self.doctor_target_keys(); + self.doctor.targets_seen(current.as_deref()); + self.maybe_auto_refresh(cx); cx.notify(); true } @@ -463,16 +625,71 @@ impl HackApp { .py_2() // v0 does not read routing status yet: shown as unknown. .child(machine_row("Routing and DNS", None, t.muted_foreground)) - .child(machine_row( - "Doctor", - (list.doctor_count > 0).then_some(list.doctor_count), - // Unknown until Doctor has actually reported. - match (list.doctor_known, list.doctor_count) { - (false, _) => t.muted_foreground, - (true, 0) => t.success, - (true, _) => t.warning, - }, - )), + .child(self.doctor_row(cx)), + ) + } + + /// The sidebar Doctor row: opens the Doctor pane (and runs doctor the + /// first time). Its badge counts the loaded findings when there are any. + fn doctor_row(&self, cx: &mut Context) -> AnyElement { + let t = cx.theme().clone(); + // Never green unless every run finished clean; grey until it ran. + let (count, color) = match &self.doctor.result { + DoctorStateView::Loaded(v) => (v.attention_count(), tone_color(self.doctor.tone(), cx)), + _ => (0, tone_color(self.doctor.tone(), cx)), + }; + div() + .id("doctor-row") + .rounded_md() + .cursor_pointer() + .when(self.pane == Pane::Doctor, |d| { + d.bg(t.sidebar_accent) + .text_color(t.sidebar_accent_foreground) + }) + .hover(|d| d.bg(t.sidebar_accent)) + .mx_2() + .px_2() + .py_1() + .child( + h_flex() + .gap_2() + .child(dot(color)) + .child(div().flex_1().child("Doctor")) + .children((count > 0).then(|| { + Tag::warning() + .xsmall() + .rounded_full() + .child(count.to_string()) + })), + ) + .on_click(cx.listener(|this, _, _, cx| this.open_doctor(None, cx))) + .into_any_element() + } + + /// "Doctor: N findings for this project", linking to the Doctor pane + /// filtered on the project. A link only: it never changes the state pill. + fn doctor_link(&self, project: &str, cx: &mut Context) -> Option { + let n = self.doctor.findings_for(project); + if n == 0 { + return None; + } + let t = cx.theme().clone(); + let name = project.to_owned(); + Some( + div() + .id("doctor-link") + .text_sm() + .text_color(t.link) + .underline() + .cursor_pointer() + .child(format!( + "Doctor: {n} finding{} for this project", + if n == 1 { "" } else { "s" } + )) + .on_click(cx.listener(move |this, _, _, cx| { + this.open_doctor(Some(name.clone()), cx); + })) + .into_any_element(), ) } @@ -486,7 +703,7 @@ impl HackApp { .py_1() .gap_2() .rounded_md() - .when(r.selected, |d| { + .when(r.selected && self.pane == Pane::Project, |d| { d.bg(t.sidebar_accent) .text_color(t.sidebar_accent_foreground) }) @@ -504,6 +721,7 @@ impl HackApp { }) .on_click(cx.listener(move |this, _, _, cx| { this.model.apply(Event::Select(name.clone())); + this.pane = Pane::Project; this.env.begin(); if this.tab == 2 { this.read_env(cx); @@ -628,6 +846,7 @@ impl HackApp { ), ) .children(reason_line(&d.state, d.last_error.as_deref(), cx)) + .children(self.doctor_link(&d.name, cx)) .children( self.slow_action_warning(&d.name) .map(|w| div().text_sm().text_color(t.warning).child(w)), @@ -1083,6 +1302,12 @@ impl Render for HackApp { let t = cx.theme().clone(); let (bg, fg, font) = (t.background, t.foreground, t.font_family.clone()); let main: AnyElement = match &list.phase { + // Machine checks matter most before any project exists, so the + // Doctor pane renders for an empty registry too. + // Diagnostics are most useful when the project list fails. + Phase::Ready | Phase::Empty | Phase::Error { .. } if self.pane == Pane::Doctor => { + crate::doctor_view::render(&self.doctor, cx) + } Phase::CliMissing => self.full_pane( "Hack CLI not found", "The app runs the hack command for everything. Install it, then reopen the app." diff --git a/apps/desktop/core/Cargo.toml b/apps/desktop/core/Cargo.toml index 7eddaaa5f..02d3488f5 100644 --- a/apps/desktop/core/Cargo.toml +++ b/apps/desktop/core/Cargo.toml @@ -11,5 +11,6 @@ publish.workspace = true serde.workspace = true serde_json.workspace = true libc = "0.2" +sha2 = "=0.10.9" [dev-dependencies] diff --git a/apps/desktop/core/src/doctor.rs b/apps/desktop/core/src/doctor.rs new file mode 100644 index 000000000..ee6b27a95 --- /dev/null +++ b/apps/desktop/core/src/doctor.rs @@ -0,0 +1,2026 @@ +//! Doctor view model (DESIGN.md section 3). +//! +//! Turns several `hack doctor --json` runs (one outside any project, one per +//! registered project) into cards grouped by area: +//! - Machine-wide findings are shown once, whichever runs reported them. +//! - Project findings carry the projects they affect (name and checkout). +//! - Only an explicit `ok` counts as passed; a missing or unfamiliar status +//! is a "Could not check" card, never a pass. +//! - Each card has exactly one action: copy one exact command (with +//! `--path ''` for project repairs) or open a doc. A one-click "Run" +//! waits for the CLI to report fix kinds (design request R7a). +//! - A run that failed or was not started keeps the view from ever looking +//! clean. +//! +//! Pure: no I/O. Reading the reports lives in [`crate::source`]. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use crate::models::{DoctorCheck, DoctorReport}; +use crate::state::Tone; + +/// Where a check belongs in the Doctor view, in display order. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Area { + Engine, + RoutingDns, + Certificates, + Daemon, + EnvSecrets, + ProjectFiles, + Native, + Other, +} + +impl Area { + #[must_use] + pub fn label(self) -> &'static str { + match self { + Self::Engine => "Engine", + Self::RoutingDns => "Routing and DNS", + Self::Certificates => "Certificates", + Self::Daemon => "Daemon", + Self::EnvSecrets => "Env and secrets", + Self::ProjectFiles => "Project files", + Self::Native => "Native", + Self::Other => "Other", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Scope { + Machine, + Project, +} + +/// Area and scope for a check `id` as `src/commands/doctor.ts` names it. +/// `None` for ids this table does not know (shown under "Other"). +#[must_use] +pub fn classify(id: &str) -> Option<(Area, Scope)> { + use Area::*; + use Scope::*; + let exact = match id { + "docker" + | "docker compose" + | "docker daemon" + | "docker plugin discovery" + | "bun" + | "mutagen" => (Engine, Machine), + "compose backend features" => (Engine, Project), + "proxy ports" + | "proxy network" + | "dnsmasq:53" + | "coredns forwarding" + | "linux dns route" + | "global files" + | "grafana" + | "browser local network" => (RoutingDns, Machine), + // Each project's internal host mapping (doctor.ts checkCaddyHostMapping). + "caddy hosts" | "DEV_HOST" => (RoutingDns, Project), + "caddy local ca" | "host tls trust" | "local ca trust" | "superseded caddy roots" => { + (Certificates, Machine) + } + "daemon" | "daemon service" | "gateway config" | "gateway tokens" => (Daemon, Machine), + "env key" + | "env mode" + | "env materialization" + | "env overlay warnings" + | "worktree keys" + | "secret store" + | "secret integrity" + | "secrets hygiene" + | "compose env at rest" => (EnvSecrets, Project), + "project" | "runtime hygiene" | "lifecycle hygiene" | "compose networks" + | "generated files" | "hack gitignore" | "worktree instances" | "sessions mux" => { + (ProjectFiles, Project) + } + "native host dns" + | "native hack.local route" + | "native hack.local repair" + | "native root trust" + | "native ca repair" + | "native https owner" + | "native https route" => (Native, Machine), + "native settings" | "native source size" | "amd64 emulation" => (Native, Project), + _ => return prefixed(id), + }; + Some(exact) +} + +fn prefixed(id: &str) -> Option<(Area, Scope)> { + if id.starts_with("resolver:") + || id.starts_with("dnsmasq.conf:") + || id.starts_with("dns:") + || id.starts_with("network:") + { + return Some((Area::RoutingDns, Scope::Machine)); + } + if id.starts_with("native ") { + return Some((Area::Native, Scope::Project)); + } + if id.ends_with("(optional)") || id.ends_with("(sessions)") { + return Some((Area::Engine, Scope::Machine)); + } + None +} + +/// Checks the CLI runs both outside a project (auditing the global Hack +/// home) and inside one: their scope follows the run that reported them. +/// (`sessions mux` resolves the global `sessions.mux` setting outside a +/// project, doctor.ts checkSessionsMuxConfig.) +const RUN_SCOPED_IDS: &[&str] = &["secrets hygiene", "sessions mux"]; + +/// The single action a card offers. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CardAction { + /// Copy this exact command; the user runs it (in a terminal for admin steps). + Copy(String), + /// Open this documentation page. + OpenDoc(String), +} + +/// An authored Hack CLI recipe, without an executable or environment. +/// Consumers must bind `argv()` to their selected, checked CLI context and +/// retain their own action-admission rules. A recipe is not permission to run +/// a fix: standalone Doctor remains copy-only until R7a/R7b. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthoredRemedy { + fixed: &'static str, + project_name: Option, + project_root: Option, +} + +impl AuthoredRemedy { + fn fixed(command: &'static str) -> Self { + Self { + fixed: command, + project_name: None, + project_root: None, + } + } + + /// Arguments only: no `hack` executable, shell parsing or environment. + /// Dynamic names and roots remain individual arguments, including roots + /// that contain whitespace, quotes, newlines or non-UTF-8 bytes. + #[must_use] + pub fn argv(&self) -> Vec { + // Only authored static constants reach `fixed`; finding text never + // supplies these words. Dynamic values are appended separately. + let mut args: Vec<_> = self + .fixed + .split_ascii_whitespace() + .skip(1) + .map(std::ffi::OsString::from) + .collect(); + if let Some(name) = &self.project_name { + args.extend(["--project".into(), name.into()]); + } + if let Some(root) = &self.project_root { + args.extend(["--path".into(), root.as_os_str().into()]); + } + args + } + + #[must_use] + pub fn project_root(&self) -> Option<&Path> { + self.project_root.as_deref() + } + + /// Standalone copy display uses `hack` from the user's terminal PATH. + /// Context-aware consumers must render from `argv()` and their checked + /// executable/environment instead of copying or parsing this string. + #[must_use] + pub fn standalone_command(&self) -> String { + let mut command = self.fixed.to_owned(); + if let Some(name) = &self.project_name { + command.push_str(&format!(" --project {}", shell_quote(name))); + } + if let Some(root) = &self.project_root { + command.push_str(&format!(" --path {}", shell_quote(&root.to_string_lossy()))); + } + command + } +} + +const DOCS: &str = "https://github.com/hack-dance/hack/blob/next/docs/"; + +fn doc_for(id: &str, area: Area) -> String { + let page = match (id, area) { + ("browser local network", _) => "guides/macos-browser-network.md", + (_, Area::Engine) => "guides/getting-started.md", + (_, Area::RoutingDns) if cfg!(target_os = "linux") => "guides/linux-setup.md", + (_, Area::RoutingDns) => "guides/getting-started.md", + (_, Area::Certificates) if cfg!(target_os = "linux") => "guides/linux-certificate-trust.md", + (_, Area::Certificates) => "guides/macos-certificate-trust.md", + (_, Area::Daemon) => "architecture.md#daemon-hackd", + (_, Area::EnvSecrets) => "env.md", + (_, Area::ProjectFiles) => "guides/init-project.md", + (_, Area::Native) => "native", + (_, Area::Other) => "cli.md", + }; + format!("{DOCS}{page}") +} + +/// Quote one argument for a POSIX shell. +#[must_use] +pub fn shell_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', r"'\''")) +} + +/// Daemon repairs depend on the state the check reports (doctor-daemon.ts). +fn daemon_command(id: &str, detail: &str) -> Option { + if detail.contains("candidate") { + return None; + } + let d = detail.to_ascii_lowercase(); + let cmd = match id { + "daemon" if d.contains("incompatible") => "hack daemon restart", + "daemon" if d.contains("stale pid/socket") || d.contains("not running") => { + "hack daemon start" + } + "daemon service" if d.contains("stale") || d.contains("not installed") => { + "hack daemon install" + } + _ => return None, + }; + Some(AuthoredRemedy::fixed(cmd)) +} + +/// Project repairs that `hack doctor --fix` applies for one checkout. +const PROJECT_FIX_IDS: &[&str] = &[ + "generated files", + "hack gitignore", + "runtime hygiene", + "lifecycle hygiene", + "secrets hygiene", +]; + +/// `caddy hosts` is the project's own host mapping, regenerated by a +/// restart, not by `doctor --fix` (doctor.ts checkCaddyHostMapping). +const PROJECT_RESTART_IDS: &[&str] = &["caddy hosts"]; + +/// Commands that act on one project and so need `--path ''`. +const PROJECT_COMMANDS: &[&str] = &[ + "hack restart", + "hack down", + "hack up", + "hack doctor", + "hack env materialize", +]; + +/// Fixed remedies the CLI names in check details. Detail text can carry +/// project-controlled strings, so only these are copied verbatim. +const KNOWN_REMEDIES: &[&str] = &[ + "hack daemon clear", + "hack daemon install", + "hack daemon restart", + "hack daemon start", + "hack doctor", + "hack doctor --fix", + "hack doctor --migrate-env-config", + "hack down", + "hack env materialize", + "hack global install", + "hack global trust", + "hack global up", + "hack projects prune", + "hack restart", + "hack up", + "hack x gateway token-create", +]; + +/// `hack projects prune --project ` interpolates the checkout's own +/// (unvalidated) project name, so the name must be plain and is quoted. +const PRUNE_PROJECT: &str = "hack projects prune --project "; + +fn is_plain_name(s: &str) -> bool { + !s.is_empty() + && !s.starts_with('-') + && s.chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) +} + +/// The command a check names in its own detail ("… (run: hack restart)"), +/// when it is a known remedy. The last `(run: …)` group is used even when +/// diagnostic text follows it (lifecycle hygiene appends the supervisor's +/// identity: "… (run: hack down); /path/hack-lifecycle-supervisor (v)"). +/// Anything other than a known remedy yields `None` (the card falls back to +/// its documented default), so a hostile project name can never put a shell +/// program on the clipboard. +fn remedy_in_detail(detail: &str) -> Option { + let detail = detail.trim(); + let start = detail.rfind("(run: ")? + "(run: ".len(); + let end = start + detail[start..].find(')')?; + let cmd = detail[start..end].trim(); + if let Some(fixed) = KNOWN_REMEDIES.iter().find(|fixed| **fixed == cmd) { + return Some(AuthoredRemedy::fixed(fixed)); + } + let name = cmd.strip_prefix(PRUNE_PROJECT)?; + is_plain_name(name).then(|| AuthoredRemedy { + project_name: Some(name.to_owned()), + ..AuthoredRemedy::fixed("hack projects prune") + }) +} + +fn with_path(mut recipe: AuthoredRemedy, root: &Path) -> AuthoredRemedy { + recipe.project_root = Some(root.to_owned()); + recipe +} + +/// The one exact command for a card, or `None` when only a doc applies. +fn command_for(id: &str, area: Area, detail: &str, affected: &Affected) -> Option { + if matches!(id, "daemon" | "daemon service") { + // The CLI's own named remedy wins: it knows cases the status words + // do not show (an incompatible systemd hackd needs `install`, and + // `restart` refuses it). + return remedy_in_detail(detail).or_else(|| daemon_command(id, detail)); + } + match affected { + Affected::Machine if !id.starts_with("native ") && remedy_in_detail(detail).is_some() => { + remedy_in_detail(detail) + } + Affected::Machine => match id { + "caddy local ca" | "host tls trust" | "local ca trust" => { + Some(AuthoredRemedy::fixed("hack global trust")) + } + "browser local network" => None, + _ if area == Area::RoutingDns => Some(AuthoredRemedy::fixed("hack global install")), + _ => None, + }, + // One exact command needs exactly one non-native checkout. The + // check's own remedy wins; project commands get that checkout's path. + Affected::Projects(ps) => match ps.as_slice() { + [p] if !p.native => { + let root = p.root.as_ref()?; + if let Some(cmd) = remedy_in_detail(detail) { + return Some( + if PROJECT_COMMANDS.iter().any(|c| cmd.fixed.starts_with(c)) { + with_path(cmd, root) + } else { + cmd + }, + ); + } + if PROJECT_RESTART_IDS.contains(&id) { + Some(with_path(AuthoredRemedy::fixed("hack restart"), root)) + } else if PROJECT_FIX_IDS.contains(&id) { + Some(with_path(AuthoredRemedy::fixed("hack doctor --fix"), root)) + } else { + None + } + } + _ => None, + }, + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Severity { + Error, + Warn, + /// The check reported no status, or one this app does not know. + CouldNotCheck, +} + +/// A project a finding belongs to. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct ProjectRef { + pub name: String, + pub root: Option, + pub native: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Affected { + Machine, + Projects(Vec), +} + +impl Affected { + #[must_use] + pub fn label(&self) -> String { + match self { + Self::Machine => "Affected: machine".into(), + Self::Projects(p) => format!( + "Affected: {}", + p.iter() + .map(|p| p.name.as_str()) + .collect::>() + .join(", ") + ), + } + } + + #[must_use] + pub fn includes(&self, project: &str) -> bool { + matches!(self, Self::Projects(ps) if ps.iter().any(|p| p.name == project)) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Card { + pub id: String, + pub area: Area, + pub severity: Severity, + pub detail: String, + pub affected: Affected, + pub action: CardAction, + /// Authored argv for context binding; never parse `action` display text. + pub authored_remedy: Option, + /// Design request that would allow a safe one-click fix (the CLI + /// reporting a fix kind). Internal metadata: never shown in the UI. + pub needs: &'static str, +} + +/// One `hack doctor --json` run: outside any project (`project: None`) or +/// for one registered project. +#[derive(Debug, Clone, PartialEq)] +pub struct DoctorRun { + pub project: Option, + pub root: Option, + pub native: bool, + /// Source verified exact CLI root selection and unchanged marker identity + /// before/after the native read. Check ids alone never prove a checkout. + pub exact_native_root: bool, + /// False for local preflight notes: no CLI checks ran. + pub probe_started: bool, + pub result: Result, +} + +impl DoctorRun { + #[must_use] + pub fn machine(result: Result) -> Self { + Self { + project: None, + root: None, + native: false, + exact_native_root: false, + probe_started: true, + result, + } + } + + #[must_use] + pub fn project(name: &str, root: &str, result: Result) -> Self { + Self { + project: Some(name.into()), + root: Some(root.into()), + native: false, + exact_native_root: false, + probe_started: true, + result, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DoctorView { + /// Areas in display order, each with its cards (errors first). + pub groups: Vec<(Area, Vec)>, + /// Distinct checks that reported an explicit `ok` and no failure elsewhere. + pub passed: usize, + /// Runs that produced no report: (project or "machine", reason). + pub failed_runs: Vec<(String, String)>, + pub projects_total: usize, + pub projects_checked: usize, + pub machine_checked: bool, + /// The project list could not be read, so no project was checked. + pub projects_unread: bool, +} + +impl DoctorView { + /// Cards needing attention; the sidebar badge. + #[must_use] + pub fn attention_count(&self) -> usize { + self.groups.iter().map(|(_, c)| c.len()).sum() + } + + #[must_use] + pub fn has_errors(&self) -> bool { + self.cards().any(|c| c.severity == Severity::Error) + } + + pub fn cards(&self) -> impl Iterator { + self.groups.iter().flat_map(|(_, c)| c) + } + + /// Clean only when every run succeeded and nothing was found. + #[must_use] + pub fn is_clean(&self) -> bool { + self.machine_checked + && self.failed_runs.is_empty() + && self.groups.is_empty() + && !self.projects_unread + } + + /// Error red; anything found or unchecked amber; green only when clean. + #[must_use] + pub fn tone(&self) -> Tone { + if self.has_errors() { + Tone::Error + } else if self.is_clean() { + Tone::Ok + } else { + Tone::Warn + } + } + + /// "Checked 3 of 5 projects; 2 could not be checked", when not complete. + #[must_use] + pub fn coverage_line(&self) -> Option { + if self.machine_checked && self.failed_runs.is_empty() && !self.projects_unread { + return None; + } + let missing = self.projects_total - self.projects_checked; + let mut parts = Vec::new(); + if self.projects_total > 0 { + parts.push(format!( + "Checked {} of {} projects", + self.projects_checked, self.projects_total + )); + if missing > 0 { + parts.push(format!("{missing} could not be checked")); + } + } + if self.projects_unread { + parts.push("the project list could not be read, so no projects were checked".into()); + } + if !self.machine_checked { + parts.push("machine checks did not run".into()); + } + Some(parts.join("; ")) + } + + /// Findings that name `project`. + #[must_use] + pub fn findings_for(&self, project: &str) -> usize { + self.cards() + .filter(|c| c.affected.includes(project)) + .count() + } +} + +fn severity(status: &str) -> Result, ()> { + match status { + "ok" => Ok(None), + "error" => Ok(Some(Severity::Error)), + "warn" => Ok(Some(Severity::Warn)), + _ => Err(()), + } +} + +type ProjectKey = (String, Severity, String); + +/// Legacy Compose output identifies the selected Hack directory. Native +/// output has no project row, so require a known project-scoped check. +pub(crate) fn project_evidence(run: &DoctorRun, report: &DoctorReport) -> bool { + let Some(root) = &run.root else { return false }; + if adopted_project_evidence(root, report) { + return true; + } + if run.native { + return run.exact_native_root + && report.checks.iter().any(|c| { + matches!( + c.id.as_str(), + "env key" + | "secret integrity" + | "compose backend features" + | "native settings" + | "native source size" + | "amd64 emulation" + ) + }); + } + report.checks.iter().any(|c| { + c.id == "project" + && c.status == "ok" + && c.detail + .strip_prefix("Found .hack/ (or legacy .dev/) at ") + .is_some_and(|p| { + let path = Path::new(p); + path == root.join(".hack") || path == root.join(".dev") + }) + }) +} + +pub(crate) fn adopted_project_evidence(root: &Path, report: &DoctorReport) -> bool { + let prefix = format!( + "Adopted Compose project at {}", + root.join(".hack").display() + ); + report.checks.iter().any(|c| c.id == "project" && match c.detail.strip_prefix(&prefix) { + Some(" ('hack config adopt'). Its Compose inputs belong to the adoption; adopted commands support retained-container start, restart, stop, ps, logs and exec.") => c.status == "ok", + Some(" has a pending or interrupted adoption operation. Use explicit adoption recovery before other project commands; values omitted.") => c.status == "warn", + _ => false, + }) +} + +/// Aggregate runs into the Doctor view. +#[must_use] +pub fn aggregate(runs: &[DoctorRun]) -> DoctorView { + let mut machine: BTreeMap = BTreeMap::new(); + let mut project: BTreeMap)> = BTreeMap::new(); + let mut ok: BTreeMap<(String, Option), ()> = BTreeMap::new(); + let mut failing: BTreeMap<(String, Option), ()> = BTreeMap::new(); + let mut failed_runs = Vec::new(); + let projects_total = runs.iter().filter(|r| r.project.is_some()).count(); + let mut projects_checked = 0; + let mut machine_checked = false; + + for run in runs { + let report = match &run.result { + Ok(r) => r, + Err(why) => { + failed_runs.push(( + run.project.clone().unwrap_or_else(|| "machine".into()), + why.clone(), + )); + continue; + } + }; + let project_proven = run.project.is_none() || project_evidence(run, report); + if let Some(name) = &run.project { + if project_proven { + projects_checked += 1; + } else { + failed_runs.push(( + name.clone(), + "could not check (no exact project evidence)".into(), + )); + } + } else { + if report.checks.is_empty() { + failed_runs.push(("machine".into(), "could not check (empty report)".into())); + continue; + } + machine_checked |= run.probe_started; + } + for DoctorCheck { id, status, detail } in &report.checks { + let (area, scope) = classify(id).unwrap_or(( + Area::Other, + if run.project.is_some() { + Scope::Project + } else { + Scope::Machine + }, + )); + let scope = if RUN_SCOPED_IDS.contains(&id.as_str()) { + if run.project.is_some() { + Scope::Project + } else { + Scope::Machine + } + } else { + scope + }; + // An unproven checkout cannot own project findings, but its + // machine diagnostics remain useful (notably native trust). + if !project_proven && scope == Scope::Project { + continue; + } + // A project check outside any project has no project to name. + let owner = match (scope, &run.project) { + (Scope::Machine, _) => None, + (Scope::Project, Some(p)) => Some(ProjectRef { + name: p.clone(), + root: run.root.clone(), + native: run.native, + }), + (Scope::Project, None) => continue, + }; + let key = (id.clone(), owner.as_ref().map(|o| o.name.clone())); + let (sev, detail) = match severity(status) { + Ok(None) => { + ok.insert(key, ()); + continue; + } + Ok(Some(s)) => (s, detail.clone()), + Err(()) => ( + Severity::CouldNotCheck, + if !detail.is_empty() { + detail.clone() + } else if status.is_empty() { + "The check reported no status".into() + } else { + format!("The check reported an unknown status {status:?}") + }, + ), + }; + failing.insert(key, ()); + match owner { + None => { + let entry = machine + .entry(id.clone()) + .or_insert((area, sev, detail.clone())); + if sev < entry.1 { + *entry = (area, sev, detail); + } + } + Some(p) => { + let entry = project + .entry((id.clone(), sev, detail)) + .or_insert((area, Vec::new())); + if !entry.1.iter().any(|q| q.name == p.name) { + entry.1.push(p); + } + } + } + } + } + + let card = |id: &str, area: Area, severity: Severity, detail: String, affected: Affected| { + let authored_remedy = if severity == Severity::CouldNotCheck { + None + } else { + command_for(id, area, &detail, &affected) + }; + let action = match &authored_remedy { + None => CardAction::OpenDoc(doc_for(id, area)), + Some(recipe) => CardAction::Copy(recipe.standalone_command()), + }; + Card { + id: id.into(), + area, + severity, + detail, + affected, + action, + authored_remedy, + needs: "R7a", + } + }; + let mut cards: Vec = machine + .into_iter() + .map(|(id, (area, sev, detail))| card(&id, area, sev, detail, Affected::Machine)) + .collect(); + cards.extend( + project + .into_iter() + .map(|((id, sev, detail), (area, mut ps))| { + ps.sort(); + card(&id, area, sev, detail, Affected::Projects(ps)) + }), + ); + cards.sort_by(|a, b| { + (a.area, a.severity, &a.id, a.affected.label()).cmp(&( + b.area, + b.severity, + &b.id, + b.affected.label(), + )) + }); + + let mut groups: Vec<(Area, Vec)> = Vec::new(); + for c in cards { + match groups.last_mut() { + Some((area, list)) if *area == c.area => list.push(c), + _ => groups.push((c.area, vec![c])), + } + } + let passed = ok.keys().filter(|k| !failing.contains_key(*k)).count(); + DoctorView { + groups, + passed, + failed_runs, + projects_total, + projects_checked, + machine_checked, + projects_unread: false, + } +} + +/// Result of the last doctor refresh. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum DoctorState { + /// Doctor has not finished a run in this session. + #[default] + NeverRun, + Loaded(DoctorView), + /// No run produced a report. + Error(String), +} + +impl DoctorState { + #[must_use] + pub fn from_runs(runs: &[DoctorRun]) -> Self { + if !runs.is_empty() && runs.iter().all(|r| r.result.is_err()) { + let why = runs + .iter() + .find_map(|r| r.result.as_ref().err()) + .cloned() + .unwrap_or_default(); + return Self::Error(why); + } + Self::Loaded(aggregate(runs)) + } +} + +/// What identifies a doctor target: name, checkout, backend and missing state. +pub type TargetKey = (String, PathBuf, bool, bool); + +fn sorted(targets: Option<&[TargetKey]>) -> Option> { + targets.map(|t| { + let mut t = t.to_vec(); + t.sort(); + t + }) +} + +/// Should the app start a doctor run on its own now? Only while the Doctor +/// pane is shown in a visible window, and only when the result is missing, +/// old or stale. ("Check again" always runs.) +#[must_use] +pub fn should_auto_refresh( + pane_shown: bool, + window_visible: bool, + pane: &DoctorPane, + now: Instant, +) -> bool { + pane_shown && window_visible && pane.needs_refresh(now) +} + +/// How often a visible Doctor pane refreshes, and when reopening re-runs. +pub const REFRESH_EVERY: Duration = Duration::from_secs(600); + +/// What happened to a finished run. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Finish { + /// A newer run started meanwhile: this result was dropped. + Discarded, + Applied, + /// Applied, but the project set changed while it ran: shown as stale; + /// the caller starts a new run. + AppliedStale, +} + +/// Doctor pane state: the last result plus refresh bookkeeping. +#[derive(Debug, Clone, Default)] +pub struct DoctorPane { + pub result: DoctorState, + generation: u64, + in_flight: Option<(u64, Option>)>, + checked_at: Option, + /// The targets the shown result checked (`None`: the project list + /// could not be read). + checked_targets: Option>, + /// The project set changed while the shown result was being made. + pub stale: bool, + /// Show only findings for this project (from a project header link). + pub filter: Option, +} + +impl DoctorPane { + /// Start a run for these targets (`None`: the project list could not + /// be read, so only machine checks run); returns its generation. + pub fn begin(&mut self, targets: Option<&[TargetKey]>) -> u64 { + self.generation += 1; + self.in_flight = Some((self.generation, sorted(targets))); + self.generation + } + + /// The registered targets changed (or became readable) since the shown + /// result was made: mark it stale so it is checked again. A run in + /// flight compares its own targets when it finishes. + pub fn targets_seen(&mut self, current: Option<&[TargetKey]>) { + if self.checked_at.is_some() + && !self.is_loading() + && sorted(current) != self.checked_targets + { + self.stale = true; + } + } + + #[must_use] + pub fn is_loading(&self) -> bool { + self.in_flight.is_some() + } + + /// A run finished. `current` is the registered target set now; a + /// change of name, checkout or backend makes the result stale. + pub fn finish( + &mut self, + generation: u64, + runs: &[DoctorRun], + current: Option<&[TargetKey]>, + now: Instant, + ) -> Finish { + let Some((g, targets)) = self.in_flight.take() else { + return Finish::Discarded; + }; + if g != generation { + self.in_flight = Some((g, targets)); + return Finish::Discarded; + } + self.result = DoctorState::from_runs(runs); + if let (DoctorState::Loaded(v), None) = (&mut self.result, &targets) { + // Machine checks ran, but no project was: never "all clear". + v.projects_unread = true; + } + self.checked_at = Some(now); + self.stale = sorted(current) != targets; + self.checked_targets = targets; + if self.stale { + Finish::AppliedStale + } else { + Finish::Applied + } + } + + /// Never run, stale, or the last result is at least [`REFRESH_EVERY`] old. + #[must_use] + pub fn needs_refresh(&self, now: Instant) -> bool { + !self.is_loading() + && (self.stale + || self + .checked_at + .is_none_or(|t| now.saturating_duration_since(t) >= REFRESH_EVERY)) + } + + #[must_use] + pub fn age_label(&self, now: Instant) -> Option { + let mins = now.saturating_duration_since(self.checked_at?).as_secs() / 60; + Some(match mins { + 0 => "Checked just now".into(), + 1 => "Checked 1 min ago".into(), + n => format!("Checked {n} min ago"), + }) + } + + /// Sidebar tone: grey until a run finished or when none produced a + /// report; never green unless every run was clean. + #[must_use] + pub fn tone(&self) -> Tone { + if self.stale { + return Tone::Warn; + } + match &self.result { + DoctorState::Loaded(v) => v.tone(), + DoctorState::NeverRun => Tone::Idle, + DoctorState::Error(_) => Tone::Warn, + } + } + + #[must_use] + pub fn findings_for(&self, project: &str) -> usize { + match &self.result { + DoctorState::Loaded(v) => v.findings_for(project), + _ => 0, + } + } +} + +/// Directories the CLI treats as a project's Hack directory +/// (`HACK_PROJECT_DIR_PRIMARY`, `HACK_PROJECT_DIR_LEGACY`). +const PROJECT_DIRS: &[&str] = &[".hack", ".dev"]; +/// Files inside those directories that make a project +/// (`PROJECT_COMPOSE_FILENAME`, `PROJECT_CONFIG_FILENAME`, +/// `PROJECT_CONFIG_LEGACY_FILENAME`). +const PROJECT_FILES: &[&str] = &["docker-compose.yml", "hack.config.json", "hack.toml"]; +/// The native project file at a checkout root (`NATIVE_PROJECT_FILENAME`). +const NATIVE_PROJECT_FILE: &str = "hack.project.json"; + +/// Does `path` exist? `NotFound` is no; any other error is an error. +fn exists(path: &Path) -> std::io::Result { + match std::fs::symlink_metadata(path) { + Ok(_) => Ok(true), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(e) => Err(e), + } +} + +/// Does this one directory hold a project marker? +fn has_marker(dir: &Path) -> std::io::Result { + if exists(&dir.join(NATIVE_PROJECT_FILE))? { + return Ok(true); + } + for d in PROJECT_DIRS { + let pd = dir.join(d); + // The primary `.hack/` is a marker on its own (conservative); the + // legacy `.dev/` only with a project file in it, as the CLI reads it. + if *d == ".hack" && exists(&pd)? { + return Ok(true); + } + for f in PROJECT_FILES { + if exists(&pd.join(f))? { + return Ok(true); + } + } + } + Ok(false) +} + +/// Is `dir`, after resolving symlinks, inside a Hack project? Errors +/// (unresolvable path, unreadable ancestor) are returned, never guessed. +/// +/// # Errors +/// Any I/O error other than a missing marker. +pub fn inside_project(dir: &Path) -> std::io::Result { + let real = dir.canonicalize()?; + for a in real.ancestors() { + if has_marker(a)? { + return Ok(true); + } + } + Ok(false) +} + +/// An absolute directory owned by this user and closed to group and +/// others (for example `XDG_RUNTIME_DIR`). +#[must_use] +pub fn is_private_dir(p: &std::path::Path) -> bool { + use std::os::unix::fs::MetadataExt; + // SAFETY: getuid has no preconditions and cannot fail. + let uid = unsafe { libc::getuid() }; + p.is_absolute() + && std::fs::metadata(p).is_ok_and(|m| m.is_dir() && m.uid() == uid && m.mode() & 0o077 == 0) +} + +/// The first candidate directory provably outside every Hack project, for +/// the machine-level doctor run. A candidate that cannot be inspected is +/// skipped; `None` when no candidate qualifies. +#[must_use] +pub fn neutral_dir(candidates: &[PathBuf]) -> Option { + candidates.iter().find_map(|d| { + let real = d.canonicalize().ok()?; + (real.is_dir() && matches!(inside_project(&real), Ok(false))).then_some(real) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn check(id: &str, status: &str, detail: &str) -> DoctorCheck { + DoctorCheck { + id: id.into(), + status: status.into(), + detail: detail.into(), + } + } + + fn report(checks: Vec) -> Result { + Ok(DoctorReport { + checks, + summary: vec![], + }) + } + + fn key(name: &str, root: &str, native: bool) -> TargetKey { + (name.into(), root.into(), native, false) + } + + fn machine(checks: Vec) -> DoctorRun { + DoctorRun::machine(report(checks)) + } + + fn proj(name: &str, mut checks: Vec) -> DoctorRun { + checks.retain(|c| c.id != "project" || c.status != "ok"); + checks.push(check( + "project", + "ok", + &format!("Found .hack/ (or legacy .dev/) at /work/{name}/.hack"), + )); + DoctorRun::project(name, &format!("/work/{name}"), report(checks)) + } + + #[test] + fn machine_findings_appear_once_with_worst_severity() { + let v = aggregate(&[ + machine(vec![check("caddy local ca", "warn", "not trusted")]), + proj( + "alpha", + vec![check("caddy local ca", "warn", "not trusted")], + ), + proj("beta", vec![check("caddy local ca", "error", "missing")]), + ]); + assert_eq!(v.attention_count(), 1); + let c = v.cards().next().unwrap(); + assert_eq!(c.affected, Affected::Machine); + assert_eq!(c.severity, Severity::Error); + assert_eq!(c.action, CardAction::Copy("hack global trust".into())); + } + + #[test] + fn browser_findings_do_not_duplicate_across_scopes() { + let v = aggregate(&[ + machine(vec![check("browser local network", "warn", "blocked")]), + proj( + "alpha", + vec![check("browser local network", "warn", "blocked")], + ), + ]); + assert_eq!(v.attention_count(), 1); + let c = v.cards().next().unwrap(); + assert_eq!(c.affected, Affected::Machine); + assert!( + matches!(&c.action, CardAction::OpenDoc(u) if u.ends_with("macos-browser-network.md")) + ); + } + + #[test] + fn project_repairs_copy_one_exact_quoted_command() { + let one = aggregate(&[DoctorRun::project( + "alpha", + "/work/it's here", + report(vec![ + check("caddy hosts", "warn", "mapping missing"), + check( + "project", + "ok", + "Found .hack/ (or legacy .dev/) at /work/it's here/.hack", + ), + ]), + )]); + let c = one.cards().next().unwrap(); + assert_eq!(c.area, Area::RoutingDns); + assert_eq!( + c.action, + CardAction::Copy(r"hack restart --path '/work/it'\''s here'".into()) + ); + // The same finding in two checkouts has no single command: doc. + let two = aggregate(&[ + proj("alpha", vec![check("generated files", "warn", "tracked")]), + proj("beta", vec![check("generated files", "warn", "tracked")]), + ]); + let c = two.cards().next().unwrap(); + assert_eq!(c.affected.label(), "Affected: alpha, beta"); + assert!(matches!(c.action, CardAction::OpenDoc(_))); + } + + #[test] + fn authored_remedies_expose_arguments_without_binding_an_executable() { + let root = PathBuf::from("/work/it's here\n$(touch never)"); + let view = aggregate(&[DoctorRun::project( + "alpha", + root.to_str().unwrap(), + report(vec![ + check( + "project", + "ok", + &format!( + "Found .hack/ (or legacy .dev/) at {}", + root.join(".hack").display() + ), + ), + check("generated files", "warn", "tracked"), + ]), + )]); + let card = view.cards().next().unwrap(); + let recipe = card.authored_remedy.as_ref().unwrap(); + assert_eq!( + recipe.argv(), + [ + std::ffi::OsString::from("doctor"), + "--fix".into(), + "--path".into(), + root.as_os_str().into() + ] + ); + assert_eq!(recipe.project_root(), Some(root.as_path())); + assert_eq!(card.action, CardAction::Copy(recipe.standalone_command())); + + let prune = + remedy_in_detail("orphan (run: hack projects prune --project my-app.v2)").unwrap(); + assert_eq!( + prune.argv(), + ["projects", "prune", "--project", "my-app.v2"].map(std::ffi::OsString::from) + ); + assert!(prune.project_root().is_none()); + + let unknown = aggregate(&[machine(vec![check("docker", "skipped", "run: hack down")])]); + assert!(unknown.cards().all(|c| c.authored_remedy.is_none())); + } + + #[test] + fn authored_root_argument_preserves_non_utf8_bytes() { + use std::os::unix::ffi::OsStringExt; + let root = PathBuf::from(std::ffi::OsString::from_vec(b"/work/\xff".to_vec())); + let affected = Affected::Projects(vec![ProjectRef { + name: "alpha".into(), + root: Some(root.clone()), + native: false, + }]); + let recipe = command_for( + "caddy hosts", + Area::RoutingDns, + "missing mapping", + &affected, + ) + .unwrap(); + assert_eq!(recipe.argv().last().unwrap(), root.as_os_str()); + } + + #[test] + fn a_checks_own_remedy_wins_over_doctor_fix() { + let card = |id: &str, detail: &str| { + aggregate(&[proj("alpha", vec![check(id, "warn", detail)])]) + .cards() + .next() + .unwrap() + .action + .clone() + }; + assert_eq!( + card( + "env materialization", + "Missing overlay (run: hack env materialize)" + ), + CardAction::Copy("hack env materialize --path '/work/alpha'".into()) + ); + // caddy hosts: the internal mapping is regenerated by a restart. + assert_eq!( + card( + "caddy hosts", + "Caddy has no route for this project (run: hack restart)" + ), + CardAction::Copy("hack restart --path '/work/alpha'".into()) + ); + // ...unless the global proxy is down, which is a machine command. + assert_eq!( + card("caddy hosts", "Caddy is not running (run: hack global up)"), + CardAction::Copy("hack global up".into()) + ); + // lifecycle hygiene naming `hack down` gets the checkout path. + assert_eq!( + card( + "lifecycle hygiene", + "Orphaned processes remain (run: hack down)" + ), + CardAction::Copy("hack down --path '/work/alpha'".into()) + ); + // The supervisor runner appends its identity after the remedy. + assert_eq!( + card( + "lifecycle hygiene", + "1 stale lifecycle entry (run: hack down); /example/bin/hack-lifecycle-supervisor (hack-lifecycle-supervisor 0.1.0 protocol=1)" + ), + CardAction::Copy("hack down --path '/work/alpha'".into()) + ); + // Trailing text cannot smuggle a command past the allowlist. + assert_eq!( + card( + "lifecycle hygiene", + "stale (run: hack down; rm -rf ~) trailing" + ), + CardAction::Copy("hack doctor --fix --path '/work/alpha'".into()) + ); + // No remedy in the detail: the documented default. + assert_eq!( + card("generated files", "A generated file is tracked by git"), + CardAction::Copy("hack doctor --fix --path '/work/alpha'".into()) + ); + // Machine checks copy their named remedy as is. + let m = aggregate(&[machine(vec![check( + "gateway tokens", + "warn", + "Token store untrusted (run: hack x gateway token-create)", + )])]); + assert_eq!( + m.cards().next().unwrap().action, + CardAction::Copy("hack x gateway token-create".into()) + ); + } + + #[test] + fn named_remedies_are_allowlisted_and_quoted() { + assert_eq!( + remedy_in_detail("service run: hack down failed (run: hack restart)") + .map(|r| r.standalone_command()), + Some("hack restart".into()) + ); + assert_eq!(remedy_in_detail("service run: hack down failed"), None); + assert_eq!( + remedy_in_detail("orphan (run: hack projects prune --project --all)"), + None + ); + let card = |detail: &str| { + aggregate(&[proj( + "alpha", + vec![check("runtime hygiene", "warn", detail)], + )]) + .cards() + .next() + .unwrap() + .action + .clone() + }; + assert_eq!( + card("Orphaned entry (run: hack projects prune --project my-app.v2)"), + CardAction::Copy("hack projects prune --project 'my-app.v2'".into()) + ); + // A hostile project name never reaches the clipboard: the card falls + // back to the documented default. + for hostile in [ + "Orphaned entry (run: hack projects prune --project safe && curl x | sh)", + "Orphaned entry (run: hack projects prune --project $(id))", + "Orphaned entry (run: hack projects prune --project `id`)", + "Orphaned entry (run: hack status && rm -rf ~)", + ] { + assert_eq!( + card(hostile), + CardAction::Copy("hack doctor --fix --path '/work/alpha'".into()), + "{hostile}" + ); + } + // Machine checks with an unknown remedy get no copied command. + let m = aggregate(&[machine(vec![check( + "gateway tokens", + "warn", + "Untrusted (run: hack x gateway token-create; echo pwned)", + )])]); + assert!(matches!( + m.cards().next().unwrap().action, + CardAction::OpenDoc(_) + )); + let m = aggregate(&[machine(vec![check( + "gateway tokens", + "warn", + "Untrusted (run: hack x gateway token-create && echo pwned)", + )])]); + assert!(!matches!( + m.cards().next().unwrap().action, + CardAction::Copy(_) + )); + } + + #[test] + fn daemon_commands_follow_the_reported_state() { + assert_eq!( + daemon_command( + "daemon", + "hackd incompatible; hackd is optional for candidate Compose workflows; daemon autostart and automatic repair are disabled." + ), + None + ); + let cmd = |detail: &str| { + aggregate(&[machine(vec![check("daemon", "warn", detail)])]) + .cards() + .next() + .unwrap() + .action + .clone() + }; + assert_eq!( + cmd("hackd is running but incompatible (run: hack daemon restart)"), + CardAction::Copy("hack daemon restart".into()) + ); + assert_eq!( + cmd("hackd not running (stale pid/socket; run: hack daemon start)"), + CardAction::Copy("hack daemon start".into()) + ); + assert_eq!( + cmd("hackd not running (run: hack daemon start)"), + CardAction::Copy("hack daemon start".into()) + ); + // Incompatible under systemd from another binary: the CLI names + // `install`, and that wins over the status-based `restart`. + assert_eq!( + cmd("hackd is running but incompatible (run: hack daemon install)"), + CardAction::Copy("hack daemon install".into()) + ); + // No named remedy: fall back to the status-based command. + assert_eq!( + cmd("hackd is running but incompatible"), + CardAction::Copy("hack daemon restart".into()) + ); + assert!(matches!( + cmd("hackd starting (pid 1): API not responding"), + CardAction::OpenDoc(_) + )); + let unit = aggregate(&[machine(vec![check( + "daemon service", + "warn", + "hackd systemd user service: stale unit", + )])]); + assert_eq!( + unit.cards().next().unwrap().action, + CardAction::Copy("hack daemon install".into()) + ); + } + + #[test] + fn no_machine_report_cannot_be_clean() { + for runs in [vec![], vec![proj("alpha", vec![])]] { + let view = aggregate(&runs); + assert!(!view.is_clean()); + assert_eq!(view.tone(), Tone::Warn); + assert!( + view.coverage_line() + .unwrap() + .contains("machine checks did not run") + ); + } + } + + #[test] + fn stale_clean_result_is_never_green() { + let mut pane = DoctorPane::default(); + let generation = pane.begin(Some(&[])); + pane.finish( + generation, + &[machine(vec![check("docker", "ok", "")])], + Some(&[]), + Instant::now(), + ); + assert_eq!(pane.tone(), Tone::Ok); + pane.targets_seen(Some(&[key("new", "/work/new", false)])); + assert!(pane.stale); + assert_eq!(pane.tone(), Tone::Warn); + let mut missing = key("new", "/work/new", false); + missing.3 = true; + let generation = pane.begin(Some(&[key("new", "/work/new", false)])); + pane.finish( + generation, + &[machine(vec![check("docker", "ok", "")])], + Some(&[missing]), + Instant::now(), + ); + assert!(pane.stale); + assert_eq!(pane.tone(), Tone::Warn); + } + + #[test] + fn project_coverage_requires_exact_evidence() { + let machine = machine(vec![check("docker", "ok", "")]); + for checks in [ + vec![], + vec![check( + "project", + "ok", + "Found .hack/ (or legacy .dev/) at /work/.hack", + )], + vec![check("native root trust", "ok", "trusted")], + ] { + for native in [false, true] { + let mut run = DoctorRun::project("alpha", "/work/alpha", report(checks.clone())); + run.native = native; + let v = aggregate(&[machine.clone(), run]); + assert_eq!(v.projects_checked, 0); + assert!(!v.is_clean()); + } + } + let mut native = DoctorRun::project( + "alpha", + "/work/alpha", + report(vec![check("native settings", "ok", "valid")]), + ); + native.native = true; + native.exact_native_root = true; + assert_eq!(aggregate(&[machine.clone(), native]).projects_checked, 1); + assert!(!aggregate(&[DoctorRun::machine(report(vec![]))]).is_clean()); + } + + #[test] + fn unproven_native_report_retains_only_machine_diagnostics() { + let mut native = DoctorRun::project( + "alpha", + "/work/alpha", + report(vec![ + check("native root trust", "error", "untrusted active root"), + check("native future project check", "warn", "not attributable"), + ]), + ); + native.native = true; + let view = aggregate(&[machine(vec![check("docker", "ok", "")]), native]); + assert_eq!(view.projects_checked, 0); + assert!(!view.is_clean()); + assert_eq!(view.cards().count(), 1); + let card = view.cards().next().unwrap(); + assert_eq!(card.id, "native root trust"); + assert_eq!(card.affected, Affected::Machine); + assert_eq!(view.tone(), Tone::Error); + } + + #[test] + fn adopted_reports_require_exact_root_and_keep_pending_findings() { + for (status, suffix) in [ + ( + "ok", + " ('hack config adopt'). Its Compose inputs belong to the adoption; adopted commands support retained-container start, restart, stop, ps, logs and exec.", + ), + ( + "warn", + " has a pending or interrupted adoption operation. Use explicit adoption recovery before other project commands; values omitted.", + ), + ] { + let report = report(vec![ + check( + "project", + status, + &format!("Adopted Compose project at /work/alpha/.hack{suffix}"), + ), + check("env key", "error", "missing"), + ]); + let v = aggregate(&[DoctorRun::project("alpha", "/work/alpha", report.clone())]); + assert_eq!(v.projects_checked, 1); + assert!(v.has_errors()); + let wrong = aggregate(&[DoctorRun::project("nested", "/work/alpha/nested", report)]); + assert_eq!(wrong.projects_checked, 0); + } + } + + #[test] + fn unknown_or_missing_status_is_could_not_check_not_passed() { + let v = aggregate(&[machine(vec![ + check("docker", "", ""), + check("bun", "skipped", ""), + check("mutagen", "ok", ""), + ])]); + assert_eq!(v.passed, 1); + let sev: Vec = v.cards().map(|c| c.severity).collect(); + assert_eq!(sev, vec![Severity::CouldNotCheck, Severity::CouldNotCheck]); + assert!( + v.cards() + .all(|c| matches!(c.action, CardAction::OpenDoc(_))) + ); + assert_eq!(v.tone(), Tone::Warn); + } + + #[test] + fn partial_failure_is_never_clean() { + let v = aggregate(&[ + machine(vec![check("docker", "ok", "")]), + proj("alpha", vec![check("project", "ok", "")]), + DoctorRun::project("beta", "/work/beta", Err("timed out".into())), + DoctorRun::project( + "gamma", + "/work/gamma", + Err("not checked (time budget)".into()), + ), + ]); + assert_eq!(v.attention_count(), 0); + assert!(!v.is_clean()); + assert_eq!(v.tone(), Tone::Warn); + assert_eq!( + v.coverage_line().as_deref(), + Some("Checked 1 of 3 projects; 2 could not be checked") + ); + let clean = aggregate(&[machine(vec![check("docker", "ok", "")])]); + assert!(clean.is_clean()); + assert_eq!(clean.tone(), Tone::Ok); + assert_eq!(clean.coverage_line(), None); + } + + #[test] + fn grouping_order_and_unknown_ids() { + let v = aggregate(&[machine(vec![ + check("brand new check", "warn", "?"), + check( + "daemon", + "warn", + "hackd not running (run: hack daemon start)", + ), + check("docker daemon", "error", "down"), + check("resolver:hack", "error", "missing"), + check("bun", "ok", ""), + check("docker", "ok", ""), + ])]); + let areas: Vec = v.groups.iter().map(|(a, _)| *a).collect(); + assert_eq!( + areas, + vec![Area::Engine, Area::RoutingDns, Area::Daemon, Area::Other] + ); + assert_eq!(v.passed, 2); + assert_eq!(v.tone(), Tone::Error); + } + + #[test] + fn native_checks_never_offer_global_commands() { + let v = aggregate(&[machine(vec![check("native root trust", "error", "x")])]); + assert!(matches!( + v.cards().next().unwrap().action, + CardAction::OpenDoc(_) + )); + } + + #[test] + fn all_failed_is_an_error_state_and_grey() { + let mut pane = DoctorPane::default(); + let g = pane.begin(Some(&[])); + pane.finish( + g, + &[DoctorRun::machine(Err("hack not found".into()))], + Some(&[]), + Instant::now(), + ); + assert_eq!(pane.result, DoctorState::Error("hack not found".into())); + assert_eq!(pane.tone(), Tone::Warn); + assert_eq!( + DoctorPane::default().tone(), + Tone::Idle, + "never run is grey" + ); + } + + #[test] + fn stale_results_are_discarded_or_marked() { + let t0 = Instant::now(); + let runs = [machine(vec![check("docker", "ok", "")])]; + let names = vec![key("alpha", "/work/alpha", false)]; + let mut pane = DoctorPane::default(); + let first = pane.begin(Some(&names)); + let second = pane.begin(Some(&names)); + assert_eq!( + pane.finish(first, &runs, Some(&names), t0), + Finish::Discarded + ); + assert!(pane.is_loading(), "the newer run is still in flight"); + assert_eq!( + pane.finish(second, &runs, Some(&names), t0), + Finish::Applied + ); + assert!(!pane.stale); + let third = pane.begin(Some(&names)); + let changed = vec![ + key("alpha", "/work/alpha", false), + key("beta", "/work/beta", false), + ]; + assert_eq!( + pane.finish(third, &runs, Some(&changed), t0), + Finish::AppliedStale + ); + assert!(pane.stale); + } + + #[test] + fn refresh_timing_and_age_label() { + let t0 = Instant::now(); + let mut pane = DoctorPane::default(); + assert!(pane.needs_refresh(t0)); + let g = pane.begin(Some(&[])); + assert!(!pane.needs_refresh(t0), "not while loading"); + pane.finish(g, &[machine(vec![])], Some(&[]), t0); + assert!(!pane.needs_refresh(t0 + Duration::from_secs(599))); + assert!(pane.needs_refresh(t0 + REFRESH_EVERY)); + assert_eq!(pane.age_label(t0).as_deref(), Some("Checked just now")); + assert_eq!( + pane.age_label(t0 + Duration::from_secs(7 * 60 + 5)) + .as_deref(), + Some("Checked 7 min ago") + ); + } + + #[test] + fn findings_for_a_project_count_its_cards() { + let mut pane = DoctorPane::default(); + let g = pane.begin(Some(&[key("alpha", "/work/alpha", false)])); + pane.finish( + g, + &[ + machine(vec![check("daemon", "warn", "x")]), + proj( + "alpha", + vec![ + check("env key", "error", "missing"), + check("project", "warn", "y"), + ], + ), + ], + Some(&[key("alpha", "/work/alpha", false)]), + Instant::now(), + ); + assert_eq!(pane.findings_for("alpha"), 2); + assert_eq!(pane.findings_for("beta"), 0); + } + + /// A fresh directory tree in the temp dir; removed on drop. + struct Tree(PathBuf); + + impl Tree { + fn new(tag: &str) -> Self { + let p = std::env::temp_dir().join(format!( + "hack-desktop-{tag}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&p).unwrap(); + Self(p) + } + } + + impl Drop for Tree { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + /// The tree's own ancestors may already be a project (a temp dir under + /// a checkout); only then is "outside" not provable. + fn tree_base_is_clean(t: &Tree) -> bool { + matches!(inside_project(&t.0), Ok(false)) + } + + #[test] + fn private_dir_requires_owner_only_mode() { + use std::os::unix::fs::PermissionsExt; + let d = std::env::temp_dir().join(format!( + "hack-desktop-private-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&d).unwrap(); + std::fs::set_permissions(&d, std::fs::Permissions::from_mode(0o700)).unwrap(); + assert!(is_private_dir(&d)); + std::fs::set_permissions(&d, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!(!is_private_dir(&d)); + assert!(!is_private_dir(std::path::Path::new("relative"))); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn neutral_dir_skips_directories_inside_a_project() { + let t = Tree::new("neutral"); + let inside = t.0.join("checkout").join("deep"); + let outside = t.0.join("plain"); + std::fs::create_dir_all(&inside).unwrap(); + std::fs::create_dir_all(t.0.join("checkout").join(".hack")).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + assert_eq!(inside_project(&inside).ok(), Some(true)); + assert_eq!(neutral_dir(std::slice::from_ref(&inside)), None); + if tree_base_is_clean(&t) { + assert_eq!( + neutral_dir(&[inside, outside.clone()]), + Some(outside.canonicalize().unwrap()) + ); + } + } + + #[test] + fn legacy_dev_compose_ancestor_is_a_project() { + let t = Tree::new("legacy"); + let deep = t.0.join("old-checkout").join("src"); + std::fs::create_dir_all(&deep).unwrap(); + // A bare `.dev/` is not a project; one with a compose file is. + std::fs::create_dir_all(t.0.join("old-checkout").join(".dev")).unwrap(); + if tree_base_is_clean(&t) { + assert_eq!(inside_project(&deep).ok(), Some(false)); + } + std::fs::write( + t.0.join("old-checkout/.dev/docker-compose.yml"), + "services: {}\n", + ) + .unwrap(); + assert_eq!(inside_project(&deep).ok(), Some(true)); + assert_eq!(neutral_dir(&[deep]), None); + } + + #[test] + fn symlink_into_a_project_is_resolved() { + let t = Tree::new("symlink"); + let real = t.0.join("checkout").join("tmp"); + std::fs::create_dir_all(&real).unwrap(); + std::fs::write(t.0.join("checkout").join("hack.project.json"), "{}").unwrap(); + let link = t.0.join("elsewhere"); + std::os::unix::fs::symlink(&real, &link).unwrap(); + // Lexically `elsewhere` has no marker above it; resolved, it does. + assert_eq!(inside_project(&link).ok(), Some(true)); + assert_eq!(neutral_dir(&[link]), None); + } + + #[test] + fn uninspectable_candidates_fail_closed() { + assert_eq!( + neutral_dir(&[PathBuf::from("/nonexistent/candidate")]), + None + ); + assert!(inside_project(Path::new("/nonexistent/candidate")).is_err()); + } + + #[test] + fn global_secrets_hygiene_from_the_machine_run_is_kept() { + // No registered projects: only the machine run reports it. + let v = aggregate(&[machine(vec![check( + "secrets hygiene", + "error", + "global store is readable by others", + )])]); + assert_eq!(v.attention_count(), 1); + let c = v.cards().next().unwrap(); + assert_eq!(c.affected, Affected::Machine); + assert_eq!(c.severity, Severity::Error); + // From a project run it stays project-scoped, with the exact repair. + let p = aggregate(&[proj("alpha", vec![check("secrets hygiene", "warn", "x")])]); + let c = p.cards().next().unwrap(); + assert!(c.affected.includes("alpha")); + assert_eq!( + c.action, + CardAction::Copy("hack doctor --fix --path '/work/alpha'".into()) + ); + } + + #[test] + fn local_ca_trust_is_one_machine_certificate_card() { + assert_eq!( + classify("local ca trust"), + Some((Area::Certificates, Scope::Machine)) + ); + let finding = || { + check( + "local ca trust", + "warn", + "The System keychain does not trust it", + ) + }; + let v = aggregate(&[ + machine(vec![finding()]), + proj("alpha", vec![finding()]), + proj("beta", vec![finding()]), + ]); + assert_eq!(v.attention_count(), 1); + let c = v.cards().next().unwrap(); + assert_eq!(c.affected, Affected::Machine); + assert_eq!(c.action, CardAction::Copy("hack global trust".into())); + } + + #[test] + fn global_sessions_mux_from_the_machine_run_is_kept() { + let v = aggregate(&[machine(vec![check( + "sessions mux", + "warn", + "sessions.mux=tmux, but tmux is missing (run: install tmux with your package manager)", + )])]); + assert!(!v.is_clean()); + let c = v.cards().next().unwrap(); + assert_eq!(c.affected, Affected::Machine); + assert!( + matches!(c.action, CardAction::OpenDoc(_)), + "not a hack command" + ); + // A project override keeps its attribution. + let p = aggregate(&[proj("alpha", vec![check("sessions mux", "warn", "x")])]); + assert!(p.cards().next().unwrap().affected.includes("alpha")); + } + + #[test] + fn unread_project_list_is_incomplete_coverage_never_green() { + let t0 = Instant::now(); + let mut pane = DoctorPane::default(); + let g = pane.begin(None); + let runs = [machine(vec![check("docker", "ok", "")])]; + assert_eq!(pane.finish(g, &runs, None, t0), Finish::Applied); + let DoctorState::Loaded(v) = &pane.result else { + panic!("machine checks ran: {:?}", pane.result) + }; + assert!(!v.is_clean()); + assert_eq!(pane.tone(), Tone::Warn); + assert!( + v.coverage_line() + .unwrap() + .contains("project list could not be read") + ); + // Once the list is readable, the result is stale and checked again. + pane.targets_seen(Some(&[])); + assert!(pane.stale); + assert!(pane.needs_refresh(t0)); + } + + #[test] + fn target_changes_after_a_run_mark_it_stale() { + let t0 = Instant::now(); + let alpha = vec![key("alpha", "/work/alpha", false)]; + let runs = [machine(vec![])]; + let mut pane = DoctorPane::default(); + pane.targets_seen(Some(&alpha)); + assert!(!pane.stale, "never run: nothing to invalidate"); + let g = pane.begin(Some(&alpha)); + pane.targets_seen(Some(&[])); + assert!(!pane.stale, "a run in flight checks on finish"); + pane.finish(g, &runs, Some(&alpha), t0); + pane.targets_seen(Some(&alpha)); + assert!(!pane.stale, "same targets stay fresh"); + for changed in [ + vec![], + vec![ + key("alpha", "/work/alpha", false), + key("beta", "/work/beta", false), + ], + vec![key("alpha", "/work/alpha", true)], + ] { + let mut p = pane.clone(); + p.targets_seen(Some(&changed)); + assert!(p.stale, "{changed:?}"); + assert!(p.needs_refresh(t0)); + } + } + + #[test] + fn same_name_with_new_root_or_backend_is_stale() { + let t0 = Instant::now(); + let runs = [machine(vec![check("docker", "ok", "")])]; + let before = vec![key("alpha", "/work/alpha", false)]; + for after in [ + vec![key("alpha", "/work/alpha-moved", false)], + vec![key("alpha", "/work/alpha", true)], + ] { + let mut pane = DoctorPane::default(); + let g = pane.begin(Some(&before)); + assert_eq!( + pane.finish(g, &runs, Some(&after), t0), + Finish::AppliedStale + ); + assert!(pane.stale); + assert!(pane.needs_refresh(t0), "a stale result asks for a refresh"); + } + } + + #[test] + fn auto_refresh_only_when_shown_in_a_visible_window() { + let t0 = Instant::now(); + let fresh = { + let mut p = DoctorPane::default(); + let g = p.begin(Some(&[])); + p.finish(g, &[machine(vec![])], Some(&[]), t0); + p + }; + let never = DoctorPane::default(); + let later = t0 + REFRESH_EVERY; + // (pane shown, window visible, pane, now) -> expected + let cases = [ + (true, true, &never, t0, true), + (true, false, &never, t0, false), + (false, true, &never, t0, false), + (true, true, &fresh, t0, false), + (true, true, &fresh, later, true), + (true, false, &fresh, later, false), + ]; + for (shown, visible, pane, now, want) in cases { + assert_eq!( + should_auto_refresh(shown, visible, pane, now), + want, + "shown={shown} visible={visible}" + ); + } + } + + /// Every check id the CLI declares (summary groups and `*_CHECK` + /// constants in `src/commands/doctor.ts` and `src/lib/doctor-*.ts`) + /// maps to a known area. + #[test] + fn cli_check_ids_all_classify() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.."); + let mut sources = vec![root.join("src/commands/doctor.ts")]; + if let Ok(dir) = std::fs::read_dir(root.join("src/lib")) { + for e in dir.flatten() { + let n = e.file_name().to_string_lossy().into_owned(); + if n.starts_with("doctor") && n.ends_with(".ts") { + sources.push(e.path()); + } + } + } + let mut ids = Vec::new(); + for path in &sources { + let Ok(text) = std::fs::read_to_string(path) else { + continue; + }; + // `const X_CHECK = "id";` + for line in text.lines() { + let l = line.trim(); + if l.contains("_CHECK = \"") + && (l.starts_with("const ") || l.starts_with("export const ")) + && let Some(id) = l.split('"').nth(1) + { + ids.push(id.to_owned()); + } + } + // String literals inside DOCTOR_SUMMARY_GROUPS. + if let Some(start) = text.find("const DOCTOR_SUMMARY_GROUPS") { + let block = &text[start..]; + let block = &block[..block.find("] as const;").unwrap_or(block.len())]; + for (i, part) in block.split('"').enumerate() { + if i % 2 == 1 && !part.is_empty() && block_is_check(part) { + ids.push(part.to_owned()); + } + } + } + } + assert!(ids.len() > 40, "expected the CLI's check ids, got {ids:?}"); + let unknown: Vec<&String> = ids.iter().filter(|id| classify(id).is_none()).collect(); + assert!( + unknown.is_empty(), + "unclassified CLI check ids: {unknown:?}" + ); + } + + /// Titles in the summary groups are not check ids. + fn block_is_check(s: &str) -> bool { + ![ + "Dependencies", + "Global runtime & agents", + "Browser access", + "Resolver & DNS", + "Project & env", + "Sessions", + ] + .contains(&s) + } +} diff --git a/apps/desktop/core/src/doctor_acl.rs b/apps/desktop/core/src/doctor_acl.rs new file mode 100644 index 000000000..e5fbf6a1c --- /dev/null +++ b/apps/desktop/core/src/doctor_acl.rs @@ -0,0 +1,130 @@ +//! Read-only ACL preflight. Unknown ACL metadata is never treated as trusted. +use std::path::Path; + +#[cfg(target_os = "macos")] +pub(crate) fn controlled(path: &Path) -> bool { + use libc::{c_char, c_int, c_void}; + use std::{ + ffi::CString, + os::unix::{ffi::OsStrExt, fs::MetadataExt}, + }; + unsafe extern "C" { + fn acl_get_file(path: *const c_char, kind: c_int) -> *mut c_void; + fn acl_get_entry(acl: *mut c_void, selector: c_int, entry: *mut *mut c_void) -> c_int; + fn acl_get_tag_type(entry: *mut c_void, tag: *mut c_int) -> c_int; + fn acl_get_permset(entry: *mut c_void, perms: *mut *mut c_void) -> c_int; + fn acl_get_perm_np(perms: *mut c_void, permission: c_int) -> c_int; + fn acl_free(acl: *mut c_void) -> c_int; + } + fn witness(path: &Path) -> Option<([u64; 9], [u64; 9])> { + fn key(m: std::fs::Metadata) -> [u64; 9] { + [ + m.dev(), + m.ino(), + u64::from(m.mode()), + u64::from(m.uid()), + u64::from(m.gid()), + m.mtime() as u64, + m.mtime_nsec() as u64, + m.ctime() as u64, + m.ctime_nsec() as u64, + ] + } + Some(( + key(std::fs::symlink_metadata(path).ok()?), + key(std::fs::metadata(path).ok()?), + )) + } + let Some(before) = witness(path) else { + return false; + }; + let Ok(name) = CString::new(path.as_os_str().as_bytes()) else { + return false; + }; + // Darwin Libc acl_file.c/filesec.c: NULL/ENOENT can mean no ACL. + // Observe existing link and target identity before/after to distinguish + // missing/changed paths. No read permission or descriptor open is required. + // Same-user/root ABA changes are outside this observational boundary. + // SAFETY: name is NUL-terminated; ACL handles stay owned by this call. + unsafe { + *libc::__error() = 0; + let acl = acl_get_file(name.as_ptr(), 0x100); + if acl.is_null() { + return std::io::Error::last_os_error().raw_os_error() == Some(libc::ENOENT) + && witness(path) == Some(before); + } + let mut entry = std::ptr::null_mut(); + let mut selector = 0; + let safe = loop { + match acl_get_entry(acl, selector, &mut entry) { + 0 => {} + -1 if std::io::Error::last_os_error().raw_os_error() == Some(libc::EINVAL) => { + break true; + } + _ => break false, + } + selector = -1; + let mut tag = 0; + if acl_get_tag_type(entry, &mut tag) != 0 { + break false; + } + if tag == 2 { + continue; + } + if tag != 1 { + break false; + } + let mut perms = std::ptr::null_mut(); + if acl_get_permset(entry, &mut perms) != 0 { + break false; + } + let rights = [ + 1 << 2, + 1 << 4, + 1 << 5, + 1 << 6, + 1 << 8, + 1 << 10, + 1 << 12, + 1 << 13, + ]; + if rights + .iter() + .any(|right| acl_get_perm_np(perms, *right) != 0) + { + break false; + } + }; + acl_free(acl); + safe && witness(path) == Some(before) + } +} + +#[cfg(target_os = "linux")] +pub(crate) fn controlled(path: &Path) -> bool { + use std::{ffi::CString, os::unix::ffi::OsStrExt}; + let Ok(path) = CString::new(path.as_os_str().as_bytes()) else { + return false; + }; + // Linux access ACLs are conservatively refused, including read-only ACLs. + // SAFETY: both names are NUL-terminated; a zero-size query writes no bytes. + let size = unsafe { + libc::getxattr( + path.as_ptr(), + c"system.posix_acl_access".as_ptr(), + std::ptr::null_mut(), + 0, + ) + }; + size == 0 + || (size < 0 + && matches!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::ENODATA | libc::ENOTSUP) + )) +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +pub(crate) fn controlled(_: &Path) -> bool { + false +} diff --git a/apps/desktop/core/src/doctor_plugins.rs b/apps/desktop/core/src/doctor_plugins.rs new file mode 100644 index 000000000..64238f8db --- /dev/null +++ b/apps/desktop/core/src/doctor_plugins.rs @@ -0,0 +1,498 @@ +//! Narrow preflight for documented Unix Docker CLI plugin discovery. +//! Context/config selectors are observed, never replaced. Config parsing keeps +//! only cliPluginsExtraDirs; credentials and other fields are ignored, not copied. + +use std::io::Read; +use std::os::unix::{ffi::OsStrExt, fs::MetadataExt}; +use std::path::{Path, PathBuf}; + +use crate::{runner::CliRunner, source::trusted_tool_path}; + +pub(crate) struct Discovery { + pub config_dir: Option, + pub unsupported_extra_selector: bool, + pub system_dirs: Vec, + /// `DOCKER_HOST` and `DOCKER_CONTEXT` as the runner passes them on. + pub docker_host: Option, + pub context: Option, +} + +impl Discovery { + pub fn from_runner(runner: &CliRunner) -> Self { + let config_dir = runner + .env_value("DOCKER_CONFIG") + .filter(|p| !p.is_empty()) + .map(PathBuf::from) + .or_else(|| { + runner + .env_value("HOME") + .filter(|p| !p.is_empty()) + .map(|p| PathBuf::from(p).join(".docker")) + }); + let text = |key| { + runner + .env_value(key) + .and_then(|v| v.into_string().ok()) + .filter(|v| !v.is_empty()) + }; + Self { + docker_host: text("DOCKER_HOST"), + context: text("DOCKER_CONTEXT"), + config_dir, + // This selector's encoding/precedence is not part of the Unix + // configfile contract below. Do not guess or silently ignore it. + unsupported_extra_selector: runner + .env_value("DOCKER_CLI_PLUGIN_EXTRA_DIRS") + .is_some_and(|v| !v.is_empty()), + // docker/cli cli-plugins/manager/manager_unix.go (standard Unix + // builds). Custom discovery requires a separate qualified adapter. + system_dirs: [ + "/usr/local/lib/docker/cli-plugins", + "/usr/local/libexec/docker/cli-plugins", + "/usr/lib/docker/cli-plugins", + "/usr/libexec/docker/cli-plugins", + ] + .map(PathBuf::from) + .to_vec(), + } + } +} + +#[derive(Default, serde::Deserialize)] +struct PluginConfig { + #[serde(default, rename = "cliPluginsExtraDirs")] + extra_dirs: Option>, + #[serde(default, rename = "currentContext")] + current_context: Option, +} + +/// Doctor's automatic `docker info` must reach only a local Unix socket: an +/// SSH or TCP endpoint makes Docker start other programs (for example `ssh` +/// and its configured helpers) that these checks do not inspect. +fn local_endpoint( + discovery: &Discovery, + config: &Path, + configured: Option<&str>, +) -> Result<(), &'static str> { + const REMOTE: &str = + "Docker is set to a remote endpoint; Doctor's automatic checks only use a local socket"; + if let Some(host) = &discovery.docker_host + && !host.starts_with("unix://") + { + return Err(REMOTE); + } + let Some(name) = discovery + .context + .as_deref() + .or(configured) + .filter(|name| *name != "default") + else { + return Ok(()); + }; + #[derive(serde::Deserialize)] + struct Meta { + #[serde(rename = "Name")] + name: String, + #[serde(rename = "Endpoints", default)] + endpoints: std::collections::BTreeMap, + } + #[derive(serde::Deserialize)] + struct Endpoint { + #[serde(rename = "Host", default)] + host: Option, + } + const UNKNOWN: &str = + "The selected Docker context's endpoint is unknown; Docker was not invoked"; + // Docker loads exactly contexts/meta//meta.json. + let digest = ::digest(name.as_bytes()); + let hex: String = digest.iter().map(|b| format!("{b:02x}")).collect(); + let dir = config.join("contexts/meta").join(hex); + // SAFETY: getuid has no preconditions and cannot fail. + let uid = unsafe { libc::getuid() }; + if trusted_tool_path(&dir, uid, true).is_none() { + return Err(UNKNOWN); + } + // No symlink and no blocking open: a FIFO or device must not hang Doctor. + let file = { + use std::os::unix::fs::OpenOptionsExt; + std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW) + .open(dir.join("meta.json")) + .map_err(|_| UNKNOWN)? + }; + let meta = file.metadata().map_err(|_| UNKNOWN)?; + // The ACL check is by path; the opened descriptor must still be that path's + // file (same device and inode), so a swap in between is refused. + let same_file = std::fs::symlink_metadata(dir.join("meta.json")) + .is_ok_and(|path| path.dev() == meta.dev() && path.ino() == meta.ino()); + if !meta.is_file() + || (meta.uid() != uid && meta.uid() != 0) + || meta.mode() & 0o022 != 0 + || meta.len() > 64 * 1024 + || !same_file + || !crate::doctor_acl::controlled(&dir.join("meta.json")) + { + return Err(UNKNOWN); + } + let meta: Meta = serde_json::from_reader(std::io::BufReader::new(file).take(64 * 1024)) + .map_err(|_| UNKNOWN)?; + if meta.name != name { + return Err(UNKNOWN); + } + match meta.endpoints.get("docker").and_then(|e| e.host.as_deref()) { + Some(host) if host.starts_with("unix://") => Ok(()), + Some(_) => Err(REMOTE), + None => Err(UNKNOWN), + } +} + +// Unlike private PATH wrappers, Docker later resolves the original names. +// Check original prefixes AND every lexical link target, not only realpath: +// an attacker must not be able to replace an intermediate alias afterward. +pub(crate) fn guarded_names( + path: &Path, + uid: u32, + links_left: &mut usize, + as_ancestor: bool, +) -> bool { + if !path.is_absolute() { + return false; + } + for (index, component) in path.ancestors().enumerate() { + let Ok(meta) = std::fs::symlink_metadata(component) else { + return false; + }; + if (meta.uid() != uid && meta.uid() != 0) || !crate::doctor_acl::controlled(component) { + return false; + } + if meta.file_type().is_symlink() { + if *links_left == 0 { + return false; + } + *links_left -= 1; + let Ok(target) = std::fs::read_link(component) else { + return false; + }; + let target = if target.is_absolute() { + target + } else { + let Some(parent) = component.parent() else { + return false; + }; + parent.join(target) + }; + if !guarded_names(&target, uid, links_left, as_ancestor || index > 0) { + return false; + } + } else { + let sticky_root = (index > 0 || as_ancestor) + && meta.is_dir() + && meta.uid() == 0 + && meta.mode() & 0o1000 != 0; + if (index > 0 && !meta.is_dir()) || (meta.mode() & 0o022 != 0 && !sticky_root) { + return false; + } + } + } + true +} + +fn guarded_dir(path: &Path, uid: u32) -> Option { + if !guarded_names(path, uid, &mut 64, false) { + return None; + } + trusted_tool_path(path, uid, true) +} + +/// Missing candidates are proven absent only below a protected existing +/// ancestor. A dangling link, unreadable path or writable ancestor is unknown. +fn present(path: &Path, uid: u32) -> Result { + if !path.is_absolute() { + return Err("relative Docker plugin discovery is unsupported"); + } + let mut current = path; + loop { + match std::fs::symlink_metadata(current) { + Ok(_) if current == path => return Ok(true), + Ok(_) => { + return guarded_dir(current, uid) + .map(|_| false) + .ok_or("Docker plugin absence cannot be proven below an untrusted ancestor"); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + current = current + .parent() + .ok_or("Docker plugin ancestry is unknown")?; + } + Err(_) => return Err("Docker plugin discovery is unreadable"), + } + } +} + +fn config_file(path: &Path, uid: u32) -> Result { + if !present(path, uid)? { + return Ok(PluginConfig::default()); + } + let real = std::fs::canonicalize(path).map_err(|_| "Docker plugin config target is unknown")?; + let meta = + std::fs::metadata(&real).map_err(|_| "Docker plugin config metadata is unreadable")?; + if !guarded_names(path, uid, &mut 64, false) + || !meta.is_file() + || (meta.uid() != uid && meta.uid() != 0) + || meta.mode() & 0o022 != 0 + || real + .parent() + .and_then(|p| trusted_tool_path(p, uid, true)) + .is_none() + { + return Err("Docker plugin config is not owner-controlled"); + } + const MAX_CONFIG: u64 = 1024 * 1024; + if meta.len() > MAX_CONFIG { + return Err("Docker plugin config exceeds the validation bound"); + } + let file = std::fs::File::open(real).map_err(|_| "Docker plugin config is unreadable")?; + // Streaming serde ignores all fields other than this public path list; + // never retain/log/copy auths, credential helper values or context contents. + serde_json::from_reader(std::io::BufReader::new(file).take(MAX_CONFIG + 1)) + .map_err(|_| "Docker plugin config is invalid or unsupported") +} + +/// Compose Doctor resolves Mutagen before PATH. Validate that fixed override +/// or managed candidate without changing its selection or reading its contents. +pub(crate) fn validate_helpers(runner: &CliRunner) -> Result { + let selector = |key| -> Result, &'static str> { + runner + .env_value(key) + .map(|v| { + v.into_string() + .map(|v| v.trim().to_owned()) + .map_err(|_| "Doctor helper selector is not UTF-8") + }) + .transpose() + .map(|v| v.filter(|v| !v.is_empty())) + }; + let candidate = if let Some(path) = selector("HACK_MUTAGEN_PATH")? { + PathBuf::from(path) + } else { + let home = if let Some(path) = selector("HACK_HOME")? { + PathBuf::from(path) + } else { + PathBuf::from(selector("HOME")?.ok_or("Doctor helper home is unknown")?).join(".hack") + }; + home.join("bin/mutagen") + }; + if !candidate.is_absolute() { + return Err("relative Doctor Mutagen helper selection is unsupported"); + } + let uid = unsafe { libc::getuid() }; + // The CLI executes this helper directly, so its #! interpreter must pass + // the same rule as PATH tools. + if present(&candidate, uid)? + && trusted_tool_path(&candidate, uid, false) + // The CLI runs the helper under Doctor's private wrapper PATH. + .is_none_or(|real| !crate::source::trusted_interpreter(&real, uid, 4, true)) + { + return Err("Doctor absolute helper or its ancestors/ACLs are untrusted"); + } + // A native candidate runtime is spawned by absolute path with a narrowed + // environment (not the wrapper PATH), so the env #! form is refused. + // Companions are spawned by absolute path with a narrowed environment + // (not the wrapper PATH), so the env #! form is refused. + let companion_ok = |path: &Path, why: &'static str| -> Result { + if present(path, uid)? { + if trusted_tool_path(path, uid, false) + .is_none_or(|real| !crate::source::trusted_interpreter(&real, uid, 4, false)) + { + return Err(why); + } + return Ok(true); + } + Ok(false) + }; + const NATIVE_UNTRUSTED: &str = "Native runtime binary or its ancestors/ACLs are untrusted"; + const SUPERVISOR_UNTRUSTED: &str = + "Lifecycle supervisor binary or its ancestors/ACLs are untrusted"; + // Companions the CLI takes from beside its own executable + // (`process.execPath`). For a compiled CLI that is the CLI's directory, + // canonical or as given. A `#!` launcher's eventual executable is not + // knowable here (see the README's Doctor limits). + let compiled_dirs: Vec = runner + .program_path() + .and_then(|program| { + let real = std::fs::canonicalize(&program).ok()?; + let mut head = [0_u8; 2]; + let launcher = std::fs::File::open(&real) + .and_then(|mut f| std::io::Read::read_exact(&mut f, &mut head)) + .map_or(true, |()| head == *b"#!"); + (!launcher).then(|| { + [real.parent(), program.parent()] + .into_iter() + .flatten() + .map(Path::to_path_buf) + .collect() + }) + }) + .unwrap_or_default(); + // The lifecycle supervisor is run by Doctor's project checks for projects + // using the supervisor runner; a compiled CLI ignores any selector for it. + for dir in &compiled_dirs { + companion_ok(&dir.join("hack-lifecycle-supervisor"), SUPERVISOR_UNTRUSTED)?; + } + if let Some(native) = selector("HACK_NATIVE_BINARY")? { + let native = PathBuf::from(native); + if !native.is_absolute() { + return Err("relative native runtime selection is unsupported"); + } + companion_ok(&native, NATIVE_UNTRUSTED)?; + return Ok(NativePin::Selected); + } + // Unset: the CLI falls back to `hack-native` beside its own executable. + // A `#!` launcher or unresolvable program gets an absent private pin + // instead of letting the CLI search. + if compiled_dirs.is_empty() { + return Ok(NativePin::Unavailable); + } + let mut found = None; + for dir in &compiled_dirs { + let candidate = dir.join("hack-native"); + if companion_ok(&candidate, NATIVE_UNTRUSTED)? && found.is_none() { + found = Some(candidate); + } + } + Ok(found.map_or(NativePin::Absent, NativePin::Adjacent)) +} + +/// Relative to the private wrapper directory; see [`NativePin::apply`]. +pub(crate) const UNAVAILABLE_NATIVE: &str = "native-unavailable/hack-native"; + +/// The native runtime selection Doctor's runs use when `HACK_NATIVE_BINARY` +/// is unset. Every variant has been checked; [`NativePin::apply`] keeps the +/// CLI from choosing anything else. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum NativePin { + /// `HACK_NATIVE_BINARY` was set and checked; the CLI uses it as is. + Selected, + /// A checked `hack-native` beside the CLI executable; pinned by path. + Adjacent(PathBuf), + /// No `hack-native` beside the CLI, and its absence is protected by + /// owner-controlled ancestry; the CLI's own fallback finds nothing. + Absent, + /// The CLI's fallback location cannot be known (a `#!` launcher or an + /// unresolvable program); a path that cannot exist is pinned. + Unavailable, +} + +impl NativePin { + /// `private_dir` must be Doctor's private wrapper directory, which only + /// this user can write. The wrapper scan creates only regular files there + /// (named after PATH tools), so a path one level below any entry name can + /// never exist: the unavailable pin stays absent even if a PATH tool is + /// named like its first component. + pub(crate) fn apply(&self, runner: CliRunner, private_dir: &Path) -> CliRunner { + match self { + Self::Selected | Self::Absent => runner, + Self::Adjacent(path) => match path.to_str() { + Some(path) => runner.with_env("HACK_NATIVE_BINARY", path), + None => Self::Unavailable.apply(runner, private_dir), + }, + Self::Unavailable => match private_dir.join(UNAVAILABLE_NATIVE).to_str() { + Some(path) => runner.with_env("HACK_NATIVE_BINARY", path), + // The wrapper PATH is required to be UTF-8 before this runs. + None => runner, + }, + } + } +} + +/// Success proves only the documented Unix discovery inputs inspected here. +/// It neither sandboxes plugin contents nor qualifies custom Docker builds. +pub(crate) fn validate(discovery: &Discovery) -> Result<(), &'static str> { + // SAFETY: getuid has no preconditions and cannot fail. + let uid = unsafe { libc::getuid() }; + if discovery.unsupported_extra_selector { + return Err( + "DOCKER_CLI_PLUGIN_EXTRA_DIRS discovery is unsupported; Docker was not invoked", + ); + } + let config = discovery + .config_dir + .as_ref() + .ok_or("Docker config selection is unknown")?; + if present(config, uid)? && guarded_dir(config, uid).is_none() { + return Err("Docker config directory is not owner-controlled"); + } + let parsed = config_file(&config.join("config.json"), uid)?; + local_endpoint(discovery, config, parsed.current_context.as_deref())?; + let mut dirs = vec![config.join("cli-plugins")]; + dirs.extend(parsed.extra_dirs.unwrap_or_default()); + dirs.extend(discovery.system_dirs.iter().cloned()); + if dirs.len() > 64 { + return Err("Docker plugin discovery exceeds 64 directories"); + } + let mut seen = std::collections::BTreeSet::new(); + let mut entries = 0; + for dir in dirs { + if !present(&dir, uid)? { + continue; + } + let real = guarded_dir(&dir, uid) + .ok_or("Docker plugin directory or target is not owner-controlled")?; + if !seen.insert(real.clone()) { + continue; + } + for entry in std::fs::read_dir(real).map_err(|_| "Docker plugin directory is unreadable")? { + entries += 1; + if entries > 8192 { + return Err("Docker plugin discovery exceeds 8192 entries"); + } + let entry = entry.map_err(|_| "Docker plugin entry is unreadable")?; + if !entry.file_name().as_bytes().starts_with(b"docker-") { + continue; + } + // Docker runs plugins by absolute path, so the interpreter of a + // script plugin must pass the same rule as PATH tools. + if !guarded_names(&entry.path(), uid, &mut 64, false) + || trusted_tool_path(&entry.path(), uid, false) + .is_none_or(|real| !crate::source::trusted_interpreter(&real, uid, 4, true)) + { + return Err( + "Docker plugin executable or symlink target is untrusted or unprovable", + ); + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn selectors_follow_effective_runner_context_without_reading_actual_config() { + let runner = CliRunner::new("unused-fixture-cli") + .with_env("HOME", "/fixture/user-root") + .with_env("DOCKER_CONFIG", "/fixture/selected-client") + .with_env("DOCKER_CONTEXT", "fixture-selected-context") + .with_env("DOCKER_CLI_PLUGIN_EXTRA_DIRS", ""); + assert_eq!( + Discovery::from_runner(&runner).config_dir, + Some("/fixture/selected-client".into()) + ); + let empty = runner.clone().with_env("DOCKER_CONFIG", ""); + assert_eq!( + Discovery::from_runner(&empty).config_dir, + Some("/fixture/user-root/.docker".into()) + ); + let opaque = runner + .clone() + .with_env("DOCKER_CLI_PLUGIN_EXTRA_DIRS", "/fixture/opaque"); + assert!(Discovery::from_runner(&opaque).unsupported_extra_selector); + assert_eq!( + runner.env_value("DOCKER_CONTEXT").unwrap(), + "fixture-selected-context" + ); + } +} diff --git a/apps/desktop/core/src/doctor_tools.rs b/apps/desktop/core/src/doctor_tools.rs new file mode 100644 index 000000000..4126ca492 --- /dev/null +++ b/apps/desktop/core/src/doctor_tools.rs @@ -0,0 +1,132 @@ +//! Fixed read-only Doctor tool dependencies (doctor.ts and its check helpers). +//! Audit and exclusions: apps/desktop/DOCTOR_TOOLS.md. Only read-path dependencies belong here. +//! This is coverage attribution, not an executable or plugin discovery framework. + +pub(crate) const NAMES: &[&str] = &[ + "bun", + "docker", + "brew", + "dnsmasq", + "mkcert", + "gum", + "fzf", + "tmux", + "mutagen", + "go", + "caddy", + "asdf", + "git", + "security", + "systemctl", + "resolvectl", + "ip", + "loginctl", + "zellij", + "ss", + "lsof", + "ps", +]; + +pub(crate) fn affected(tool: &str, id: &str) -> bool { + if id == tool || id == format!("{tool} (optional)") || id == format!("{tool} (sessions)") { + return true; + } + match tool { + "docker" => { + matches!(id, "docker compose" | "docker daemon") + || id.starts_with("network:") + || id.starts_with("dnsmasq.conf:") + || (id.starts_with("dns:") && !matches!(id, "dns:hack" | "dns:hack.gy")) + || matches!( + id, + "proxy network" + | "proxy ports" + | "coredns forwarding" + | "caddy hosts" + | "caddy local ca" + | "runtime hygiene" + | "lifecycle hygiene" + | "worktree instances" + ) + } + "git" => matches!( + id, + "env key" + | "worktree keys" + | "generated files" + | "hack gitignore" + | "secrets hygiene" + | "secret integrity" + | "env materialization" + ), + // mkcert is only an optional tool-presence check above. Certificate + // inspection uses Node parsing, Docker and security, never mkcert or openssl. + "security" => matches!( + id, + "host tls trust" + | "superseded caddy roots" + | "native root trust" + | "native https owner" + | "native https route" + ), + "brew" => id.starts_with("dnsmasq.conf:") || id == "native hack.local route", + "systemctl" => matches!(id, "daemon service" | "linux dns route"), + "loginctl" => id == "daemon service", + "ip" | "resolvectl" => id == "linux dns route", + "tmux" => matches!( + id, + "sessions mux" | "lifecycle hygiene" | "runtime hygiene" | "worktree instances" + ), + "zellij" => matches!( + id, + "lifecycle hygiene" | "runtime hygiene" | "worktree instances" + ), + "ps" => id == "lifecycle hygiene", + // lsof/ss only enrich an already-failed proxy diagnostic. Their absence + // is covered by the tool card, never erases the verified port failure. + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn file_only_checks_never_inherit_path_tool_dependencies() { + for tool in NAMES { + for id in [ + "project", + "compose networks", + "compose backend features", + "compose env at rest", + "native source size", + "resolver:hack.local", + "dnsmasq:53", + "grafana", + "env mode", + "env overlay warnings", + "global files", + ] { + assert!( + !affected(tool, id), + "{tool} cannot invalidate file-only/internal observation {id}" + ); + } + } + for tool in ["dig", "scutil", "openssl"] { + assert!(!NAMES.contains(&tool)); + } + for tool in [ + "mkcert", "caddy", "dnsmasq", "go", "asdf", "bun", "gum", "fzf", "mutagen", + ] { + assert!(affected(tool, tool)); + for id in ["caddy local ca", "host tls trust", "native root trust"] { + assert!( + !affected(tool, id), + "optional/presence-only {tool} cannot invalidate {id}" + ); + } + } + } +} diff --git a/apps/desktop/core/src/fixtures.rs b/apps/desktop/core/src/fixtures.rs index e9ee41a87..f4262fc27 100644 --- a/apps/desktop/core/src/fixtures.rs +++ b/apps/desktop/core/src/fixtures.rs @@ -40,10 +40,19 @@ pub enum Fixture { Stopping, FailedStart, NeedsAttention, + // Doctor pane. + /// Findings across areas, machine and project scoped, one failed run. + Doctor, + /// Every check passed. + DoctorClean, + /// A project whose header links to its doctor findings. + DoctorLink, + /// No projects registered: the Doctor pane still shows machine checks. + DoctorEmpty, } impl Fixture { - pub const ALL: [Self; 21] = [ + pub const ALL: [Self; 25] = [ Self::EnvLoading, Self::EnvError, Self::EnvNormal, @@ -65,6 +74,10 @@ impl Fixture { Self::Stopping, Self::FailedStart, Self::NeedsAttention, + Self::Doctor, + Self::DoctorClean, + Self::DoctorLink, + Self::DoctorEmpty, ]; #[must_use] @@ -91,6 +104,10 @@ impl Fixture { Self::Stopping => "stopping", Self::FailedStart => "failed-start", Self::NeedsAttention => "needs-attention", + Self::Doctor => "doctor", + Self::DoctorClean => "doctor-clean", + Self::DoctorLink => "doctor-link", + Self::DoctorEmpty => "doctor-empty", } } @@ -354,7 +371,7 @@ pub fn snapshot(f: Fixture) -> Result { _ => {} } let mut projects = project(f); - if f == Fixture::Empty { + if matches!(f, Fixture::Empty | Fixture::DoctorEmpty) { projects.projects.clear(); } if f == Fixture::ManyProjects { @@ -437,10 +454,160 @@ pub fn log_tail(f: Fixture) -> Vec { } } +/// Fixtures that open the Doctor pane instead of a project. +#[must_use] +pub fn opens_doctor(f: Fixture) -> bool { + matches!( + f, + Fixture::Doctor | Fixture::DoctorClean | Fixture::DoctorEmpty + ) +} + +/// Fixtures that load doctor results (and may still show a project). +#[must_use] +pub fn loads_doctor(f: Fixture) -> bool { + opens_doctor(f) || f == Fixture::DoctorLink +} + +/// Canned doctor runs (machine run first, then per project). +#[must_use] +pub fn doctor_runs(f: Fixture) -> Vec { + use crate::doctor::DoctorRun; + let c = |id: &str, status: &str, detail: &str| DoctorCheck { + id: id.into(), + status: status.into(), + detail: detail.into(), + }; + let report = |checks: Vec| { + Ok(DoctorReport { + checks, + summary: vec![], + }) + }; + let ok_machine = || { + vec![ + c("bun", "ok", ""), + c("docker", "ok", ""), + c("docker compose", "ok", ""), + c("docker daemon", "ok", ""), + c("daemon", "ok", ""), + c("caddy local ca", "ok", ""), + c("resolver:hack", "ok", ""), + c("dnsmasq:53", "ok", ""), + ] + }; + if f == Fixture::DoctorEmpty { + let mut machine = ok_machine(); + machine[5] = c( + "caddy local ca", + "warn", + "Local CA is not trusted by the system", + ); + return vec![DoctorRun::machine(report(machine))]; + } + if f == Fixture::DoctorClean { + return vec![ + DoctorRun::machine(report(ok_machine())), + DoctorRun::project( + "storefront", + "/work/storefront", + report(vec![ + c( + "project", + "ok", + "Found .hack/ (or legacy .dev/) at /work/storefront/.hack", + ), + c("env key", "ok", ""), + ]), + ), + ]; + } + let machine = vec![ + c("bun", "ok", ""), + c("docker", "ok", ""), + c("docker daemon", "ok", ""), + c( + "daemon", + "warn", + "hackd is running but incompatible with this CLI version", + ), + c( + "caddy local ca", + "warn", + "Local CA is not trusted by the system", + ), + c( + "resolver:hack", + "error", + "No resolver file routes .hack to the local DNS server", + ), + c("dnsmasq:53", "ok", ""), + c( + "brand new check", + "warn", + "A check this app does not know yet", + ), + ]; + vec![ + DoctorRun::machine(report(machine)), + DoctorRun::project( + "storefront", + "/work/storefront", + report(vec![ + c( + "project", + "ok", + "Found .hack/ (or legacy .dev/) at /work/storefront/.hack", + ), + c("env key", "error", "The env key file is missing"), + c( + "caddy local ca", + "warn", + "Local CA is not trusted by the system", + ), + c( + "generated files", + "warn", + "A generated file is tracked by git", + ), + ]), + ), + DoctorRun::project( + "billing-api", + "/work/billing-api", + report(vec![ + c( + "project", + "ok", + "Found .hack/ (or legacy .dev/) at /work/billing-api/.hack", + ), + c("env key", "error", "The env key file is missing"), + ]), + ), + DoctorRun::project( + "notes", + "/work/notes", + Err("hack did not answer within 60s".into()), + ), + ] +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn doctor_fixtures_aggregate_as_designed() { + let v = crate::doctor::aggregate(&doctor_runs(Fixture::Doctor)); + // daemon, caddy ca, resolver, unknown (machine); env key, generated files (project). + assert_eq!(v.attention_count(), 6); + assert_eq!(v.failed_runs.len(), 1); + let clean = crate::doctor::aggregate(&doctor_runs(Fixture::DoctorClean)); + assert_eq!(clean.attention_count(), 0); + assert!(clean.passed >= 8); + assert!(opens_doctor(Fixture::Doctor) && !opens_doctor(Fixture::Running)); + } + #[test] fn names_round_trip() { for f in Fixture::ALL { @@ -456,7 +623,10 @@ mod tests { (Fixture::Loading, Err(SourceError::Pending)) | (Fixture::CliMissing, Err(SourceError::CliMissing)) | (Fixture::Error, Err(SourceError::Cli { .. })) => {} - (_, Ok(s)) => assert_eq!(s.projects.projects.is_empty(), f == Fixture::Empty), + (_, Ok(s)) => assert_eq!( + s.projects.projects.is_empty(), + matches!(f, Fixture::Empty | Fixture::DoctorEmpty) + ), (f, other) => panic!("{f:?}: {other:?}"), } } diff --git a/apps/desktop/core/src/lib.rs b/apps/desktop/core/src/lib.rs index b365509ee..8dca100f9 100644 --- a/apps/desktop/core/src/lib.rs +++ b/apps/desktop/core/src/lib.rs @@ -11,6 +11,10 @@ //! credentials; every action is one CLI command. pub mod daemon; +pub mod doctor; +mod doctor_acl; +mod doctor_plugins; +mod doctor_tools; pub mod fixtures; pub mod logs; pub mod models; diff --git a/apps/desktop/core/src/runner.rs b/apps/desktop/core/src/runner.rs index 724ca9d9f..1039b80cf 100644 --- a/apps/desktop/core/src/runner.rs +++ b/apps/desktop/core/src/runner.rs @@ -71,10 +71,55 @@ impl std::fmt::Display for RunError { pub struct CliRunner { program: PathBuf, env: Vec<(String, String)>, + cwd: Option, /// `on_path` found no executable: spawn reports `NotFound`. missing: bool, /// How long a timed-out command gets after SIGTERM before SIGKILL. term_grace: Duration, + /// Remove inherited variables that make interpreters load code. + strip_startup: bool, +} + +/// Inherited variables that make a shell or language runtime load and run +/// code at startup, or preload libraries. Doctor's tool runs remove them. +pub const INTERPRETER_STARTUP_VARS: &[&str] = &[ + "BASH_ENV", + "ENV", + "SHELLOPTS", + "BASHOPTS", + "PS4", + "ZDOTDIR", + "PERL5OPT", + "PERL5LIB", + "PERLLIB", + "PYTHONSTARTUP", + "PYTHONPATH", + "PYTHONHOME", + "RUBYOPT", + "RUBYLIB", + "NODE_OPTIONS", + "NODE_PATH", + // The Hack CLI is a Bun-compiled binary: these make its runtime preload + // code (the same set the CLI removes before re-executing itself). + "BUN_OPTIONS", + "BUN_BE_BUN", +]; + +/// Every variable with one of these prefixes is removed too: all dynamic +/// loader controls (`LD_*`, `DYLD_*`, including versioned and fallback search +/// paths), exported Bash functions, and Bun runtime configuration and +/// inspector settings. +pub const INTERPRETER_STARTUP_PREFIXES: &[&str] = + &["LD_", "DYLD_", "BASH_FUNC_", "BUN_CONFIG_", "BUN_INSPECT"]; + +fn is_startup_var(name: &std::ffi::OsStr) -> bool { + let bytes = name.as_encoded_bytes(); + INTERPRETER_STARTUP_VARS + .iter() + .any(|v| bytes == v.as_bytes()) + || INTERPRETER_STARTUP_PREFIXES + .iter() + .any(|p| bytes.starts_with(p.as_bytes())) } /// Default grace after SIGTERM. The CLI forwards SIGTERM to the detached @@ -89,8 +134,10 @@ impl CliRunner { Self { program: program.into(), env: vec![("HACK_NO_INTERACTIVE".into(), "1".into())], + cwd: None, missing: false, term_grace: TERM_GRACE, + strip_startup: false, } } @@ -104,9 +151,10 @@ impl CliRunner { /// Find `name` on `PATH` and run it by absolute path. /// /// Only absolute `PATH` entries are searched: an empty or relative entry - /// (such as `.`) would resolve against the working directory, which - /// another local user may control. When nothing is found the runner - /// reports `NotFound` without spawning anything. + /// (such as `.`) would resolve against the working directory, which may + /// differ from the user's checkout for machine checks. When nothing is + /// found the runner reports `NotFound` without + /// spawning anything. #[must_use] pub fn on_path(name: &str) -> Self { let path = std::env::var_os("PATH"); @@ -120,6 +168,14 @@ impl CliRunner { } } + /// Run every command from `dir` (for checks that must run outside any + /// project, such as the machine-level doctor run). + #[must_use] + pub fn with_cwd(mut self, dir: impl Into) -> Self { + self.cwd = Some(dir.into()); + self + } + /// Add an environment variable for every command (tests use this for /// a temporary `HACK_HOME`). #[must_use] @@ -128,6 +184,38 @@ impl CliRunner { self } + /// Effective selector without reading any configuration or secret value. + /// Remove inherited interpreter startup and preload variables + /// ([`INTERPRETER_STARTUP_VARS`] and exported Bash functions) from every + /// child, so a tool script cannot be made to load code from the + /// environment. + #[must_use] + pub fn without_interpreter_startup(mut self) -> Self { + self.strip_startup = true; + self + } + + /// The absolute executable this runner would start, without starting it. + pub(crate) fn program_path(&self) -> Option { + if self.missing { + return None; + } + if self.program.is_absolute() { + return Some(self.program.clone()); + } + let name = self.program.to_str()?; + resolve_on_path(name, self.env_value("PATH").as_deref()) + } + + pub(crate) fn env_value(&self, key: &str) -> Option { + self.env + .iter() + .rev() + .find(|(k, _)| k == key) + .map(|(_, v)| v.into()) + .or_else(|| std::env::var_os(key)) + } + fn command(&self, args: &[&str], path: Option<&Path>) -> Command { use std::os::unix::process::CommandExt; let mut cmd = Command::new(&self.program); @@ -144,6 +232,23 @@ impl CliRunner { for (k, v) in &self.env { cmd.env(k, v); } + if self.strip_startup { + for key in INTERPRETER_STARTUP_VARS { + cmd.env_remove(key); + } + // Prefix matches cover inherited and runner-set names alike. Only + // names are read here, never values. + let inherited = std::env::vars_os().map(|(key, _)| key); + let authored = self.env.iter().map(|(key, _)| key.into()); + for key in inherited.chain(authored) { + if is_startup_var(&key) { + cmd.env_remove(key); + } + } + } + if let Some(dir) = &self.cwd { + cmd.current_dir(dir); + } cmd.stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -1075,6 +1180,58 @@ mod tests { assert_retired(pid); } + #[test] + fn interpreter_startup_variables_never_reach_doctor_children() { + // A run-time check with variables SIP-protected macOS shells keep; + // loader (DYLD_*) variables are checked on the Command below. + let f = FakeHack::new(r#"printf '%s|%s' "${BASH_ENV-unset}" "${NODE_OPTIONS-unset}""#); + let runner = f + .runner() + .with_env("BASH_ENV", "/example/startup") + .with_env("NODE_OPTIONS", "--require=/example/preload"); + let (seen, _, _) = runner.run_raw(&["x"], None, T).unwrap(); + assert_eq!( + seen, "/example/startup|--require=/example/preload", + "positive control" + ); + let (stripped, _, _) = runner + .without_interpreter_startup() + .run_raw(&["x"], None, T) + .unwrap(); + assert_eq!(stripped, "unset|unset"); + } + + #[test] + fn loader_and_function_prefixes_are_removed_from_the_command() { + let names = [ + "DYLD_VERSIONED_LIBRARY_PATH", + "DYLD_FALLBACK_LIBRARY_PATH", + "LD_AUDIT", + "BASH_FUNC_x%%", + "BUN_OPTIONS", + "BUN_BE_BUN", + "BUN_CONFIG_REGISTRY", + "BUN_INSPECT_CONNECT_TO", + ]; + let mut runner = CliRunner::new("/example/hack"); + for name in names { + runner = runner.with_env(name, "/example/value"); + } + let removed = |runner: &CliRunner| { + let command = runner.command(&["x"], None); + names + .iter() + .filter(|name| { + command + .get_envs() + .any(|(key, value)| key == std::ffi::OsStr::new(name) && value.is_none()) + }) + .count() + }; + assert_eq!(removed(&runner), 0, "positive control: set, not removed"); + assert_eq!(removed(&runner.without_interpreter_startup()), names.len()); + } + #[test] fn flood_finishes_without_ui_drain_and_reports_bounded_loss() { let total = STREAM_QUEUE + 10_000; @@ -1294,6 +1451,7 @@ exit 1"#, fn path_lookup_ignores_relative_entries() { let f = FakeHack::new("echo '{}'"); let dir = f.dir.to_string_lossy().into_owned(); + // A relative entry that would resolve to the fake via the cwd. let rel = std::ffi::OsString::from(format!(".:{dir}")); assert_eq!( resolve_on_path("hack", Some(&rel)), diff --git a/apps/desktop/core/src/source.rs b/apps/desktop/core/src/source.rs index 6a154f824..d080ce1cd 100644 --- a/apps/desktop/core/src/source.rs +++ b/apps/desktop/core/src/source.rs @@ -4,6 +4,7 @@ use std::path::{Path, PathBuf}; use std::time::Duration; use crate::daemon::DaemonClient; +use crate::doctor::DoctorRun; use crate::fixtures::{self, Fixture}; use crate::models::{ DaemonApiStatus, DoctorReport, LifecycleResult, LogEntry, OpenResult, ProjectsPayload, @@ -179,6 +180,10 @@ pub trait Source: Send + Sync { fn log_tail(&self, repo_root: &Path, native: bool, lines: u32) -> Result, String>; + /// Doctor runs: one outside any project, then one per project + /// (DESIGN.md section 3). Each run fails on its own. + fn doctor_runs(&self, projects: &[DoctorTarget]) -> Vec; + /// Start a log stream for the Logs tab (`args` from `LogsModel::cli_args`). /// Dropping the feed stops the stream. /// @@ -192,6 +197,381 @@ pub trait Source: Send + Sync { ) -> Result, String>; } +/// A project to run doctor for. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DoctorTarget { + pub missing: bool, + pub name: String, + pub repo_root: std::path::PathBuf, + pub native: bool, +} + +/// Identity of each target (name, checkout, backend) for the stale guard. +#[must_use] +pub fn target_keys(targets: &[DoctorTarget]) -> Vec { + targets + .iter() + .map(|t| (t.name.clone(), t.repo_root.clone(), t.native, t.missing)) + .collect() +} + +impl DoctorTarget { + fn run(&self, result: Result) -> DoctorRun { + DoctorRun { + project: Some(self.name.clone()), + root: Some(self.repo_root.clone()), + native: self.native, + exact_native_root: false, + probe_started: true, + result, + } + } + + /// Retain missing rows for coverage, but never probe them. + #[must_use] + pub fn from_projects(p: &ProjectsPayload) -> Vec { + p.projects + .iter() + .map(|r| Self { + missing: r.status == "missing" || r.repo_root.is_none(), + name: r.name.clone(), + repo_root: r.repo_root.clone().unwrap_or_default().into(), + native: r.is_native(), + }) + .collect() + } +} + +/// Doctor reads take long (they probe DNS, trust and the engine). +pub const DOCTOR_TIMEOUT: Duration = Duration::from_secs(60); +/// At most this many doctor runs at once. +const DOCTOR_PARALLEL: usize = 4; +/// One refresh never takes longer than this. +pub const DOCTOR_BUDGET: Duration = Duration::from_secs(90); +/// The reason shown for projects the budget did not reach. +pub const NOT_CHECKED_BUDGET: &str = "not checked (time budget)"; + +/// Read one doctor report. Exit 1 still carries a report; a payload +/// without a `checks` array is not a report. +fn read_doctor( + runner: &CliRunner, + root: Option<&Path>, + timeout: Duration, +) -> Result { + // Doctor invokes Git read commands. Repository config must never turn + // those observations into fsmonitor or hook execution. + let v: serde_json::Value = runner + .clone() + .with_env("GIT_CONFIG_PARAMETERS", "") + .with_env("GIT_CONFIG_COUNT", "2") + .with_env("GIT_CONFIG_KEY_0", "core.fsmonitor") + .with_env("GIT_CONFIG_VALUE_0", "false") + .with_env("GIT_CONFIG_KEY_1", "core.hooksPath") + .with_env("GIT_CONFIG_VALUE_1", "/dev/null") + .run_json(&["doctor", "--json"], root, timeout) + .map_err(|e| e.to_string())?; + if !v + .get("checks") + .is_some_and(|c| c.as_array().is_some_and(|a| !a.is_empty())) + { + return Err("doctor printed no checks list".into()); + } + serde_json::from_value(v).map_err(|e| e.to_string()) +} + +/// Owner-write is allowed; group/other-write and foreign ownership are not. +/// A root-owned sticky ancestor (such as /tmp) may contain a private entry. +pub(crate) fn trusted_tool_path(path: &Path, uid: u32, directory: bool) -> Option { + use std::os::unix::fs::MetadataExt; + if !crate::doctor_plugins::guarded_names(path, uid, &mut 64, false) { + return None; + } + let real = std::fs::canonicalize(path).ok()?; + for (index, component) in real.ancestors().enumerate() { + let meta = std::fs::metadata(component).ok()?; + let sticky_root = index > 0 && meta.uid() == 0 && meta.mode() & 0o1000 != 0; + if !crate::doctor_acl::controlled(component) + || (index > 0 || directory) && !meta.is_dir() + || (index == 0 && !directory && (!meta.is_file() || meta.mode() & 0o111 == 0)) + || (meta.uid() != uid && meta.uid() != 0) + || (meta.mode() & 0o022 != 0 && !sticky_root) + { + return None; + } + } + Some(real) +} + +/// A script's `#!` interpreter runs with the script's authority, so it must +/// pass the same ownership and write rules as the script (recursively, for +/// interpreters that are scripts themselves). `#!/usr/bin/env name` is safe +/// once env is trusted: Doctor children see only the private wrapper PATH. +/// A file this user cannot read is refused: the kernel still follows the `#!` +/// line of an execute-only script, so its interpreter could not be checked. +/// Any other read failure or unparsable line refuses the tool too. +/// `wrapper_path`: the program's children see only Doctor's private wrapper +/// PATH, so `#!/usr/bin/env ` resolves to a checked tool. Callers whose +/// children use an ordinary PATH must pass false, which refuses the env form. +pub(crate) fn trusted_interpreter(real: &Path, uid: u32, depth: u8, wrapper_path: bool) -> bool { + use std::io::Read; + use std::os::unix::ffi::OsStrExt; + let mut file = match std::fs::File::open(real) { + Ok(file) => file, + Err(_) => return false, + }; + let mut head = [0_u8; 512]; + let Ok(read) = file.read(&mut head) else { + return false; + }; + let Some(line) = head[..read].strip_prefix(b"#!") else { + return true; + }; + let Some(end) = line.iter().position(|b| *b == b'\n') else { + return false; + }; + let tokens: Vec<&[u8]> = line[..end] + .split(|b| *b == b' ' || *b == b'\t') + .filter(|token| !token.is_empty()) + .collect(); + let Some((interpreter, args)) = tokens.split_first() else { + return false; + }; + let interpreter = Path::new(std::ffi::OsStr::from_bytes(interpreter)); + if depth == 0 || !interpreter.is_absolute() { + return false; + } + let Some(real_interpreter) = trusted_tool_path(interpreter, uid, false) else { + return false; + }; + // Only two forms are accepted; anything else (options, file or path + // arguments, variables, assignments) fails closed as Unknown: + // - `#!` with no arguments; + // - exactly `#!/usr/bin/env ` (env identified by real path), whose + // name resolves only through Doctor's private wrapper PATH. + match args { + [] => trusted_interpreter(&real_interpreter, uid, depth - 1, wrapper_path), + [name] => { + wrapper_path + && real_interpreter == Path::new("/usr/bin/env") + && !name.starts_with(b"-") + && name + .iter() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'+' | b'-')) + } + _ => false, + } +} + +/// A per-read private PATH containing only wrappers for checked executables. +/// Wrappers exec the original absolute path so shims keep their normal $0. +/// No credential/context values are stored. Drop removes only this owned scratch. +#[derive(Debug)] +struct ProbeTools { + dir: PathBuf, + coverage: Option<&'static str>, + unchecked: std::collections::BTreeSet<&'static str>, +} + +impl ProbeTools { + fn path(&self) -> &std::ffi::OsStr { + self.dir.as_os_str() + } +} + +impl Drop for ProbeTools { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.dir); + } +} + +fn trusted_probe_path(path: &std::ffi::OsStr) -> Result { + trusted_probe_path_with_limits(path, 64, 8192) +} + +fn trusted_probe_path_with_limits( + path: &std::ffi::OsStr, + max_dirs: usize, + max_entries: usize, +) -> Result { + use std::os::unix::fs::DirBuilderExt; + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT: AtomicU64 = AtomicU64::new(0); + // SAFETY: getuid has no preconditions and cannot fail. + let uid = unsafe { libc::getuid() }; + if trusted_tool_path(Path::new("/bin/sh"), uid, false).is_none() { + return Err("Doctor wrapper interpreter is not trusted".into()); + } + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|e| e.to_string())? + .as_nanos(); + let dir = std::env::temp_dir().join(format!( + "hack-desktop-tools-{}-{stamp}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + std::fs::DirBuilder::new() + .mode(0o700) + .create(&dir) + .map_err(|e| e.to_string())?; + let mut tools = ProbeTools { + dir, + coverage: None, + unchecked: std::collections::BTreeSet::new(), + }; + if trusted_tool_path(&tools.dir, uid, true).is_none() { + return Err("Doctor tool scratch is not private".into()); + } + let mut seen_dirs = std::collections::BTreeSet::new(); + let mut names = std::collections::BTreeSet::new(); + let mut entries = 0; + 'directories: for p in std::env::split_paths(path) { + if !p.is_absolute() + || !seen_dirs.insert(std::fs::canonicalize(&p).unwrap_or_else(|_| p.clone())) + { + continue; + } + if seen_dirs.len() > max_dirs { + tools.coverage = Some( + "Doctor tool PATH directory scan limit reached; remaining tools were not searched", + ); + break; + } + let Some(real) = trusted_tool_path(&p, uid, true) else { + for tool in crate::doctor_tools::NAMES { + if names.contains(std::ffi::OsStr::new(tool)) { + continue; + } + if !matches!(std::fs::symlink_metadata(p.join(tool)), Err(e) if e.kind() == std::io::ErrorKind::NotFound) + { + tools.unchecked.insert(tool); + } + } + continue; + }; + let Ok(children) = std::fs::read_dir(real) else { + tools.coverage = + Some("Doctor could not enumerate a PATH directory; tools there were not checked"); + continue; + }; + for entry in children { + entries += 1; + if entries > max_entries { + tools.coverage = Some( + "Doctor tool PATH entry scan limit reached; remaining tools were not searched", + ); + break 'directories; + } + let Ok(entry) = entry else { + tools.coverage = + Some("Doctor could not inspect a PATH entry; tool coverage is incomplete"); + continue; + }; + let name = entry.file_name(); + if names.contains(&name) { + continue; + } + if trusted_tool_path(&entry.path(), uid, false) + .is_none_or(|real| !trusted_interpreter(&real, uid, 4, true)) + { + if let Some(tool) = crate::doctor_tools::NAMES + .iter() + .find(|tool| name == std::ffi::OsStr::new(**tool)) + { + tools.unchecked.insert(tool); + } + continue; + } + write_wrapper(&tools.dir, &name, &entry.path())?; + names.insert(name); + } + } + // Candidate launchers (hack-v5, hack-next) need dirname before they run + // the CLI. Offer it from a trusted system location when the scan did not. + if !names.contains(std::ffi::OsStr::new("dirname")) + && let Some(found) = ["/usr/bin/dirname", "/bin/dirname"] + .iter() + .map(Path::new) + .find(|p| { + trusted_tool_path(p, uid, false) + .is_some_and(|real| trusted_interpreter(&real, uid, 4, true)) + }) + { + write_wrapper(&tools.dir, std::ffi::OsStr::new("dirname"), found)?; + names.insert("dirname".into()); + } + if tools.coverage.is_some() { + for tool in crate::doctor_tools::NAMES { + if !names.contains(std::ffi::OsStr::new(tool)) { + tools.unchecked.insert(tool); + } + } + } + Ok(tools) +} + +/// A private wrapper that execs `target` by absolute path, so shims keep their +/// normal `$0`. Created new with owner-only permissions. +fn write_wrapper(dir: &Path, name: &std::ffi::OsStr, target: &Path) -> Result<(), String> { + use std::io::Write; + use std::os::unix::{ffi::OsStrExt, fs::OpenOptionsExt}; + let mut script = b"#!/bin/sh\nexec '".to_vec(); + for byte in target.as_os_str().as_bytes() { + if *byte == b'\'' { + script.extend_from_slice(b"'\\''"); + } else { + script.push(*byte); + } + } + script.extend_from_slice(b"' \"$@\"\n"); + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o700) + .open(dir.join(name)) + .map_err(|e| e.to_string())?; + file.write_all(&script).map_err(|e| e.to_string()) +} + +fn apply_tool_coverage(runs: &mut Vec, tools: &ProbeTools) { + if tools.unchecked.is_empty() { + return; + } + let names = tools + .unchecked + .iter() + .copied() + .collect::>() + .join(", "); + let note = format!( + "Doctor did not check tools: {names}; install location/target permissions or bounded PATH scan prevented validation" + ); + for run in runs.iter_mut() { + if let Ok(report) = &mut run.result { + for check in &mut report.checks { + if tools + .unchecked + .iter() + .any(|tool| crate::doctor_tools::affected(tool, &check.id)) + { + check.status = "unknown".into(); + check.detail = "A required tool was filtered or not searched; this check could not be established".into(); + } + } + } + } + let mut coverage = DoctorRun::machine(Ok(crate::models::DoctorReport { + checks: vec![crate::models::DoctorCheck { + id: "desktop tool coverage".into(), + status: "unknown".into(), + detail: note, + }], + summary: vec![], + })); + coverage.probe_started = false; + runs.push(coverage); +} + /// Live data from a matching hackd. CLI listing fallback needs R18: /// today's `projects` command auto-registers projects and writes the registry. pub struct LiveSource { @@ -199,7 +579,187 @@ pub struct LiveSource { pub daemon: Option, } +/// SIGTERM grace for doctor reads: they only read, so they own no builds +/// to retire, and a short grace keeps a refresh within its budget. +pub const DOCTOR_TERM_GRACE: Duration = Duration::from_secs(2); + impl LiveSource { + /// Doctor runs under one overall `budget`: each call gets the smaller of + /// `per_call` and the time left, and projects not started before the + /// budget runs out are reported as not checked. + pub fn doctor_runs_within( + &self, + projects: &[DoctorTarget], + budget: Duration, + per_call: Duration, + ) -> Vec { + self.doctor_runs_with_discovery( + projects, + budget, + per_call, + &crate::doctor_plugins::Discovery::from_runner(&self.runner), + ) + } + + fn doctor_runs_with_discovery( + &self, + projects: &[DoctorTarget], + budget: Duration, + per_call: Duration, + discovery: &crate::doctor_plugins::Discovery, + ) -> Vec { + let deadline = std::time::Instant::now() + budget; + let native_pin = match crate::doctor_plugins::validate_helpers(&self.runner) + .and_then(|pin| crate::doctor_plugins::validate(discovery).map(|()| pin)) + { + Ok(pin) => pin, + Err(reason) => { + let why = format!("Could not check Doctor executable discovery: {reason}"); + let report = crate::models::DoctorReport { + checks: vec![crate::models::DoctorCheck { + id: "desktop preflight".into(), + status: "unknown".into(), + detail: why.clone(), + }], + summary: vec![], + }; + let mut machine = DoctorRun::machine(Ok(report)); + machine.probe_started = false; + return std::iter::once(machine) + .chain(projects.iter().map(|t| t.run(Err(why.clone())))) + .collect(); + } + }; + // Resolve once per refresh, not once per project. The private PATH + // stays alive until every bounded child has completed. + let tools = match trusted_probe_path( + self.runner + .env_value("PATH") + .unwrap_or_default() + .as_os_str(), + ) { + Ok(tools) => tools, + Err(why) => { + return std::iter::once(DoctorRun::machine(Err(why.clone()))) + .chain(projects.iter().map(|t| t.run(Err(why.clone())))) + .collect(); + } + }; + let Some(tool_path) = tools.path().to_str() else { + let why = "Doctor tool PATH is not UTF-8".to_owned(); + return std::iter::once(DoctorRun::machine(Err(why.clone()))) + .chain(projects.iter().map(|t| t.run(Err(why.clone())))) + .collect(); + }; + // Each call's timeout leaves room for its SIGTERM grace, so stopping + // a slow check also finishes within the overall budget. + let left = || { + deadline + .saturating_duration_since(std::time::Instant::now()) + .saturating_sub(DOCTOR_TERM_GRACE) + }; + let read = |runner: &CliRunner, root: Option<&Path>, timeout: Duration| { + read_doctor(runner, root, timeout) + }; + // The machine run must happen outside every checkout, or doctor + // would report that checkout's project checks as machine checks. + // Prefer a user-private directory, then root. A shared temp cwd + // would permit another user to plant a project marker after inspection. + let mut candidates: Vec = Vec::new(); + if let Some(run) = std::env::var_os("XDG_RUNTIME_DIR").map(std::path::PathBuf::from) + && crate::doctor::is_private_dir(&run) + { + candidates.push(run); + } + candidates.push(std::path::PathBuf::from("/")); + let machine = match crate::doctor::neutral_dir(&candidates) { + Some(dir) if !left().is_zero() => read( + &native_pin + .apply(self.runner.clone(), &tools.dir) + .with_env("PATH", tool_path) + .without_interpreter_startup() + .with_cwd(dir) + .with_term_grace(DOCTOR_TERM_GRACE), + None, + per_call.min(left()), + ), + Some(_) => Err(NOT_CHECKED_BUDGET.to_owned()), + None => Err("machine checks could not run outside a project".into()), + }; + let mut runs = vec![DoctorRun::machine(machine)]; + for chunk in projects.chunks(DOCTOR_PARALLEL) { + let chunk_runs: Vec = std::thread::scope(|s| { + let handles: Vec<_> = chunk + .iter() + .map(|t| { + let runner = native_pin + .apply(self.runner_for(t.native), &tools.dir) + .with_env("PATH", tool_path) + .without_interpreter_startup() + .with_term_grace(DOCTOR_TERM_GRACE); + let timeout = per_call.min(left()); + s.spawn(move || { + let before = (t.native && !t.missing) + .then(|| exact_doctor_root(&t.repo_root)) + .flatten(); + let result = if t.missing { + Err("not checked (checkout missing)".into()) + } else if timeout.is_zero() { + Err(NOT_CHECKED_BUDGET.to_owned()) + } else { + read(&runner, Some(&t.repo_root), timeout) + }; + let mut run = t.run(result); + run.exact_native_root = + before.is_some() && before == exact_doctor_root(&t.repo_root); + // Adoption is reported as unsupported native input + // by legacy project views, but Doctor selects it + // through Compose. Accept only exact adopted-root + // evidence from this bounded read-only retry. + if t.native + && run + .result + .as_ref() + .is_ok_and(|r| !crate::doctor::project_evidence(&run, r)) + { + let timeout = per_call.min(left()); + if !timeout.is_zero() { + let compose = self + .runner_for(false) + .with_env("PATH", tool_path) + .without_interpreter_startup() + .with_term_grace(DOCTOR_TERM_GRACE); + if let Ok(report) = read(&compose, Some(&t.repo_root), timeout) + && crate::doctor::adopted_project_evidence( + &t.repo_root, + &report, + ) + { + let mut adopted = t.run(Ok(report)); + adopted.native = false; + return adopted; + } + } + } + run + }) + }) + .collect(); + handles + .into_iter() + .zip(chunk) + .map(|(h, t)| { + h.join() + .unwrap_or_else(|_| t.run(Err("doctor read panicked".into()))) + }) + .collect() + }); + runs.extend(chunk_runs); + } + apply_tool_coverage(&mut runs, &tools); + runs + } + /// The live source exactly as the app builds it: `hack` resolved on /// absolute PATH entries, and the hackd socket under `HACK_HOME` /// (else `~/.hack`). Used by the window and by `hack-desktop-state`. @@ -221,7 +781,9 @@ impl LiveSource { .clone() .with_env("HACK_RUNTIME_BACKEND", "native") } else { - self.runner.clone() + self.runner + .clone() + .with_env("HACK_RUNTIME_BACKEND", "compose") } } @@ -365,6 +927,10 @@ impl Source for LiveSource { }) } + fn doctor_runs(&self, projects: &[DoctorTarget]) -> Vec { + self.doctor_runs_within(projects, DOCTOR_BUDGET, DOCTOR_TIMEOUT) + } + fn log_tail( &self, repo_root: &Path, @@ -521,13 +1087,40 @@ pub fn trusted_root(root: &Path) -> Result { Ok(real) } -/// Mirror of the CLI's project discovery from `root` (see -/// `discoverProjectInputs` in `src/lib/project-input-selection.ts`): `true` -/// only when discovery starting at `root` selects `root` itself. -/// - `.hack/hack.project.json` (native) or `.hack/docker-compose.yml` at -/// `root` selects it; -/// - `.dev/docker-compose.yml` alone selects it unless an ancestor with -/// `.hack/docker-compose.yml` is reached before an ancestor native marker. +#[derive(PartialEq, Eq)] +struct DoctorRootWitness(PathBuf, PathBuf, u64, u64, i64, i64); + +/// Read-only witness of exact discovery, not a lock against same-user edits. +fn exact_doctor_root(root: &Path) -> Option { + use std::os::unix::fs::MetadataExt; + if !root.is_absolute() { + return None; + } + let real = std::fs::canonicalize(root).ok()?; + if !cli_selects_exactly(&real) { + return None; + } + let marker = [ + ".hack/hack.project.json", + ".hack/docker-compose.yml", + ".dev/docker-compose.yml", + ] + .into_iter() + .map(|p| real.join(p)) + .find(|p| p.is_file())?; + let meta = std::fs::metadata(&marker).ok()?; + Some(DoctorRootWitness( + real, + marker, + meta.dev(), + meta.ino(), + meta.mtime(), + meta.mtime_nsec(), + )) +} + +/// Mirror of `discoverProjectInputs`: native or .hack Compose at this root +/// wins; .dev Compose may lose to an ancestor .hack before a native boundary. fn cli_selects_exactly(root: &Path) -> bool { let native = |d: &Path| d.join(".hack/hack.project.json").exists(); let hack_compose = |d: &Path| d.join(".hack/docker-compose.yml").is_file(); @@ -587,6 +1180,10 @@ impl Source for FixtureSource { fixtures::snapshot(self.0) } + fn doctor_runs(&self, _projects: &[DoctorTarget]) -> Vec { + fixtures::doctor_runs(self.0) + } + fn act( &self, _repo_root: &Path, @@ -619,6 +1216,57 @@ impl Source for FixtureSource { mod tests { use super::*; + fn fixture_runner(hack: &Path) -> CliRunner { + let root = hack.parent().unwrap().to_str().unwrap(); + let bin = hack.parent().unwrap().join("fixture-tools"); + std::fs::create_dir_all(&bin).unwrap(); + for name in ["sleep", "cat", "pwd", "git"] { + let tool = bin.join(name); + if !tool.exists() { + let installed = if name == "git" { + Path::new("/usr/bin") + } else { + Path::new("/bin") + }; + std::os::unix::fs::symlink(installed.join(name), tool).unwrap(); + } + } + CliRunner::new(hack) + .with_env("HOME", root) + .with_env("HACK_HOME", root) + .with_env("HACK_MUTAGEN_PATH", "") + .with_env("PATH", bin.to_str().unwrap()) + } + + impl LiveSource { + // Fake CLIs must not inspect the runner's actual Docker configuration + // or installed plugin directories. The production gate is exercised + // with explicit owned inputs in the dedicated discovery tests below. + fn fixture_doctor_runs(&self, projects: &[DoctorTarget]) -> Vec { + self.fixture_doctor_runs_within(projects, DOCTOR_BUDGET, DOCTOR_TIMEOUT) + } + + fn fixture_doctor_runs_within( + &self, + projects: &[DoctorTarget], + budget: Duration, + timeout: Duration, + ) -> Vec { + let fake = fake_hack("plugin-inputs", "echo '{}'"); + let root = fake.parent().unwrap(); + let discovery = crate::doctor_plugins::Discovery { + config_dir: Some(root.join("client")), + unsupported_extra_selector: false, + docker_host: None, + context: None, + system_dirs: vec![root.join("system-plugins")], + }; + let runs = self.doctor_runs_with_discovery(projects, budget, timeout, &discovery); + let _ = std::fs::remove_dir_all(root); + runs + } + } + fn fake_daemon(projects: &str) -> (DaemonClient, std::thread::JoinHandle<()>) { use std::io::Write; use std::os::unix::{fs::DirBuilderExt, net::UnixListener}; @@ -756,7 +1404,7 @@ esac ); // Complete macOS first-launch scanning before the fake daemon's // bounded accept window starts. version --json has no fixture effects. - CliRunner::new(&hack) + fixture_runner(&hack) .with_env("HOME", &dir.to_string_lossy()) .with_env("HACK_HOME", &dir.to_string_lossy()) .with_env("HACK_DAEMON_AUTOSTART_DISABLE", "1") @@ -766,7 +1414,7 @@ esac r#"{"runtime_ok":true,"projects":[{"name":"svc","status":"running","repo_root":"/work/svc"},{"name":"nat","status":"unavailable","repo_root":"/work/nat","backend":"native"}]}"#, ); let src = LiveSource { - runner: CliRunner::new(&hack) + runner: fixture_runner(&hack) .with_env("HACK_HOME", &dir.to_string_lossy()) .with_env("HOME", &dir.to_string_lossy()) .with_env("HACK_DAEMON_AUTOSTART_DISABLE", "1"), @@ -812,7 +1460,7 @@ esac &hack, "#!/bin/sh\ncase \"$1\" in version) echo '{\"ok\":true,\"data\":{\"schema\":\"hack.version.v1\",\"version\":\"5.0.0\"}}' ;; projects) echo '{}' ;; esac\n", ); - CliRunner::new(&hack) + fixture_runner(&hack) .with_env("HOME", &dir.to_string_lossy()) .with_env("HACK_HOME", &dir.to_string_lossy()) .with_env("HACK_DAEMON_AUTOSTART_DISABLE", "1") @@ -820,7 +1468,7 @@ esac .unwrap(); let (daemon, server) = fake_daemon("{}"); let src = LiveSource { - runner: CliRunner::new(&hack) + runner: fixture_runner(&hack) .with_env("HOME", &dir.to_string_lossy()) .with_env("HACK_HOME", &dir.to_string_lossy()) .with_env("HACK_DAEMON_AUTOSTART_DISABLE", "1"), @@ -853,6 +1501,1471 @@ esac ); } + /// Docker's metadata directory name for a context: hex sha256 of the name. + fn context_dir(name: &str) -> String { + ::digest(name.as_bytes()) + .iter() + .map(|b| format!("{b:02x}")) + .collect() + } + + fn fake_hack(tag: &str, body: &str) -> std::path::PathBuf { + use std::os::unix::fs::PermissionsExt; + let dir = std::env::temp_dir().join(format!( + "hack-desktop-{tag}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let hack = dir.join("hack"); + std::fs::write( + &hack, + format!("#!/bin/sh\nif [ \"$1\" = __warmup ]; then echo '{{}}'; exit 0; fi\n{body}\n"), + ) + .unwrap(); + std::fs::set_permissions(&hack, std::fs::Permissions::from_mode(0o755)).unwrap(); + // Match the core fixture warmup: macOS first-launch scanning must + // finish before Doctor's short budget tests start their clocks. + fixture_runner(&hack) + .run_json::(&["__warmup"], None, Duration::from_secs(60)) + .expect("Doctor fake CLI warmup"); + hack + } + + fn targets(n: usize) -> Vec { + (0..n) + .map(|i| DoctorTarget { + missing: false, + name: format!("p{i}"), + repo_root: format!("/work/p{i}").into(), + native: false, + }) + .collect() + } + + #[test] + fn doctor_probes_only_read_and_select_each_backend() { + let hack = fake_hack( + "doctor-contract", + r#"[ "$HACK_NO_INTERACTIVE" = 1 ] || exit 2 +case "$*" in + 'doctor --json') + detail=machine ;; + 'doctor --json --path /work/p0') + [ "$HACK_RUNTIME_BACKEND" = native ] || exit 3 + detail=native ;; + 'doctor --json --path /work/p1') + [ "$HACK_RUNTIME_BACKEND" = compose ] || exit 4 + detail=compose ;; + *) exit 5 ;; +esac +printf '{"ok":true,"data":{"checks":[{"id":"probe","status":"ok","detail":"%s"}]}}' "$detail" +"#, + ); + let src = LiveSource { + runner: fixture_runner(&hack).with_env("HACK_RUNTIME_BACKEND", "native"), + daemon: None, + }; + let mut projects = targets(2); + projects[0].native = true; + let runs = src.fixture_doctor_runs(&projects); + let _ = std::fs::remove_dir_all(hack.parent().unwrap()); + let details: Vec<_> = runs + .iter() + .map(|run| run.result.as_ref().unwrap().checks[0].detail.as_str()) + .collect(); + assert_eq!(details, ["machine", "native", "compose"]); + } + + #[test] + fn adopted_target_inferred_native_is_checked_with_compose() { + let hack = fake_hack( + "doctor-adopted", + r#"if [ "$HACK_RUNTIME_BACKEND" = compose ]; then +echo "{\"ok\":true,\"data\":{\"checks\":[{\"id\":\"project\",\"status\":\"warn\",\"detail\":\"Adopted Compose project at /work/p0/.hack has a pending or interrupted adoption operation. Use explicit adoption recovery before other project commands; values omitted.\"}]}}" +else +echo '{"ok":true,"data":{"checks":[{"id":"native root trust","status":"ok","detail":"trusted"}]}}' +fi"#, + ); + let source = LiveSource { + runner: fixture_runner(&hack), + daemon: None, + }; + let mut targets = targets(1); + targets[0].native = true; + let runs = source.fixture_doctor_runs(&targets); + let _ = std::fs::remove_dir_all(hack.parent().unwrap()); + assert!(!runs[1].native); + let view = crate::doctor::aggregate(&runs); + assert_eq!(view.projects_checked, 1); + assert!(!view.is_clean()); + } + + #[test] + fn doctor_git_reads_disable_repository_fsmonitor() { + use std::os::unix::fs::PermissionsExt; + let hack = fake_hack( + "doctor-git", + r#"git -C "$HACK_HOME/repo" ls-files >/dev/null || exit 2 +echo '{"ok":true,"data":{"checks":[{"id":"docker","status":"ok","detail":""}]}}'"#, + ); + let dir = hack.parent().unwrap(); + let repo = dir.join("repo"); + std::fs::create_dir(&repo).unwrap(); + let git = |args: &[&str]| { + std::process::Command::new("/usr/bin/git") + .env("HOME", dir) + .env("PATH", "/usr/bin:/bin") + .arg("-C") + .arg(&repo) + .args(args) + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("HACK_HOME", dir) + .output() + .unwrap() + }; + assert!(git(&["init", "-q"]).status.success()); + std::fs::write(repo.join("tracked"), "fixture").unwrap(); + assert!(git(&["add", "tracked"]).status.success()); + let hook = dir.join("fsmonitor"); + std::fs::write( + &hook, + "#!/bin/sh\nprintf called > \"$HACK_HOME/executed\"\n", + ) + .unwrap(); + std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!( + git(&["config", "core.fsmonitor", hook.to_str().unwrap()]) + .status + .success() + ); + assert!(git(&["ls-files"]).status.success()); + assert!( + dir.join("executed").exists(), + "negative control must run the harmless hook" + ); + std::fs::remove_file(dir.join("executed")).unwrap(); + let source = LiveSource { + runner: fixture_runner(&hack).with_env("HACK_HOME", &dir.to_string_lossy()), + daemon: None, + }; + let runs = source.fixture_doctor_runs(&[]); + assert!(runs[0].result.is_ok()); + assert!(!dir.join("executed").exists()); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn doctor_nested_tools_cannot_use_writable_absolute_path_entries() { + use std::os::unix::fs::PermissionsExt; + let hack = fake_hack( + "doctor-path", + "git --version >/dev/null || exit 2\necho '{}'", + ); + let dir = hack.parent().unwrap(); + let hostile = dir.join("writable"); + std::fs::create_dir(&hostile).unwrap(); + std::fs::set_permissions(&hostile, std::fs::Permissions::from_mode(0o777)).unwrap(); + let git = hostile.join("git"); + std::fs::write(&git, "#!/bin/sh\nprintf called > \"$HACK_HOME/executed\"\n").unwrap(); + std::fs::set_permissions(&git, std::fs::Permissions::from_mode(0o755)).unwrap(); + let inherited = format!("{}:/usr/bin:/bin", hostile.display()); + let runner = fixture_runner(&hack).with_env("HACK_HOME", &dir.to_string_lossy()); + runner + .clone() + .with_env("PATH", &inherited) + .run_json::(&[], None, Duration::from_secs(60)) + .unwrap(); + assert!( + dir.join("executed").exists(), + "negative control must execute the harmless nested tool" + ); + std::fs::remove_file(dir.join("executed")).unwrap(); + let trusted = trusted_probe_path(std::ffi::OsStr::new(&inherited)).unwrap(); + assert_ne!(Path::new(trusted.path()), hostile); + runner + .with_env("PATH", &trusted.path().to_string_lossy()) + .run_json::(&[], None, Duration::from_secs(60)) + .unwrap(); + assert!(!dir.join("executed").exists()); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn doctor_nested_tools_reject_writable_files_and_symlink_targets() { + use std::os::unix::fs::{PermissionsExt, symlink}; + let hack = fake_hack( + "doctor-tool-file", + "git --version >/dev/null || exit 2\necho '{}'", + ); + let dir = hack.parent().unwrap(); + let bin = dir.join("bin"); + std::fs::create_dir(&bin).unwrap(); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap(); + let target = dir.join("writable-tool"); + let body = "#!/bin/sh\nprintf called > \"$HACK_HOME/executed\"\n"; + std::fs::write(&target, body).unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o777)).unwrap(); + let git = bin.join("git"); + let inherited = format!("{}:/usr/bin:/bin", bin.display()); + let runner = fixture_runner(&hack).with_env("HACK_HOME", &dir.to_string_lossy()); + for linked in [false, true] { + if linked { + symlink(&target, &git).unwrap(); + } else { + std::fs::write(&git, body).unwrap(); + std::fs::set_permissions(&git, std::fs::Permissions::from_mode(0o777)).unwrap(); + } + runner + .clone() + .with_env("PATH", &inherited) + .run_json::(&[], None, Duration::from_secs(60)) + .unwrap(); + assert!(dir.join("executed").exists(), "negative control {linked}"); + std::fs::remove_file(dir.join("executed")).unwrap(); + let trusted = trusted_probe_path(std::ffi::OsStr::new(&inherited)).unwrap(); + assert_ne!(Path::new(trusted.path()), bin); + runner + .clone() + .with_env("PATH", &trusted.path().to_string_lossy()) + .run_json::(&[], None, Duration::from_secs(60)) + .unwrap(); + assert!(!dir.join("executed").exists()); + std::fs::remove_file(&git).unwrap(); + } + let _ = std::fs::remove_dir_all(dir); + } + + #[cfg(target_os = "macos")] + #[test] + fn docker_context_metadata_rejects_acl_write_grants() { + let hack = fake_hack("doctor-context-acl", "echo '{}'"); + let root = std::fs::canonicalize(hack.parent().unwrap()).unwrap(); + let client = root.join("client"); + let meta = client.join("contexts/meta").join(context_dir("selected")); + std::fs::create_dir_all(&meta).unwrap(); + let file = meta.join("meta.json"); + std::fs::write( + &file, + r#"{"Name":"selected","Endpoints":{"docker":{"Host":"unix:///example/docker.sock"}}}"#, + ) + .unwrap(); + let discovery = crate::doctor_plugins::Discovery { + config_dir: Some(client.clone()), + unsupported_extra_selector: false, + system_dirs: vec![], + docker_host: None, + context: Some("selected".into()), + }; + assert!( + crate::doctor_plugins::validate(&discovery).is_ok(), + "positive control" + ); + assert!( + std::process::Command::new("/bin/chmod") + .args(["+a", "everyone allow write"]) + .arg(&file) + .status() + .unwrap() + .success() + ); + assert!(crate::doctor_plugins::validate(&discovery).is_err()); + let _ = std::fs::remove_dir_all(root); + } + + #[cfg(target_os = "macos")] + #[test] + fn tools_and_plugins_reject_acl_mutation_grants() { + let hack = fake_hack("doctor-tool-acl", "echo '{}'"); + let dir = hack.parent().unwrap(); + let uid = unsafe { libc::getuid() }; + assert!( + std::process::Command::new("/bin/chmod") + .arg("-N") + .arg(&hack) + .status() + .unwrap() + .success() + ); + assert!( + crate::doctor_acl::controlled(&hack), + "no ACL is not an unknown ACL" + ); + assert!( + !crate::doctor_acl::controlled(&dir.join("missing")), + "missing is not no-ACL" + ); + use std::os::unix::fs::PermissionsExt; + for mode in [0o711, 0o111] { + std::fs::set_permissions(&hack, std::fs::Permissions::from_mode(mode)).unwrap(); + assert!( + trusted_tool_path(&hack, uid, false).is_some(), + "owned execute-only tools must not need read access for ACL inspection" + ); + } + std::fs::set_permissions(&hack, std::fs::Permissions::from_mode(0o755)).unwrap(); + for rule in ["everyone deny delete", "everyone allow read"] { + assert!( + std::process::Command::new("/bin/chmod") + .args(["+a", rule]) + .arg(&hack) + .status() + .unwrap() + .success() + ); + assert!(trusted_tool_path(&hack, uid, false).is_some()); + assert!( + std::process::Command::new("/bin/chmod") + .arg("-N") + .arg(&hack) + .status() + .unwrap() + .success() + ); + } + for path in [&hack, dir] { + assert!( + std::process::Command::new("/bin/chmod") + .args(["+a", "everyone allow write,delete"]) + .arg(path) + .status() + .unwrap() + .success() + ); + assert!(trusted_tool_path(&hack, uid, false).is_none()); + assert!(!crate::doctor_plugins::guarded_names( + &hack, uid, &mut 64, false + )); + assert!( + std::process::Command::new("/bin/chmod") + .arg("-N") + .arg(path) + .status() + .unwrap() + .success() + ); + } + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn plugins_and_native_runtime_require_a_trusted_interpreter() { + use std::os::unix::fs::PermissionsExt; + let hack = fake_hack("doctor-plugin-native-interp", "echo '{}'"); + let root = std::fs::canonicalize(hack.parent().unwrap()).unwrap(); + let shared = root.join("shared"); + std::fs::create_dir(&shared).unwrap(); + std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(0o777)).unwrap(); + std::fs::copy("/bin/sh", shared.join("interp")).unwrap(); + let client = root.join("client"); + std::fs::create_dir_all(client.join("cli-plugins")).unwrap(); + let script = |path: &Path, shebang: &str| { + std::fs::write(path, format!("#!{shebang}\n")).unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); + }; + let plugin = client.join("cli-plugins/docker-compose"); + let discovery = crate::doctor_plugins::Discovery { + config_dir: Some(client.clone()), + unsupported_extra_selector: false, + docker_host: None, + context: None, + system_dirs: vec![], + }; + script(&plugin, "/bin/sh"); + assert!(crate::doctor_plugins::validate(&discovery).is_ok()); + script(&plugin, shared.join("interp").to_str().unwrap()); + assert!(crate::doctor_plugins::validate(&discovery).is_err()); + // A native candidate runtime is spawned directly, without the + // wrapper PATH: an unsafe interpreter and the env form are refused. + let native = root.join("native-runtime"); + let runner = fixture_runner(&hack) + .with_env("HACK_HOME", root.to_str().unwrap()) + .with_env("HACK_NATIVE_BINARY", native.to_str().unwrap()); + for (shebang, ok) in [ + ("/bin/sh", true), + (shared.join("interp").to_str().unwrap(), false), + ("/usr/bin/env sh", false), + ] { + script(&native, shebang); + assert_eq!( + crate::doctor_plugins::validate_helpers(&runner).is_ok(), + ok, + "{shebang}" + ); + } + assert!( + crate::doctor_plugins::validate_helpers( + &runner.with_env("HACK_NATIVE_BINARY", "relative-runtime") + ) + .is_err() + ); + let _ = std::fs::remove_dir_all(root); + } + + /// With `HACK_NATIVE_BINARY` unset the CLI would run `hack-native` from + /// beside its own executable, so Doctor checks that fallback and pins the + /// result; a `#!` launcher's fallback is unknowable and pinned absent. + #[test] + fn doctor_checks_and_pins_the_adjacent_native_fallback() { + use crate::doctor_plugins::{NativePin, validate_helpers}; + use std::os::unix::fs::PermissionsExt; + let launcher = fake_hack("doctor-native-fallback", "echo '{}'"); + let root = std::fs::canonicalize(launcher.parent().unwrap()).unwrap(); + let private = root.join("private-tools"); + let pinned = |runner: CliRunner| { + NativePin::apply(&validate_helpers(&runner).unwrap(), runner, &private) + .env_value("HACK_NATIVE_BINARY") + }; + let base = |hack: &Path| fixture_runner(hack).with_env("HACK_HOME", root.to_str().unwrap()); + // A #! launcher: its eventual executable is unknown. + assert_eq!( + validate_helpers(&base(&launcher)), + Ok(NativePin::Unavailable) + ); + assert_eq!( + pinned(base(&launcher)), + Some(private.join("native-unavailable/hack-native").into()) + ); + // A trusted PATH tool named like the pin's first component is wrapped + // as a file, so the pinned path still cannot exist. + let tools_dir = root.join("collide"); + std::fs::create_dir(&tools_dir).unwrap(); + std::fs::set_permissions(&tools_dir, std::fs::Permissions::from_mode(0o700)).unwrap(); + for name in ["native-unavailable", "hack-native", "sh"] { + let tool = tools_dir.join(name); + std::fs::write(&tool, b"\x7fELF fixture").unwrap(); + std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + let tools = trusted_probe_path(tools_dir.as_os_str()).unwrap(); + let wrappers = Path::new(tools.path()); + assert!(wrappers.join("native-unavailable").is_file()); + let pin = NativePin::Unavailable + .apply(base(&launcher), wrappers) + .env_value("HACK_NATIVE_BINARY") + .unwrap(); + assert_eq!( + Path::new(&pin), + wrappers.join(crate::doctor_plugins::UNAVAILABLE_NATIVE) + ); + assert!(std::fs::symlink_metadata(&pin).is_err()); + drop(tools); + // A compiled CLI (no #!): its own directory is the fallback. + let compiled = root.join("compiled"); + std::fs::create_dir(&compiled).unwrap(); + std::fs::set_permissions(&compiled, std::fs::Permissions::from_mode(0o700)).unwrap(); + let hack = compiled.join("hack"); + std::fs::write(&hack, b"\x7fELF fixture").unwrap(); + std::fs::set_permissions(&hack, std::fs::Permissions::from_mode(0o700)).unwrap(); + assert_eq!(validate_helpers(&base(&hack)), Ok(NativePin::Absent)); + assert_eq!(pinned(base(&hack)), None); + let native = compiled.join("hack-native"); + std::fs::write(&native, "#!/bin/sh\nexit 0\n").unwrap(); + std::fs::set_permissions(&native, std::fs::Permissions::from_mode(0o700)).unwrap(); + assert_eq!( + validate_helpers(&base(&hack)), + Ok(NativePin::Adjacent(native.clone())) + ); + assert_eq!(pinned(base(&hack)), Some(native.clone().into())); + // Writable by others, or an untrusted interpreter: Doctor refuses. + std::fs::set_permissions(&native, std::fs::Permissions::from_mode(0o722)).unwrap(); + assert!(validate_helpers(&base(&hack)).is_err()); + std::fs::set_permissions(&native, std::fs::Permissions::from_mode(0o700)).unwrap(); + std::fs::write(&native, "#!/usr/bin/env sh\n").unwrap(); + assert!(validate_helpers(&base(&hack)).is_err()); + // The lifecycle supervisor beside a compiled CLI is checked too; a + // compiled CLI ignores any selector for it. + std::fs::remove_file(&native).unwrap(); + let supervisor = compiled.join("hack-lifecycle-supervisor"); + std::fs::write(&supervisor, "#!/bin/sh\nexit 0\n").unwrap(); + std::fs::set_permissions(&supervisor, std::fs::Permissions::from_mode(0o722)).unwrap(); + assert!(validate_helpers(&base(&hack)).is_err()); + std::fs::set_permissions(&supervisor, std::fs::Permissions::from_mode(0o700)).unwrap(); + assert_eq!(validate_helpers(&base(&hack)), Ok(NativePin::Absent)); + std::fs::write(&supervisor, "#!/usr/bin/env sh\n").unwrap(); + assert!(validate_helpers(&base(&hack)).is_err()); + std::fs::remove_file(&supervisor).unwrap(); + // An explicit selection is checked and left as the CLI's choice. + let selected = base(&hack).with_env("HACK_NATIVE_BINARY", "/example/absent/hack-native"); + assert_eq!(validate_helpers(&selected), Ok(NativePin::Selected)); + assert_eq!(pinned(selected), Some("/example/absent/hack-native".into())); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn doctor_refuses_remote_or_unknown_docker_endpoints() { + let hack = fake_hack("doctor-docker-endpoint", "echo '{}'"); + let root = std::fs::canonicalize(hack.parent().unwrap()).unwrap(); + let client = root.join("client"); + let meta = client.join("contexts/meta").join(context_dir("selected")); + // A decoy elsewhere that claims the same name with a local endpoint. + let decoy = client.join("contexts/meta/decoy"); + std::fs::create_dir_all(&decoy).unwrap(); + std::fs::write( + decoy.join("meta.json"), + r#"{"Name":"selected","Endpoints":{"docker":{"Host":"unix:///example/decoy.sock"}}}"#, + ) + .unwrap(); + std::fs::create_dir_all(&meta).unwrap(); + let discovery = + |host: Option<&str>, context: Option<&str>| crate::doctor_plugins::Discovery { + config_dir: Some(client.clone()), + unsupported_extra_selector: false, + system_dirs: vec![], + docker_host: host.map(str::to_owned), + context: context.map(str::to_owned), + }; + let write = |host: &str| { + std::fs::write( + meta.join("meta.json"), + format!(r#"{{"Name":"selected","Endpoints":{{"docker":{{"Host":"{host}"}}}}}}"#), + ) + .unwrap(); + }; + write("unix:///example/docker.sock"); + assert!(crate::doctor_plugins::validate(&discovery(None, None)).is_ok()); + assert!(crate::doctor_plugins::validate(&discovery(None, Some("default"))).is_ok()); + assert!(crate::doctor_plugins::validate(&discovery(None, Some("selected"))).is_ok()); + assert!( + crate::doctor_plugins::validate(&discovery(Some("unix:///example/docker.sock"), None)) + .is_ok() + ); + assert!( + crate::doctor_plugins::validate(&discovery(Some("tcp://example:2375"), None)).is_err() + ); + assert!(crate::doctor_plugins::validate(&discovery(None, Some("missing"))).is_err()); + write("ssh://user@example"); + assert!(crate::doctor_plugins::validate(&discovery(None, Some("selected"))).is_err()); + // A FIFO at the exact path is refused without blocking. + std::fs::remove_file(meta.join("meta.json")).unwrap(); + let fifo = std::ffi::CString::new(meta.join("meta.json").to_str().unwrap()).unwrap(); + // SAFETY: fifo is a valid NUL-terminated path for this call. + assert_eq!(unsafe { libc::mkfifo(fifo.as_ptr(), 0o600) }, 0); + assert!(crate::doctor_plugins::validate(&discovery(None, Some("selected"))).is_err()); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn absolute_mutagen_helpers_require_trusted_paths() { + use std::os::unix::fs::PermissionsExt; + let hack = fake_hack("doctor-mutagen-helper", "echo '{}'"); + let dir = hack.parent().unwrap(); + let managed = dir.join("bin/mutagen"); + std::fs::create_dir(dir.join("bin")).unwrap(); + std::fs::copy(&hack, &managed).unwrap(); + let runner = fixture_runner(&hack) + .with_env("HACK_HOME", dir.to_str().unwrap()) + .with_env("HACK_MUTAGEN_PATH", ""); + // A trusted helper script whose #! interpreter another user can replace. + let shared = dir.join("shared"); + std::fs::create_dir(&shared).unwrap(); + std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(0o777)).unwrap(); + std::fs::copy("/bin/sh", shared.join("interp")).unwrap(); + let scripted = dir.join("bin/scripted-mutagen"); + std::fs::write( + &scripted, + format!("#!{}\n", shared.join("interp").display()), + ) + .unwrap(); + std::fs::set_permissions(&scripted, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!( + crate::doctor_plugins::validate_helpers( + &runner + .clone() + .with_env("HACK_MUTAGEN_PATH", scripted.to_str().unwrap()) + ) + .is_err(), + "absolute helpers need a trusted interpreter too" + ); + for explicit in [false, true] { + let runner = if explicit { + runner + .clone() + .with_env("HACK_MUTAGEN_PATH", managed.to_str().unwrap()) + } else { + runner.clone() + }; + std::fs::set_permissions(&managed, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!(crate::doctor_plugins::validate_helpers(&runner).is_ok()); + std::fs::set_permissions(&managed, std::fs::Permissions::from_mode(0o777)).unwrap(); + assert!(crate::doctor_plugins::validate_helpers(&runner).is_err()); + } + assert!( + crate::doctor_plugins::validate_helpers( + &runner.with_env("HACK_MUTAGEN_PATH", "relative-helper") + ) + .is_err() + ); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn filtered_docker_preserves_file_only_compose_and_native_warnings() { + use std::os::unix::fs::PermissionsExt; + let hack = fake_hack("doctor-file-only-coverage", "echo '{}'"); + let root = hack.parent().unwrap(); + let bin = root.join("bin"); + std::fs::create_dir(&bin).unwrap(); + std::fs::copy(&hack, bin.join("docker")).unwrap(); + std::fs::set_permissions(bin.join("docker"), std::fs::Permissions::from_mode(0o775)) + .unwrap(); + let tools = trusted_probe_path(bin.as_os_str()).unwrap(); + assert!(tools.unchecked.contains("docker")); + for native in [false, true] { + let checks = vec![ + crate::models::DoctorCheck { id: "project".into(), status: "ok".into(), detail: format!("Found .hack/ (or legacy .dev/) at {}", root.join(".hack").display()) }, + crate::models::DoctorCheck { id: "compose networks".into(), status: "warn".into(), detail: "Internal services attached to hack-dev without Caddy labels: worker".into() }, + crate::models::DoctorCheck { id: "compose backend features".into(), status: "warn".into(), detail: "External network blocks native up; select the Compose backend or documented isolatedNetworks adaptation.".into() }, + ]; + let mut run = DoctorRun::project( + "fixture", + root.to_str().unwrap(), + Ok(crate::models::DoctorReport { + checks, + summary: vec![], + }), + ); + run.native = native; + run.exact_native_root = native; + let mut runs = vec![run]; + let before = crate::doctor::aggregate(&runs); + assert_eq!(before.cards().count(), 2); + apply_tool_coverage(&mut runs, &tools); + let after = crate::doctor::aggregate(&runs); + for original in before.cards() { + let preserved = after.cards().find(|c| c.id == original.id).unwrap(); + assert_eq!( + preserved, original, + "warn, ingress/native guidance and any authored recipe must survive filtered Docker" + ); + assert_eq!(preserved.severity, crate::doctor::Severity::Warn); + } + } + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn rejected_later_path_directory_cannot_invalidate_selected_tool() { + use std::os::unix::fs::PermissionsExt; + let hack = fake_hack("doctor-path-priority", "echo '{}'"); + let root = hack.parent().unwrap(); + let trusted = root.join("trusted"); + let shared = root.join("shared"); + for dir in [&trusted, &shared] { + std::fs::create_dir(dir).unwrap(); + std::fs::copy(&hack, dir.join("docker")).unwrap(); + } + std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(0o775)).unwrap(); + let path = std::env::join_paths([&trusted, &shared]).unwrap(); + let tools = trusted_probe_path(&path).unwrap(); + assert!(Path::new(tools.path()).join("docker").exists()); + assert!(!tools.unchecked.contains("docker")); + let mut runs = vec![DoctorRun::machine(Ok(crate::models::DoctorReport { + checks: vec![crate::models::DoctorCheck { + id: "docker daemon".into(), + status: "error".into(), + detail: "validated Docker reported an engine error".into(), + }], + summary: vec![], + }))]; + let before = crate::doctor::aggregate(&runs); + apply_tool_coverage(&mut runs, &tools); + assert_eq!( + crate::doctor::aggregate(&runs), + before, + "later rejected shadows preserve findings and remedies" + ); + let reverse = std::env::join_paths([&shared, &trusted]).unwrap(); + assert!( + trusted_probe_path(&reverse) + .unwrap() + .unchecked + .contains("docker"), + "a rejected earlier selection still has incomplete coverage" + ); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn unsafe_optional_mkcert_preserves_certificate_errors_and_remedies() { + use std::os::unix::fs::PermissionsExt; + let hack = fake_hack("doctor-mkcert-independent", "echo '{}'"); + let root = hack.parent().unwrap(); + let bin = root.join("bin"); + std::fs::create_dir(&bin).unwrap(); + for name in ["mkcert", "openssl"] { + let tool = bin.join(name); + std::fs::copy(&hack, &tool).unwrap(); + std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o775)).unwrap(); + } + let tools = trusted_probe_path(bin.as_os_str()).unwrap(); + assert_eq!( + tools.unchecked.iter().copied().collect::>(), + vec!["mkcert"] + ); + let checks = [ + ("caddy local ca", "stale CA"), + ("host tls trust", "missing host trust bundle"), + ] + .map(|(id, detail)| crate::models::DoctorCheck { + id: id.into(), + status: "error".into(), + detail: detail.into(), + }) + .to_vec(); + let mut runs = vec![DoctorRun::machine(Ok(crate::models::DoctorReport { + checks, + summary: vec![], + }))]; + let before = crate::doctor::aggregate(&runs); + apply_tool_coverage(&mut runs, &tools); + let after = crate::doctor::aggregate(&runs); + for original in before.cards() { + let preserved = after.cards().find(|card| card.id == original.id).unwrap(); + assert_eq!( + preserved, original, + "independent error, detail and authored remedy must remain unchanged" + ); + assert_eq!(preserved.severity, crate::doctor::Severity::Error); + assert!(matches!( + preserved.action, + crate::doctor::CardAction::Copy(_) + )); + } + assert!(!after.is_clean()); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn private_path_always_offers_dirname_for_candidate_launchers() { + use std::os::unix::fs::PermissionsExt; + let hack = fake_hack("doctor-dirname", "echo '{}'"); + let root = std::fs::canonicalize(hack.parent().unwrap()).unwrap(); + let bin = root.join("bin"); + std::fs::create_dir(&bin).unwrap(); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap(); + let tools = trusted_probe_path(bin.as_os_str()).unwrap(); + let system = ["/usr/bin/dirname", "/bin/dirname"] + .iter() + .any(|p| Path::new(p).exists()); + assert_eq!(tools.dir.join("dirname").exists(), system); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn script_tools_require_a_trusted_interpreter_chain() { + use std::os::unix::fs::PermissionsExt; + let hack = fake_hack("doctor-shebang", "echo '{}'"); + let root = hack.parent().unwrap(); + let bin = root.join("bin"); + let shared = root.join("shared"); + std::fs::create_dir(&bin).unwrap(); + std::fs::create_dir(&shared).unwrap(); + // Another account could replace anything in this directory. + std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(0o777)).unwrap(); + let unsafe_interpreter = shared.join("interp"); + std::fs::copy("/bin/sh", &unsafe_interpreter).unwrap(); + // An owned interpreter that is itself a script pointing at it. + let nested = bin.join("nested-interp"); + let docker = bin.join("docker"); + let write = |path: &Path, shebang: &Path| { + std::fs::write(path, format!("#!{}\necho '{{}}'\n", shebang.display())).unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); + }; + write(&nested, &unsafe_interpreter); + let unsafe_env = format!("/usr/bin/env {}", unsafe_interpreter.display()); + let env_path = format!("/usr/bin/env PATH={} sh", shared.display()); + let env_split_absolute = format!("/usr/bin/env -S {}", unsafe_interpreter.display()); + // An owned copy of env under another name gets no env options. + let env_copy = bin.join("env-copy"); + std::fs::copy("/usr/bin/env", &env_copy).unwrap(); + std::fs::set_permissions(&env_copy, std::fs::Permissions::from_mode(0o755)).unwrap(); + let renamed_env = format!("{} -S -P bin sh", env_copy.display()); + for (shebang, trusted) in [ + ("/bin/sh", true), + ("/usr/bin/env sh", true), + (unsafe_interpreter.to_str().unwrap(), false), + (nested.to_str().unwrap(), false), + (unsafe_env.as_str(), false), + (env_path.as_str(), false), + ("/usr/bin/env -S sh", false), + ("/usr/bin/env -S ${TOOL_INTERPRETER}", false), + (env_split_absolute.as_str(), false), + ("/usr/bin/env -P bin sh", false), + ("/usr/bin/env -- sh", false), + (renamed_env.as_str(), false), + ("/bin/sh -eu", false), + // A plain argument can be a script file the shell would run. + ("/bin/sh payload", false), + ("/usr/bin/env sh extra", false), + ] { + write(&docker, Path::new(shebang)); + let tools = trusted_probe_path(bin.as_os_str()).unwrap(); + assert_eq!(tools.dir.join("docker").exists(), trusted, "{shebang}"); + assert_eq!(tools.unchecked.contains("docker"), !trusted); + } + // An execute-only script cannot be read here, yet the kernel would + // still follow its #! line: it is refused, never assumed a binary. + // SAFETY: getuid has no preconditions and cannot fail. + if unsafe { libc::getuid() } != 0 { + write(&docker, Path::new("/bin/sh")); + std::fs::set_permissions(&docker, std::fs::Permissions::from_mode(0o111)).unwrap(); + let tools = trusted_probe_path(bin.as_os_str()).unwrap(); + assert!(!tools.dir.join("docker").exists()); + assert!(tools.unchecked.contains("docker")); + std::fs::set_permissions(&docker, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn filtered_doctor_tools_only_downgrade_dependent_checks() { + use std::os::unix::fs::PermissionsExt; + let hack = fake_hack("doctor-scoped-coverage", "echo '{}'"); + let root = hack.parent().unwrap(); + let bin = root.join("bin"); + std::fs::create_dir(&bin).unwrap(); + let unrelated = bin.join("unrelated-execute-only"); + std::fs::write(&unrelated, "fixture").unwrap(); + std::fs::set_permissions(&unrelated, std::fs::Permissions::from_mode(0o777)).unwrap(); + let tools = trusted_probe_path(bin.as_os_str()).unwrap(); + assert!( + tools.unchecked.is_empty(), + "unrelated unsafe tools do not affect Doctor coverage" + ); + let docker = bin.join("docker"); + std::fs::copy(&hack, &docker).unwrap(); + for directory in [false, true] { + std::fs::set_permissions( + &bin, + std::fs::Permissions::from_mode(if directory { 0o775 } else { 0o755 }), + ) + .unwrap(); + std::fs::set_permissions( + &docker, + std::fs::Permissions::from_mode(if directory { 0o755 } else { 0o777 }), + ) + .unwrap(); + let tools = trusted_probe_path(bin.as_os_str()).unwrap(); + assert_eq!( + tools.unchecked.iter().copied().collect::>(), + vec!["docker"] + ); + let checks = [ + "docker", + "docker daemon", + "resolver:hack", + "host tls trust", + "env mode", + "project", + ] + .map(|id| crate::models::DoctorCheck { + id: id.into(), + status: "ok".into(), + detail: "independently observed".into(), + }) + .to_vec(); + let mut runs = vec![DoctorRun::machine(Ok(crate::models::DoctorReport { + checks, + summary: vec![], + }))]; + apply_tool_coverage(&mut runs, &tools); + let report = runs[0].result.as_ref().unwrap(); + assert!(report.checks[..2].iter().all(|c| c.status == "unknown")); + assert!( + report.checks[2..] + .iter() + .all(|c| c.status == "ok" && c.detail == "independently observed") + ); + let view = crate::doctor::aggregate(&runs); + assert!(!view.is_clean()); + assert_eq!( + view.cards() + .filter(|c| c.id == "desktop tool coverage") + .count(), + 1 + ); + assert!( + view.cards() + .any(|c| c.id == "desktop tool coverage" && c.detail.contains("docker")) + ); + } + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn path_caps_keep_checked_tools_and_report_unsearched_coverage() { + use std::os::unix::fs::PermissionsExt; + let hack = std::fs::canonicalize(fake_hack("doctor-path-bounds", "echo '{}'")).unwrap(); + // Keep every input in this owned tree and use its canonical spelling + // (macOS temp roots may have /var-style aliases). This test qualifies + // PATH coverage, not absent-helper/plugin discovery on a host image. + let root = std::fs::canonicalize(hack.parent().unwrap()).unwrap(); + let root = root.as_path(); + std::fs::set_permissions(root, std::fs::Permissions::from_mode(0o700)).unwrap(); + let first = root.join("first"); + let second = root.join("second"); + std::fs::create_dir(&first).unwrap(); + std::fs::create_dir(&second).unwrap(); + for dir in [&first, &second] { + std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + std::os::unix::fs::symlink(&hack, first.join("git")).unwrap(); + std::os::unix::fs::symlink(&hack, second.join("docker")).unwrap(); + let repeated = std::env::join_paths(std::iter::repeat_n(&first, 69)).unwrap(); + let tools = trusted_probe_path_with_limits(&repeated, 1, 1).unwrap(); + assert!(tools.coverage.is_none()); + assert!(Path::new(tools.path()).join("git").exists()); + let path = std::env::join_paths([&first, &second]).unwrap(); + for (dirs, entries) in [(1, 10), (10, 1)] { + let tools = trusted_probe_path_with_limits(&path, dirs, entries).unwrap(); + assert!(tools.coverage.is_some()); + assert!(Path::new(tools.path()).join("git").exists()); + assert!(!Path::new(tools.path()).join("docker").exists()); + } + let mut dirs = vec![first.clone()]; + for index in 1..65 { + let dir = root.join(format!("path-{index}")); + std::fs::create_dir(&dir).unwrap(); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap(); + dirs.push(dir); + } + let ordinary = + std::env::join_paths(dirs[..64].iter().chain(std::iter::repeat_n(&first, 5))).unwrap(); + let tools = trusted_probe_path(&ordinary).unwrap(); + assert!( + tools.coverage.is_none(), + "69 raw / 64 unique must be supported" + ); + let over = std::env::join_paths(&dirs).unwrap(); + std::fs::write( + &hack, + r#"#!/bin/sh +echo '{"checks":[{"id":"docker","status":"ok","detail":"healthy"}]}' +"#, + ) + .unwrap(); + let client = root.join("client"); + let system_plugins = root.join("system-plugins"); + std::fs::create_dir_all(client.join("cli-plugins")).unwrap(); + std::fs::create_dir(&system_plugins).unwrap(); + std::fs::write(client.join("config.json"), "{}").unwrap(); + for dir in [&client, &client.join("cli-plugins"), &system_plugins] { + std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + let discovery = crate::doctor_plugins::Discovery { + config_dir: Some(client), + unsupported_extra_selector: false, + docker_host: None, + context: None, + system_dirs: vec![system_plugins], + }; + let source = LiveSource { + runner: fixture_runner(&hack) + .with_env("HOME", root.to_str().unwrap()) + .with_env("HACK_HOME", root.to_str().unwrap()) + .with_env("HACK_MUTAGEN_PATH", hack.to_str().unwrap()) + .with_env("PATH", over.to_str().unwrap()), + daemon: None, + }; + assert_eq!( + crate::doctor_plugins::validate_helpers(&source.runner).map(|_| ()), + Ok(()), + "stage=helper-preflight" + ); + assert_eq!( + crate::doctor_plugins::validate(&discovery), + Ok(()), + "stage=plugin-preflight" + ); + let runs = + source.doctor_runs_with_discovery(&[], DOCTOR_BUDGET, DOCTOR_TIMEOUT, &discovery); + // A synthetic preflight card is also Ok/Unknown, but proves nothing + // about the bounded scan. Fail at the actual stage with static reasons. + assert!( + runs[0].probe_started, + "stage=preflight-refused reason={}", + runs[0].result.as_ref().unwrap().checks[0].detail + ); + let report = runs[0] + .result + .as_ref() + .expect("bounded scan still runs Doctor"); + assert_eq!(report.checks.len(), 1, "stage=fake-report-count"); + assert_eq!(report.checks[0].id, "docker", "stage=fake-cli-not-run"); + assert!( + report.checks.iter().all(|c| c.status == "unknown"), + "stage=coverage-not-applied" + ); + let view = crate::doctor::aggregate(&runs); + assert!(!view.is_clean()); + assert!( + view.cards() + .any(|card| card.detail.contains("bounded PATH scan")), + "stage=coverage-card-missing" + ); + assert_eq!( + view.cards() + .filter(|card| card.id == "desktop tool coverage") + .count(), + 1, + "stage=coverage-card-count" + ); + let refused = crate::doctor_plugins::Discovery { + unsupported_extra_selector: true, + docker_host: None, + context: None, + ..discovery + }; + let refused_runs = + source.doctor_runs_with_discovery(&[], DOCTOR_BUDGET, DOCTOR_TIMEOUT, &refused); + assert!( + !refused_runs[0].probe_started, + "stage=negative-preflight-control" + ); + let refused_view = crate::doctor::aggregate(&refused_runs); + assert!(!refused_view.machine_checked); + assert!( + !refused_view + .cards() + .any(|card| card.id == "desktop tool coverage"), + "stage=preflight-must-not-masquerade-as-scan" + ); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn tool_links_reject_writable_intermediate_alias() { + use std::os::unix::fs::{PermissionsExt, symlink}; + let hack = fake_hack("doctor-tool-hop", "echo '{}'"); + let dir = hack.parent().unwrap(); + let bin = dir.join("bin"); + let shared = dir.join("shared"); + std::fs::create_dir(&bin).unwrap(); + std::fs::create_dir(&shared).unwrap(); + let hop = shared.join("current"); + symlink(&hack, &hop).unwrap(); + let tool = bin.join("git"); + symlink(&hop, &tool).unwrap(); + let uid = unsafe { libc::getuid() }; + assert!(trusted_tool_path(&tool, uid, false).is_some()); + std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(0o777)).unwrap(); + assert!(trusted_tool_path(&tool, uid, false).is_none()); + let tools = trusted_probe_path(bin.as_os_str()).unwrap(); + assert!(!Path::new(tools.path()).join("git").exists()); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn homebrew_owned_paths_keep_nested_tools_and_shims_despite_unrelated_unsafe_files() { + use std::os::unix::fs::{PermissionsExt, symlink}; + let hack = fake_hack( + "doctor-homebrew", + "docker --version >/dev/null || exit 2\ngit --version >/dev/null || exit 3\ntmux -V >/dev/null || exit 4\nmise --version >/dev/null || exit 5\necho '{}'", + ); + // macOS temp paths can be aliases (/var -> /private/var). Reproduce + // that using only an owned fixture symlink, on every platform. + let fixture_root = std::fs::canonicalize(hack.parent().unwrap()).unwrap(); + let alias = fixture_root.join("fixture-root-alias"); + symlink(&fixture_root, &alias).unwrap(); + let dir = alias.as_path(); + let brew = dir.join("opt/homebrew"); + let bin = brew.join("bin"); + let cellar = brew.join("Cellar/tools/1.0/bin"); + let shims = brew.join("var/mise/shims' owned"); + for p in [&bin, &cellar, &shims] { + std::fs::create_dir_all(p).unwrap(); + std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + for (name, body) in [ + ("mise", "printf 'mise fixture\\n'"), + ( + "docker", + "git --version >/dev/null || exit 8\nprintf 'docker fixture\\n'", + ), + ( + "tmux", + "mise --version >/dev/null || exit 9\nprintf 'tmux fixture\\n'", + ), + ] { + let target = cellar.join(name); + std::fs::write(&target, format!("#!/bin/sh\n{body}\n")).unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap(); + symlink(&target, bin.join(name)).unwrap(); + } + let git = shims.join("git"); + std::fs::write(&git, "#!/bin/sh\n[ \"$0\" = \"$HACK_HOME/opt/homebrew/var/mise/shims' owned/git\" ] || exit 10\nmise --version >/dev/null || exit 11\nprintf 'git fixture\\n'\n").unwrap(); + std::fs::set_permissions(&git, std::fs::Permissions::from_mode(0o755)).unwrap(); + // A hosted image can have one unsafe tool beside a trusted Git. + // Reject that tool, never discard every safe sibling in the directory. + let unsafe_tool = bin.join("unsafe-unrelated"); + std::fs::write( + &unsafe_tool, + "#!/bin/sh\nprintf bad > \"$HACK_HOME/executed\"\n", + ) + .unwrap(); + std::fs::set_permissions(&unsafe_tool, std::fs::Permissions::from_mode(0o777)).unwrap(); + let inherited = std::env::join_paths([&bin, &shims]).unwrap(); + let tools = trusted_probe_path(&inherited).unwrap(); + assert!(!Path::new(tools.path()).join("unsafe-unrelated").exists()); + for name in ["docker", "git", "tmux", "mise"] { + assert!(Path::new(tools.path()).join(name).is_file()); + } + let runner = fixture_runner(&hack).with_env("PATH", &tools.path().to_string_lossy()); + // Reproduce the reported Mac Exit(2) without depending on /var or + // any runner Homebrew path: spelling an aliased root as $0 fails. + let aliased = runner + .clone() + .with_env("HACK_HOME", &dir.to_string_lossy()) + .run_json::(&[], None, Duration::from_secs(60)); + assert!(matches!(aliased, Err(RunError::Exit { code: Some(2), .. }))); + runner + // Tool wrappers use canonical directory paths; compare $0 with + // this fixture's canonical root, never an actual Homebrew prefix. + .with_env("HACK_HOME", &fixture_root.to_string_lossy()) + .run_json::(&[], None, Duration::from_secs(60)) + .unwrap(); + assert!(!dir.join("executed").exists()); + let private = PathBuf::from(tools.path()); + drop(tools); + assert!(!private.exists()); + let _ = std::fs::remove_dir_all(&fixture_root); + } + + #[test] + fn doctor_payload_without_checks_is_not_a_report() { + let hack = fake_hack("doctor-empty", r#"echo '{"ok":true,"data":{}}'"#); + let src = LiveSource { + runner: fixture_runner(&hack), + daemon: None, + }; + let runs = src.fixture_doctor_runs(&targets(1)); + let _ = std::fs::remove_dir_all(hack.parent().unwrap()); + assert!(runs.iter().all(|r| r.result.is_err()), "{runs:?}"); + assert!(matches!( + crate::doctor::DoctorState::from_runs(&runs), + crate::doctor::DoctorState::Error(_) + )); + } + + #[test] + fn docker_plugin_preflight_preserves_context_and_blocks_untrusted_or_unknown_discovery() { + use std::os::unix::fs::{PermissionsExt, symlink}; + let hack = fake_hack( + "plugin-context", + "printf called > \"$HACK_HOME/hack-ran\"\ndocker compose ps >/dev/null || exit 7\necho '{\"ok\":true,\"data\":{\"checks\":[{\"id\":\"docker daemon\",\"status\":\"ok\",\"detail\":\"fixture\"}]}}'", + ); + let root = std::fs::canonicalize(hack.parent().unwrap()).unwrap(); + let client = root.join("client"); + let plugins = client.join("cli-plugins"); + let bin = root.join("bin"); + let extra = root.join("plugins extra"); + let extra_link = root.join("extra-link"); + let home = root.join("home"); + for p in [&plugins, &bin, &extra, &home] { + std::fs::create_dir_all(p).unwrap(); + } + symlink(&extra, &extra_link).unwrap(); + let target = root.join("owned-compose"); + let mutable_alias = root.join("mutable-alias"); + std::fs::create_dir(&mutable_alias).unwrap(); + std::fs::write( + &target, + "#!/bin/sh\nprintf called > \"$HACK_HOME/plugin-ran\"\n", + ) + .unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap(); + symlink(&target, plugins.join("docker-compose")).unwrap(); + symlink(&target, mutable_alias.join("plugin-hop")).unwrap(); + let docker = bin.join("docker"); + std::fs::write(&docker, "#!/bin/sh\n[ \"$DOCKER_CONFIG\" = \"$HACK_HOME/client\" ] || exit 91\n[ \"$HOME\" = \"$HACK_HOME/home\" ] || exit 92\n[ \"$DOCKER_CONTEXT\" = fixture-context ] || exit 93\nexec \"$DOCKER_CONFIG/cli-plugins/docker-compose\" \"$@\"\n").unwrap(); + std::fs::set_permissions(&docker, std::fs::Permissions::from_mode(0o755)).unwrap(); + let config = serde_json::json!({"currentContext":"fixture-context", "cliPluginsExtraDirs":[extra_link], "auths":{"example.invalid":{"auth":"NOT_A_REAL_CREDENTIAL"}}}); + std::fs::write( + client.join("config.json"), + serde_json::to_vec(&config).unwrap(), + ) + .unwrap(); + // The selected context must resolve to a local Unix socket. + let meta = client + .join("contexts/meta") + .join(context_dir("fixture-context")); + std::fs::create_dir_all(&meta).unwrap(); + std::fs::write( + meta.join("meta.json"), + r#"{"Name":"fixture-context","Endpoints":{"docker":{"Host":"unix:///example/docker.sock"}}}"#, + ) + .unwrap(); + let source = LiveSource { + runner: fixture_runner(&hack) + .with_env("PATH", &bin.to_string_lossy()) + .with_env("HACK_HOME", &root.to_string_lossy()) + .with_env("HOME", &home.to_string_lossy()) + .with_env("DOCKER_CONFIG", &client.to_string_lossy()) + .with_env("DOCKER_CONTEXT", "fixture-context") + .with_env("DOCKER_CLI_PLUGIN_EXTRA_DIRS", ""), + daemon: None, + }; + let mut discovery = crate::doctor_plugins::Discovery::from_runner(&source.runner); + discovery.system_dirs = vec![root.join("system-plugins")]; + let runs = + source.doctor_runs_with_discovery(&[], DOCTOR_BUDGET, DOCTOR_TIMEOUT, &discovery); + assert!(runs[0].result.is_ok(), "{runs:?}"); + assert!(root.join("hack-ran").exists() && root.join("plugin-ran").exists()); + assert_eq!( + std::fs::read(client.join("config.json")).unwrap(), + serde_json::to_vec(&config).unwrap(), + "context/config bytes are never rewritten or copied" + ); + for (case, reason) in [ + ("file", "untrusted"), + ("symlink-hop", "untrusted"), + ("extra-dir", "owner-controlled"), + ("config-dir", "owner-controlled"), + ("opaque-extra", "unsupported"), + ("relative-extra", "unsupported"), + ("invalid-config", "invalid or unsupported"), + ] { + std::fs::remove_file(root.join("hack-ran")).ok(); + std::fs::remove_file(root.join("plugin-ran")).ok(); + for p in [&target, &extra, &client, &mutable_alias] { + std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + std::fs::write( + client.join("config.json"), + serde_json::to_vec(&config).unwrap(), + ) + .unwrap(); + discovery.unsupported_extra_selector = case == "opaque-extra"; + std::fs::remove_file(plugins.join("docker-compose")).unwrap(); + symlink(&target, plugins.join("docker-compose")).unwrap(); + match case { + "symlink-hop" => { + std::fs::remove_file(plugins.join("docker-compose")).unwrap(); + symlink( + mutable_alias.join("plugin-hop"), + plugins.join("docker-compose"), + ) + .unwrap(); + std::fs::set_permissions( + &mutable_alias, + std::fs::Permissions::from_mode(0o777), + ) + .unwrap(); + } + "file" => std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o777)) + .unwrap(), + "extra-dir" => { + std::fs::set_permissions(&extra, std::fs::Permissions::from_mode(0o775)) + .unwrap() + } + "config-dir" => { + std::fs::set_permissions(&client, std::fs::Permissions::from_mode(0o775)) + .unwrap() + } + "relative-extra" => std::fs::write( + client.join("config.json"), + r#"{"cliPluginsExtraDirs":["relative"]}"#, + ) + .unwrap(), + "invalid-config" => { + std::fs::write(client.join("config.json"), "{invalid NOT_A_REAL_CREDENTIAL") + .unwrap() + } + _ => {} + } + let runs = + source.doctor_runs_with_discovery(&[], DOCTOR_BUDGET, DOCTOR_TIMEOUT, &discovery); + let view = crate::doctor::aggregate(&runs); + assert!(!view.is_clean()); + assert!(!view.machine_checked, "preflight did not invoke the CLI"); + let card = view.cards().next().unwrap(); + assert_eq!(card.severity, crate::doctor::Severity::CouldNotCheck); + assert!(card.detail.contains(reason), "{case}: {}", card.detail); + assert!(!card.detail.contains("NOT_A_REAL_CREDENTIAL")); + assert!(card.authored_remedy.is_none()); + assert!(!root.join("hack-ran").exists() && !root.join("plugin-ran").exists()); + } + assert_eq!( + source.runner.env_value("DOCKER_CONTEXT").unwrap(), + "fixture-context" + ); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn native_checkout_evidence_does_not_accept_ancestor_checks_when_marker_is_missing_or_changes() + { + let hack = fake_hack( + "native-root", + r#"case "$*" in +*--path*) + if [ "$DROP_MARKER" = 1 ] && [ -f "$HACK_HOME/parent/child/.hack/hack.project.json" ]; then /bin/rm "$HACK_HOME/parent/child/.hack/hack.project.json"; fi + echo '{"ok":true,"data":{"checks":[{"id":"env key","status":"error","detail":"could be ancestor"},{"id":"native settings","status":"ok","detail":"could be ancestor"},{"id":"native root trust","status":"warn","detail":"machine diagnostic"}]}}' ;; +*) echo '{"ok":true,"data":{"checks":[{"id":"docker","status":"ok","detail":"fixture"}]}}' ;; +esac"#, + ); + let root = std::fs::canonicalize(hack.parent().unwrap()).unwrap(); + let parent = root.join("parent"); + let child = parent.join("child"); + std::fs::create_dir_all(parent.join(".hack")).unwrap(); + std::fs::create_dir_all(child.join(".hack")).unwrap(); + std::fs::write(parent.join(".hack/docker-compose.yml"), "services: {}\n").unwrap(); + let marker = child.join(".hack/hack.project.json"); + std::fs::write(&marker, "{}").unwrap(); + let source = LiveSource { + runner: fixture_runner(&hack).with_env("HACK_HOME", &root.to_string_lossy()), + daemon: None, + }; + let target = DoctorTarget { + name: "child".into(), + repo_root: child, + native: true, + missing: false, + }; + let exact = source.fixture_doctor_runs(std::slice::from_ref(&target)); + assert!(exact[1].exact_native_root); + assert_eq!(crate::doctor::aggregate(&exact).projects_checked, 1); + let changed = LiveSource { + runner: source.runner.clone().with_env("DROP_MARKER", "1"), + daemon: None, + } + .fixture_doctor_runs(std::slice::from_ref(&target)); + assert!(!changed[1].exact_native_root); + let missing = source.fixture_doctor_runs(&[target]); + for runs in [&changed, &missing] { + let view = crate::doctor::aggregate(runs); + assert_eq!(view.projects_checked, 0); + assert!(!view.is_clean()); + assert!(view.cards().any(|c| c.id == "native root trust")); + assert!(!view.cards().any(|c| c.id == "env key")); + } + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn missing_registry_targets_are_not_probed_but_remain_unchecked() { + let hack = fake_hack( + "doctor-missing", + r#"[ "$*" = 'doctor --json' ] || exit 9 +echo '{"ok":true,"data":{"checks":[{"id":"docker","status":"ok","detail":""}]}}'"#, + ); + let source = LiveSource { + runner: fixture_runner(&hack), + daemon: None, + }; + let payload: ProjectsPayload = serde_json::from_str(r#"{"projects":[{"name":"gone","status":"missing","repo_root":"/work/gone"},{"name":"no-root","status":"missing"}]}"#).unwrap(); + let runs = source.fixture_doctor_runs(&DoctorTarget::from_projects(&payload)); + let _ = std::fs::remove_dir_all(hack.parent().unwrap()); + assert_eq!(runs.len(), 3); + assert!( + runs[1..] + .iter() + .all(|r| r.result.as_ref().unwrap_err() == "not checked (checkout missing)") + ); + let view = crate::doctor::aggregate(&runs); + assert_eq!((view.projects_total, view.projects_checked), (2, 0)); + assert!(!view.is_clean()); + } + + #[test] + fn doctor_budget_holds_even_when_checks_ignore_sigterm() { + // Every call ignores SIGTERM, so each stop costs the full grace. + let hack = fake_hack( + "doctor-stubborn", + "trap '' TERM\nwhile :; do sleep 0.05; done", + ); + let src = LiveSource { + runner: fixture_runner(&hack), + daemon: None, + }; + let budget = Duration::from_secs(4); + let started = std::time::Instant::now(); + let runs = src.fixture_doctor_runs_within(&targets(6), budget, Duration::from_secs(60)); + let _ = std::fs::remove_dir_all(hack.parent().unwrap()); + assert!( + started.elapsed() < budget + Duration::from_millis(800), + "took {:?} for a {budget:?} budget", + started.elapsed() + ); + assert!(runs.iter().all(|r| r.result.is_err())); + } + + #[test] + fn doctor_budget_marks_late_projects_not_checked() { + let hack = fake_hack("doctor-slow", "sleep 5"); + let src = LiveSource { + runner: fixture_runner(&hack), + daemon: None, + }; + let started = std::time::Instant::now(); + let runs = src.fixture_doctor_runs_within( + &targets(6), + Duration::from_millis(400), + Duration::from_secs(5), + ); + let _ = std::fs::remove_dir_all(hack.parent().unwrap()); + assert!( + started.elapsed() < Duration::from_secs(3), + "budget not honoured" + ); + assert_eq!(runs.len(), 7); + let not_checked = runs + .iter() + .filter(|r| r.result.as_ref().err().map(String::as_str) == Some(NOT_CHECKED_BUDGET)) + .count(); + assert!(not_checked >= 2, "{runs:?}"); + assert!(runs.iter().all(|r| r.result.is_err())); + } + + /// Doctor exits 1 when a check errors but still prints the report. + #[test] + fn doctor_runs_keep_reports_from_exit_1() { + use std::os::unix::fs::PermissionsExt; + let dir = std::env::temp_dir().join(format!( + "hack-desktop-doctor-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let hack = dir.join("hack"); + std::fs::write( + &hack, + r#"#!/bin/sh +case "$*" in + *--path*) echo '{"ok":true,"data":{"checks":[{"id":"env key","status":"error","detail":"key missing"},{"id":"project","status":"ok","detail":"Found .hack/ (or legacy .dev/) at /work/alpha/.hack"}],"summary":[],"counts":{"ok":0,"warn":0,"error":1}}}'; exit 1 ;; + *) echo '{"ok":true,"data":{"checks":[{"id":"docker daemon","status":"ok","detail":""}],"summary":[],"counts":{"ok":1,"warn":0,"error":0}}}' ;; +esac +"#, + ) + .unwrap(); + std::fs::set_permissions(&hack, std::fs::Permissions::from_mode(0o755)).unwrap(); + let src = LiveSource { + runner: fixture_runner(&hack).with_env("HACK_HOME", &dir.to_string_lossy()), + daemon: None, + }; + let runs = src.fixture_doctor_runs(&[DoctorTarget { + missing: false, + name: "alpha".into(), + repo_root: "/work/alpha".into(), + native: false, + }]); + let _ = std::fs::remove_dir_all(&dir); + assert_eq!(runs.len(), 2); + assert!(runs[0].project.is_none()); + let alpha = runs[1].result.as_ref().expect("exit 1 keeps the report"); + assert_eq!(alpha.checks[0].id, "env key"); + let v = crate::doctor::aggregate(&runs); + assert_eq!(v.attention_count(), 1); + assert_eq!(v.passed, 2); + } + /// Whether the temp dir's own ancestors are private enough for the /// positive cases. Decided independently of `trusted_root`, so an /// unexpected rejection fails the test instead of skipping it. @@ -1035,7 +3148,7 @@ esac "#!/bin/sh\nsleep 1\necho '{\"ok\":true,\"data\":{\"action\":\"up\",\"services\":{\"failed\":[]}}}'\n", ); let src = LiveSource { - runner: CliRunner::new(&hack), + runner: fixture_runner(&hack), daemon: None, }; if host_temp_is_trustworthy() { diff --git a/apps/desktop/core/src/viewmodel.rs b/apps/desktop/core/src/viewmodel.rs index 9cc66a8d7..a8c6372bb 100644 --- a/apps/desktop/core/src/viewmodel.rs +++ b/apps/desktop/core/src/viewmodel.rs @@ -309,6 +309,17 @@ impl AppModel { .is_none_or(|c| c.in_flight.is_none()) } + /// The last good snapshot, if any. + #[must_use] + pub fn snapshot(&self) -> Option<&Snapshot> { + self.snapshot.as_ref() + } + + #[must_use] + pub fn project_list_readable(&self) -> bool { + self.snapshot.is_some() && self.last_error.is_none() + } + #[must_use] pub fn selected_row(&self) -> Option<&ProjectRow> { let s = self.snapshot.as_ref()?; @@ -667,6 +678,7 @@ mod tests { #[test] fn failed_refresh_keeps_last_good_and_marks_stale() { let mut m = model(Fixture::Running); + assert!(m.project_list_readable()); m.apply(Event::Refreshed(Err(SourceError::Cli { code: "E_UNEXPECTED".into(), message: "boom".into(), @@ -675,6 +687,7 @@ mod tests { let v = m.list(); assert_eq!(v.phase, Phase::Ready); assert!(matches!(v.banners.last(), Some(Banner::Stale { .. }))); + assert!(!m.project_list_readable()); assert!(v.running.is_empty()); assert_eq!(v.counts.unknown, 4); assert!(m.detail().unwrap().actions.is_empty()); diff --git a/apps/desktop/screenshots/doctor-dark.png b/apps/desktop/screenshots/doctor-dark.png new file mode 100644 index 000000000..eed03a2aa Binary files /dev/null and b/apps/desktop/screenshots/doctor-dark.png differ diff --git a/apps/desktop/screenshots/doctor-light.png b/apps/desktop/screenshots/doctor-light.png new file mode 100644 index 000000000..62ae9f0d6 Binary files /dev/null and b/apps/desktop/screenshots/doctor-light.png differ