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_outbox_record(peer: &str, record_bytes: &[u8]) -> Result<PathBuf> {
110 ensure_dirs()?;
111 let normalized = crate::agent_card::bare_handle(peer);
112 let path = outbox_dir()?.join(format!("{normalized}.jsonl"));
113 let lock = outbox_lock(&path);
114 let _g = lock.lock().expect("outbox per-path mutex poisoned");
115 let mut f = fs::OpenOptions::new()
116 .create(true)
117 .append(true)
118 .open(&path)
119 .with_context(|| format!("opening outbox {path:?}"))?;
120 let mut buf = Vec::with_capacity(record_bytes.len() + 1);
121 buf.extend_from_slice(record_bytes);
122 buf.push(b'\n');
123 f.write_all(&buf)
124 .with_context(|| format!("appending to {path:?}"))?;
125 Ok(path)
126}
127
128pub fn is_initialized() -> Result<bool> {
130 Ok(private_key_path()?.exists() && agent_card_path()?.exists())
131}
132
133pub fn ensure_dirs() -> Result<()> {
135 let cfg = config_dir()?;
136 fs::create_dir_all(&cfg).with_context(|| format!("creating {cfg:?}"))?;
137 fs::create_dir_all(state_dir()?)?;
138 fs::create_dir_all(inbox_dir()?)?;
139 fs::create_dir_all(outbox_dir()?)?;
140 set_dir_mode_0700(&cfg)?;
141 Ok(())
142}
143
144#[cfg(unix)]
145fn set_dir_mode_0700(path: &Path) -> Result<()> {
146 use std::os::unix::fs::PermissionsExt;
147 let mut perms = fs::metadata(path)?.permissions();
148 perms.set_mode(0o700);
149 fs::set_permissions(path, perms)?;
150 Ok(())
151}
152
153#[cfg(not(unix))]
154fn set_dir_mode_0700(_: &Path) -> Result<()> {
155 Ok(())
156}
157
158pub fn write_private_key(seed: &[u8; 32]) -> Result<()> {
160 let path = private_key_path()?;
161 fs::write(&path, seed).with_context(|| format!("writing {path:?}"))?;
162 set_file_mode_0600(&path)?;
163 Ok(())
164}
165
166#[cfg(unix)]
167fn set_file_mode_0600(path: &Path) -> Result<()> {
168 use std::os::unix::fs::PermissionsExt;
169 let mut perms = fs::metadata(path)?.permissions();
170 perms.set_mode(0o600);
171 fs::set_permissions(path, perms)?;
172 Ok(())
173}
174
175#[cfg(not(unix))]
176fn set_file_mode_0600(_: &Path) -> Result<()> {
177 Ok(())
178}
179
180pub fn read_private_key() -> Result<[u8; 32]> {
182 let path = private_key_path()?;
183 let bytes = fs::read(&path).with_context(|| format!("reading {path:?}"))?;
184 if bytes.len() != 32 {
185 return Err(anyhow!(
186 "private key file has wrong length ({} != 32)",
187 bytes.len()
188 ));
189 }
190 let mut seed = [0u8; 32];
191 seed.copy_from_slice(&bytes);
192 Ok(seed)
193}
194
195pub fn op_key_path() -> Result<PathBuf> {
201 Ok(config_dir()?.join("op.key"))
202}
203
204fn did_filename(did: &str) -> String {
206 did.chars()
207 .map(|c| {
208 if c.is_ascii_alphanumeric() || c == '-' {
209 c
210 } else {
211 '_'
212 }
213 })
214 .collect()
215}
216
217pub fn org_key_path(org_did: &str) -> Result<PathBuf> {
218 Ok(config_dir()?
219 .join("orgs")
220 .join(format!("{}.key", did_filename(org_did))))
221}
222
223fn write_seed_0600(path: &Path, seed: &[u8; 32]) -> Result<()> {
224 if let Some(parent) = path.parent() {
225 fs::create_dir_all(parent)?;
226 }
227 fs::write(path, seed).with_context(|| format!("writing {path:?}"))?;
228 set_file_mode_0600(path)?;
229 Ok(())
230}
231
232fn read_seed(path: &Path) -> Result<[u8; 32]> {
233 let bytes = fs::read(path).with_context(|| format!("reading {path:?}"))?;
234 if bytes.len() != 32 {
235 return Err(anyhow!(
236 "key file {path:?} has wrong length ({} != 32)",
237 bytes.len()
238 ));
239 }
240 let mut seed = [0u8; 32];
241 seed.copy_from_slice(&bytes);
242 Ok(seed)
243}
244
245pub fn write_op_key(seed: &[u8; 32]) -> Result<()> {
246 write_seed_0600(&op_key_path()?, seed)
247}
248pub fn read_op_key() -> Result<[u8; 32]> {
249 read_seed(&op_key_path()?)
250}
251pub fn write_org_key(org_did: &str, seed: &[u8; 32]) -> Result<()> {
252 write_seed_0600(&org_key_path(org_did)?, seed)
253}
254pub fn read_org_key(org_did: &str) -> Result<[u8; 32]> {
255 read_seed(&org_key_path(org_did)?)
256}
257
258pub fn op_meta_path() -> Result<PathBuf> {
259 Ok(config_dir()?.join("op.json"))
260}
261
262pub fn write_op_handle(handle: &str) -> Result<()> {
265 let path = op_meta_path()?;
266 if let Some(p) = path.parent() {
267 fs::create_dir_all(p)?;
268 }
269 fs::write(
270 &path,
271 serde_json::to_vec_pretty(&serde_json::json!({ "handle": handle }))?,
272 )?;
273 set_file_mode_0600(&path)?;
274 Ok(())
275}
276
277pub fn read_op_handle() -> Result<Option<String>> {
278 let Ok(bytes) = fs::read(op_meta_path()?) else {
279 return Ok(None);
280 };
281 let v: Value = serde_json::from_slice(&bytes)?;
282 Ok(v.get("handle").and_then(Value::as_str).map(str::to_string))
283}
284
285pub fn memberships_path() -> Result<PathBuf> {
286 Ok(config_dir()?.join("memberships.json"))
287}
288
289pub fn add_membership(org_did: &str, org_pubkey: &str, member_cert: &str) -> Result<()> {
293 let mut list = read_memberships()?;
294 list.retain(|m| m.get("org_did").and_then(Value::as_str) != Some(org_did));
295 list.push(serde_json::json!({
296 "org_did": org_did, "org_pubkey": org_pubkey, "member_cert": member_cert
297 }));
298 let path = memberships_path()?;
299 if let Some(p) = path.parent() {
300 fs::create_dir_all(p)?;
301 }
302 fs::write(&path, serde_json::to_vec_pretty(&Value::Array(list))?)?;
303 Ok(())
304}
305
306pub fn read_memberships() -> Result<Vec<Value>> {
308 let Ok(bytes) = fs::read(memberships_path()?) else {
309 return Ok(vec![]);
310 };
311 Ok(serde_json::from_slice::<Value>(&bytes)
312 .ok()
313 .and_then(|v| v.as_array().cloned())
314 .unwrap_or_default())
315}
316
317pub fn write_agent_card(card: &Value) -> Result<()> {
318 let path = agent_card_path()?;
319 let body = serde_json::to_vec_pretty(card)?;
320 let tmp = path.with_extension("json.tmp");
326 fs::write(&tmp, body).with_context(|| format!("writing tmp {tmp:?}"))?;
327 fs::rename(&tmp, &path).with_context(|| format!("atomic rename {tmp:?} → {path:?}"))?;
328 Ok(())
329}
330
331pub fn read_agent_card() -> Result<Value> {
332 let path = agent_card_path()?;
333 let body = fs::read(&path).with_context(|| format!("reading {path:?}"))?;
334 Ok(serde_json::from_slice(&body)?)
335}
336
337pub fn display_overrides_path() -> Result<PathBuf> {
345 Ok(config_dir()?.join("display.json"))
346}
347
348#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
349pub struct DisplayOverrides {
350 #[serde(default, skip_serializing_if = "Option::is_none")]
351 pub nickname: Option<String>,
352 #[serde(default, skip_serializing_if = "Option::is_none")]
353 pub emoji: Option<String>,
354}
355
356pub fn read_display_overrides() -> Result<DisplayOverrides> {
357 read_display_overrides_at(&display_overrides_path()?)
358}
359
360pub fn read_display_overrides_at(path: &Path) -> Result<DisplayOverrides> {
361 if !path.exists() {
362 return Ok(DisplayOverrides::default());
363 }
364 let body = fs::read(path).with_context(|| format!("reading {path:?}"))?;
365 Ok(serde_json::from_slice(&body)?)
366}
367
368pub fn write_display_overrides(overrides: &DisplayOverrides) -> Result<()> {
369 let path = display_overrides_path()?;
370 if let Some(parent) = path.parent() {
371 fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
372 }
373 let body = serde_json::to_vec_pretty(overrides)?;
374 let tmp = path.with_extension("json.tmp");
378 fs::write(&tmp, body).with_context(|| format!("writing tmp {tmp:?}"))?;
379 fs::rename(&tmp, &path).with_context(|| format!("atomic rename {tmp:?} → {path:?}"))?;
380 Ok(())
381}
382
383pub fn write_trust(trust: &Value) -> Result<()> {
384 let path = trust_path()?;
385 let body = serde_json::to_vec_pretty(trust)?;
386 fs::write(&path, body).with_context(|| format!("writing {path:?}"))?;
387 Ok(())
388}
389
390pub fn read_trust() -> Result<Value> {
391 let path = trust_path()?;
392 if !path.exists() {
393 return Ok(crate::trust::empty_trust());
394 }
395 let body = fs::read(&path).with_context(|| format!("reading {path:?}"))?;
396 Ok(serde_json::from_slice(&body)?)
397}
398
399pub fn relay_state_path() -> Result<PathBuf> {
404 Ok(config_dir()?.join("relay.json"))
405}
406
407pub fn read_relay_state() -> Result<Value> {
408 let path = relay_state_path()?;
409 if !path.exists() {
410 return Ok(serde_json::json!({"self": Value::Null, "peers": {}}));
411 }
412 let body = fs::read(&path).with_context(|| format!("reading {path:?}"))?;
413 Ok(serde_json::from_slice(&body)?)
414}
415
416pub fn write_relay_state(state: &Value) -> Result<()> {
429 use fs2::FileExt;
430 let lock_path = relay_state_lock_path()?;
431 if let Some(parent) = lock_path.parent() {
432 fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
433 }
434 let lock_file = fs::OpenOptions::new()
435 .create(true)
436 .truncate(false)
437 .read(true)
438 .write(true)
439 .open(&lock_path)
440 .with_context(|| format!("opening {lock_path:?}"))?;
441 lock_file
442 .lock_exclusive()
443 .with_context(|| format!("flock {lock_path:?}"))?;
444 let r = write_relay_state_unlocked(state);
445 let _ = fs2::FileExt::unlock(&lock_file);
446 r
447}
448
449fn write_relay_state_unlocked(state: &Value) -> Result<()> {
454 let path = relay_state_path()?;
455 let body = serde_json::to_vec_pretty(state)?;
456 let tmp = path.with_extension("json.tmp");
457 fs::write(&tmp, &body).with_context(|| format!("writing tmp {tmp:?}"))?;
458 set_file_mode_0600(&tmp)?;
459 fs::rename(&tmp, &path).with_context(|| format!("atomic rename {tmp:?} → {path:?}"))?;
460 Ok(())
461}
462
463fn relay_state_lock_path() -> Result<PathBuf> {
468 Ok(config_dir()?.join("relay.lock"))
469}
470
471pub fn update_relay_state<F>(modifier: F) -> Result<()>
486where
487 F: FnOnce(&mut Value) -> Result<()>,
488{
489 use fs2::FileExt;
490 let lock_path = relay_state_lock_path()?;
491 if let Some(parent) = lock_path.parent() {
492 fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
493 }
494 let lock_file = fs::OpenOptions::new()
497 .create(true)
498 .truncate(false)
499 .read(true)
500 .write(true)
501 .open(&lock_path)
502 .with_context(|| format!("opening {lock_path:?}"))?;
503 lock_file
504 .lock_exclusive()
505 .with_context(|| format!("flock {lock_path:?}"))?;
506
507 let mut state = read_relay_state()?;
510 let result = modifier(&mut state);
511 let write_result = if result.is_ok() {
512 write_relay_state_unlocked(&state)
515 } else {
516 Ok(())
517 };
518 let _ = fs2::FileExt::unlock(&lock_file);
521 result?;
522 write_result?;
523 Ok(())
524}
525
526#[cfg(test)]
531pub(crate) mod test_support {
532 use std::sync::Mutex;
533
534 pub static ENV_LOCK: Mutex<()> = Mutex::new(());
535
536 pub fn with_temp_home<F: FnOnce()>(f: F) {
537 let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
539 let tmp = std::env::temp_dir().join(format!("wire-test-{}", rand::random::<u32>()));
540 unsafe { std::env::set_var("WIRE_HOME", &tmp) };
542 let _ = std::fs::remove_dir_all(&tmp);
543 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
544 unsafe { std::env::remove_var("WIRE_HOME") };
545 let _ = std::fs::remove_dir_all(&tmp);
546 if let Err(e) = result {
547 std::panic::resume_unwind(e);
548 }
549 }
550}
551
552#[cfg(test)]
553mod tests {
554 use super::*;
555 use serde_json::json;
556
557 #[test]
558 fn did_filename_sanitizes_did_punctuation() {
559 assert_eq!(
560 did_filename("did:wire:org:slanchaai-abc123"),
561 "did_wire_org_slanchaai-abc123"
562 );
563 let f = did_filename("did:wire:org:x/../../etc");
565 assert!(!f.contains('/') && !f.contains('.'));
566 }
567
568 #[test]
569 fn op_and_org_key_roundtrip() {
570 with_temp_home(|| {
571 let op_seed = [7u8; 32];
572 write_op_key(&op_seed).unwrap();
573 assert_eq!(read_op_key().unwrap(), op_seed);
574
575 let org_did = "did:wire:org:slanchaai-deadbeef";
576 let org_seed = [9u8; 32];
577 write_org_key(org_did, &org_seed).unwrap();
578 assert_eq!(read_org_key(org_did).unwrap(), org_seed);
579 });
580 }
581
582 fn with_temp_home<F: FnOnce()>(f: F) {
583 super::test_support::with_temp_home(f)
584 }
585
586 #[test]
587 fn config_dir_honors_wire_home() {
588 with_temp_home(|| {
589 let dir = config_dir().unwrap();
590 assert!(dir.ends_with("wire"), "got {dir:?}");
591 assert!(dir.to_string_lossy().contains("wire-test-"));
592 });
593 }
594
595 #[test]
596 fn ensure_dirs_creates_layout() {
597 with_temp_home(|| {
598 ensure_dirs().unwrap();
599 assert!(config_dir().unwrap().is_dir());
600 assert!(state_dir().unwrap().is_dir());
601 assert!(inbox_dir().unwrap().is_dir());
602 assert!(outbox_dir().unwrap().is_dir());
603 });
604 }
605
606 #[test]
607 fn private_key_roundtrip() {
608 with_temp_home(|| {
609 ensure_dirs().unwrap();
610 let seed = [42u8; 32];
611 write_private_key(&seed).unwrap();
612 let read_back = read_private_key().unwrap();
613 assert_eq!(seed, read_back);
614 });
615 }
616
617 #[test]
618 fn agent_card_roundtrip() {
619 with_temp_home(|| {
620 ensure_dirs().unwrap();
621 let card = json!({"did": "did:wire:paul", "name": "Paul"});
622 write_agent_card(&card).unwrap();
623 let read_back = read_agent_card().unwrap();
624 assert_eq!(card, read_back);
625 });
626 }
627
628 #[test]
629 fn trust_returns_empty_when_missing() {
630 with_temp_home(|| {
631 ensure_dirs().unwrap();
632 let t = read_trust().unwrap();
633 assert_eq!(t["version"], 1);
634 assert!(t["agents"].is_object());
635 });
636 }
637
638 #[test]
639 fn update_relay_state_writes_through_lock() {
640 with_temp_home(|| {
646 ensure_dirs().unwrap();
647 let initial = json!({"self": null, "peers": {}});
649 write_relay_state(&initial).unwrap();
650 super::update_relay_state(|state| {
652 state["self"] = json!({
653 "relay_url": "https://test",
654 "slot_id": "abc",
655 "slot_token": "tok",
656 });
657 Ok(())
658 })
659 .unwrap();
660 let after = read_relay_state().unwrap();
662 assert_eq!(after["self"]["relay_url"], "https://test");
663 assert_eq!(after["self"]["slot_id"], "abc");
664 });
665 }
666
667 #[test]
668 fn write_relay_state_never_tears_under_concurrency() {
669 with_temp_home(|| {
676 ensure_dirs().unwrap();
677 write_relay_state(&json!({"self": null, "peers": {}})).unwrap();
678 let handles: Vec<_> = (0..8)
679 .map(|w| {
680 std::thread::spawn(move || {
681 for j in 0..25 {
682 let body = if j % 2 == 0 {
683 json!({"self": {"w": w, "j": j, "pad": "x".repeat(2048)}})
684 } else {
685 json!({"self": {"w": w}})
686 };
687 write_relay_state(&body).unwrap();
688 read_relay_state().expect("relay.json must always parse");
690 }
691 })
692 })
693 .collect();
694 for h in handles {
695 h.join().unwrap();
696 }
697 assert!(read_relay_state().unwrap().get("self").is_some());
698 });
699 }
700
701 #[test]
702 fn update_relay_state_modifier_error_does_not_clobber() {
703 with_temp_home(|| {
707 ensure_dirs().unwrap();
708 let initial = json!({"self": {"relay_url": "https://prior"}, "peers": {}});
709 write_relay_state(&initial).unwrap();
710 let result = super::update_relay_state(|state| {
711 state["self"] = json!({"relay_url": "https://NEVER_PERSIST"});
713 anyhow::bail!("simulated mid-RMW error")
715 });
716 assert!(result.is_err());
717 let after = read_relay_state().unwrap();
718 assert_eq!(
719 after["self"]["relay_url"], "https://prior",
720 "state on disk must not reflect aborted modifier"
721 );
722 });
723 }
724
725 #[test]
726 fn is_initialized_true_only_after_both_files_written() {
727 with_temp_home(|| {
728 ensure_dirs().unwrap();
729 assert!(!is_initialized().unwrap());
730 write_private_key(&[0u8; 32]).unwrap();
731 assert!(!is_initialized().unwrap()); write_agent_card(&json!({"did": "did:wire:paul"})).unwrap();
733 assert!(is_initialized().unwrap());
734 });
735 }
736
737 #[cfg(unix)]
738 #[test]
739 fn append_outbox_record_normalizes_fqdn_to_bare_handle() {
740 with_temp_home(|| {
744 let path_fqdn = append_outbox_record("bob@wireup.net", b"{\"kind\":1100}").unwrap();
745 let path_bare = append_outbox_record("bob", b"{\"kind\":1100}").unwrap();
746 assert_eq!(path_fqdn, path_bare, "FQDN form should normalize to bare");
748 assert!(
749 path_fqdn.file_name().unwrap().to_string_lossy() == "bob.jsonl",
750 "expected bob.jsonl, got {path_fqdn:?}"
751 );
752 let outbox = outbox_dir().unwrap();
754 assert!(
755 !outbox.join("bob@wireup.net.jsonl").exists(),
756 "FQDN-named file must not be created"
757 );
758 let body = std::fs::read_to_string(&path_bare).unwrap();
760 assert_eq!(body.matches("kind").count(), 2, "got: {body}");
761 });
762 }
763
764 #[test]
765 fn private_key_is_mode_0600() {
766 use std::os::unix::fs::PermissionsExt;
767 with_temp_home(|| {
768 ensure_dirs().unwrap();
769 write_private_key(&[1u8; 32]).unwrap();
770 let mode = fs::metadata(private_key_path().unwrap())
771 .unwrap()
772 .permissions()
773 .mode();
774 assert_eq!(mode & 0o777, 0o600, "got {:o}", mode & 0o777);
775 });
776 }
777}