use crate::vpn::ConnectionInfo;
use std::path::PathBuf;
fn state_dir() -> PathBuf {
state_dir_from(
std::env::var("ZAKURO_STATE_DIR").ok(),
crate::credentials::fallback_home(),
)
}
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(())
}
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();
}
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 _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")
);
}
#[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());
}
}