zc2 0.0.28

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. `~` is
/// [`crate::credentials::fallback_home`], which in the unit-test binary is a
/// scratch directory, never the real home (zc#211).
fn state_dir() -> PathBuf {
    state_dir_from(
        std::env::var("ZAKURO_STATE_DIR").ok(),
        crate::credentials::fallback_home(),
    )
}

/// The precedence rule of [`state_dir`], as a pure function of its inputs.
fn state_dir_from(state_dir: Option<String>, home: Option<std::ffi::OsString>) -> PathBuf {
    if let Some(d) = state_dir {
        if !d.trim().is_empty() {
            return PathBuf::from(d);
        }
    }
    if let Some(home) = 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) {
    if crate::vpn::host_ops_allowed("chown the state dir").is_err() {
        return;
    }
    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() {
        // `ZAKURO_STATE_DIR` is process-global: every test that moves it holds
        // HOME_ENV_LOCK, or it races `vpn::tests::mesh_agent_uses_connect_proxy_…`
        // (zc#211; it failed CI with NotFound on the load below).
        let _env = crate::credentials::HOME_ENV_LOCK
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let prev_state_dir = std::env::var_os("ZAKURO_STATE_DIR");
        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());

        match prev_state_dir {
            Some(v) => std::env::set_var("ZAKURO_STATE_DIR", v),
            None => std::env::remove_var("ZAKURO_STATE_DIR"),
        }
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn state_dir_prefers_the_override_then_home() {
        assert_eq!(
            state_dir_from(Some("/state".into()), Some("/h".into())),
            PathBuf::from("/state")
        );
        assert_eq!(
            state_dir_from(None, Some("/h".into())),
            PathBuf::from("/h/.config/zakuro")
        );
        assert_eq!(
            state_dir_from(Some(" ".into()), None),
            std::env::temp_dir().join("zakuro")
        );
    }

    /// zc#211: `disconnect()` clears the connection file, so a unit test that
    /// reached it deleted the real `~/.config/zakuro/connection.json`. Tests
    /// that set `ZAKURO_STATE_DIR` point it into the temp dir, and the
    /// fallback is a scratch home there too, so the path is under it either way.
    #[test]
    fn state_path_is_under_the_temp_dir_in_unit_tests() {
        let path = state_path();
        assert!(path.starts_with(std::env::temp_dir()), "{}", path.display());
    }
}