1use anyhow::{Context, Result, anyhow};
20use serde_json::Value;
21use std::collections::HashMap;
22use std::fs;
23use std::io::Write;
24use std::path::{Path, PathBuf};
25use std::sync::{Arc, Mutex, OnceLock};
26
27pub fn config_dir() -> Result<PathBuf> {
32 if let Ok(home) = std::env::var("WIRE_HOME") {
33 return Ok(PathBuf::from(home).join("config").join("wire"));
34 }
35 dirs::config_dir()
36 .map(|d| d.join("wire"))
37 .ok_or_else(|| anyhow!("could not resolve XDG_CONFIG_HOME — set WIRE_HOME"))
38}
39
40pub fn state_dir() -> Result<PathBuf> {
44 if let Ok(home) = std::env::var("WIRE_HOME") {
45 return Ok(PathBuf::from(home).join("state").join("wire"));
46 }
47 dirs::state_dir()
48 .or_else(dirs::data_local_dir)
49 .map(|d| d.join("wire"))
50 .ok_or_else(|| anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))
51}
52
53pub fn private_key_path() -> Result<PathBuf> {
54 Ok(config_dir()?.join("private.key"))
55}
56pub fn agent_card_path() -> Result<PathBuf> {
57 Ok(config_dir()?.join("agent-card.json"))
58}
59pub fn trust_path() -> Result<PathBuf> {
60 Ok(config_dir()?.join("trust.json"))
61}
62pub fn config_toml_path() -> Result<PathBuf> {
63 Ok(config_dir()?.join("config.toml"))
64}
65pub fn inbox_dir() -> Result<PathBuf> {
66 Ok(state_dir()?.join("inbox"))
67}
68pub fn outbox_dir() -> Result<PathBuf> {
69 Ok(state_dir()?.join("outbox"))
70}
71
72static OUTBOX_LOCKS: OnceLock<Mutex<HashMap<PathBuf, Arc<Mutex<()>>>>> = OnceLock::new();
84
85fn outbox_lock(path: &Path) -> Arc<Mutex<()>> {
86 let registry = OUTBOX_LOCKS.get_or_init(|| Mutex::new(HashMap::new()));
87 let mut g = registry.lock().expect("OUTBOX_LOCKS poisoned");
88 g.entry(path.to_path_buf())
89 .or_insert_with(|| Arc::new(Mutex::new(())))
90 .clone()
91}
92
93pub fn append_pushed_log(peer: &str, event_id: &str, ts: &str) -> Result<PathBuf> {
125 ensure_dirs()?;
126 let normalized = crate::agent_card::bare_handle(peer);
127 let path = outbox_dir()?.join(format!("{normalized}.pushed.jsonl"));
128 let lock = outbox_lock(&path);
129 let _g = lock.lock().expect("pushed-log per-path mutex poisoned");
130 let mut f = fs::OpenOptions::new()
131 .create(true)
132 .append(true)
133 .open(&path)
134 .with_context(|| format!("opening pushed-log {path:?}"))?;
135 let line = serde_json::to_string(&serde_json::json!({
136 "ts": ts,
137 "event_id": event_id,
138 }))?;
139 f.write_all(line.as_bytes())
140 .with_context(|| format!("appending to {path:?}"))?;
141 f.write_all(b"\n")?;
142 Ok(path)
143}
144
145pub fn compute_pending_push_count() -> u64 {
156 compute_pending_push_breakdown()
157 .iter()
158 .map(|p| p.count)
159 .sum()
160}
161
162#[derive(Debug, Clone, serde::Serialize)]
176pub struct PendingPushPerPeer {
177 pub peer: String,
178 pub tier: String,
179 pub count: u64,
180}
181
182pub fn compute_pending_push_breakdown() -> Vec<PendingPushPerPeer> {
183 let trust = match read_trust() {
184 Ok(t) => t,
185 Err(_) => return Vec::new(),
186 };
187 let agents = match trust.get("agents").and_then(serde_json::Value::as_object) {
188 Some(a) => a.clone(),
189 None => return Vec::new(),
190 };
191 let relay_state = read_relay_state().unwrap_or_else(|_| serde_json::json!({"peers": {}}));
195 let mut out: Vec<PendingPushPerPeer> = Vec::new();
196 for (peer_handle, _agent) in agents.iter() {
197 let pushed_ids = read_pushed_event_ids(peer_handle);
198 let outbox_path = match outbox_dir() {
199 Ok(d) => d.join(format!("{peer_handle}.jsonl")),
200 Err(_) => continue,
201 };
202 let body = match fs::read_to_string(&outbox_path) {
203 Ok(b) => b,
204 Err(_) => continue,
205 };
206 let mut count: u64 = 0;
207 for line in body.lines() {
208 if let Some(eid) = serde_json::from_str::<serde_json::Value>(line)
209 .ok()
210 .and_then(|v| {
211 v.get("event_id")
212 .and_then(serde_json::Value::as_str)
213 .map(str::to_string)
214 })
215 && !pushed_ids.contains(&eid)
216 {
217 count += 1;
218 }
219 }
220 if count > 0 {
221 let tier = crate::trust::effective_tier(&trust, &relay_state, peer_handle);
226 out.push(PendingPushPerPeer {
227 peer: peer_handle.clone(),
228 tier,
229 count,
230 });
231 }
232 }
233 out.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.peer.cmp(&b.peer)));
236 out
237}
238
239pub fn read_stream_state() -> serde_json::Value {
244 state_dir()
245 .ok()
246 .and_then(|d| fs::read_to_string(d.join("stream_state.json")).ok())
247 .and_then(|body| serde_json::from_str::<serde_json::Value>(&body).ok())
248 .unwrap_or(serde_json::Value::Null)
249}
250
251pub fn stale_sync(last_sync_age_seconds: Option<u64>) -> bool {
255 match last_sync_age_seconds {
256 Some(age) => age > 60,
257 None => true,
258 }
259}
260
261pub fn read_pushed_event_ids(peer: &str) -> std::collections::HashSet<String> {
266 let normalized = crate::agent_card::bare_handle(peer);
267 let path = match outbox_dir() {
268 Ok(d) => d.join(format!("{normalized}.pushed.jsonl")),
269 Err(_) => return std::collections::HashSet::new(),
270 };
271 let body = match fs::read_to_string(&path) {
272 Ok(b) => b,
273 Err(_) => return std::collections::HashSet::new(),
274 };
275 body.lines()
276 .filter_map(|line| {
277 serde_json::from_str::<serde_json::Value>(line)
278 .ok()?
279 .get("event_id")?
280 .as_str()
281 .map(str::to_string)
282 })
283 .collect()
284}
285
286pub fn append_outbox_record(peer: &str, record_bytes: &[u8]) -> Result<PathBuf> {
287 ensure_dirs()?;
288 let normalized = crate::agent_card::bare_handle(peer);
289 let path = outbox_dir()?.join(format!("{normalized}.jsonl"));
290 let lock = outbox_lock(&path);
291 let _g = lock.lock().expect("outbox per-path mutex poisoned");
292 let mut f = fs::OpenOptions::new()
293 .create(true)
294 .append(true)
295 .open(&path)
296 .with_context(|| format!("opening outbox {path:?}"))?;
297 let mut buf = Vec::with_capacity(record_bytes.len() + 1);
298 buf.extend_from_slice(record_bytes);
299 buf.push(b'\n');
300 f.write_all(&buf)
301 .with_context(|| format!("appending to {path:?}"))?;
302 Ok(path)
303}
304
305pub fn is_initialized() -> Result<bool> {
307 Ok(private_key_path()?.exists() && agent_card_path()?.exists())
308}
309
310pub fn ensure_dirs() -> Result<()> {
312 let cfg = config_dir()?;
313 fs::create_dir_all(&cfg).with_context(|| format!("creating {cfg:?}"))?;
314 fs::create_dir_all(state_dir()?)?;
315 fs::create_dir_all(inbox_dir()?)?;
316 fs::create_dir_all(outbox_dir()?)?;
317 set_dir_mode_0700(&cfg)?;
318 Ok(())
319}
320
321#[cfg(unix)]
322fn set_dir_mode_0700(path: &Path) -> Result<()> {
323 use std::os::unix::fs::PermissionsExt;
324 let mut perms = fs::metadata(path)?.permissions();
325 perms.set_mode(0o700);
326 fs::set_permissions(path, perms)?;
327 Ok(())
328}
329
330#[cfg(not(unix))]
331fn set_dir_mode_0700(_: &Path) -> Result<()> {
332 Ok(())
333}
334
335pub fn write_private_key(seed: &[u8; 32]) -> Result<()> {
337 let path = private_key_path()?;
338 fs::write(&path, seed).with_context(|| format!("writing {path:?}"))?;
339 set_file_mode_0600(&path)?;
340 Ok(())
341}
342
343#[cfg(unix)]
344fn set_file_mode_0600(path: &Path) -> Result<()> {
345 use std::os::unix::fs::PermissionsExt;
346 let mut perms = fs::metadata(path)?.permissions();
347 perms.set_mode(0o600);
348 fs::set_permissions(path, perms)?;
349 Ok(())
350}
351
352#[cfg(not(unix))]
353fn set_file_mode_0600(_: &Path) -> Result<()> {
354 Ok(())
355}
356
357pub fn read_private_key() -> Result<[u8; 32]> {
359 let path = private_key_path()?;
360 let bytes = fs::read(&path).with_context(|| format!("reading {path:?}"))?;
361 if bytes.len() != 32 {
362 return Err(anyhow!(
363 "private key file has wrong length ({} != 32)",
364 bytes.len()
365 ));
366 }
367 let mut seed = [0u8; 32];
368 seed.copy_from_slice(&bytes);
369 Ok(seed)
370}
371
372pub fn op_key_path() -> Result<PathBuf> {
378 Ok(config_dir()?.join("op.key"))
379}
380
381fn did_filename(did: &str) -> String {
383 did.chars()
384 .map(|c| {
385 if c.is_ascii_alphanumeric() || c == '-' {
386 c
387 } else {
388 '_'
389 }
390 })
391 .collect()
392}
393
394pub fn org_key_path(org_did: &str) -> Result<PathBuf> {
395 Ok(config_dir()?
396 .join("orgs")
397 .join(format!("{}.key", did_filename(org_did))))
398}
399
400fn write_seed_0600(path: &Path, seed: &[u8; 32]) -> Result<()> {
401 if let Some(parent) = path.parent() {
402 fs::create_dir_all(parent)?;
403 }
404 fs::write(path, seed).with_context(|| format!("writing {path:?}"))?;
405 set_file_mode_0600(path)?;
406 Ok(())
407}
408
409fn read_seed(path: &Path) -> Result<[u8; 32]> {
410 let bytes = fs::read(path).with_context(|| format!("reading {path:?}"))?;
411 if bytes.len() != 32 {
412 return Err(anyhow!(
413 "key file {path:?} has wrong length ({} != 32)",
414 bytes.len()
415 ));
416 }
417 let mut seed = [0u8; 32];
418 seed.copy_from_slice(&bytes);
419 Ok(seed)
420}
421
422pub fn write_op_key(seed: &[u8; 32]) -> Result<()> {
423 write_seed_0600(&op_key_path()?, seed)
424}
425pub fn read_op_key() -> Result<[u8; 32]> {
426 read_seed(&op_key_path()?)
427}
428pub fn write_org_key(org_did: &str, seed: &[u8; 32]) -> Result<()> {
429 write_seed_0600(&org_key_path(org_did)?, seed)
430}
431pub fn read_org_key(org_did: &str) -> Result<[u8; 32]> {
432 read_seed(&org_key_path(org_did)?)
433}
434
435pub fn succession_log_path() -> Result<PathBuf> {
436 Ok(config_dir()?.join("succession.jsonl"))
437}
438
439pub fn append_succession_record(
443 kind: &str,
444 old_did: &str,
445 new_did: &str,
446 cert: &str,
447) -> Result<()> {
448 let path = succession_log_path()?;
449 if let Some(p) = path.parent() {
450 fs::create_dir_all(p)?;
451 }
452 let at_unix = std::time::SystemTime::now()
453 .duration_since(std::time::UNIX_EPOCH)
454 .map(|d| d.as_secs())
455 .unwrap_or(0);
456 let line = serde_json::to_string(&serde_json::json!({
457 "kind": kind,
458 "old_did": old_did,
459 "new_did": new_did,
460 "cert": cert,
461 "at_unix": at_unix,
462 }))?;
463 use std::io::Write;
464 let mut f = fs::OpenOptions::new()
465 .create(true)
466 .append(true)
467 .open(&path)
468 .with_context(|| format!("opening {path:?}"))?;
469 writeln!(f, "{line}")?;
470 set_file_mode_0600(&path)?;
471 Ok(())
472}
473
474pub fn op_meta_path() -> Result<PathBuf> {
475 Ok(config_dir()?.join("op.json"))
476}
477
478pub fn write_op_handle(handle: &str) -> Result<()> {
481 let path = op_meta_path()?;
482 if let Some(p) = path.parent() {
483 fs::create_dir_all(p)?;
484 }
485 fs::write(
486 &path,
487 serde_json::to_vec_pretty(&serde_json::json!({ "handle": handle }))?,
488 )?;
489 set_file_mode_0600(&path)?;
490 Ok(())
491}
492
493pub fn read_op_handle() -> Result<Option<String>> {
494 let Ok(bytes) = fs::read(op_meta_path()?) else {
495 return Ok(None);
496 };
497 let v: Value = serde_json::from_slice(&bytes)?;
498 Ok(v.get("handle").and_then(Value::as_str).map(str::to_string))
499}
500
501pub fn memberships_path() -> Result<PathBuf> {
502 Ok(config_dir()?.join("memberships.json"))
503}
504
505pub fn add_membership(org_did: &str, org_pubkey: &str, member_cert: &str) -> Result<()> {
509 let mut list = read_memberships()?;
510 list.retain(|m| m.get("org_did").and_then(Value::as_str) != Some(org_did));
511 list.push(serde_json::json!({
512 "org_did": org_did, "org_pubkey": org_pubkey, "member_cert": member_cert
513 }));
514 let path = memberships_path()?;
515 if let Some(p) = path.parent() {
516 fs::create_dir_all(p)?;
517 }
518 fs::write(&path, serde_json::to_vec_pretty(&Value::Array(list))?)?;
519 Ok(())
520}
521
522pub fn read_memberships() -> Result<Vec<Value>> {
524 let Ok(bytes) = fs::read(memberships_path()?) else {
525 return Ok(vec![]);
526 };
527 Ok(serde_json::from_slice::<Value>(&bytes)
528 .ok()
529 .and_then(|v| v.as_array().cloned())
530 .unwrap_or_default())
531}
532
533pub fn write_agent_card(card: &Value) -> Result<()> {
534 let path = agent_card_path()?;
535 let body = serde_json::to_vec_pretty(card)?;
536 let tmp = path.with_extension("json.tmp");
542 fs::write(&tmp, body).with_context(|| format!("writing tmp {tmp:?}"))?;
543 fs::rename(&tmp, &path).with_context(|| format!("atomic rename {tmp:?} → {path:?}"))?;
544 Ok(())
545}
546
547pub fn read_agent_card() -> Result<Value> {
548 let path = agent_card_path()?;
549 let body = fs::read(&path).with_context(|| format!("reading {path:?}"))?;
550 Ok(serde_json::from_slice(&body)?)
551}
552
553pub fn display_overrides_path() -> Result<PathBuf> {
561 Ok(config_dir()?.join("display.json"))
562}
563
564#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
565pub struct DisplayOverrides {
566 #[serde(default, skip_serializing_if = "Option::is_none")]
567 pub nickname: Option<String>,
568 #[serde(default, skip_serializing_if = "Option::is_none")]
569 pub emoji: Option<String>,
570}
571
572pub fn read_display_overrides() -> Result<DisplayOverrides> {
573 read_display_overrides_at(&display_overrides_path()?)
574}
575
576pub fn read_display_overrides_at(path: &Path) -> Result<DisplayOverrides> {
577 if !path.exists() {
578 return Ok(DisplayOverrides::default());
579 }
580 let body = fs::read(path).with_context(|| format!("reading {path:?}"))?;
581 Ok(serde_json::from_slice(&body)?)
582}
583
584pub fn write_display_overrides(overrides: &DisplayOverrides) -> Result<()> {
585 let path = display_overrides_path()?;
586 if let Some(parent) = path.parent() {
587 fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
588 }
589 let body = serde_json::to_vec_pretty(overrides)?;
590 let tmp = path.with_extension("json.tmp");
594 fs::write(&tmp, body).with_context(|| format!("writing tmp {tmp:?}"))?;
595 fs::rename(&tmp, &path).with_context(|| format!("atomic rename {tmp:?} → {path:?}"))?;
596 Ok(())
597}
598
599pub fn write_trust(trust: &Value) -> Result<()> {
600 let path = trust_path()?;
601 let body = serde_json::to_vec_pretty(trust)?;
602 fs::write(&path, body).with_context(|| format!("writing {path:?}"))?;
603 Ok(())
604}
605
606pub fn read_trust() -> Result<Value> {
607 let path = trust_path()?;
608 if !path.exists() {
609 return Ok(crate::trust::empty_trust());
610 }
611 let body = fs::read(&path).with_context(|| format!("reading {path:?}"))?;
612 Ok(serde_json::from_slice(&body)?)
613}
614
615pub fn relay_state_path() -> Result<PathBuf> {
620 Ok(config_dir()?.join("relay.json"))
621}
622
623pub fn read_relay_state() -> Result<Value> {
624 let path = relay_state_path()?;
625 if !path.exists() {
626 return Ok(serde_json::json!({"self": Value::Null, "peers": {}}));
627 }
628 let body = fs::read(&path).with_context(|| format!("reading {path:?}"))?;
629 Ok(serde_json::from_slice(&body)?)
630}
631
632pub fn write_relay_state(state: &Value) -> Result<()> {
645 use fs2::FileExt;
646 let lock_path = relay_state_lock_path()?;
647 if let Some(parent) = lock_path.parent() {
648 fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
649 }
650 let lock_file = fs::OpenOptions::new()
651 .create(true)
652 .truncate(false)
653 .read(true)
654 .write(true)
655 .open(&lock_path)
656 .with_context(|| format!("opening {lock_path:?}"))?;
657 lock_file
658 .lock_exclusive()
659 .with_context(|| format!("flock {lock_path:?}"))?;
660 let r = write_relay_state_unlocked(state);
661 let _ = fs2::FileExt::unlock(&lock_file);
662 r
663}
664
665fn write_relay_state_unlocked(state: &Value) -> Result<()> {
670 let path = relay_state_path()?;
671 let body = serde_json::to_vec_pretty(state)?;
672 let tmp = path.with_extension("json.tmp");
673 fs::write(&tmp, &body).with_context(|| format!("writing tmp {tmp:?}"))?;
674 set_file_mode_0600(&tmp)?;
675 fs::rename(&tmp, &path).with_context(|| format!("atomic rename {tmp:?} → {path:?}"))?;
676 Ok(())
677}
678
679fn relay_state_lock_path() -> Result<PathBuf> {
684 Ok(config_dir()?.join("relay.lock"))
685}
686
687pub fn update_relay_state<F>(modifier: F) -> Result<()>
702where
703 F: FnOnce(&mut Value) -> Result<()>,
704{
705 use fs2::FileExt;
706 let lock_path = relay_state_lock_path()?;
707 if let Some(parent) = lock_path.parent() {
708 fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
709 }
710 let lock_file = fs::OpenOptions::new()
713 .create(true)
714 .truncate(false)
715 .read(true)
716 .write(true)
717 .open(&lock_path)
718 .with_context(|| format!("opening {lock_path:?}"))?;
719 lock_file
720 .lock_exclusive()
721 .with_context(|| format!("flock {lock_path:?}"))?;
722
723 let mut state = read_relay_state()?;
726 let result = modifier(&mut state);
727 let write_result = if result.is_ok() {
728 write_relay_state_unlocked(&state)
731 } else {
732 Ok(())
733 };
734 let _ = fs2::FileExt::unlock(&lock_file);
737 result?;
738 write_result?;
739 Ok(())
740}
741
742#[cfg(test)]
747pub(crate) mod test_support {
748 use std::sync::Mutex;
749
750 pub static ENV_LOCK: Mutex<()> = Mutex::new(());
751
752 pub fn with_temp_home<F: FnOnce()>(f: F) {
753 let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
755 let tmp = std::env::temp_dir().join(format!("wire-test-{}", rand::random::<u32>()));
756 unsafe { std::env::set_var("WIRE_HOME", &tmp) };
758 let _ = std::fs::remove_dir_all(&tmp);
759 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
760 unsafe { std::env::remove_var("WIRE_HOME") };
761 let _ = std::fs::remove_dir_all(&tmp);
762 if let Err(e) = result {
763 std::panic::resume_unwind(e);
764 }
765 }
766}
767
768#[cfg(test)]
769mod tests {
770 use super::*;
771 use serde_json::json;
772
773 #[test]
774 fn did_filename_sanitizes_did_punctuation() {
775 assert_eq!(
776 did_filename("did:wire:org:slanchaai-abc123"),
777 "did_wire_org_slanchaai-abc123"
778 );
779 let f = did_filename("did:wire:org:x/../../etc");
781 assert!(!f.contains('/') && !f.contains('.'));
782 }
783
784 #[test]
785 fn op_and_org_key_roundtrip() {
786 with_temp_home(|| {
787 let op_seed = [7u8; 32];
788 write_op_key(&op_seed).unwrap();
789 assert_eq!(read_op_key().unwrap(), op_seed);
790
791 let org_did = "did:wire:org:slanchaai-deadbeef";
792 let org_seed = [9u8; 32];
793 write_org_key(org_did, &org_seed).unwrap();
794 assert_eq!(read_org_key(org_did).unwrap(), org_seed);
795 });
796 }
797
798 fn with_temp_home<F: FnOnce()>(f: F) {
799 super::test_support::with_temp_home(f)
800 }
801
802 #[test]
803 fn config_dir_honors_wire_home() {
804 with_temp_home(|| {
805 let dir = config_dir().unwrap();
806 assert!(dir.ends_with("wire"), "got {dir:?}");
807 assert!(dir.to_string_lossy().contains("wire-test-"));
808 });
809 }
810
811 #[test]
812 fn ensure_dirs_creates_layout() {
813 with_temp_home(|| {
814 ensure_dirs().unwrap();
815 assert!(config_dir().unwrap().is_dir());
816 assert!(state_dir().unwrap().is_dir());
817 assert!(inbox_dir().unwrap().is_dir());
818 assert!(outbox_dir().unwrap().is_dir());
819 });
820 }
821
822 #[test]
823 fn private_key_roundtrip() {
824 with_temp_home(|| {
825 ensure_dirs().unwrap();
826 let seed = [42u8; 32];
827 write_private_key(&seed).unwrap();
828 let read_back = read_private_key().unwrap();
829 assert_eq!(seed, read_back);
830 });
831 }
832
833 #[test]
834 fn agent_card_roundtrip() {
835 with_temp_home(|| {
836 ensure_dirs().unwrap();
837 let card = json!({"did": "did:wire:paul", "name": "Paul"});
838 write_agent_card(&card).unwrap();
839 let read_back = read_agent_card().unwrap();
840 assert_eq!(card, read_back);
841 });
842 }
843
844 #[test]
845 fn trust_returns_empty_when_missing() {
846 with_temp_home(|| {
847 ensure_dirs().unwrap();
848 let t = read_trust().unwrap();
849 assert_eq!(t["version"], 1);
850 assert!(t["agents"].is_object());
851 });
852 }
853
854 #[test]
855 fn update_relay_state_writes_through_lock() {
856 with_temp_home(|| {
862 ensure_dirs().unwrap();
863 let initial = json!({"self": null, "peers": {}});
865 write_relay_state(&initial).unwrap();
866 super::update_relay_state(|state| {
868 state["self"] = json!({
869 "relay_url": "https://test",
870 "slot_id": "abc",
871 "slot_token": "tok",
872 });
873 Ok(())
874 })
875 .unwrap();
876 let after = read_relay_state().unwrap();
878 assert_eq!(after["self"]["relay_url"], "https://test");
879 assert_eq!(after["self"]["slot_id"], "abc");
880 });
881 }
882
883 #[test]
884 fn write_relay_state_never_tears_under_concurrency() {
885 with_temp_home(|| {
892 ensure_dirs().unwrap();
893 write_relay_state(&json!({"self": null, "peers": {}})).unwrap();
894 let handles: Vec<_> = (0..8)
895 .map(|w| {
896 std::thread::spawn(move || {
897 for j in 0..25 {
898 let body = if j % 2 == 0 {
899 json!({"self": {"w": w, "j": j, "pad": "x".repeat(2048)}})
900 } else {
901 json!({"self": {"w": w}})
902 };
903 write_relay_state(&body).unwrap();
904 read_relay_state().expect("relay.json must always parse");
906 }
907 })
908 })
909 .collect();
910 for h in handles {
911 h.join().unwrap();
912 }
913 assert!(read_relay_state().unwrap().get("self").is_some());
914 });
915 }
916
917 #[test]
918 fn update_relay_state_modifier_error_does_not_clobber() {
919 with_temp_home(|| {
923 ensure_dirs().unwrap();
924 let initial = json!({"self": {"relay_url": "https://prior"}, "peers": {}});
925 write_relay_state(&initial).unwrap();
926 let result = super::update_relay_state(|state| {
927 state["self"] = json!({"relay_url": "https://NEVER_PERSIST"});
929 anyhow::bail!("simulated mid-RMW error")
931 });
932 assert!(result.is_err());
933 let after = read_relay_state().unwrap();
934 assert_eq!(
935 after["self"]["relay_url"], "https://prior",
936 "state on disk must not reflect aborted modifier"
937 );
938 });
939 }
940
941 #[test]
942 fn is_initialized_true_only_after_both_files_written() {
943 with_temp_home(|| {
944 ensure_dirs().unwrap();
945 assert!(!is_initialized().unwrap());
946 write_private_key(&[0u8; 32]).unwrap();
947 assert!(!is_initialized().unwrap()); write_agent_card(&json!({"did": "did:wire:paul"})).unwrap();
949 assert!(is_initialized().unwrap());
950 });
951 }
952
953 #[cfg(unix)]
954 #[test]
955 fn append_outbox_record_normalizes_fqdn_to_bare_handle() {
956 with_temp_home(|| {
960 let path_fqdn = append_outbox_record("bob@wireup.net", b"{\"kind\":1100}").unwrap();
961 let path_bare = append_outbox_record("bob", b"{\"kind\":1100}").unwrap();
962 assert_eq!(path_fqdn, path_bare, "FQDN form should normalize to bare");
964 assert!(
965 path_fqdn.file_name().unwrap().to_string_lossy() == "bob.jsonl",
966 "expected bob.jsonl, got {path_fqdn:?}"
967 );
968 let outbox = outbox_dir().unwrap();
970 assert!(
971 !outbox.join("bob@wireup.net.jsonl").exists(),
972 "FQDN-named file must not be created"
973 );
974 let body = std::fs::read_to_string(&path_bare).unwrap();
976 assert_eq!(body.matches("kind").count(), 2, "got: {body}");
977 });
978 }
979
980 #[test]
981 fn pending_push_breakdown_attributes_per_peer_with_tier() {
982 with_temp_home(|| {
983 ensure_dirs().unwrap();
984 let trust = json!({
986 "agents": {
987 "alpha-fox": {"tier": "VERIFIED"},
988 "beta-newt": {"tier": "PENDING_ACK"},
989 "gamma-otter": {"tier": "UNTRUSTED"},
990 }
991 });
992 write_trust(&trust).unwrap();
993 let relay = json!({
999 "self": null,
1000 "peers": {
1001 "alpha-fox": {
1002 "bilateral_completed_at": "2026-06-01T00:00:00Z"
1003 }
1004 }
1005 });
1006 write_relay_state(&relay).unwrap();
1007 let out = outbox_dir().unwrap();
1015 std::fs::write(
1016 out.join("alpha-fox.jsonl"),
1017 "{\"event_id\":\"a1\"}\n{\"event_id\":\"a2\"}\n",
1018 )
1019 .unwrap();
1020 std::fs::write(
1021 out.join("alpha-fox.pushed.jsonl"),
1022 "{\"event_id\":\"a1\"}\n",
1023 )
1024 .unwrap();
1025 std::fs::write(
1026 out.join("beta-newt.jsonl"),
1027 "{\"event_id\":\"b1\"}\n{\"event_id\":\"b2\"}\n{\"event_id\":\"b3\"}\n",
1028 )
1029 .unwrap();
1030 let bd = compute_pending_push_breakdown();
1031 assert_eq!(bd.len(), 2, "got: {bd:?}");
1032 assert_eq!(bd[0].peer, "beta-newt");
1033 assert_eq!(bd[0].tier, "PENDING_ACK");
1034 assert_eq!(bd[0].count, 3);
1035 assert_eq!(bd[1].peer, "alpha-fox");
1036 assert_eq!(bd[1].tier, "VERIFIED");
1037 assert_eq!(bd[1].count, 1);
1038 assert_eq!(compute_pending_push_count(), 4);
1040 });
1041 }
1042
1043 #[test]
1044 fn private_key_is_mode_0600() {
1045 use std::os::unix::fs::PermissionsExt;
1046 with_temp_home(|| {
1047 ensure_dirs().unwrap();
1048 write_private_key(&[1u8; 32]).unwrap();
1049 let mode = fs::metadata(private_key_path().unwrap())
1050 .unwrap()
1051 .permissions()
1052 .mode();
1053 assert_eq!(mode & 0o777, 0o600, "got {:o}", mode & 0o777);
1054 });
1055 }
1056}