use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedRelay {
pub addr: String,
pub pin: String,
pub token: String,
}
pub fn store_path() -> Result<PathBuf> {
if let Some(explicit) = std::env::var_os("PCC_RELAYS") {
return Ok(PathBuf::from(explicit));
}
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
.context("no HOME to put the relay store under; set PCC_RELAYS to a path")?;
Ok(base.join("pcc").join("relays.json"))
}
pub fn load_from(path: &Path) -> Result<BTreeMap<String, SavedRelay>> {
match std::fs::read_to_string(path) {
Ok(text) => serde_json::from_str(&text)
.with_context(|| format!("{} is not valid relay store JSON", path.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(BTreeMap::new()),
Err(e) => Err(e).with_context(|| format!("could not read {}", path.display())),
}
}
pub fn save_to(path: &Path, relays: &BTreeMap<String, SavedRelay>) -> Result<()> {
if let Some(dir) = path.parent() {
let created = !dir.as_os_str().is_empty() && !dir.exists();
std::fs::create_dir_all(dir)
.with_context(|| format!("could not create {}", dir.display()))?;
if created {
restrict_to_owner(dir, 0o700)?;
}
}
let text = serde_json::to_string_pretty(relays)?;
std::fs::write(path, text).with_context(|| format!("could not write {}", path.display()))?;
restrict_to_owner(path, 0o600)
}
#[cfg(unix)]
fn restrict_to_owner(path: &Path, mode: u32) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
.with_context(|| format!("could not restrict {}", path.display()))
}
#[cfg(not(unix))]
fn restrict_to_owner(_path: &Path, _mode: u32) -> Result<()> {
Ok(())
}
pub fn load() -> Result<BTreeMap<String, SavedRelay>> {
load_from(&store_path()?)
}
pub fn remember(name: &str, addr: &str, pin: &str, token: &str) -> Result<PathBuf> {
let path = store_path()?;
let mut relays = load_from(&path)?;
relays.insert(
name.to_string(),
SavedRelay {
addr: addr.to_string(),
pin: pin.to_string(),
token: token.to_string(),
},
);
save_to(&path, &relays)?;
Ok(path)
}
pub fn forget(name: &str) -> Result<bool> {
let path = store_path()?;
let mut relays = load_from(&path)?;
let removed = relays.remove(name).is_some();
save_to(&path, &relays)?;
Ok(removed)
}
pub fn get(name: &str) -> Result<Option<SavedRelay>> {
Ok(load()?.remove(name))
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_path(tag: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("pcc-relays-test-{tag}-{}", std::process::id()));
let _ = std::fs::remove_file(&p);
p
}
#[test]
fn a_saved_relay_comes_back_identical() {
let path = temp_path("round");
let mut store = BTreeMap::new();
store.insert(
"hetzner".to_string(),
SavedRelay {
addr: "203.0.113.5:5900".into(),
pin: "ab".repeat(32),
token: "TOKEN1234".into(),
},
);
save_to(&path, &store).unwrap();
assert_eq!(load_from(&path).unwrap(), store);
let _ = std::fs::remove_file(&path);
}
#[test]
fn a_missing_store_is_empty_rather_than_an_error() {
let path = temp_path("missing");
assert!(load_from(&path).unwrap().is_empty());
}
#[test]
fn a_corrupt_store_is_reported_rather_than_discarded() {
let path = temp_path("corrupt");
std::fs::write(&path, "{ not json").unwrap();
let err = load_from(&path).unwrap_err().to_string();
assert!(err.contains("not valid relay store JSON"), "got: {err}");
assert!(path.exists(), "the original file must be left alone");
let _ = std::fs::remove_file(&path);
}
#[cfg(unix)]
#[test]
fn the_store_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
let path = temp_path("perms");
save_to(&path, &BTreeMap::new()).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "store must be 0600, got {mode:o}");
let _ = std::fs::remove_file(&path);
}
}