use std::fs;
use std::io::Write as _;
use std::path::Path;
use anyhow::{Context as _, Result};
use openmoat_audit::{Event, Store};
use openmoat_core::DEFAULT_POLICY;
use openmoat_hosts::Host;
use serde::Serialize;
use serde_json::Value;
use super::json::Row;
use crate::exit::Code;
use crate::home::Home;
use crate::install::{HookState, HostConfig};
use crate::protection::{self, Level};
mod os;
const NOTICE: &str = "Written by `openmoat doctor --bundle` on this machine; nothing was sent. \
Recent decisions keep only time, host, tool name, verdict and rule ids: no commands, \
paths, arguments, URLs or file contents. The home directory reads `~`. Read the file \
before you attach it to an issue.";
struct Facts<'a> {
policy: PolicyFacts,
agents: Vec<Agent>,
doctor: &'a [Row],
audit: AuditFacts,
}
#[derive(Serialize)]
struct Bundle<'a> {
notice: &'static str,
moat: Build,
system: os::System,
policy: PolicyFacts,
agents: Vec<Agent>,
doctor: &'a [Row],
audit: AuditFacts,
}
#[derive(Serialize)]
struct Build {
version: &'static str,
target: String,
profile: &'static str,
}
#[derive(Debug, Default, Serialize)]
struct PolicyFacts {
loads: bool,
error: Option<String>,
version: Option<u32>,
lint_warnings: Vec<String>,
matches_default: Option<bool>,
default_changes: Option<String>,
}
#[derive(Debug, Serialize)]
struct Agent {
host: &'static str,
hook: &'static str,
protection: Level,
reason: Option<String>,
}
#[derive(Debug, Default, Serialize)]
struct AuditFacts {
error: Option<String>,
recent_decisions: Vec<Decision>,
}
#[derive(Debug, Serialize)]
struct Decision {
time: String,
host: String,
tool: String,
verdict: &'static str,
rules: Vec<String>,
}
impl From<&Event> for Decision {
fn from(event: &Event) -> Self {
Self {
time: crate::time::timestamp(event.ts_ms),
host: event.host.clone(),
tool: event.tool.clone(),
verdict: event.verdict.as_str(),
rules: event.rules.clone(),
}
}
}
pub(super) fn write(
home: &Home,
binary: &Path,
rows: &[Row],
path: &Path,
events: usize,
) -> Result<Code> {
crate::terminal::refuse_agent("openmoat doctor --bundle")?;
let facts = Facts {
policy: policy_facts(home),
agents: agents(home, binary)?,
doctor: rows,
audit: audit_facts(home, events),
};
let user_home = crate::home::user_home().ok();
let mut text = serde_json::to_string_pretty(&document(facts, user_home.as_deref())?)?;
text.push('\n');
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
.with_context(|| {
format!(
"creating {}; name a file that does not exist yet",
path.display()
)
})?;
file.write_all(text.as_bytes())
.with_context(|| format!("writing {}", path.display()))?;
let mut out = std::io::stdout().lock();
writeln!(out, "wrote {}", path.display())?;
writeln!(
out,
"Nothing was sent. Read the file before you attach it to an issue: it lists your \
agents, the doctor checks, policy findings and the last {events} decisions \
(time, host, tool name, verdict and rule ids only)."
)?;
Ok(Code::Ok)
}
fn document(facts: Facts<'_>, user_home: Option<&Path>) -> Result<Value> {
let bundle = Bundle {
notice: NOTICE,
moat: Build {
version: env!("CARGO_PKG_VERSION"),
target: format!("{}-{}", std::env::consts::ARCH, std::env::consts::OS),
profile: if cfg!(debug_assertions) {
"debug"
} else {
"release"
},
},
system: os::System::this(),
policy: facts.policy,
agents: facts.agents,
doctor: facts.doctor,
audit: facts.audit,
};
let value = openmoat_audit::redact_value(serde_json::to_value(&bundle)?);
let homes = home_spellings(user_home);
Ok(map_strings(value, &|text| {
homes.iter().fold(text, |text, home| tilde(&text, home))
}))
}
fn home_spellings(user_home: Option<&Path>) -> Vec<String> {
let Some(home) =
user_home.map(|h| h.to_string_lossy().trim_end_matches(['/', '\\']).to_owned())
else {
return Vec::new();
};
if home.is_empty() {
return Vec::new();
}
let slashed = home.replace('\\', "/");
if slashed == home {
vec![home]
} else {
vec![home, slashed]
}
}
fn tilde(text: &str, home: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(at) = rest.find(home) {
let after = &rest[at + home.len()..];
let whole = after
.chars()
.next()
.is_none_or(|c| !(c.is_alphanumeric() || matches!(c, '-' | '_' | '.')));
out.push_str(&rest[..at]);
out.push_str(if whole { "~" } else { home });
rest = after;
}
out.push_str(rest);
out
}
fn map_strings(value: Value, f: &impl Fn(String) -> String) -> Value {
match value {
Value::String(text) => Value::String(f(text)),
Value::Array(items) => Value::Array(items.into_iter().map(|v| map_strings(v, f)).collect()),
Value::Object(map) => Value::Object(
map.into_iter()
.map(|(key, v)| (key, map_strings(v, f)))
.collect(),
),
other => other,
}
}
fn policy_facts(home: &Home) -> PolicyFacts {
match home.load_policy() {
Ok(policy) => PolicyFacts {
loads: true,
error: None,
version: Some(policy.version),
lint_warnings: openmoat_core::lint::warnings(&policy)
.iter()
.map(ToString::to_string)
.collect(),
matches_default: fs::read_to_string(home.policy_path())
.ok()
.map(|text| text.replace("\r\n", "\n") == DEFAULT_POLICY),
default_changes: crate::upgrade::available(home),
},
Err(error) => PolicyFacts {
error: Some(format!("{error:#}")),
..PolicyFacts::default()
},
}
}
fn agents(home: &Home, binary: &Path) -> Result<Vec<Agent>> {
protection::report(home, binary)?
.into_iter()
.map(|agent| {
let host: Host = agent.host.parse()?;
let config = HostConfig::for_host(home, host)?;
let hook = match config.state(binary) {
HookState::Installed => "installed",
HookState::Missing if !config.host_present() => "host not found",
HookState::Missing => "missing",
HookState::Stale { .. } => "out of date",
HookState::Unreadable(_) => "unreadable",
};
Ok(Agent {
host: agent.host,
hook,
protection: agent.level,
reason: agent.reason,
})
})
.collect()
}
fn audit_facts(home: &Home, events: usize) -> AuditFacts {
match Store::open_read_only(&home.audit_path()).and_then(|store| store.recent(events)) {
Ok(recent) => AuditFacts {
error: None,
recent_decisions: recent.iter().map(Decision::from).collect(),
},
Err(error) => AuditFacts {
error: Some(error.to_string()),
recent_decisions: Vec::new(),
},
}
}
#[cfg(test)]
mod tests;