zc2 0.0.25

P2P compute broker with credit-based billing, WAL, and broker mesh support
//! Persisted connection state so `status`/`disconnect` work across processes
//! and `connect` is idempotent.

use crate::vpn::ConnectionInfo;
use std::path::PathBuf;

/// Directory holding zc state. Honors `ZAKURO_STATE_DIR` (tests), else
/// `~/.config/zakuro`, else the system temp dir.
fn state_dir() -> PathBuf {
    if let Ok(d) = std::env::var("ZAKURO_STATE_DIR") {
        if !d.trim().is_empty() {
            return PathBuf::from(d);
        }
    }
    if let Ok(home) = std::env::var("HOME") {
        if !home.is_empty() {
            return PathBuf::from(home).join(".config").join("zakuro");
        }
    }
    std::env::temp_dir().join("zakuro")
}

pub fn state_path() -> PathBuf {
    state_dir().join("connection.json")
}

pub fn save(info: &ConnectionInfo) -> std::io::Result<()> {
    let dir = state_dir();
    std::fs::create_dir_all(&dir)?;
    let json = serde_json::to_string_pretty(info).map_err(std::io::Error::other)?;
    std::fs::write(state_path(), json)?;
    reown_to_sudo_user(&dir);
    Ok(())
}

/// When invoked via `sudo` (native wg-quick needs root), hand the state dir back
/// to the real user so a later non-root `zc vpn connect --docker` can still write
/// it. Without this, one `sudo` run leaves root-owned state that breaks every
/// subsequent non-sudo run with EACCES. Best-effort; ignored when not under sudo.
fn reown_to_sudo_user(dir: &std::path::Path) {
    let (Ok(uid), Ok(gid)) = (std::env::var("SUDO_UID"), std::env::var("SUDO_GID")) else {
        return;
    };
    if uid.is_empty() {
        return;
    }
    let _ = std::process::Command::new("chown")
        .arg("-R")
        .arg(format!("{uid}:{gid}"))
        .arg(dir)
        .status();
}

/// Persist state, downgrading failure to a warning. By the time we save, the
/// tunnel is already up — a non-writable state dir (commonly root-owned by a
/// prior `sudo zc`) must NOT fail the whole connect. Returns whether it saved.
pub fn save_or_warn(info: &ConnectionInfo) -> bool {
    match save(info) {
        Ok(()) => true,
        Err(e) => {
            eprintln!("  ⚠ could not persist connection state: {e}");
            if e.kind() == std::io::ErrorKind::PermissionDenied {
                eprintln!(
                    "    {} is not writable — likely left root-owned by an earlier `sudo zc`.",
                    state_path().display()
                );
                eprintln!(
                    "    Fix once: sudo chown -R $USER {}",
                    state_dir().display()
                );
            }
            eprintln!("    The tunnel is up, but `zc vpn status`/`disconnect` may not track it.");
            false
        }
    }
}

pub fn load() -> Option<ConnectionInfo> {
    let body = std::fs::read_to_string(state_path()).ok()?;
    serde_json::from_str(&body).ok()
}

pub fn clear() -> std::io::Result<()> {
    match std::fs::remove_file(state_path()) {
        Ok(()) => Ok(()),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
        Err(e) => Err(e),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::vpn::{Backend, PeerStatus};

    #[test]
    fn save_load_clear_roundtrip() {
        let dir = std::env::temp_dir().join(format!("zc-vpn-state-{}", std::process::id()));
        std::env::set_var("ZAKURO_STATE_DIR", &dir);

        assert!(load().is_none());

        let info = ConnectionInfo {
            backend: Backend::Docker,
            address: "10.13.13.6/24".into(),
            link: "zakuro-wg".into(),
            peers: vec![PeerStatus {
                ip: "10.13.13.1".into(),
                last_handshake_secs: Some(3),
                reachable: true,
            }],
            host_routable: false,
            proxy: None,
        };
        save(&info).expect("save");

        let loaded = load().expect("load");
        assert_eq!(loaded.link, "zakuro-wg");
        assert_eq!(loaded.backend, Backend::Docker);
        assert_eq!(loaded.peers.len(), 1);

        clear().expect("clear");
        assert!(load().is_none());

        std::env::remove_var("ZAKURO_STATE_DIR");
        let _ = std::fs::remove_dir_all(&dir);
    }
}