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> {
100 ensure_dirs()?;
101 let path = outbox_dir()?.join(format!("{peer}.jsonl"));
102 let lock = outbox_lock(&path);
103 let _g = lock.lock().expect("outbox per-path mutex poisoned");
104 let mut f = fs::OpenOptions::new()
105 .create(true)
106 .append(true)
107 .open(&path)
108 .with_context(|| format!("opening outbox {path:?}"))?;
109 let mut buf = Vec::with_capacity(record_bytes.len() + 1);
110 buf.extend_from_slice(record_bytes);
111 buf.push(b'\n');
112 f.write_all(&buf)
113 .with_context(|| format!("appending to {path:?}"))?;
114 Ok(path)
115}
116
117pub fn is_initialized() -> Result<bool> {
119 Ok(private_key_path()?.exists() && agent_card_path()?.exists())
120}
121
122pub fn ensure_dirs() -> Result<()> {
124 let cfg = config_dir()?;
125 fs::create_dir_all(&cfg).with_context(|| format!("creating {cfg:?}"))?;
126 fs::create_dir_all(state_dir()?)?;
127 fs::create_dir_all(inbox_dir()?)?;
128 fs::create_dir_all(outbox_dir()?)?;
129 set_dir_mode_0700(&cfg)?;
130 Ok(())
131}
132
133#[cfg(unix)]
134fn set_dir_mode_0700(path: &Path) -> Result<()> {
135 use std::os::unix::fs::PermissionsExt;
136 let mut perms = fs::metadata(path)?.permissions();
137 perms.set_mode(0o700);
138 fs::set_permissions(path, perms)?;
139 Ok(())
140}
141
142#[cfg(not(unix))]
143fn set_dir_mode_0700(_: &Path) -> Result<()> {
144 Ok(())
145}
146
147pub fn write_private_key(seed: &[u8; 32]) -> Result<()> {
149 let path = private_key_path()?;
150 fs::write(&path, seed).with_context(|| format!("writing {path:?}"))?;
151 set_file_mode_0600(&path)?;
152 Ok(())
153}
154
155#[cfg(unix)]
156fn set_file_mode_0600(path: &Path) -> Result<()> {
157 use std::os::unix::fs::PermissionsExt;
158 let mut perms = fs::metadata(path)?.permissions();
159 perms.set_mode(0o600);
160 fs::set_permissions(path, perms)?;
161 Ok(())
162}
163
164#[cfg(not(unix))]
165fn set_file_mode_0600(_: &Path) -> Result<()> {
166 Ok(())
167}
168
169pub fn read_private_key() -> Result<[u8; 32]> {
171 let path = private_key_path()?;
172 let bytes = fs::read(&path).with_context(|| format!("reading {path:?}"))?;
173 if bytes.len() != 32 {
174 return Err(anyhow!(
175 "private key file has wrong length ({} != 32)",
176 bytes.len()
177 ));
178 }
179 let mut seed = [0u8; 32];
180 seed.copy_from_slice(&bytes);
181 Ok(seed)
182}
183
184pub fn write_agent_card(card: &Value) -> Result<()> {
185 let path = agent_card_path()?;
186 let body = serde_json::to_vec_pretty(card)?;
187 fs::write(&path, body).with_context(|| format!("writing {path:?}"))?;
188 Ok(())
189}
190
191pub fn read_agent_card() -> Result<Value> {
192 let path = agent_card_path()?;
193 let body = fs::read(&path).with_context(|| format!("reading {path:?}"))?;
194 Ok(serde_json::from_slice(&body)?)
195}
196
197pub fn write_trust(trust: &Value) -> Result<()> {
198 let path = trust_path()?;
199 let body = serde_json::to_vec_pretty(trust)?;
200 fs::write(&path, body).with_context(|| format!("writing {path:?}"))?;
201 Ok(())
202}
203
204pub fn read_trust() -> Result<Value> {
205 let path = trust_path()?;
206 if !path.exists() {
207 return Ok(crate::trust::empty_trust());
208 }
209 let body = fs::read(&path).with_context(|| format!("reading {path:?}"))?;
210 Ok(serde_json::from_slice(&body)?)
211}
212
213pub fn relay_state_path() -> Result<PathBuf> {
218 Ok(config_dir()?.join("relay.json"))
219}
220
221pub fn read_relay_state() -> Result<Value> {
222 let path = relay_state_path()?;
223 if !path.exists() {
224 return Ok(serde_json::json!({"self": Value::Null, "peers": {}}));
225 }
226 let body = fs::read(&path).with_context(|| format!("reading {path:?}"))?;
227 Ok(serde_json::from_slice(&body)?)
228}
229
230pub fn write_relay_state(state: &Value) -> Result<()> {
231 let path = relay_state_path()?;
232 let body = serde_json::to_vec_pretty(state)?;
233 fs::write(&path, body).with_context(|| format!("writing {path:?}"))?;
234 set_file_mode_0600(&path)?;
235 Ok(())
236}
237
238fn relay_state_lock_path() -> Result<PathBuf> {
243 Ok(config_dir()?.join("relay.lock"))
244}
245
246pub fn update_relay_state<F>(modifier: F) -> Result<()>
261where
262 F: FnOnce(&mut Value) -> Result<()>,
263{
264 use fs2::FileExt;
265 let lock_path = relay_state_lock_path()?;
266 if let Some(parent) = lock_path.parent() {
267 fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
268 }
269 let lock_file = fs::OpenOptions::new()
272 .create(true)
273 .read(true)
274 .write(true)
275 .open(&lock_path)
276 .with_context(|| format!("opening {lock_path:?}"))?;
277 lock_file
278 .lock_exclusive()
279 .with_context(|| format!("flock {lock_path:?}"))?;
280
281 let mut state = read_relay_state()?;
284 let result = modifier(&mut state);
285 let write_result = if result.is_ok() {
286 write_relay_state(&state)
287 } else {
288 Ok(())
289 };
290 let _ = fs2::FileExt::unlock(&lock_file);
293 result?;
294 write_result?;
295 Ok(())
296}
297
298#[cfg(test)]
303pub(crate) mod test_support {
304 use std::sync::Mutex;
305
306 pub static ENV_LOCK: Mutex<()> = Mutex::new(());
307
308 pub fn with_temp_home<F: FnOnce()>(f: F) {
309 let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
311 let tmp = std::env::temp_dir().join(format!("wire-test-{}", rand::random::<u32>()));
312 unsafe { std::env::set_var("WIRE_HOME", &tmp) };
314 let _ = std::fs::remove_dir_all(&tmp);
315 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
316 unsafe { std::env::remove_var("WIRE_HOME") };
317 let _ = std::fs::remove_dir_all(&tmp);
318 if let Err(e) = result {
319 std::panic::resume_unwind(e);
320 }
321 }
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327 use serde_json::json;
328
329 fn with_temp_home<F: FnOnce()>(f: F) {
330 super::test_support::with_temp_home(f)
331 }
332
333 #[test]
334 fn config_dir_honors_wire_home() {
335 with_temp_home(|| {
336 let dir = config_dir().unwrap();
337 assert!(dir.ends_with("wire"), "got {dir:?}");
338 assert!(dir.to_string_lossy().contains("wire-test-"));
339 });
340 }
341
342 #[test]
343 fn ensure_dirs_creates_layout() {
344 with_temp_home(|| {
345 ensure_dirs().unwrap();
346 assert!(config_dir().unwrap().is_dir());
347 assert!(state_dir().unwrap().is_dir());
348 assert!(inbox_dir().unwrap().is_dir());
349 assert!(outbox_dir().unwrap().is_dir());
350 });
351 }
352
353 #[test]
354 fn private_key_roundtrip() {
355 with_temp_home(|| {
356 ensure_dirs().unwrap();
357 let seed = [42u8; 32];
358 write_private_key(&seed).unwrap();
359 let read_back = read_private_key().unwrap();
360 assert_eq!(seed, read_back);
361 });
362 }
363
364 #[test]
365 fn agent_card_roundtrip() {
366 with_temp_home(|| {
367 ensure_dirs().unwrap();
368 let card = json!({"did": "did:wire:paul", "name": "Paul"});
369 write_agent_card(&card).unwrap();
370 let read_back = read_agent_card().unwrap();
371 assert_eq!(card, read_back);
372 });
373 }
374
375 #[test]
376 fn trust_returns_empty_when_missing() {
377 with_temp_home(|| {
378 ensure_dirs().unwrap();
379 let t = read_trust().unwrap();
380 assert_eq!(t["version"], 1);
381 assert!(t["agents"].is_object());
382 });
383 }
384
385 #[test]
386 fn update_relay_state_writes_through_lock() {
387 with_temp_home(|| {
393 ensure_dirs().unwrap();
394 let initial = json!({"self": null, "peers": {}});
396 write_relay_state(&initial).unwrap();
397 super::update_relay_state(|state| {
399 state["self"] = json!({
400 "relay_url": "https://test",
401 "slot_id": "abc",
402 "slot_token": "tok",
403 });
404 Ok(())
405 })
406 .unwrap();
407 let after = read_relay_state().unwrap();
409 assert_eq!(after["self"]["relay_url"], "https://test");
410 assert_eq!(after["self"]["slot_id"], "abc");
411 });
412 }
413
414 #[test]
415 fn update_relay_state_modifier_error_does_not_clobber() {
416 with_temp_home(|| {
420 ensure_dirs().unwrap();
421 let initial = json!({"self": {"relay_url": "https://prior"}, "peers": {}});
422 write_relay_state(&initial).unwrap();
423 let result = super::update_relay_state(|state| {
424 state["self"] = json!({"relay_url": "https://NEVER_PERSIST"});
426 anyhow::bail!("simulated mid-RMW error")
428 });
429 assert!(result.is_err());
430 let after = read_relay_state().unwrap();
431 assert_eq!(
432 after["self"]["relay_url"], "https://prior",
433 "state on disk must not reflect aborted modifier"
434 );
435 });
436 }
437
438 #[test]
439 fn is_initialized_true_only_after_both_files_written() {
440 with_temp_home(|| {
441 ensure_dirs().unwrap();
442 assert!(!is_initialized().unwrap());
443 write_private_key(&[0u8; 32]).unwrap();
444 assert!(!is_initialized().unwrap()); write_agent_card(&json!({"did": "did:wire:paul"})).unwrap();
446 assert!(is_initialized().unwrap());
447 });
448 }
449
450 #[cfg(unix)]
451 #[test]
452 fn private_key_is_mode_0600() {
453 use std::os::unix::fs::PermissionsExt;
454 with_temp_home(|| {
455 ensure_dirs().unwrap();
456 write_private_key(&[1u8; 32]).unwrap();
457 let mode = fs::metadata(private_key_path().unwrap())
458 .unwrap()
459 .permissions()
460 .mode();
461 assert_eq!(mode & 0o777, 0o600, "got {:o}", mode & 0o777);
462 });
463 }
464}