zc2 0.0.27

P2P compute broker with credit-based billing, WAL, and broker mesh support
//! On-disk state of `zc agent` under `~/.zakuro/agent/` (spec §6.2).

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";

/// `agent.json`: how the app and the CLI reach the running agent.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AgentFile {
    pub v: u32,
    pub port: u16,
    pub token: String,
    pub pid: u32,
    pub version: String,
}

/// `state.json`: what the owner asked for. The agent is its only owner.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DesiredState {
    pub sharing: bool,
    pub workers: u32,
    /// Hub worker-row ids of this Mac already seen, for new-row price seeding (§6.7).
    #[serde(default)]
    pub known_worker_ids: Vec<i64>,
}

impl Default for DesiredState {
    fn default() -> Self {
        Self {
            sharing: false,
            workers: 1,
            known_worker_ids: vec![],
        }
    }
}

/// A child the agent spawned, identified across agent restarts by pid + start time.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ChildRecord {
    pub pid: u32,
    pub started_at: String,
    /// The broker's port, so an adopted broker is talked to on the port it
    /// actually started on (it may have fallen back from 9000). `None` for a
    /// worker record (its port is deterministic), and for a broker record
    /// written before this field existed -- a missing value means 9000.
    #[serde(default)]
    pub port: Option<u16>,
}

/// `children.json`: the broker and workers (keyed by index) the agent owns.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Children {
    #[serde(default)]
    pub broker: Option<ChildRecord>,
    #[serde(default)]
    pub workers: BTreeMap<String, ChildRecord>,
}

/// Create `dir` (and parents) and set it to 0700.
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(())
}

/// Write `bytes` to `path` as 0600, atomically (temp file + rename), so a
/// reader never sees half a token.
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)
}

/// 32 random bytes as unpadded base64url: always 43 characters.
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)
}

/// Load `agent.json`, keeping its token across restarts; `rotate` (from
/// `zc agent install`) mints a new one. Always rewrites pid/port/version.
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)
}

/// `pub(crate)` so other agent test modules can reuse `tmp`.
#[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)
        );
    }

    /// A `children.json` written before `port` existed decodes with `None`,
    /// which the supervisor treats as 9000 on adoption.
    #[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![]
            }
        );
    }
}