use std::path::{Path, PathBuf};
use std::time::Duration;
use crate::connector::{ServiceConnector, ServiceInfo, ServiceLifecycle, ServiceStatus};
use super::helpers::{VersionProbe, binary_on_path, binary_version};
const BINARY: &str = "trusty-analyze";
const HEALTH_TIMEOUT: Duration = Duration::from_secs(3);
const METHOD_HEALTH: &str = "analyze.health";
#[derive(Debug, serde::Deserialize)]
struct HealthEnvelope {
#[allow(dead_code)]
status: String,
version: Option<String>,
}
pub struct AnalyzeConnector {
socket: Option<PathBuf>,
}
impl AnalyzeConnector {
pub fn new() -> Self {
Self { socket: None }
}
pub fn with_socket(socket: PathBuf) -> Self {
Self {
socket: Some(socket),
}
}
fn socket_path(&self) -> Result<PathBuf, String> {
match &self.socket {
Some(p) => Ok(p.clone()),
None => trusty_common::daemon_socket_path("trusty-analyze")
.map_err(|e| format!("could not resolve the trusty-analyze socket path: {e:#}")),
}
}
}
impl Default for AnalyzeConnector {
fn default() -> Self {
Self::new()
}
}
fn probe_health(socket: &Path) -> Option<HealthEnvelope> {
let socket = socket.to_path_buf();
let handle = std::thread::Builder::new()
.name("console-analyze-probe".to_owned())
.spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.ok()?;
rt.block_on(async {
let request = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": METHOD_HEALTH,
});
let response: trusty_common::uds::server::RpcResponse =
trusty_common::uds::send_framed_request(&socket, &request, HEALTH_TIMEOUT)
.await
.ok()?;
serde_json::from_value::<HealthEnvelope>(response.result?).ok()
})
})
.ok()?;
handle.join().ok()?
}
impl ServiceConnector for AnalyzeConnector {
fn id(&self) -> &'static str {
"trusty-analyze"
}
fn display_name(&self) -> &'static str {
"Trusty Analyze"
}
fn lifecycle(&self) -> ServiceLifecycle {
ServiceLifecycle::OnDemand
}
fn detect(&self) -> ServiceInfo {
self.detect_from(self.socket_path())
}
}
impl AnalyzeConnector {
fn detect_from(&self, socket: Result<PathBuf, String>) -> ServiceInfo {
let base =
|status: ServiceStatus, version: Option<String>, hint: Option<String>| ServiceInfo {
id: self.id().to_string(),
display_name: self.display_name().to_string(),
status,
version,
url: None,
hint,
lifecycle: self.lifecycle(),
};
if !binary_on_path(BINARY) {
return base(ServiceStatus::Absent, None, None);
}
let (socket_hint, dialled) = match socket {
Ok(path) => (None, probe_health(&path)),
Err(reason) => (Some(reason), None),
};
if let Some(health) = dialled {
return base(ServiceStatus::Running, health.version, None);
}
match binary_version(BINARY) {
VersionProbe::Ran(version) => base(ServiceStatus::Available, version, socket_hint),
VersionProbe::CannotExecute(why) => base(
ServiceStatus::Degraded,
None,
Some(format!(
"{BINARY} is on PATH but did not run: {why}. Reinstall it \
with `cargo install {BINARY}`."
)),
),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn analyze_connector_reports_available_when_nothing_is_serving() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let connector = AnalyzeConnector::with_socket(tmp.path().join("absent.sock"));
let info = connector.detect();
let expected = if which::which("trusty-analyze").is_ok() {
ServiceStatus::Available
} else {
ServiceStatus::Absent
};
assert_eq!(info.status, expected);
assert_eq!(info.id, "trusty-analyze");
assert_eq!(info.display_name, "Trusty Analyze");
assert!(info.url.is_none(), "a UDS daemon has no URL to render");
assert!(
info.status != ServiceStatus::Absent || info.version.is_none(),
"Absent must have no version"
);
}
#[test]
fn analyze_connector_surfaces_an_unresolvable_socket_path_as_a_hint() {
if which::which("trusty-analyze").is_err() {
eprintln!("skip: trusty-analyze is not on PATH, so detect() short-circuits to Absent");
return;
}
let info = AnalyzeConnector::new().detect_from(Err(
"could not resolve the trusty-analyze socket path: nope".to_string(),
));
assert_eq!(
info.status,
ServiceStatus::Available,
"nothing was observed, so the verdict must not claim more than that"
);
let hint = info.hint.expect("an unresolvable path must explain itself");
assert!(
hint.contains("socket path"),
"the hint must name what could not be resolved: {hint}"
);
}
#[test]
fn analyze_connector_attaches_no_hint_when_the_path_resolves() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let info = AnalyzeConnector::new().detect_from(Ok(tmp.path().join("absent.sock")));
assert!(
info.hint.is_none(),
"a resolvable path must not carry a remediation hint: {:?}",
info.hint
);
}
#[tokio::test(flavor = "multi_thread")]
async fn analyze_connector_reads_the_version_off_a_live_socket() {
if which::which("trusty-analyze").is_err() {
eprintln!("skip: trusty-analyze is not on PATH, so detect() short-circuits to Absent");
return;
}
let tmp = tempfile::TempDir::new().expect("tempdir");
let socket = tmp.path().join("sockets").join("analyze.sock");
let listener = trusty_common::uds::bind_hardened(&socket).expect("bind");
tokio::spawn(async move {
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
let Ok((mut conn, _)) = listener.accept().await else {
return;
};
let mut sink = Vec::new();
let _ = conn.read_to_end(&mut sink).await;
let reply =
br#"{"jsonrpc":"2.0","id":1,"result":{"status":"ok","version":"9.9.9","search_reachable":true}}"#;
let _ = conn.write_all(reply).await;
let _ = conn.write_all(b"\n").await;
let _ = conn.flush().await;
});
let connector = AnalyzeConnector::with_socket(socket);
let info = tokio::task::spawn_blocking(move || connector.detect())
.await
.expect("detect");
assert_eq!(info.status, ServiceStatus::Running);
assert_eq!(info.version.as_deref(), Some("9.9.9"));
}
#[test]
fn detect_never_starts_a_server() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let socket = tmp.path().join("must-stay-absent.sock");
let info = AnalyzeConnector::with_socket(socket.clone()).detect();
assert_ne!(
info.status,
ServiceStatus::Running,
"nothing was serving that path, so no verdict may claim it was"
);
assert!(
!socket.exists(),
"detect must observe, never start: {} was created",
socket.display()
);
}
#[test]
fn analyze_reports_an_on_demand_lifecycle_on_every_verdict() {
let tmp = tempfile::TempDir::new().expect("tempdir");
for socket in [Ok(tmp.path().join("absent.sock")), Err("nope".to_string())] {
let payload =
serde_json::to_value(AnalyzeConnector::new().detect_from(socket)).expect("json");
assert_eq!(
payload.get("lifecycle"),
Some(&serde_json::json!("on_demand")),
"the card branches on this key: {payload}"
);
}
}
#[test]
fn analyze_reads_a_version_off_the_binary_when_nothing_is_serving() {
if which::which(BINARY).is_err() {
eprintln!("skip: trusty-analyze is not on PATH, so detect() short-circuits to Absent");
return;
}
let tmp = tempfile::TempDir::new().expect("tempdir");
let info = AnalyzeConnector::new().detect_from(Ok(tmp.path().join("absent.sock")));
assert_eq!(info.status, ServiceStatus::Available);
assert!(
info.version.is_some(),
"an installed on-demand member renders the version it prints"
);
}
}