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}