use std::io::Write;
use anyhow::{anyhow, bail};
use serde_json::Value;
use trusty_secrets::BackendId;
use trusty_secrets::server::{BackendStatus, ClientError, DOCTOR, DoctorResponse, StoragePosture};
use super::Ctx;
use super::session::{describe, is_project_refusal};
pub(super) async fn doctor(ctx: &Ctx<'_>, out: &mut dyn Write) -> anyhow::Result<()> {
let (report, project) = ask(ctx, out).await?;
render(&report, out)?;
verdict(&report, project, ctx.ci)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Judged {
Project,
MachineDefault,
}
pub(super) fn is_ci(value: Option<&str>) -> bool {
value.is_some_and(|v| v.eq_ignore_ascii_case("true") || v == "1")
}
async fn ask(ctx: &Ctx<'_>, out: &mut dyn Write) -> anyhow::Result<(DoctorResponse, Judged)> {
let socket = ctx.client.socket();
let mut judged = Judged::Project;
let mut answer = ctx.call_raw(DOCTOR, Value::Null).await?;
if let Err(e) = &answer
&& is_project_refusal(e)
{
writeln!(out, "project: {}", describe(e, socket))?;
judged = Judged::MachineDefault;
answer = ctx.client.call(DOCTOR, Value::Null).await;
}
let report = match answer {
Ok(report) => report,
Err(e) => {
let state = match e {
ClientError::Rpc(_) => "reachable",
_ => "unreachable",
};
writeln!(out, "socket {}: {state}", socket.display())?;
bail!(describe(&e, socket));
}
};
let report = serde_json::from_value(report)
.map_err(|_| anyhow!("tm secrets doctor: the answer did not decode"))?;
Ok((report, judged))
}
pub(super) fn render(report: &DoctorResponse, out: &mut dyn Write) -> anyhow::Result<()> {
writeln!(out, "socket {}: reachable", report.socket.display())?;
writeln!(out, "index: {}", report.index_root.display())?;
writeln!(out, "machine config: {}", report.machine_config.display())?;
if let Some(account) = &report.account_config {
writeln!(out, "account machine config: {}", account.display())?;
}
if let Some(root) = &report.project_root {
writeln!(out, "project: {}", root.display())?;
}
writeln!(out, "selected backend: {}", report.selected_backend)?;
if let Some(posture) = report.posture {
writeln!(out, "posture: {}", posture_text(posture))?;
}
let token = report.headless.map(|h| h.onepassword_token);
for backend in &report.backends {
writeln!(out, "backend {}: {}", backend.id, row_text(backend, token))?;
}
if let Some(token) = token {
let present = if token { "yes" } else { "no" };
writeln!(
out,
"headless: 1Password service-account token at server start: {present}"
)?;
}
let mut absent = Vec::new();
for tool in &report.tools {
match &tool.path {
Some(path) if tool.installed => writeln!(
out,
"tool {} ({}): installed at {}; {}",
tool.id,
tool.program,
path.display(),
if tool.supported {
"supported"
} else {
"no trusty-secrets backend"
}
)?,
_ => absent.push(tool.program.as_str()),
}
}
if !absent.is_empty() {
writeln!(out, "tools not installed: {}", absent.join(", "))?;
}
Ok(())
}
fn posture_text(posture: StoragePosture) -> &'static str {
match posture {
StoragePosture::Keychain => "keychain",
StoragePosture::FileDegraded => "file_degraded (values are plaintext 0600 files)",
_ => "other",
}
}
fn row_text(backend: &BackendStatus, onepassword_token: Option<bool>) -> String {
if !backend.available {
return match (backend.reason, &backend.detail) {
(Some(reason), Some(detail)) => format!("unavailable ({}): {detail}", reason.as_str()),
(Some(reason), None) => format!("unavailable ({})", reason.as_str()),
_ => "unavailable".to_owned(),
};
}
let mut text = format!("available [{}]", backend.capabilities.join(", "));
if backend.id == BackendId::onepassword() && onepassword_token == Some(false) {
text.push_str(
"; no service-account token, so it needs an unlocked 1Password app or an \
`op signin` session",
);
}
text
}
pub(super) fn verdict(report: &DoctorResponse, judged: Judged, ci: bool) -> anyhow::Result<()> {
let selected = report
.backends
.iter()
.any(|b| b.available && b.id == report.selected_backend);
if !selected {
bail!(
"tm secrets doctor: the selected backend `{}` is unavailable",
report.selected_backend
);
}
if judged == Judged::MachineDefault {
bail!(
"tm secrets doctor: the project was refused, so the report above is the machine \
default's; fix the `project:` line above"
);
}
let token = report.headless.is_some_and(|h| h.onepassword_token);
if ci && report.selected_backend == BackendId::onepassword() && !token {
bail!(
"tm secrets doctor: CI=true and the selected backend `onepassword` has no headless \
credential: OP_SERVICE_ACCOUNT_TOKEN was not set where the secrets server started"
);
}
Ok(())
}