use anyhow::{Context, Result, anyhow};
use serde_json::Value;
use std::collections::HashMap;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
pub fn config_dir() -> Result<PathBuf> {
if let Ok(home) = std::env::var("WIRE_HOME") {
return Ok(PathBuf::from(home).join("config").join("wire"));
}
dirs::config_dir()
.map(|d| d.join("wire"))
.ok_or_else(|| anyhow!("could not resolve XDG_CONFIG_HOME — set WIRE_HOME"))
}
pub fn state_dir() -> Result<PathBuf> {
if let Ok(home) = std::env::var("WIRE_HOME") {
return Ok(PathBuf::from(home).join("state").join("wire"));
}
dirs::state_dir()
.or_else(dirs::data_local_dir)
.map(|d| d.join("wire"))
.ok_or_else(|| anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))
}
pub fn private_key_path() -> Result<PathBuf> {
Ok(config_dir()?.join("private.key"))
}
pub fn agent_card_path() -> Result<PathBuf> {
Ok(config_dir()?.join("agent-card.json"))
}
pub fn trust_path() -> Result<PathBuf> {
Ok(config_dir()?.join("trust.json"))
}
pub fn config_toml_path() -> Result<PathBuf> {
Ok(config_dir()?.join("config.toml"))
}
pub fn inbox_dir() -> Result<PathBuf> {
Ok(state_dir()?.join("inbox"))
}
pub fn outbox_dir() -> Result<PathBuf> {
Ok(state_dir()?.join("outbox"))
}
static OUTBOX_LOCKS: OnceLock<Mutex<HashMap<PathBuf, Arc<Mutex<()>>>>> = OnceLock::new();
fn outbox_lock(path: &Path) -> Arc<Mutex<()>> {
let registry = OUTBOX_LOCKS.get_or_init(|| Mutex::new(HashMap::new()));
let mut g = registry.lock().expect("OUTBOX_LOCKS poisoned");
g.entry(path.to_path_buf())
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone()
}
pub fn append_outbox_record(peer: &str, record_bytes: &[u8]) -> Result<PathBuf> {
ensure_dirs()?;
let path = outbox_dir()?.join(format!("{peer}.jsonl"));
let lock = outbox_lock(&path);
let _g = lock.lock().expect("outbox per-path mutex poisoned");
let mut f = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.with_context(|| format!("opening outbox {path:?}"))?;
let mut buf = Vec::with_capacity(record_bytes.len() + 1);
buf.extend_from_slice(record_bytes);
buf.push(b'\n');
f.write_all(&buf)
.with_context(|| format!("appending to {path:?}"))?;
Ok(path)
}
pub fn is_initialized() -> Result<bool> {
Ok(private_key_path()?.exists() && agent_card_path()?.exists())
}
pub fn ensure_dirs() -> Result<()> {
let cfg = config_dir()?;
fs::create_dir_all(&cfg).with_context(|| format!("creating {cfg:?}"))?;
fs::create_dir_all(state_dir()?)?;
fs::create_dir_all(inbox_dir()?)?;
fs::create_dir_all(outbox_dir()?)?;
set_dir_mode_0700(&cfg)?;
Ok(())
}
#[cfg(unix)]
fn set_dir_mode_0700(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(path)?.permissions();
perms.set_mode(0o700);
fs::set_permissions(path, perms)?;
Ok(())
}
#[cfg(not(unix))]
fn set_dir_mode_0700(_: &Path) -> Result<()> {
Ok(())
}
pub fn write_private_key(seed: &[u8; 32]) -> Result<()> {
let path = private_key_path()?;
fs::write(&path, seed).with_context(|| format!("writing {path:?}"))?;
set_file_mode_0600(&path)?;
Ok(())
}
#[cfg(unix)]
fn set_file_mode_0600(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(path)?.permissions();
perms.set_mode(0o600);
fs::set_permissions(path, perms)?;
Ok(())
}
#[cfg(not(unix))]
fn set_file_mode_0600(_: &Path) -> Result<()> {
Ok(())
}
pub fn read_private_key() -> Result<[u8; 32]> {
let path = private_key_path()?;
let bytes = fs::read(&path).with_context(|| format!("reading {path:?}"))?;
if bytes.len() != 32 {
return Err(anyhow!(
"private key file has wrong length ({} != 32)",
bytes.len()
));
}
let mut seed = [0u8; 32];
seed.copy_from_slice(&bytes);
Ok(seed)
}
pub fn write_agent_card(card: &Value) -> Result<()> {
let path = agent_card_path()?;
let body = serde_json::to_vec_pretty(card)?;
fs::write(&path, body).with_context(|| format!("writing {path:?}"))?;
Ok(())
}
pub fn read_agent_card() -> Result<Value> {
let path = agent_card_path()?;
let body = fs::read(&path).with_context(|| format!("reading {path:?}"))?;
Ok(serde_json::from_slice(&body)?)
}
pub fn write_trust(trust: &Value) -> Result<()> {
let path = trust_path()?;
let body = serde_json::to_vec_pretty(trust)?;
fs::write(&path, body).with_context(|| format!("writing {path:?}"))?;
Ok(())
}
pub fn read_trust() -> Result<Value> {
let path = trust_path()?;
if !path.exists() {
return Ok(crate::trust::empty_trust());
}
let body = fs::read(&path).with_context(|| format!("reading {path:?}"))?;
Ok(serde_json::from_slice(&body)?)
}
pub fn relay_state_path() -> Result<PathBuf> {
Ok(config_dir()?.join("relay.json"))
}
pub fn read_relay_state() -> Result<Value> {
let path = relay_state_path()?;
if !path.exists() {
return Ok(serde_json::json!({"self": Value::Null, "peers": {}}));
}
let body = fs::read(&path).with_context(|| format!("reading {path:?}"))?;
Ok(serde_json::from_slice(&body)?)
}
pub fn write_relay_state(state: &Value) -> Result<()> {
let path = relay_state_path()?;
let body = serde_json::to_vec_pretty(state)?;
fs::write(&path, body).with_context(|| format!("writing {path:?}"))?;
set_file_mode_0600(&path)?;
Ok(())
}
fn relay_state_lock_path() -> Result<PathBuf> {
Ok(config_dir()?.join("relay.lock"))
}
pub fn update_relay_state<F>(modifier: F) -> Result<()>
where
F: FnOnce(&mut Value) -> Result<()>,
{
use fs2::FileExt;
let lock_path = relay_state_lock_path()?;
if let Some(parent) = lock_path.parent() {
fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
}
let lock_file = fs::OpenOptions::new()
.create(true)
.read(true)
.write(true)
.open(&lock_path)
.with_context(|| format!("opening {lock_path:?}"))?;
lock_file
.lock_exclusive()
.with_context(|| format!("flock {lock_path:?}"))?;
let mut state = read_relay_state()?;
let result = modifier(&mut state);
let write_result = if result.is_ok() {
write_relay_state(&state)
} else {
Ok(())
};
let _ = fs2::FileExt::unlock(&lock_file);
result?;
write_result?;
Ok(())
}
#[cfg(test)]
pub(crate) mod test_support {
use std::sync::Mutex;
pub static ENV_LOCK: Mutex<()> = Mutex::new(());
pub fn with_temp_home<F: FnOnce()>(f: F) {
let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let tmp = std::env::temp_dir().join(format!("wire-test-{}", rand::random::<u32>()));
unsafe { std::env::set_var("WIRE_HOME", &tmp) };
let _ = std::fs::remove_dir_all(&tmp);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
unsafe { std::env::remove_var("WIRE_HOME") };
let _ = std::fs::remove_dir_all(&tmp);
if let Err(e) = result {
std::panic::resume_unwind(e);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn with_temp_home<F: FnOnce()>(f: F) {
super::test_support::with_temp_home(f)
}
#[test]
fn config_dir_honors_wire_home() {
with_temp_home(|| {
let dir = config_dir().unwrap();
assert!(dir.ends_with("wire"), "got {dir:?}");
assert!(dir.to_string_lossy().contains("wire-test-"));
});
}
#[test]
fn ensure_dirs_creates_layout() {
with_temp_home(|| {
ensure_dirs().unwrap();
assert!(config_dir().unwrap().is_dir());
assert!(state_dir().unwrap().is_dir());
assert!(inbox_dir().unwrap().is_dir());
assert!(outbox_dir().unwrap().is_dir());
});
}
#[test]
fn private_key_roundtrip() {
with_temp_home(|| {
ensure_dirs().unwrap();
let seed = [42u8; 32];
write_private_key(&seed).unwrap();
let read_back = read_private_key().unwrap();
assert_eq!(seed, read_back);
});
}
#[test]
fn agent_card_roundtrip() {
with_temp_home(|| {
ensure_dirs().unwrap();
let card = json!({"did": "did:wire:paul", "name": "Paul"});
write_agent_card(&card).unwrap();
let read_back = read_agent_card().unwrap();
assert_eq!(card, read_back);
});
}
#[test]
fn trust_returns_empty_when_missing() {
with_temp_home(|| {
ensure_dirs().unwrap();
let t = read_trust().unwrap();
assert_eq!(t["version"], 1);
assert!(t["agents"].is_object());
});
}
#[test]
fn update_relay_state_writes_through_lock() {
with_temp_home(|| {
ensure_dirs().unwrap();
let initial = json!({"self": null, "peers": {}});
write_relay_state(&initial).unwrap();
super::update_relay_state(|state| {
state["self"] = json!({
"relay_url": "https://test",
"slot_id": "abc",
"slot_token": "tok",
});
Ok(())
})
.unwrap();
let after = read_relay_state().unwrap();
assert_eq!(after["self"]["relay_url"], "https://test");
assert_eq!(after["self"]["slot_id"], "abc");
});
}
#[test]
fn update_relay_state_modifier_error_does_not_clobber() {
with_temp_home(|| {
ensure_dirs().unwrap();
let initial = json!({"self": {"relay_url": "https://prior"}, "peers": {}});
write_relay_state(&initial).unwrap();
let result = super::update_relay_state(|state| {
state["self"] = json!({"relay_url": "https://NEVER_PERSIST"});
anyhow::bail!("simulated mid-RMW error")
});
assert!(result.is_err());
let after = read_relay_state().unwrap();
assert_eq!(
after["self"]["relay_url"], "https://prior",
"state on disk must not reflect aborted modifier"
);
});
}
#[test]
fn is_initialized_true_only_after_both_files_written() {
with_temp_home(|| {
ensure_dirs().unwrap();
assert!(!is_initialized().unwrap());
write_private_key(&[0u8; 32]).unwrap();
assert!(!is_initialized().unwrap()); write_agent_card(&json!({"did": "did:wire:paul"})).unwrap();
assert!(is_initialized().unwrap());
});
}
#[cfg(unix)]
#[test]
fn private_key_is_mode_0600() {
use std::os::unix::fs::PermissionsExt;
with_temp_home(|| {
ensure_dirs().unwrap();
write_private_key(&[1u8; 32]).unwrap();
let mode = fs::metadata(private_key_path().unwrap())
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600, "got {:o}", mode & 0o777);
});
}
}