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_pushed_log(peer: &str, event_id: &str, ts: &str) -> Result<PathBuf> {
ensure_dirs()?;
let normalized = crate::agent_card::bare_handle(peer);
let path = outbox_dir()?.join(format!("{normalized}.pushed.jsonl"));
let lock = outbox_lock(&path);
let _g = lock.lock().expect("pushed-log per-path mutex poisoned");
let mut f = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.with_context(|| format!("opening pushed-log {path:?}"))?;
let line = serde_json::to_string(&serde_json::json!({
"ts": ts,
"event_id": event_id,
}))?;
f.write_all(line.as_bytes())
.with_context(|| format!("appending to {path:?}"))?;
f.write_all(b"\n")?;
Ok(path)
}
pub fn compute_pending_push_count() -> u64 {
compute_pending_push_breakdown()
.iter()
.map(|p| p.count)
.sum()
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct PendingPushPerPeer {
pub peer: String,
pub tier: String,
pub count: u64,
}
pub fn compute_pending_push_breakdown() -> Vec<PendingPushPerPeer> {
let trust = match read_trust() {
Ok(t) => t,
Err(_) => return Vec::new(),
};
let agents = match trust.get("agents").and_then(serde_json::Value::as_object) {
Some(a) => a.clone(),
None => return Vec::new(),
};
let relay_state = read_relay_state().unwrap_or_else(|_| serde_json::json!({"peers": {}}));
let mut out: Vec<PendingPushPerPeer> = Vec::new();
for (peer_handle, _agent) in agents.iter() {
let pushed_ids = read_pushed_event_ids(peer_handle);
let outbox_path = match outbox_dir() {
Ok(d) => d.join(format!("{peer_handle}.jsonl")),
Err(_) => continue,
};
let body = match fs::read_to_string(&outbox_path) {
Ok(b) => b,
Err(_) => continue,
};
let mut count: u64 = 0;
for line in body.lines() {
if let Some(eid) = serde_json::from_str::<serde_json::Value>(line)
.ok()
.and_then(|v| {
v.get("event_id")
.and_then(serde_json::Value::as_str)
.map(str::to_string)
})
&& !pushed_ids.contains(&eid)
{
count += 1;
}
}
if count > 0 {
let tier = crate::trust::effective_tier(&trust, &relay_state, peer_handle);
out.push(PendingPushPerPeer {
peer: peer_handle.clone(),
tier,
count,
});
}
}
out.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.peer.cmp(&b.peer)));
out
}
pub fn read_stream_state() -> serde_json::Value {
state_dir()
.ok()
.and_then(|d| fs::read_to_string(d.join("stream_state.json")).ok())
.and_then(|body| serde_json::from_str::<serde_json::Value>(&body).ok())
.unwrap_or(serde_json::Value::Null)
}
pub fn stale_sync(last_sync_age_seconds: Option<u64>) -> bool {
match last_sync_age_seconds {
Some(age) => age > 60,
None => true,
}
}
pub fn read_pushed_event_ids(peer: &str) -> std::collections::HashSet<String> {
let normalized = crate::agent_card::bare_handle(peer);
let path = match outbox_dir() {
Ok(d) => d.join(format!("{normalized}.pushed.jsonl")),
Err(_) => return std::collections::HashSet::new(),
};
let body = match fs::read_to_string(&path) {
Ok(b) => b,
Err(_) => return std::collections::HashSet::new(),
};
body.lines()
.filter_map(|line| {
serde_json::from_str::<serde_json::Value>(line)
.ok()?
.get("event_id")?
.as_str()
.map(str::to_string)
})
.collect()
}
pub fn drain_outbox_delivered(peer: &str) -> Result<()> {
let normalized = crate::agent_card::bare_handle(peer);
let path = outbox_dir()?.join(format!("{normalized}.jsonl"));
let delivered = read_pushed_event_ids(peer);
if delivered.is_empty() {
return Ok(());
}
let lock = outbox_lock(&path);
let _g = lock.lock().expect("outbox per-path mutex poisoned");
let body = match fs::read_to_string(&path) {
Ok(b) => b,
Err(_) => return Ok(()), };
let mut kept = String::with_capacity(body.len());
let mut dropped = 0usize;
for line in body.lines() {
let is_delivered = serde_json::from_str::<serde_json::Value>(line)
.ok()
.and_then(|v| {
v.get("event_id")
.and_then(|e| e.as_str())
.map(str::to_string)
})
.map(|id| delivered.contains(&id))
.unwrap_or(false); if is_delivered {
dropped += 1;
} else {
kept.push_str(line);
kept.push('\n');
}
}
if dropped == 0 {
return Ok(()); }
let tmp = path.with_extension("jsonl.tmp");
fs::write(&tmp, kept.as_bytes()).with_context(|| format!("writing {tmp:?}"))?;
fs::rename(&tmp, &path).with_context(|| format!("renaming {tmp:?} -> {path:?}"))?;
Ok(())
}
pub fn append_outbox_record(peer: &str, record_bytes: &[u8]) -> Result<PathBuf> {
ensure_dirs()?;
let normalized = crate::agent_card::bare_handle(peer);
let path = outbox_dir()?.join(format!("{normalized}.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 op_key_path() -> Result<PathBuf> {
Ok(config_dir()?.join("op.key"))
}
fn did_filename(did: &str) -> String {
did.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' {
c
} else {
'_'
}
})
.collect()
}
pub fn org_key_path(org_did: &str) -> Result<PathBuf> {
Ok(config_dir()?
.join("orgs")
.join(format!("{}.key", did_filename(org_did))))
}
fn write_seed_0600(path: &Path, seed: &[u8; 32]) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, seed).with_context(|| format!("writing {path:?}"))?;
set_file_mode_0600(path)?;
Ok(())
}
fn read_seed(path: &Path) -> Result<[u8; 32]> {
let bytes = fs::read(path).with_context(|| format!("reading {path:?}"))?;
if bytes.len() != 32 {
return Err(anyhow!(
"key file {path:?} has wrong length ({} != 32)",
bytes.len()
));
}
let mut seed = [0u8; 32];
seed.copy_from_slice(&bytes);
Ok(seed)
}
pub fn write_op_key(seed: &[u8; 32]) -> Result<()> {
write_seed_0600(&op_key_path()?, seed)
}
pub fn read_op_key() -> Result<[u8; 32]> {
read_seed(&op_key_path()?)
}
pub fn nostr_key_path() -> Result<PathBuf> {
Ok(config_dir()?.join("nostr.key"))
}
pub fn write_nostr_key(secret: &[u8; 32]) -> Result<()> {
write_seed_0600(&nostr_key_path()?, secret)
}
pub fn read_nostr_key() -> Result<[u8; 32]> {
read_seed(&nostr_key_path()?)
}
pub fn write_org_key(org_did: &str, seed: &[u8; 32]) -> Result<()> {
write_seed_0600(&org_key_path(org_did)?, seed)
}
pub fn read_org_key(org_did: &str) -> Result<[u8; 32]> {
read_seed(&org_key_path(org_did)?)
}
pub fn succession_log_path() -> Result<PathBuf> {
Ok(config_dir()?.join("succession.jsonl"))
}
pub fn append_succession_record(
kind: &str,
old_did: &str,
new_did: &str,
cert: &str,
) -> Result<()> {
let path = succession_log_path()?;
if let Some(p) = path.parent() {
fs::create_dir_all(p)?;
}
let at_unix = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let line = serde_json::to_string(&serde_json::json!({
"kind": kind,
"old_did": old_did,
"new_did": new_did,
"cert": cert,
"at_unix": at_unix,
}))?;
use std::io::Write;
let mut f = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.with_context(|| format!("opening {path:?}"))?;
writeln!(f, "{line}")?;
set_file_mode_0600(&path)?;
Ok(())
}
pub fn op_meta_path() -> Result<PathBuf> {
Ok(config_dir()?.join("op.json"))
}
pub fn write_op_handle(handle: &str) -> Result<()> {
let path = op_meta_path()?;
if let Some(p) = path.parent() {
fs::create_dir_all(p)?;
}
fs::write(
&path,
serde_json::to_vec_pretty(&serde_json::json!({ "handle": handle }))?,
)?;
set_file_mode_0600(&path)?;
Ok(())
}
pub fn read_op_handle() -> Result<Option<String>> {
let Ok(bytes) = fs::read(op_meta_path()?) else {
return Ok(None);
};
let v: Value = serde_json::from_slice(&bytes)?;
Ok(v.get("handle").and_then(Value::as_str).map(str::to_string))
}
pub fn memberships_path() -> Result<PathBuf> {
Ok(config_dir()?.join("memberships.json"))
}
pub fn add_membership(org_did: &str, org_pubkey: &str, member_cert: &str) -> Result<()> {
let mut list = read_memberships()?;
list.retain(|m| m.get("org_did").and_then(Value::as_str) != Some(org_did));
list.push(serde_json::json!({
"org_did": org_did, "org_pubkey": org_pubkey, "member_cert": member_cert
}));
let path = memberships_path()?;
if let Some(p) = path.parent() {
fs::create_dir_all(p)?;
}
fs::write(&path, serde_json::to_vec_pretty(&Value::Array(list))?)?;
Ok(())
}
pub fn read_memberships() -> Result<Vec<Value>> {
let Ok(bytes) = fs::read(memberships_path()?) else {
return Ok(vec![]);
};
Ok(serde_json::from_slice::<Value>(&bytes)
.ok()
.and_then(|v| v.as_array().cloned())
.unwrap_or_default())
}
pub fn write_agent_card(card: &Value) -> Result<()> {
let path = agent_card_path()?;
let body = serde_json::to_vec_pretty(card)?;
let tmp = path.with_extension("json.tmp");
fs::write(&tmp, body).with_context(|| format!("writing tmp {tmp:?}"))?;
fs::rename(&tmp, &path).with_context(|| format!("atomic rename {tmp:?} → {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 display_overrides_path() -> Result<PathBuf> {
Ok(config_dir()?.join("display.json"))
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct DisplayOverrides {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nickname: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub emoji: Option<String>,
}
pub fn read_display_overrides() -> Result<DisplayOverrides> {
read_display_overrides_at(&display_overrides_path()?)
}
pub fn read_display_overrides_at(path: &Path) -> Result<DisplayOverrides> {
if !path.exists() {
return Ok(DisplayOverrides::default());
}
let body = fs::read(path).with_context(|| format!("reading {path:?}"))?;
Ok(serde_json::from_slice(&body)?)
}
pub fn write_display_overrides(overrides: &DisplayOverrides) -> Result<()> {
let path = display_overrides_path()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
}
let body = serde_json::to_vec_pretty(overrides)?;
let tmp = path.with_extension("json.tmp");
fs::write(&tmp, body).with_context(|| format!("writing tmp {tmp:?}"))?;
fs::rename(&tmp, &path).with_context(|| format!("atomic rename {tmp:?} → {path:?}"))?;
Ok(())
}
fn trust_state_lock_path() -> Result<PathBuf> {
Ok(config_dir()?.join("trust.lock"))
}
pub fn write_trust(trust: &Value) -> Result<()> {
use fs2::FileExt;
let lock_path = trust_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)
.truncate(false)
.read(true)
.write(true)
.open(&lock_path)
.with_context(|| format!("opening {lock_path:?}"))?;
lock_file
.lock_exclusive()
.with_context(|| format!("flock {lock_path:?}"))?;
let r = write_trust_unlocked(trust);
let _ = fs2::FileExt::unlock(&lock_file);
r
}
fn write_trust_unlocked(trust: &Value) -> Result<()> {
let path = trust_path()?;
let body = serde_json::to_vec_pretty(trust)?;
let tmp = path.with_extension("json.tmp");
fs::write(&tmp, &body).with_context(|| format!("writing tmp {tmp:?}"))?;
fs::rename(&tmp, &path).with_context(|| format!("atomic rename {tmp:?} → {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 update_trust<F>(modifier: F) -> Result<()>
where
F: FnOnce(&mut Value) -> Result<()>,
{
use fs2::FileExt;
let lock_path = trust_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)
.truncate(false)
.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 trust = read_trust()?;
let result = modifier(&mut trust);
let write_result = if result.is_ok() {
write_trust_unlocked(&trust)
} else {
Ok(())
};
let _ = fs2::FileExt::unlock(&lock_file);
result?;
write_result?;
Ok(())
}
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<()> {
use fs2::FileExt;
let lock_file = acquire_relay_lock(std::process::id())?;
let r = write_relay_state_unlocked(state);
let _ = FileExt::unlock(&lock_file);
r
}
fn write_relay_state_unlocked(state: &Value) -> Result<()> {
let path = relay_state_path()?;
let body = serde_json::to_vec_pretty(state)?;
let tmp = path.with_extension("json.tmp");
fs::write(&tmp, &body).with_context(|| format!("writing tmp {tmp:?}"))?;
set_file_mode_0600(&tmp)?;
fs::rename(&tmp, &path).with_context(|| format!("atomic rename {tmp:?} → {path:?}"))?;
Ok(())
}
fn relay_state_lock_path() -> Result<PathBuf> {
Ok(config_dir()?.join("relay.lock"))
}
fn relay_lock_timeout() -> std::time::Duration {
std::env::var("WIRE_RELAY_LOCK_TIMEOUT_SECS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.map(std::time::Duration::from_secs)
.unwrap_or_else(|| std::time::Duration::from_secs(10))
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum LockAttemptOutcome {
HeldByAlive(u32),
HeldByDeadOrAbsent(Option<u32>),
}
pub(crate) fn classify_contention(
body: &[u8],
is_alive: impl Fn(u32) -> bool,
) -> LockAttemptOutcome {
let pid = std::str::from_utf8(body)
.ok()
.and_then(|s| s.trim().parse::<u32>().ok());
match pid {
Some(p) if is_alive(p) => LockAttemptOutcome::HeldByAlive(p),
Some(p) => LockAttemptOutcome::HeldByDeadOrAbsent(Some(p)),
None => LockAttemptOutcome::HeldByDeadOrAbsent(None),
}
}
fn relay_state_lock_owner_path() -> Result<PathBuf> {
Ok(config_dir()?.join("relay.lock.owner"))
}
fn acquire_relay_lock(our_pid: u32) -> Result<fs::File> {
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 owner_path = relay_state_lock_owner_path()?;
let deadline = std::time::Instant::now() + relay_lock_timeout();
let mut backoff = std::time::Duration::from_millis(10);
loop {
let lock_file = fs::OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(&lock_path)
.with_context(|| format!("opening {lock_path:?}"))?;
match lock_file.try_lock_exclusive() {
Ok(()) => {
let _ = fs::write(&owner_path, our_pid.to_string());
return Ok(lock_file);
}
Err(_) => {
drop(lock_file);
let body = fs::read(&owner_path).unwrap_or_default();
match classify_contention(&body, crate::platform::process_alive) {
LockAttemptOutcome::HeldByDeadOrAbsent(_) => {
std::thread::sleep(std::time::Duration::from_millis(1));
}
LockAttemptOutcome::HeldByAlive(holder_pid) => {
if std::time::Instant::now() >= deadline {
return Err(anyhow!(
"relay.lock held by live pid {holder_pid} after {}s — \
likely a hung wire process. Run `wire doctor`, or \
kill {holder_pid} and retry.",
relay_lock_timeout().as_secs(),
));
}
std::thread::sleep(backoff);
backoff = (backoff * 2).min(std::time::Duration::from_millis(200));
}
}
}
}
}
}
pub fn update_relay_state<F>(modifier: F) -> Result<()>
where
F: FnOnce(&mut Value) -> Result<()>,
{
use fs2::FileExt;
let lock_file = acquire_relay_lock(std::process::id())?;
let mut state = read_relay_state()?;
let result = modifier(&mut state);
let write_result = if result.is_ok() {
write_relay_state_unlocked(&state)
} else {
Ok(())
};
let _ = 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;
#[test]
fn did_filename_sanitizes_did_punctuation() {
assert_eq!(
did_filename("did:wire:org:slanchaai-abc123"),
"did_wire_org_slanchaai-abc123"
);
let f = did_filename("did:wire:org:x/../../etc");
assert!(!f.contains('/') && !f.contains('.'));
}
#[test]
fn op_and_org_key_roundtrip() {
with_temp_home(|| {
let op_seed = [7u8; 32];
write_op_key(&op_seed).unwrap();
assert_eq!(read_op_key().unwrap(), op_seed);
let org_did = "did:wire:org:slanchaai-deadbeef";
let org_seed = [9u8; 32];
write_org_key(org_did, &org_seed).unwrap();
assert_eq!(read_org_key(org_did).unwrap(), org_seed);
});
}
fn with_temp_home<F: FnOnce()>(f: F) {
super::test_support::with_temp_home(f)
}
#[test]
fn read_trust_missing_is_ok_empty_but_corrupt_is_err() {
with_temp_home(|| {
let t = read_trust().unwrap();
assert!(t.get("agents").is_some(), "missing trust → empty skeleton");
ensure_dirs().unwrap();
std::fs::write(trust_path().unwrap(), b"{ this is not json").unwrap();
assert!(
read_trust().is_err(),
"corrupt trust.json must Err, not swallow"
);
});
}
#[test]
fn drain_outbox_removes_only_delivered_lines() {
with_temp_home(|| {
let peer = "alpha-fox";
for id in ["e1", "e2", "e3"] {
append_outbox_record(peer, format!("{{\"event_id\":\"{id}\"}}").as_bytes())
.unwrap();
}
append_pushed_log(peer, "e1", "t").unwrap();
append_pushed_log(peer, "e3", "t").unwrap();
drain_outbox_delivered(peer).unwrap();
let body = fs::read_to_string(outbox_dir().unwrap().join("alpha-fox.jsonl")).unwrap();
assert!(body.contains("\"e2\""), "undelivered line kept");
assert!(!body.contains("\"e1\""), "delivered line dropped");
assert!(!body.contains("\"e3\""), "delivered line dropped");
assert_eq!(body.lines().count(), 1, "only the pending line remains");
});
}
#[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 write_relay_state_never_tears_under_concurrency() {
with_temp_home(|| {
ensure_dirs().unwrap();
write_relay_state(&json!({"self": null, "peers": {}})).unwrap();
let handles: Vec<_> = (0..8)
.map(|w| {
std::thread::spawn(move || {
for j in 0..25 {
let body = if j % 2 == 0 {
json!({"self": {"w": w, "j": j, "pad": "x".repeat(2048)}})
} else {
json!({"self": {"w": w}})
};
write_relay_state(&body).unwrap();
read_relay_state().expect("relay.json must always parse");
}
})
})
.collect();
for h in handles {
h.join().unwrap();
}
assert!(read_relay_state().unwrap().get("self").is_some());
});
}
#[test]
fn write_trust_round_trips_and_leaves_no_tmp() {
with_temp_home(|| {
ensure_dirs().unwrap();
let t = json!({"version": 1, "agents": {"did:wire:raven-kettle-465c3352": {"tier": "VERIFIED"}}});
write_trust(&t).unwrap();
let back = read_trust().unwrap();
assert_eq!(
back["agents"]["did:wire:raven-kettle-465c3352"]["tier"],
"VERIFIED"
);
let tmp = trust_path().unwrap().with_extension("json.tmp");
assert!(!tmp.exists(), "tmp file must be consumed by the rename");
});
}
#[test]
fn write_trust_never_tears_under_concurrency() {
with_temp_home(|| {
ensure_dirs().unwrap();
write_trust(&json!({"version": 1, "agents": {}})).unwrap();
let handles: Vec<_> = (0..8)
.map(|w| {
std::thread::spawn(move || {
for j in 0..25 {
let body = if j % 2 == 0 {
json!({"version": 1, "agents": {"a": {"w": w, "pad": "x".repeat(2048)}}})
} else {
json!({"version": 1, "agents": {"a": {"w": w}}})
};
write_trust(&body).unwrap();
read_trust().expect("trust.json must always parse");
}
})
})
.collect();
for h in handles {
h.join().unwrap();
}
assert_eq!(read_trust().unwrap()["version"], 1);
});
}
#[test]
fn update_trust_no_lost_update_under_concurrency() {
with_temp_home(|| {
ensure_dirs().unwrap();
write_trust(&json!({"version": 1, "agents": {}})).unwrap();
let handles: Vec<_> = (0..8)
.map(|w| {
std::thread::spawn(move || {
for j in 0..15 {
let key = format!("peer-{w}-{j}");
update_trust(|t| {
t["agents"][&key] = json!({"tier": "VERIFIED"});
Ok(())
})
.unwrap();
}
})
})
.collect();
for h in handles {
h.join().unwrap();
}
let agents = read_trust().unwrap();
let n = agents["agents"].as_object().unwrap().len();
assert_eq!(
n,
8 * 15,
"every concurrent add must survive (no lost update)"
);
});
}
#[test]
fn update_trust_modifier_error_does_not_clobber() {
with_temp_home(|| {
ensure_dirs().unwrap();
write_trust(&json!({"version": 1, "agents": {"keep": {"tier": "VERIFIED"}}})).unwrap();
let r = update_trust(|t| {
t["agents"]["transient"] = json!({"tier": "X"});
anyhow::bail!("simulated mid-RMW error")
});
assert!(r.is_err());
let after = read_trust().unwrap();
assert!(
after["agents"]["keep"].is_object(),
"prior pin must survive"
);
assert!(
after["agents"]["transient"].is_null(),
"aborted modifier must not persist"
);
});
}
#[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 append_outbox_record_normalizes_fqdn_to_bare_handle() {
with_temp_home(|| {
let path_fqdn = append_outbox_record("bob@wireup.net", b"{\"kind\":1100}").unwrap();
let path_bare = append_outbox_record("bob", b"{\"kind\":1100}").unwrap();
assert_eq!(path_fqdn, path_bare, "FQDN form should normalize to bare");
assert!(
path_fqdn.file_name().unwrap().to_string_lossy() == "bob.jsonl",
"expected bob.jsonl, got {path_fqdn:?}"
);
let outbox = outbox_dir().unwrap();
assert!(
!outbox.join("bob@wireup.net.jsonl").exists(),
"FQDN-named file must not be created"
);
let body = std::fs::read_to_string(&path_bare).unwrap();
assert_eq!(body.matches("kind").count(), 2, "got: {body}");
});
}
#[test]
fn pending_push_breakdown_attributes_per_peer_with_tier() {
with_temp_home(|| {
ensure_dirs().unwrap();
let trust = json!({
"agents": {
"alpha-fox": {"tier": "VERIFIED"},
"beta-newt": {"tier": "PENDING_ACK"},
"gamma-otter": {"tier": "UNTRUSTED"},
}
});
write_trust(&trust).unwrap();
let relay = json!({
"self": null,
"peers": {
"alpha-fox": {
"bilateral_completed_at": "2026-06-01T00:00:00Z"
}
}
});
write_relay_state(&relay).unwrap();
let out = outbox_dir().unwrap();
std::fs::write(
out.join("alpha-fox.jsonl"),
"{\"event_id\":\"a1\"}\n{\"event_id\":\"a2\"}\n",
)
.unwrap();
std::fs::write(
out.join("alpha-fox.pushed.jsonl"),
"{\"event_id\":\"a1\"}\n",
)
.unwrap();
std::fs::write(
out.join("beta-newt.jsonl"),
"{\"event_id\":\"b1\"}\n{\"event_id\":\"b2\"}\n{\"event_id\":\"b3\"}\n",
)
.unwrap();
let bd = compute_pending_push_breakdown();
assert_eq!(bd.len(), 2, "got: {bd:?}");
assert_eq!(bd[0].peer, "beta-newt");
assert_eq!(bd[0].tier, "PENDING_ACK");
assert_eq!(bd[0].count, 3);
assert_eq!(bd[1].peer, "alpha-fox");
assert_eq!(bd[1].tier, "VERIFIED");
assert_eq!(bd[1].count, 1);
assert_eq!(compute_pending_push_count(), 4);
});
}
#[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);
});
}
#[test]
fn classify_contention_dead_pid_says_reclaim() {
let body = b"12345";
let outcome = classify_contention(body, |_| false);
assert_eq!(outcome, LockAttemptOutcome::HeldByDeadOrAbsent(Some(12345)));
}
#[test]
fn classify_contention_live_pid_says_wait() {
let body = b"54321";
let outcome = classify_contention(body, |pid| pid == 54321);
assert_eq!(outcome, LockAttemptOutcome::HeldByAlive(54321));
}
#[test]
fn classify_contention_empty_body_says_reclaim() {
let outcome = classify_contention(b"", |_| true);
assert_eq!(outcome, LockAttemptOutcome::HeldByDeadOrAbsent(None));
}
#[test]
fn classify_contention_garbage_body_says_reclaim() {
let outcome = classify_contention(b"not-a-pid\n\0\xff", |_| true);
assert_eq!(outcome, LockAttemptOutcome::HeldByDeadOrAbsent(None));
}
#[test]
fn classify_contention_trims_whitespace() {
let body = b" 789\n";
let outcome = classify_contention(body, |pid| pid == 789);
assert_eq!(outcome, LockAttemptOutcome::HeldByAlive(789));
}
#[test]
fn acquire_relay_lock_stamps_our_pid_into_owner_sidecar() {
use fs2::FileExt;
with_temp_home(|| {
ensure_dirs().unwrap();
let pid = std::process::id();
let lock = acquire_relay_lock(pid).expect("acquire fresh lock");
let body = fs::read(relay_state_lock_owner_path().unwrap()).unwrap();
assert_eq!(
std::str::from_utf8(&body).unwrap().trim(),
pid.to_string(),
"owner sidecar must hold our PID after acquire"
);
let _ = FileExt::unlock(&lock);
drop(lock);
});
}
#[test]
fn acquire_relay_lock_reclaims_when_owner_pid_is_dead() {
with_temp_home(|| {
ensure_dirs().unwrap();
let owner_path = relay_state_lock_owner_path().unwrap();
if let Some(parent) = owner_path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&owner_path, u32::MAX.to_string()).unwrap();
unsafe { std::env::set_var("WIRE_RELAY_LOCK_TIMEOUT_SECS", "2") };
let started = std::time::Instant::now();
let lock =
acquire_relay_lock(std::process::id()).expect("dead-owner lock must be reclaimed");
assert!(
started.elapsed() < std::time::Duration::from_secs(2),
"reclaim should be fast (well inside timeout); took {:?}",
started.elapsed()
);
drop(lock);
unsafe { std::env::remove_var("WIRE_RELAY_LOCK_TIMEOUT_SECS") };
});
}
#[test]
fn acquire_relay_lock_times_out_when_owner_is_alive() {
use fs2::FileExt;
with_temp_home(|| {
ensure_dirs().unwrap();
let lock_path = relay_state_lock_path().unwrap();
if let Some(parent) = lock_path.parent() {
fs::create_dir_all(parent).unwrap();
}
let holder = fs::OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(&lock_path)
.unwrap();
holder.lock_exclusive().unwrap();
let our_pid = std::process::id();
fs::write(relay_state_lock_owner_path().unwrap(), our_pid.to_string()).unwrap();
unsafe { std::env::set_var("WIRE_RELAY_LOCK_TIMEOUT_SECS", "1") };
let started = std::time::Instant::now();
let result = acquire_relay_lock(our_pid);
let elapsed = started.elapsed();
let _ = FileExt::unlock(&holder);
unsafe { std::env::remove_var("WIRE_RELAY_LOCK_TIMEOUT_SECS") };
let err = result.expect_err("live-owner contention must time out");
let msg = format!("{err}");
assert!(
msg.contains(&our_pid.to_string()),
"timeout error must surface the live holder's PID; got: {msg}"
);
assert!(
elapsed >= std::time::Duration::from_secs(1),
"must respect the bounded timeout; elapsed={elapsed:?}"
);
assert!(
elapsed < std::time::Duration::from_secs(3),
"must not run wildly past the bounded timeout; elapsed={elapsed:?}"
);
});
}
}