use serde::{de::DeserializeOwned, Deserialize, Serialize};
use std::collections::BTreeMap;
use std::io::Write;
use std::path::Path;
pub const AGENT_FILE: &str = "agent.json";
pub const STATE_FILE: &str = "state.json";
pub const CHILDREN_FILE: &str = "children.json";
pub const LOG_FILE: &str = "agent.log";
pub const WORKER_LOG_DIR: &str = "workers";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AgentFile {
pub v: u32,
pub port: u16,
pub token: String,
pub pid: u32,
pub version: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DesiredState {
pub sharing: bool,
pub workers: u32,
#[serde(default)]
pub known_worker_ids: Vec<i64>,
}
impl Default for DesiredState {
fn default() -> Self {
Self {
sharing: false,
workers: 1,
known_worker_ids: vec![],
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ChildRecord {
pub pid: u32,
pub started_at: String,
#[serde(default)]
pub port: Option<u16>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Children {
#[serde(default)]
pub broker: Option<ChildRecord>,
#[serde(default)]
pub workers: BTreeMap<String, ChildRecord>,
}
pub fn ensure_dir(dir: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dir)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
}
Ok(())
}
pub fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
let tmp = path.with_extension("tmp");
{
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut f = opts.open(&tmp)?;
f.write_all(bytes)?;
f.sync_all()?;
}
std::fs::rename(&tmp, path)
}
pub fn load_json<T: DeserializeOwned>(path: &Path) -> Option<T> {
serde_json::from_slice(&std::fs::read(path).ok()?).ok()
}
pub fn save_json<T: Serialize>(path: &Path, value: &T) -> std::io::Result<()> {
let bytes = serde_json::to_vec_pretty(value).map_err(std::io::Error::other)?;
write_private(path, &bytes)
}
pub fn new_token() -> String {
use base64::Engine;
use ring::rand::SecureRandom;
let mut buf = [0u8; 32];
ring::rand::SystemRandom::new()
.fill(&mut buf)
.expect("system rng");
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(buf)
}
pub fn load_or_init_agent_file(dir: &Path, port: u16, rotate: bool) -> std::io::Result<AgentFile> {
ensure_dir(dir)?;
let path = dir.join(AGENT_FILE);
let token = match (rotate, load_json::<AgentFile>(&path)) {
(false, Some(existing)) if existing.token.len() == 43 => existing.token,
_ => new_token(),
};
let file = AgentFile {
v: 1,
port,
token,
pid: std::process::id(),
version: env!("CARGO_PKG_VERSION").to_string(),
};
save_json(&path, &file)?;
Ok(file)
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use std::path::PathBuf;
pub(crate) fn tmp(tag: &str) -> PathBuf {
use std::sync::atomic::{AtomicU32, Ordering};
static N: AtomicU32 = AtomicU32::new(0);
let d = std::env::temp_dir().join(format!(
"zc-agent-{tag}-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
let _ = std::fs::remove_dir_all(&d);
d
}
#[test]
fn token_is_43_char_base64url_and_random() {
let t = new_token();
assert_eq!(t.len(), 43);
assert!(t
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
assert_ne!(t, new_token());
}
#[test]
fn token_survives_a_restart_and_rotates_on_install() {
let d = tmp("tok");
let first = load_or_init_agent_file(&d, 4720, false).unwrap();
let restart = load_or_init_agent_file(&d, 4720, false).unwrap();
assert_eq!(first.token, restart.token);
assert_eq!(restart.v, 1);
assert_eq!(restart.pid, std::process::id());
let installed = load_or_init_agent_file(&d, 4720, true).unwrap();
assert_ne!(first.token, installed.token);
}
#[cfg(unix)]
#[test]
fn agent_files_are_private() {
use std::os::unix::fs::PermissionsExt;
let d = tmp("modes");
load_or_init_agent_file(&d, 4720, false).unwrap();
save_json(&d.join(STATE_FILE), &DesiredState::default()).unwrap();
let mode = |p: &std::path::Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
assert_eq!(mode(&d), 0o700);
assert_eq!(mode(&d.join(AGENT_FILE)), 0o600);
assert_eq!(mode(&d.join(STATE_FILE)), 0o600);
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn state_and_children_round_trip() {
let d = tmp("rt");
ensure_dir(&d).unwrap();
let state = DesiredState {
sharing: true,
workers: 2,
known_worker_ids: vec![123, 124],
};
save_json(&d.join(STATE_FILE), &state).unwrap();
assert_eq!(load_json::<DesiredState>(&d.join(STATE_FILE)), Some(state));
let raw: serde_json::Value =
serde_json::from_slice(&std::fs::read(d.join(STATE_FILE)).unwrap()).unwrap();
assert_eq!(
raw,
serde_json::json!({"sharing":true,"workers":2,"known_worker_ids":[123,124]})
);
let mut children = Children::default();
children.broker = Some(ChildRecord {
pid: 7,
started_at: "t0".into(),
port: Some(9001),
});
children.workers.insert(
"0".into(),
ChildRecord {
pid: 8,
started_at: "t1".into(),
port: None,
},
);
save_json(&d.join(CHILDREN_FILE), &children).unwrap();
assert_eq!(
load_json::<Children>(&d.join(CHILDREN_FILE)),
Some(children)
);
}
#[test]
fn a_broker_record_without_a_port_field_decodes_as_none() {
let raw = r#"{"broker":{"pid":7,"started_at":"t0"},"workers":{}}"#;
let c: Children = serde_json::from_str(raw).unwrap();
assert_eq!(c.broker.unwrap().port, None);
}
#[test]
fn a_missing_state_file_means_one_worker_not_sharing() {
let d = tmp("missing");
let s = load_json::<DesiredState>(&d.join(STATE_FILE)).unwrap_or_default();
assert_eq!(
s,
DesiredState {
sharing: false,
workers: 1,
known_worker_ids: vec![]
}
);
}
}