trusty_console/detect/mpm.rs
1//! `ServiceConnector` implementation for `trusty-mpm` (#1222).
2//!
3//! Why: the console's Overview must show trusty-mpm alongside the other services,
4//! and the Sessions tab depends on the trusty-mpm daemon being reachable. Unlike
5//! the other services (which write a plain `http_addr` file), the trusty-mpm
6//! daemon records its bound address in a TOML lock file at
7//! `~/.trusty-mpm/daemon.lock` (`addr = "http://127.0.0.1:<port>"`). This
8//! connector parses that lock file and TCP-probes the port.
9//! What: `MpmConnector` implements `ServiceConnector::detect()`: binary check →
10//! parse `daemon.lock` `addr` → TCP probe → `Running`/`Available`/`Absent`. The
11//! daemon's HTTP is internal plumbing (#1104); the console never calls it
12//! directly — the connector only probes liveness for the status badge.
13//! Test: `mpm_connector_absent_binary`, `mpm_connector_parses_lock_addr`,
14//! `mpm_connector_no_lock_file` below.
15
16use std::path::PathBuf;
17
18use crate::connector::{ServiceConnector, ServiceInfo, ServiceStatus};
19
20use super::helpers::{binary_on_path, tcp_probe};
21
22/// ServiceConnector for `trusty-mpm`.
23///
24/// Why: trusty-mpm stores its daemon lock under `~/.trusty-mpm/daemon.lock`. The
25/// `addr` line there gives the exact loopback address the daemon bound (the port
26/// is dynamic/auto). When present and reachable the service is `Running`.
27/// What: implements `detect()` parsing the lock file's `addr` and TCP-probing it.
28/// Test: `mpm_connector_parses_lock_addr`, `mpm_connector_no_lock_file`.
29pub struct MpmConnector {
30 /// Override for the home directory (used in tests).
31 home_dir: Option<PathBuf>,
32}
33
34impl MpmConnector {
35 /// Create a new `MpmConnector`.
36 ///
37 /// Why: production callers use `new()`; tests use `with_home()`.
38 /// What: stores no state except the optional home override.
39 /// Test: created in `all_connectors()` and in unit tests.
40 pub fn new() -> Self {
41 Self { home_dir: None }
42 }
43
44 /// Create a connector that uses `home_dir` instead of the real home.
45 ///
46 /// Why: unit tests must not read the real user's `~/.trusty-mpm`.
47 /// What: stores `home_dir` for use in `lock_file_path()`.
48 /// Test: `mpm_connector_parses_lock_addr`, `mpm_connector_no_lock_file`.
49 #[cfg(test)]
50 pub fn with_home(home_dir: PathBuf) -> Self {
51 Self {
52 home_dir: Some(home_dir),
53 }
54 }
55
56 fn lock_file_path(&self) -> PathBuf {
57 let home = self
58 .home_dir
59 .clone()
60 .or_else(dirs::home_dir)
61 .unwrap_or_else(|| PathBuf::from("/tmp"));
62 home.join(".trusty-mpm").join("daemon.lock")
63 }
64}
65
66impl Default for MpmConnector {
67 fn default() -> Self {
68 Self::new()
69 }
70}
71
72/// Extract the host:port from a trusty-mpm `daemon.lock` TOML body.
73///
74/// Why: the lock file is TOML with `addr = "http://127.0.0.1:<port>"`; the TCP
75/// probe needs a bare `host:port` with no scheme. A tiny line scan avoids
76/// pulling a TOML dependency into the console for one field.
77/// What: splits each line on the FIRST `=` into key/value, matches the key
78/// EXACTLY against `addr` (so `addr_extra` is rejected), consumes exactly one
79/// `=`, strips the quotes and any `http(s)://` scheme, and returns the
80/// `host:port`. Returns `None` when absent/malformed.
81///
82/// The exact-key match and single-`=` split are deliberate (review finding #4):
83/// the previous `strip_prefix("addr")` + `trim_start_matches([' ', '='])` matched
84/// `addr_extra = "…"` and stripped ALL leading spaces/`=`, which could yield a
85/// garbage address. Splitting on the first `=` and comparing the trimmed key for
86/// equality fixes both issues.
87/// Test: `parse_lock_addr_strips_scheme`, `parse_lock_addr_none_when_absent`,
88/// `parse_lock_addr_well_formed_no_scheme`, `parse_lock_addr_ignores_prefixed_key`,
89/// `parse_lock_addr_prefers_exact_key_over_decoy`.
90fn parse_lock_addr(body: &str) -> Option<String> {
91 for line in body.lines() {
92 // Split on the FIRST `=` only; a value like an IPv6 host:port has no `=`
93 // but this keeps any stray `=` inside the quoted value intact.
94 let Some((key, value)) = line.split_once('=') else {
95 continue;
96 };
97 // Exact key match — `addr_extra`, `addr2`, etc. must NOT match.
98 if key.trim() != "addr" {
99 continue;
100 }
101 let unquoted = value.trim().trim_matches('"');
102 let host_port = unquoted
103 .strip_prefix("http://")
104 .or_else(|| unquoted.strip_prefix("https://"))
105 .unwrap_or(unquoted);
106 if !host_port.is_empty() {
107 return Some(host_port.to_string());
108 }
109 }
110 None
111}
112
113impl ServiceConnector for MpmConnector {
114 fn id(&self) -> &'static str {
115 "trusty-mpm"
116 }
117
118 fn display_name(&self) -> &'static str {
119 "Trusty MPM"
120 }
121
122 /// Detect trusty-mpm status.
123 ///
124 /// Why: reads `~/.trusty-mpm/daemon.lock` (TOML) — the file the daemon writes
125 /// after binding its (dynamic) port. The console only probes liveness; it
126 /// never calls the daemon HTTP directly (#1104).
127 /// What: binary check → parse lock `addr` → TCP probe → status. No discovery
128 /// file (or unreachable) with the binary present yields `Available`.
129 /// Test: `mpm_connector_parses_lock_addr`, `mpm_connector_no_lock_file`.
130 fn detect(&self) -> ServiceInfo {
131 if !binary_on_path("trusty-mpm") {
132 return ServiceInfo {
133 id: self.id().to_string(),
134 display_name: self.display_name().to_string(),
135 status: ServiceStatus::Absent,
136 version: None,
137 url: None,
138 hint: None,
139 };
140 }
141
142 if let Ok(body) = std::fs::read_to_string(self.lock_file_path())
143 && let Some(addr) = parse_lock_addr(&body)
144 && tcp_probe(&addr)
145 {
146 return ServiceInfo {
147 id: self.id().to_string(),
148 display_name: self.display_name().to_string(),
149 status: ServiceStatus::Running,
150 // The daemon HTTP is internal plumbing; do not surface a URL the
151 // operator might call directly (use the console's session routes).
152 version: None,
153 url: None,
154 hint: None,
155 };
156 }
157
158 ServiceInfo {
159 id: self.id().to_string(),
160 display_name: self.display_name().to_string(),
161 status: ServiceStatus::Available,
162 version: None,
163 url: None,
164 hint: None,
165 }
166 }
167}
168
169// ─── tests ────────────────────────────────────────────────────────────────────
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174 use std::fs;
175 use tempfile::TempDir;
176
177 /// Why: the TCP probe needs a bare host:port; the parser must strip the TOML
178 /// quoting and the `http://` scheme.
179 /// Test: this test.
180 #[test]
181 fn parse_lock_addr_strips_scheme() {
182 let body = "pid = 42\naddr = \"http://127.0.0.1:7880\"\nstarted_at = \"x\"\n";
183 assert_eq!(parse_lock_addr(body).as_deref(), Some("127.0.0.1:7880"));
184 }
185
186 /// Why: a lock file without an addr line must yield None (treated Available).
187 /// Test: this test.
188 #[test]
189 fn parse_lock_addr_none_when_absent() {
190 assert_eq!(parse_lock_addr("pid = 42\n"), None);
191 }
192
193 /// Why: a well-formed `addr = "host:port"` (no scheme) must parse to the bare
194 /// host:port unchanged — the common case for a scheme-less lock value.
195 /// Test: this test.
196 #[test]
197 fn parse_lock_addr_well_formed_no_scheme() {
198 assert_eq!(
199 parse_lock_addr("addr = \"127.0.0.1:9001\"\n").as_deref(),
200 Some("127.0.0.1:9001")
201 );
202 }
203
204 /// Why: the key match must be EXACT — a different key whose name merely starts
205 /// with `addr` (e.g. `addr_extra`) must NOT be mistaken for the `addr` line.
206 /// The old `strip_prefix("addr")` matched `addr_extra` and, after stripping
207 /// the leading ` _extra =` punctuation loosely, could have yielded a garbage
208 /// address. Here the lone non-`addr` key must parse to `None`.
209 /// Test: this test (regression guard for review finding #4).
210 #[test]
211 fn parse_lock_addr_ignores_prefixed_key() {
212 // Only `addr_extra` present — no real `addr` key — must yield None.
213 assert_eq!(
214 parse_lock_addr("addr_extra = \"http://6.6.6.6:6666\"\n"),
215 None
216 );
217 }
218
219 /// Why: when BOTH `addr_extra` and the real `addr` are present, the parser
220 /// must return the value of the EXACT `addr` key, never the prefixed decoy —
221 /// regardless of declaration order.
222 /// Test: this test (regression guard for review finding #4).
223 #[test]
224 fn parse_lock_addr_prefers_exact_key_over_decoy() {
225 let body = "addr_extra = \"http://6.6.6.6:6666\"\naddr = \"http://127.0.0.1:7880\"\n";
226 assert_eq!(parse_lock_addr(body).as_deref(), Some("127.0.0.1:7880"));
227 }
228
229 /// Why: with no binary on PATH the connector must report Absent regardless of
230 /// any stray lock file.
231 /// Test: this test.
232 #[test]
233 fn mpm_connector_absent_binary() {
234 // Only meaningful when the binary is genuinely not installed (CI).
235 if which::which("trusty-mpm").is_ok() {
236 return;
237 }
238 let tmp = TempDir::new().expect("tempdir");
239 let info = MpmConnector::with_home(tmp.path().to_path_buf()).detect();
240 assert_eq!(info.status, ServiceStatus::Absent);
241 assert_eq!(info.id, "trusty-mpm");
242 }
243
244 /// Why: a stale lock pointing at a dead port must yield Available (binary
245 /// present) — never Running — because the TCP probe fails.
246 /// What: writes a lock with an unlikely port, calls detect(), and asserts the
247 /// status is deterministic given binary presence.
248 /// Test: this test.
249 #[test]
250 fn mpm_connector_parses_lock_addr() {
251 let tmp = TempDir::new().expect("tempdir");
252 let lock = tmp.path().join(".trusty-mpm").join("daemon.lock");
253 fs::create_dir_all(lock.parent().expect("parent")).expect("mkdir");
254 fs::write(&lock, "pid = 1\naddr = \"http://127.0.0.1:14998\"\n").expect("write");
255 let info = MpmConnector::with_home(tmp.path().to_path_buf()).detect();
256 if which::which("trusty-mpm").is_ok() {
257 // Binary present, dead port → Available (not Running).
258 assert_eq!(info.status, ServiceStatus::Available);
259 } else {
260 assert_eq!(info.status, ServiceStatus::Absent);
261 }
262 }
263
264 /// Why: no lock file with the binary present must yield Available.
265 /// Test: this test.
266 #[test]
267 fn mpm_connector_no_lock_file() {
268 let tmp = TempDir::new().expect("tempdir");
269 let info = MpmConnector::with_home(tmp.path().to_path_buf()).detect();
270 if which::which("trusty-mpm").is_ok() {
271 assert_eq!(info.status, ServiceStatus::Available);
272 } else {
273 assert_eq!(info.status, ServiceStatus::Absent);
274 }
275 }
276}