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 write_agent_card(card: &Value) -> Result<()> {
196 let path = agent_card_path()?;
197 let body = serde_json::to_vec_pretty(card)?;
198 let tmp = path.with_extension("json.tmp");
204 fs::write(&tmp, body).with_context(|| format!("writing tmp {tmp:?}"))?;
205 fs::rename(&tmp, &path).with_context(|| format!("atomic rename {tmp:?} → {path:?}"))?;
206 Ok(())
207}
208
209pub fn read_agent_card() -> Result<Value> {
210 let path = agent_card_path()?;
211 let body = fs::read(&path).with_context(|| format!("reading {path:?}"))?;
212 Ok(serde_json::from_slice(&body)?)
213}
214
215pub fn display_overrides_path() -> Result<PathBuf> {
223 Ok(config_dir()?.join("display.json"))
224}
225
226#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
227pub struct DisplayOverrides {
228 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub nickname: Option<String>,
230 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub emoji: Option<String>,
232}
233
234pub fn read_display_overrides() -> Result<DisplayOverrides> {
235 read_display_overrides_at(&display_overrides_path()?)
236}
237
238pub fn read_display_overrides_at(path: &Path) -> Result<DisplayOverrides> {
239 if !path.exists() {
240 return Ok(DisplayOverrides::default());
241 }
242 let body = fs::read(path).with_context(|| format!("reading {path:?}"))?;
243 Ok(serde_json::from_slice(&body)?)
244}
245
246pub fn write_display_overrides(overrides: &DisplayOverrides) -> Result<()> {
247 let path = display_overrides_path()?;
248 if let Some(parent) = path.parent() {
249 fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
250 }
251 let body = serde_json::to_vec_pretty(overrides)?;
252 let tmp = path.with_extension("json.tmp");
256 fs::write(&tmp, body).with_context(|| format!("writing tmp {tmp:?}"))?;
257 fs::rename(&tmp, &path).with_context(|| format!("atomic rename {tmp:?} → {path:?}"))?;
258 Ok(())
259}
260
261pub fn write_trust(trust: &Value) -> Result<()> {
262 let path = trust_path()?;
263 let body = serde_json::to_vec_pretty(trust)?;
264 fs::write(&path, body).with_context(|| format!("writing {path:?}"))?;
265 Ok(())
266}
267
268pub fn read_trust() -> Result<Value> {
269 let path = trust_path()?;
270 if !path.exists() {
271 return Ok(crate::trust::empty_trust());
272 }
273 let body = fs::read(&path).with_context(|| format!("reading {path:?}"))?;
274 Ok(serde_json::from_slice(&body)?)
275}
276
277pub fn relay_state_path() -> Result<PathBuf> {
282 Ok(config_dir()?.join("relay.json"))
283}
284
285pub fn read_relay_state() -> Result<Value> {
286 let path = relay_state_path()?;
287 if !path.exists() {
288 return Ok(serde_json::json!({"self": Value::Null, "peers": {}}));
289 }
290 let body = fs::read(&path).with_context(|| format!("reading {path:?}"))?;
291 Ok(serde_json::from_slice(&body)?)
292}
293
294pub fn write_relay_state(state: &Value) -> Result<()> {
307 use fs2::FileExt;
308 let lock_path = relay_state_lock_path()?;
309 if let Some(parent) = lock_path.parent() {
310 fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
311 }
312 let lock_file = fs::OpenOptions::new()
313 .create(true)
314 .truncate(false)
315 .read(true)
316 .write(true)
317 .open(&lock_path)
318 .with_context(|| format!("opening {lock_path:?}"))?;
319 lock_file
320 .lock_exclusive()
321 .with_context(|| format!("flock {lock_path:?}"))?;
322 let r = write_relay_state_unlocked(state);
323 let _ = fs2::FileExt::unlock(&lock_file);
324 r
325}
326
327fn write_relay_state_unlocked(state: &Value) -> Result<()> {
332 let path = relay_state_path()?;
333 let body = serde_json::to_vec_pretty(state)?;
334 let tmp = path.with_extension("json.tmp");
335 fs::write(&tmp, &body).with_context(|| format!("writing tmp {tmp:?}"))?;
336 set_file_mode_0600(&tmp)?;
337 fs::rename(&tmp, &path).with_context(|| format!("atomic rename {tmp:?} → {path:?}"))?;
338 Ok(())
339}
340
341fn relay_state_lock_path() -> Result<PathBuf> {
346 Ok(config_dir()?.join("relay.lock"))
347}
348
349pub fn update_relay_state<F>(modifier: F) -> Result<()>
364where
365 F: FnOnce(&mut Value) -> Result<()>,
366{
367 use fs2::FileExt;
368 let lock_path = relay_state_lock_path()?;
369 if let Some(parent) = lock_path.parent() {
370 fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
371 }
372 let lock_file = fs::OpenOptions::new()
375 .create(true)
376 .truncate(false)
377 .read(true)
378 .write(true)
379 .open(&lock_path)
380 .with_context(|| format!("opening {lock_path:?}"))?;
381 lock_file
382 .lock_exclusive()
383 .with_context(|| format!("flock {lock_path:?}"))?;
384
385 let mut state = read_relay_state()?;
388 let result = modifier(&mut state);
389 let write_result = if result.is_ok() {
390 write_relay_state_unlocked(&state)
393 } else {
394 Ok(())
395 };
396 let _ = fs2::FileExt::unlock(&lock_file);
399 result?;
400 write_result?;
401 Ok(())
402}
403
404#[cfg(test)]
409pub(crate) mod test_support {
410 use std::sync::Mutex;
411
412 pub static ENV_LOCK: Mutex<()> = Mutex::new(());
413
414 pub fn with_temp_home<F: FnOnce()>(f: F) {
415 let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
417 let tmp = std::env::temp_dir().join(format!("wire-test-{}", rand::random::<u32>()));
418 unsafe { std::env::set_var("WIRE_HOME", &tmp) };
420 let _ = std::fs::remove_dir_all(&tmp);
421 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
422 unsafe { std::env::remove_var("WIRE_HOME") };
423 let _ = std::fs::remove_dir_all(&tmp);
424 if let Err(e) = result {
425 std::panic::resume_unwind(e);
426 }
427 }
428}
429
430#[cfg(test)]
431mod tests {
432 use super::*;
433 use serde_json::json;
434
435 fn with_temp_home<F: FnOnce()>(f: F) {
436 super::test_support::with_temp_home(f)
437 }
438
439 #[test]
440 fn config_dir_honors_wire_home() {
441 with_temp_home(|| {
442 let dir = config_dir().unwrap();
443 assert!(dir.ends_with("wire"), "got {dir:?}");
444 assert!(dir.to_string_lossy().contains("wire-test-"));
445 });
446 }
447
448 #[test]
449 fn ensure_dirs_creates_layout() {
450 with_temp_home(|| {
451 ensure_dirs().unwrap();
452 assert!(config_dir().unwrap().is_dir());
453 assert!(state_dir().unwrap().is_dir());
454 assert!(inbox_dir().unwrap().is_dir());
455 assert!(outbox_dir().unwrap().is_dir());
456 });
457 }
458
459 #[test]
460 fn private_key_roundtrip() {
461 with_temp_home(|| {
462 ensure_dirs().unwrap();
463 let seed = [42u8; 32];
464 write_private_key(&seed).unwrap();
465 let read_back = read_private_key().unwrap();
466 assert_eq!(seed, read_back);
467 });
468 }
469
470 #[test]
471 fn agent_card_roundtrip() {
472 with_temp_home(|| {
473 ensure_dirs().unwrap();
474 let card = json!({"did": "did:wire:paul", "name": "Paul"});
475 write_agent_card(&card).unwrap();
476 let read_back = read_agent_card().unwrap();
477 assert_eq!(card, read_back);
478 });
479 }
480
481 #[test]
482 fn trust_returns_empty_when_missing() {
483 with_temp_home(|| {
484 ensure_dirs().unwrap();
485 let t = read_trust().unwrap();
486 assert_eq!(t["version"], 1);
487 assert!(t["agents"].is_object());
488 });
489 }
490
491 #[test]
492 fn update_relay_state_writes_through_lock() {
493 with_temp_home(|| {
499 ensure_dirs().unwrap();
500 let initial = json!({"self": null, "peers": {}});
502 write_relay_state(&initial).unwrap();
503 super::update_relay_state(|state| {
505 state["self"] = json!({
506 "relay_url": "https://test",
507 "slot_id": "abc",
508 "slot_token": "tok",
509 });
510 Ok(())
511 })
512 .unwrap();
513 let after = read_relay_state().unwrap();
515 assert_eq!(after["self"]["relay_url"], "https://test");
516 assert_eq!(after["self"]["slot_id"], "abc");
517 });
518 }
519
520 #[test]
521 fn write_relay_state_never_tears_under_concurrency() {
522 with_temp_home(|| {
529 ensure_dirs().unwrap();
530 write_relay_state(&json!({"self": null, "peers": {}})).unwrap();
531 let handles: Vec<_> = (0..8)
532 .map(|w| {
533 std::thread::spawn(move || {
534 for j in 0..25 {
535 let body = if j % 2 == 0 {
536 json!({"self": {"w": w, "j": j, "pad": "x".repeat(2048)}})
537 } else {
538 json!({"self": {"w": w}})
539 };
540 write_relay_state(&body).unwrap();
541 read_relay_state().expect("relay.json must always parse");
543 }
544 })
545 })
546 .collect();
547 for h in handles {
548 h.join().unwrap();
549 }
550 assert!(read_relay_state().unwrap().get("self").is_some());
551 });
552 }
553
554 #[test]
555 fn update_relay_state_modifier_error_does_not_clobber() {
556 with_temp_home(|| {
560 ensure_dirs().unwrap();
561 let initial = json!({"self": {"relay_url": "https://prior"}, "peers": {}});
562 write_relay_state(&initial).unwrap();
563 let result = super::update_relay_state(|state| {
564 state["self"] = json!({"relay_url": "https://NEVER_PERSIST"});
566 anyhow::bail!("simulated mid-RMW error")
568 });
569 assert!(result.is_err());
570 let after = read_relay_state().unwrap();
571 assert_eq!(
572 after["self"]["relay_url"], "https://prior",
573 "state on disk must not reflect aborted modifier"
574 );
575 });
576 }
577
578 #[test]
579 fn is_initialized_true_only_after_both_files_written() {
580 with_temp_home(|| {
581 ensure_dirs().unwrap();
582 assert!(!is_initialized().unwrap());
583 write_private_key(&[0u8; 32]).unwrap();
584 assert!(!is_initialized().unwrap()); write_agent_card(&json!({"did": "did:wire:paul"})).unwrap();
586 assert!(is_initialized().unwrap());
587 });
588 }
589
590 #[cfg(unix)]
591 #[test]
592 fn append_outbox_record_normalizes_fqdn_to_bare_handle() {
593 with_temp_home(|| {
597 let path_fqdn = append_outbox_record("bob@wireup.net", b"{\"kind\":1100}").unwrap();
598 let path_bare = append_outbox_record("bob", b"{\"kind\":1100}").unwrap();
599 assert_eq!(path_fqdn, path_bare, "FQDN form should normalize to bare");
601 assert!(
602 path_fqdn.file_name().unwrap().to_string_lossy() == "bob.jsonl",
603 "expected bob.jsonl, got {path_fqdn:?}"
604 );
605 let outbox = outbox_dir().unwrap();
607 assert!(
608 !outbox.join("bob@wireup.net.jsonl").exists(),
609 "FQDN-named file must not be created"
610 );
611 let body = std::fs::read_to_string(&path_bare).unwrap();
613 assert_eq!(body.matches("kind").count(), 2, "got: {body}");
614 });
615 }
616
617 #[test]
618 fn private_key_is_mode_0600() {
619 use std::os::unix::fs::PermissionsExt;
620 with_temp_home(|| {
621 ensure_dirs().unwrap();
622 write_private_key(&[1u8; 32]).unwrap();
623 let mode = fs::metadata(private_key_path().unwrap())
624 .unwrap()
625 .permissions()
626 .mode();
627 assert_eq!(mode & 0o777, 0o600, "got {:o}", mode & 0o777);
628 });
629 }
630}