Skip to main content

trusty_console/detect/
mpm.rs

1//! `ServiceConnector` implementation for `trusty-mpm` (#1222, #1849).
2//!
3//! Why: the console's Overview must show trusty-mpm alongside the other services,
4//! and the `/api/mpm/*` reverse-proxy route (#1849 Phase 2) requires a live URL
5//! from the connector so the proxy handler can resolve the upstream daemon.
6//! What: `MpmConnector` implements `ServiceConnector::detect()` using the standard
7//! `trusty-common` http_addr discovery file written by the daemon after bind
8//! (#1849 Phase 1). Primary path: binary check → `trusty-mpm` http_addr file
9//! (written via `write_daemon_addr`) → TCP probe → `Running`/`Available`/`Absent`.
10//! Backward-compat fallback: when the http_addr file is absent (old daemon that
11//! pre-dates #1849), the connector checks the TOML lock file `~/.trusty-mpm/
12//! daemon.lock` and reports Running without a URL (so the service badge is
13//! accurate but the proxy cannot reach it).
14//! Test: `mpm_connector_absent_binary`, `mpm_connector_parses_lock_addr`,
15//! `mpm_connector_no_lock_file`, `mpm_connector_surfaces_url_via_http_addr` below.
16
17use std::path::PathBuf;
18
19use crate::connector::{ServiceConnector, ServiceInfo, ServiceLifecycle, ServiceStatus};
20
21use super::helpers::{binary_on_path, detect_service, tcp_probe};
22
23/// ServiceConnector for `trusty-mpm`.
24///
25/// Why: surfaces the running trusty-mpm daemon in the console Overview and
26/// enables the `/api/mpm/*` route by providing the daemon's live base URL
27/// via the standard `http_addr` discovery file (#1849 Phase 1). A backward-compat
28/// fallback reads the TOML lock file for daemons that pre-date the http_addr write.
29/// What: implements `detect()` using the standard `trusty-common` data-dir
30/// discovery path (`resolve_data_dir("trusty-mpm")/http_addr`) as the primary
31/// source and the TOML lock at `~/.trusty-mpm/daemon.lock` as a fallback.
32/// Test: unit tests below; run with `cargo test -p trusty-console`.
33pub struct MpmConnector {
34    /// Override for the home directory (used in lock-file fallback tests).
35    home_dir: Option<PathBuf>,
36}
37
38impl MpmConnector {
39    /// Create a new `MpmConnector`.
40    ///
41    /// Why: production callers use `new()`; tests use `with_home()`.
42    /// What: stores no state except the optional home override.
43    /// Test: created in `all_connectors()` and in unit tests.
44    pub fn new() -> Self {
45        Self { home_dir: None }
46    }
47
48    /// Create a connector that uses `home_dir` instead of the real home.
49    ///
50    /// Why: unit tests for the lock-file fallback must not read the real user's
51    /// `~/.trusty-mpm`.
52    /// What: stores `home_dir` for use in `lock_file_path()`.
53    /// Test: `mpm_connector_parses_lock_addr`, `mpm_connector_no_lock_file`.
54    #[cfg(test)]
55    pub fn with_home(home_dir: PathBuf) -> Self {
56        Self {
57            home_dir: Some(home_dir),
58        }
59    }
60
61    fn lock_file_path(&self) -> PathBuf {
62        let home = self
63            .home_dir
64            .clone()
65            .or_else(dirs::home_dir)
66            .unwrap_or_else(|| PathBuf::from("/tmp"));
67        home.join(".trusty-mpm").join("daemon.lock")
68    }
69}
70
71impl Default for MpmConnector {
72    fn default() -> Self {
73        Self::new()
74    }
75}
76
77/// Extract the host:port from a trusty-mpm `daemon.lock` TOML body.
78///
79/// Why: the lock file is TOML with `addr = "http://127.0.0.1:<port>"`; the TCP
80/// probe needs a bare `host:port` with no scheme. A tiny line scan avoids
81/// pulling a TOML dependency into the console for one field.
82/// What: splits each line on the FIRST `=` into key/value, matches the key
83/// EXACTLY against `addr` (so `addr_extra` is rejected), consumes exactly one
84/// `=`, strips the quotes and any `http(s)://` scheme, and returns the
85/// `host:port`. Returns `None` when absent/malformed.
86///
87/// The exact-key match and single-`=` split are deliberate (review finding #4):
88/// the previous `strip_prefix("addr")` + `trim_start_matches([' ', '='])` matched
89/// `addr_extra = "…"` and stripped ALL leading spaces/`=`, which could yield a
90/// garbage address. Splitting on the first `=` and comparing the trimmed key for
91/// equality fixes both issues.
92/// Test: `parse_lock_addr_strips_scheme`, `parse_lock_addr_none_when_absent`,
93/// `parse_lock_addr_well_formed_no_scheme`, `parse_lock_addr_ignores_prefixed_key`,
94/// `parse_lock_addr_prefers_exact_key_over_decoy`.
95fn parse_lock_addr(body: &str) -> Option<String> {
96    for line in body.lines() {
97        // Split on the FIRST `=` only; a value like an IPv6 host:port has no `=`
98        // but this keeps any stray `=` inside the quoted value intact.
99        let Some((key, value)) = line.split_once('=') else {
100            continue;
101        };
102        // Exact key match — `addr_extra`, `addr2`, etc. must NOT match.
103        if key.trim() != "addr" {
104            continue;
105        }
106        let unquoted = value.trim().trim_matches('"');
107        let host_port = unquoted
108            .strip_prefix("http://")
109            .or_else(|| unquoted.strip_prefix("https://"))
110            .unwrap_or(unquoted);
111        if !host_port.is_empty() {
112            return Some(host_port.to_string());
113        }
114    }
115    None
116}
117
118impl ServiceConnector for MpmConnector {
119    fn id(&self) -> &'static str {
120        "trusty-mpm"
121    }
122
123    fn display_name(&self) -> &'static str {
124        "Trusty MPM"
125    }
126
127    /// Detect trusty-mpm status, surfacing the daemon URL when reachable.
128    ///
129    /// Why: Phase 1 (#1849) wires trusty-mpm into the console reverse proxy;
130    /// the proxy handler resolves the daemon base URL from the connector's
131    /// `ServiceInfo.url` field, so this method must surface a URL when the
132    /// daemon is reachable via the standard `http_addr` discovery file.
133    /// Primary path: binary check → `resolve_data_dir("trusty-mpm")/http_addr`
134    /// (written by the daemon via `write_daemon_addr` after bind) → TCP probe
135    /// → `Running` with `url: Some(base_url)`. If the http_addr file is absent
136    /// (old daemon that pre-dates #1849), falls back to the TOML lock file and
137    /// reports `Running` without a URL (proxy cannot reach it but the badge is
138    /// correct). Binary absent → `Absent`.
139    /// What: delegates to `detect_service()` for the http_addr path (which adds
140    /// the URL and version); the lock-file fallback uses a direct tcp_probe.
141    /// Test: `mpm_connector_surfaces_url_via_http_addr` (primary path),
142    /// `mpm_connector_parses_lock_addr` (fallback path).
143    fn detect(&self) -> ServiceInfo {
144        if !binary_on_path("trusty-mpm") {
145            return ServiceInfo {
146                id: self.id().to_string(),
147                display_name: self.display_name().to_string(),
148                status: ServiceStatus::Absent,
149                version: None,
150                url: None,
151                hint: None,
152                lifecycle: ServiceLifecycle::Daemon,
153            };
154        }
155
156        // Primary path: standard http_addr file written by #1849 daemon.
157        // `resolve_data_dir` is infallible in practice; degrade to the lock-file
158        // fallback if the data directory cannot be resolved.
159        if let Ok(dir) = trusty_common::resolve_data_dir("trusty-mpm") {
160            let addr_file = dir.join("http_addr");
161            if addr_file.exists() {
162                // Delegate to the shared helper which does addr-file read,
163                // TCP probe, version fetch, and builds ServiceInfo with url.
164                return detect_service(self.id(), self.display_name(), "trusty-mpm", addr_file);
165            }
166        }
167
168        // Backward-compat fallback: old daemons (pre-#1849) only write the TOML
169        // lock file. Report Running without a URL so the badge is correct, but
170        // the proxy cannot be used until the daemon is restarted with the new
171        // version that writes the http_addr file.
172        if let Ok(body) = std::fs::read_to_string(self.lock_file_path())
173            && let Some(addr) = parse_lock_addr(&body)
174            && tcp_probe(&addr)
175        {
176            return ServiceInfo {
177                id: self.id().to_string(),
178                display_name: self.display_name().to_string(),
179                status: ServiceStatus::Running,
180                version: None,
181                // URL intentionally absent: old daemon does not write http_addr,
182                // so the proxy allowlist cannot resolve a safe upstream URL.
183                url: None,
184                hint: Some(
185                    "daemon is running but pre-dates #1849 — restart to enable proxy".to_string(),
186                ),
187                lifecycle: ServiceLifecycle::Daemon,
188            };
189        }
190
191        ServiceInfo {
192            id: self.id().to_string(),
193            display_name: self.display_name().to_string(),
194            status: ServiceStatus::Available,
195            version: None,
196            url: None,
197            hint: None,
198            // #6416: trusty-mpm is a resident daemon; `Available` means stopped.
199            lifecycle: ServiceLifecycle::Daemon,
200        }
201    }
202}
203
204// ─── tests ────────────────────────────────────────────────────────────────────
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use std::fs;
210    use std::net::TcpListener;
211    use tempfile::TempDir;
212    // #3331: use the shared `detect::ENV_LOCK` so these env-mutating tests
213    // serialise against the sibling agents-connector tests, which also point
214    // `resolve_data_dir` at a tempdir via the same process-global env var.
215    use super::super::ENV_LOCK;
216    use trusty_common::DATA_DIR_OVERRIDE_ENV;
217
218    /// Why: the TCP probe needs a bare host:port; the parser must strip the TOML
219    /// quoting and the `http://` scheme.
220    /// Test: this test.
221    #[test]
222    fn parse_lock_addr_strips_scheme() {
223        let body = "pid = 42\naddr = \"http://127.0.0.1:7880\"\nstarted_at = \"x\"\n";
224        assert_eq!(parse_lock_addr(body).as_deref(), Some("127.0.0.1:7880"));
225    }
226
227    /// Why: a lock file without an addr line must yield None (treated Available).
228    /// Test: this test.
229    #[test]
230    fn parse_lock_addr_none_when_absent() {
231        assert_eq!(parse_lock_addr("pid = 42\n"), None);
232    }
233
234    /// Why: a well-formed `addr = "host:port"` (no scheme) must parse to the bare
235    /// host:port unchanged — the common case for a scheme-less lock value.
236    /// Test: this test.
237    #[test]
238    fn parse_lock_addr_well_formed_no_scheme() {
239        assert_eq!(
240            parse_lock_addr("addr = \"127.0.0.1:9001\"\n").as_deref(),
241            Some("127.0.0.1:9001")
242        );
243    }
244
245    /// Why: the key match must be EXACT — a different key whose name merely starts
246    /// with `addr` (e.g. `addr_extra`) must NOT be mistaken for the `addr` line.
247    /// Test: this test (regression guard for review finding #4).
248    #[test]
249    fn parse_lock_addr_ignores_prefixed_key() {
250        // Only `addr_extra` present — no real `addr` key — must yield None.
251        assert_eq!(
252            parse_lock_addr("addr_extra = \"http://6.6.6.6:6666\"\n"),
253            None
254        );
255    }
256
257    /// Why: when BOTH `addr_extra` and the real `addr` are present, the parser
258    /// must return the value of the EXACT `addr` key, never the prefixed decoy —
259    /// regardless of declaration order.
260    /// Test: this test (regression guard for review finding #4).
261    #[test]
262    fn parse_lock_addr_prefers_exact_key_over_decoy() {
263        let body = "addr_extra = \"http://6.6.6.6:6666\"\naddr = \"http://127.0.0.1:7880\"\n";
264        assert_eq!(parse_lock_addr(body).as_deref(), Some("127.0.0.1:7880"));
265    }
266
267    /// Why: with no binary on PATH the connector must report Absent regardless of
268    /// any stale lock file.
269    /// Test: this test.
270    #[test]
271    fn mpm_connector_absent_binary() {
272        // Only meaningful when the binary is genuinely not installed (CI).
273        if which::which("trusty-mpm").is_ok() {
274            return;
275        }
276        let tmp = TempDir::new().expect("tempdir");
277        let info = MpmConnector::with_home(tmp.path().to_path_buf()).detect();
278        assert_eq!(info.status, ServiceStatus::Absent);
279        assert_eq!(info.id, "trusty-mpm");
280    }
281
282    /// Why: a stale lock pointing at a dead port must yield Available (binary
283    /// present) — never Running — because the TCP probe fails, and there is no
284    /// http_addr file to pick up.
285    /// What: writes a lock with an unlikely port, calls detect(), and asserts the
286    /// status is deterministic given binary presence.
287    /// Test: this test.
288    #[test]
289    fn mpm_connector_parses_lock_addr() {
290        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
291        let tmp = TempDir::new().expect("tempdir");
292        // Override data dir to a path that has NO http_addr file so the
293        // connector falls through to the lock-file path.
294        let data_tmp = TempDir::new().expect("data-tempdir");
295        unsafe {
296            std::env::set_var(DATA_DIR_OVERRIDE_ENV, data_tmp.path());
297        }
298        let lock = tmp.path().join(".trusty-mpm").join("daemon.lock");
299        fs::create_dir_all(lock.parent().expect("parent")).expect("mkdir");
300        fs::write(&lock, "pid = 1\naddr = \"http://127.0.0.1:14998\"\n").expect("write");
301        let info = MpmConnector::with_home(tmp.path().to_path_buf()).detect();
302        unsafe {
303            std::env::remove_var(DATA_DIR_OVERRIDE_ENV);
304        }
305        if which::which("trusty-mpm").is_ok() {
306            // Binary present, no http_addr, dead lock port → Available (not Running).
307            assert_eq!(info.status, ServiceStatus::Available);
308        } else {
309            assert_eq!(info.status, ServiceStatus::Absent);
310        }
311    }
312
313    /// Why: no lock file and no http_addr with the binary present must yield
314    /// Available.
315    /// Test: this test.
316    #[test]
317    fn mpm_connector_no_lock_file() {
318        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
319        let tmp = TempDir::new().expect("tempdir");
320        let data_tmp = TempDir::new().expect("data-tempdir");
321        unsafe {
322            std::env::set_var(DATA_DIR_OVERRIDE_ENV, data_tmp.path());
323        }
324        let info = MpmConnector::with_home(tmp.path().to_path_buf()).detect();
325        unsafe {
326            std::env::remove_var(DATA_DIR_OVERRIDE_ENV);
327        }
328        if which::which("trusty-mpm").is_ok() {
329            assert_eq!(info.status, ServiceStatus::Available);
330        } else {
331            assert_eq!(info.status, ServiceStatus::Absent);
332        }
333    }
334
335    /// Why: the primary path (#1849) must surface `url: Some(base_url)` when the
336    /// http_addr file exists and the port is reachable; this is what the proxy
337    /// handler reads to forward requests.
338    /// What: writes a valid addr to the standard http_addr file under a temp
339    /// TRUSTY_DATA_DIR_OVERRIDE, binds a real listening port so tcp_probe passes,
340    /// calls detect(), and asserts the url and Running status.
341    /// Test: this test (key regression guard for #1849 Phase 1).
342    #[test]
343    fn mpm_connector_surfaces_url_via_http_addr() {
344        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
345        let data_tmp = TempDir::new().expect("data-tempdir");
346        unsafe {
347            std::env::set_var(DATA_DIR_OVERRIDE_ENV, data_tmp.path());
348        }
349        // Write the http_addr file with a listening port so tcp_probe passes.
350        let mpm_dir = data_tmp.path().join("trusty-mpm");
351        fs::create_dir_all(&mpm_dir).expect("mkdir");
352        let listener = TcpListener::bind("127.0.0.1:0").expect("bind free port");
353        let addr = listener.local_addr().expect("local_addr").to_string();
354        fs::write(mpm_dir.join("http_addr"), &addr).expect("write addr");
355
356        let info = MpmConnector::new().detect();
357
358        // Drop listener after detect() so the port is open during the probe.
359        drop(listener);
360        unsafe {
361            std::env::remove_var(DATA_DIR_OVERRIDE_ENV);
362        }
363
364        if which::which("trusty-mpm").is_ok() {
365            assert_eq!(
366                info.status,
367                ServiceStatus::Running,
368                "http_addr present + port open must yield Running, got: {info:?}"
369            );
370            assert_eq!(
371                info.url,
372                Some(format!("http://{addr}")),
373                "Running status must include daemon base URL for proxy routing"
374            );
375        } else {
376            assert_eq!(info.status, ServiceStatus::Absent);
377        }
378    }
379}