use serde::{Deserialize, Serialize};
use std::fs::{self, OpenOptions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
pub const SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Health {
#[default]
Ok,
Watch,
Warning,
Urgent,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Metric {
pub label: String,
pub value: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unit: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Event {
pub time: String,
pub severity: String,
pub text: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Action {
pub label: String,
pub argv: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolStatus {
pub schema_version: u32,
pub tool: String,
pub version: String,
pub host: String,
pub updated_at: String,
pub health: Health,
pub summary: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub metrics: Vec<Metric>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub events: Vec<Event>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub actions: Vec<Action>,
}
impl Default for ToolStatus {
fn default() -> Self {
Self {
schema_version: SCHEMA_VERSION,
tool: "cybercore-tool".to_string(),
version: "0.1.0".to_string(),
host: hostname(),
updated_at: now_rfc3339(),
health: Health::Ok,
summary: "Operational".to_string(),
metrics: Vec::new(),
events: Vec::new(),
actions: Vec::new(),
}
}
}
#[must_use]
pub fn hostname() -> String {
hostname::get()
.map(|h| h.to_string_lossy().into_owned())
.unwrap_or_else(|_| "localhost".to_string())
}
#[must_use]
pub fn now_rfc3339() -> String {
chrono::Utc::now().to_rfc3339()
}
pub fn validate_tool_name(tool_name: &str) -> io::Result<()> {
let ok = !tool_name.is_empty()
&& tool_name.len() <= 64
&& !tool_name.starts_with('-')
&& tool_name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_');
if ok {
Ok(())
} else {
Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid cybercore tool name {tool_name:?}: use letters, digits, '-' or '_'"),
))
}
}
pub fn dir() -> io::Result<PathBuf> {
let non_empty = |key: &str| std::env::var_os(key).filter(|v| !v.is_empty());
if let Some(runtime) = non_empty("XDG_RUNTIME_DIR") {
return Ok(PathBuf::from(runtime).join("cybercore"));
}
if let Some(home) = non_empty("HOME") {
return Ok(PathBuf::from(home).join(".local/state/cybercore/status"));
}
Err(io::Error::new(
io::ErrorKind::NotFound,
"neither XDG_RUNTIME_DIR nor HOME is set; cannot locate the cybercore status directory",
))
}
pub fn path_in(dir: &Path, tool_name: &str) -> io::Result<PathBuf> {
validate_tool_name(tool_name)?;
Ok(dir.join(format!("{tool_name}.json")))
}
pub fn path(tool_name: &str) -> io::Result<PathBuf> {
path_in(&dir()?, tool_name)
}
pub fn write(status: &ToolStatus) -> io::Result<PathBuf> {
write_in(&dir()?, status)
}
pub fn write_in(dir: &Path, status: &ToolStatus) -> io::Result<PathBuf> {
let destination = path_in(dir, &status.tool)?;
create_private_dir(dir)?;
let mut json = serde_json::to_vec_pretty(status).map_err(io::Error::other)?;
json.push(b'\n');
let temporary = dir.join(format!(".{}.{}.tmp", status.tool, unique_suffix()));
let result = (|| {
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options.open(&temporary)?;
file.write_all(&json)?;
file.sync_all()?;
fs::rename(&temporary, &destination)
})();
if let Err(error) = result {
let _ = fs::remove_file(&temporary);
return Err(io::Error::new(
error.kind(),
format!("publishing status {}: {error}", destination.display()),
));
}
Ok(destination)
}
pub fn read(path: &Path) -> io::Result<ToolStatus> {
let bytes = fs::read(path)?;
serde_json::from_slice(&bytes).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
}
fn create_private_dir(dir: &Path) -> io::Result<()> {
let mut builder = fs::DirBuilder::new();
builder.recursive(true);
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
builder.mode(0o700);
}
builder.create(dir)
}
fn unique_suffix() -> String {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
format!(
"{}.{nanos}.{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed)
)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample(tool: &str) -> ToolStatus {
ToolStatus {
tool: tool.to_string(),
version: "0.6.0".to_string(),
host: "blackbox".to_string(),
updated_at: "2026-09-27T19:00:00Z".to_string(),
summary: "All systems nominal".to_string(),
metrics: vec![Metric {
label: "Score".to_string(),
value: "100".to_string(),
unit: Some("pts".to_string()),
}],
..ToolStatus::default()
}
}
fn scratch_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"cybercore-status-test-{}-{name}-{}",
std::process::id(),
unique_suffix()
));
let _ = fs::remove_dir_all(&dir);
dir
}
#[test]
fn serializes_to_the_schema_shape() {
let json = serde_json::to_value(sample("omniscient")).unwrap();
assert_eq!(json["schema_version"], SCHEMA_VERSION);
assert_eq!(json["tool"], "omniscient");
assert_eq!(json["health"], "ok");
assert_eq!(json["metrics"][0]["label"], "Score");
assert!(json.get("events").is_none(), "empty lists are omitted");
}
#[test]
fn accepts_plain_tool_names() {
for name in ["omniscient", "cyber-vault", "sentry_grid", "a1"] {
assert!(validate_tool_name(name).is_ok(), "{name}");
}
}
#[test]
fn rejects_names_that_could_escape_the_directory() {
for name in [
"",
"../x",
"a/b",
"a\\b",
".hidden",
"-flag",
"x.json",
&"a".repeat(65),
] {
let err = validate_tool_name(name).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidInput, "{name:?}");
}
}
#[test]
fn write_then_read_round_trips() {
let dir = scratch_dir("roundtrip");
let status = sample("roundtrip");
let path = write_in(&dir, &status).unwrap();
assert_eq!(path, dir.join("roundtrip.json"));
assert_eq!(read(&path).unwrap(), status);
let leftovers: Vec<_> = fs::read_dir(&dir)
.unwrap()
.filter_map(Result::ok)
.filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
.collect();
assert!(leftovers.is_empty(), "temporary files are cleaned up");
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn rewrite_replaces_the_previous_snapshot() {
let dir = scratch_dir("rewrite");
let mut status = sample("rewrite");
write_in(&dir, &status).unwrap();
status.health = Health::Urgent;
status.summary = "Disk almost full".to_string();
let path = write_in(&dir, &status).unwrap();
let back = read(&path).unwrap();
assert_eq!(back.health, Health::Urgent);
assert_eq!(back.summary, "Disk almost full");
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn invalid_tool_name_writes_nothing() {
let dir = scratch_dir("invalid");
let err = write_in(&dir, &sample("../escape")).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
assert!(!dir.exists(), "no directory or file is created");
}
#[cfg(unix)]
#[test]
fn status_directory_and_file_are_private() {
use std::os::unix::fs::PermissionsExt;
let dir = scratch_dir("private");
let path = write_in(&dir, &sample("private")).unwrap();
let dir_mode = fs::metadata(&dir).unwrap().permissions().mode() & 0o777;
let file_mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(dir_mode, 0o700);
assert_eq!(file_mode, 0o600);
fs::remove_dir_all(&dir).unwrap();
}
}