Skip to main content

wire/
config.rs

1//! On-disk state for `wire`.
2//!
3//! Layout:
4//!   `$XDG_CONFIG_HOME/wire/` (defaults to `~/.config/wire/`)
5//!     - `private.key`     — 32-byte raw Ed25519 seed (mode 0600)
6//!     - `agent-card.json` — signed self-card (mode 0644, public)
7//!     - `trust.json`      — pinned peers + tiers
8//!     - `config.toml`     — relay URL, body cap, etc. (created lazily)
9//!
10//!   `$XDG_STATE_HOME/wire/` (defaults to `~/.local/state/wire/`)
11//!     - `inbox/<peer>.jsonl`  — verified inbound events
12//!     - `outbox/<peer>.jsonl` — agent-appended outbound events (daemon flushes)
13//!     - `spool/`              — daemon-internal staging
14//!
15//! All paths are configurable via `WIRE_HOME` env var (overrides both dirs to
16//! `$WIRE_HOME/{config,state}/`). Used by the test harness to keep tests
17//! isolated from the operator's real config.
18
19use 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
27/// Root configuration directory. Honors `WIRE_HOME` for testing.
28///
29/// With `WIRE_HOME=/tmp/foo`, returns `/tmp/foo/config/wire`.
30/// Without it, returns the XDG default (e.g. `~/.config/wire/`).
31pub 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
40/// Root state directory (rotating data — inbox/outbox/spool).
41///
42/// With `WIRE_HOME=/tmp/foo`, returns `/tmp/foo/state/wire`.
43pub 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
72/// Per-outbox-path mutex registry. Serializes intra-process appends so that
73/// concurrent `wire_send` calls (e.g. multiple agents driving the same MCP
74/// server) cannot interleave bytes mid-line. POSIX `O_APPEND` is atomic only
75/// for writes ≤ PIPE_BUF (typically 4096 bytes); wire events can exceed that
76/// (per-event cap is 256 KiB).
77///
78/// **Inter-process scope (CLI vs MCP-server vs daemon):** v0.1 does not take
79/// an OS-level flock — the daemon only reads the outbox + a cursor file, and
80/// concurrent CLI `wire send` invocations against a running MCP server are
81/// rare enough we accept the risk for now. v0.2 BACKLOG: switch to
82/// `fs2::FileExt::lock_exclusive` for cross-process safety.
83static 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
93/// Append a single JSONL record to the outbox for `peer`, holding the
94/// per-path mutex to keep concurrent appenders from interleaving lines.
95///
96/// `record_bytes` should be the full canonical JSON of the signed event,
97/// without trailing newline (the helper appends it). All bytes are written
98/// in one `write_all` while the lock is held.
99///
100/// The `peer` arg is normalized to its bare handle (`bob@relay.example` →
101/// `bob`) so the outbox filename is always `<bare_handle>.jsonl`. This is
102/// the canonical form the push enumerator and daemon reader expect; the
103/// normalization at this chokepoint guarantees correctness for every
104/// future caller, even if they forget to `bare_handle()` first. The
105/// original silent-fail of v0.5.11 was a caller that passed the FQDN
106/// form (issue #2 — 25-minute message-loss incident, surface fix in
107/// v0.5.13). This defense-in-depth makes the on-disk contract self-
108/// enforcing instead of caller-policed.
109pub 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
128/// Whether `wire init` has already been run (private key + card both present).
129pub fn is_initialized() -> Result<bool> {
130    Ok(private_key_path()?.exists() && agent_card_path()?.exists())
131}
132
133/// Create directory tree with restrictive permissions on the config dir.
134pub 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
158/// Write a private key file with mode 0600.
159pub 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
180/// Read the saved private key seed (32 bytes).
181pub 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
195// ── RFC-001 operator / organization key storage ───────────────────────────
196// Operator + org root private keys live alongside the session `private.key`,
197// same 0600 raw-32-byte-seed convention. These anchor the offline identity
198// layer's `op_did` / `org_did` (each DID commits to its key).
199
200pub fn op_key_path() -> Result<PathBuf> {
201    Ok(config_dir()?.join("op.key"))
202}
203
204/// Sanitize a DID into a safe filename component (DIDs carry `:`).
205fn 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
262/// Persist the operator handle chosen at `wire enroll op`. The op_did derives
263/// from handle + op key; card-emit re-derives it at card-build time.
264pub 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
289/// Append an org membership the operator holds (org_did / org_pubkey /
290/// member_cert) for card-emit to attach. Replaces any existing entry for the
291/// same org_did (re-issued certs supersede).
292pub 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
306/// Read the operator's stored org memberships (empty if none/malformed).
307pub 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    // v0.7.0-alpha.8 (review-fix #7): atomic write via tmp+rename so
321    // a power-loss / SIGKILL mid-write doesn't leave a 0-byte agent-
322    // card that `is_initialized()` claims is fine but `read_agent_card`
323    // can't parse. `cmd_identity_rename` made this a hot path; the
324    // pre-existing fs::write pattern was a corruption risk every call.
325    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
337// ---------- display overrides (v0.7.0-alpha.3) ----------
338
339/// Path to `display.json` — operator-chosen character nickname + emoji
340/// override. Sidecar to agent-card. NOT signed (display-only, local-only).
341///
342/// Format: `{"nickname": "foxtrot-meadow", "emoji": "🦊"}` — both fields
343/// optional, omitted means use the auto-derived value.
344pub 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    // v0.7.0-alpha.8 (review-fix #7): atomic write — consistent with
375    // write_agent_card now that they share the cmd_identity_rename
376    // call path.
377    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
399// ---------- relay binding state ----------
400
401/// Path to `relay.json` — holds our own slot binding and pinned peer slots.
402/// Contains slot-tokens, so always written mode 0600.
403pub 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
416/// Atomic, lock-serialized write of the full relay-state. Every direct caller
417/// (foreground `wire dial`, the background daemon, MCP) funnels through here,
418/// so a foreground write can neither TEAR nor lost-update against the daemon.
419/// Holds the same `relay.lock` flock as [`update_relay_state`] and writes via
420/// tmp+rename.
421///
422/// Bug #3 (v0.13.2): the old raw `fs::write` here was non-atomic and lockless.
423/// A foreground `wire dial` and the daemon both rewrote `relay.json`
424/// concurrently, interleaving bytes and leaving trailing garbage ("trailing
425/// characters at line N") that made the file unparseable — breaking all
426/// push/pull until hand-repaired. Surfaced on Windows (file-sharing
427/// semantics make the interleave easy to hit) but the race was cross-platform.
428pub 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
449/// Atomic relay-state write WITHOUT taking `relay.lock` — the caller must
450/// already hold it (only [`update_relay_state`], which writes inside its own
451/// locked transaction). tmp+rename so a concurrent reader sees either the old
452/// or new whole file, never a partial one.
453fn 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
463/// Path to the flock file that serialises concurrent read-modify-write
464/// transactions against `relay.json`. Separate file because flock on the
465/// data file itself races with file replacement (fs::write truncates +
466/// rewrites — atomic-ish but the lock identity disappears).
467fn relay_state_lock_path() -> Result<PathBuf> {
468    Ok(config_dir()?.join("relay.lock"))
469}
470
471/// Atomic read-modify-write against `relay.json`. Holds an exclusive
472/// `fs2::FileExt::lock_exclusive` for the whole transaction so concurrent
473/// `wire` processes (multiple daemons, CLI vs daemon, CLI vs MCP) cannot
474/// race the cursor or peer-pin entries.
475///
476/// P0.3 (0.5.11). Today's debug had three concurrent `wire` processes
477/// (stale 0.2.4 daemon, fresh 0.5.10 daemon, and the CLI) racing the
478/// `self.last_pulled_event_id` cursor — one would advance it past an
479/// event, another would later rewind via stale snapshot. flock makes
480/// that impossible.
481///
482/// Lock timeout: blocks indefinitely (well-behaved processes release in
483/// < 1ms). Use sparingly outside short RMW windows — long holds will
484/// stall every other `wire` process.
485pub 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    // Open / create the lock file. Holding a handle keeps the file
495    // alive for the lifetime of the transaction.
496    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    // Read fresh state INSIDE the lock — any prior snapshot would be a
508    // race window. Then run the modifier. Then write atomically.
509    let mut state = read_relay_state()?;
510    let result = modifier(&mut state);
511    let write_result = if result.is_ok() {
512        // We already hold relay.lock — use the unlocked writer to avoid
513        // re-acquiring the same flock (which would deadlock).
514        write_relay_state_unlocked(&state)
515    } else {
516        Ok(())
517    };
518    // RAII: drop releases the lock. Explicit unlock for clarity + to
519    // ensure unlock happens even if Drop ordering ever changes.
520    let _ = fs2::FileExt::unlock(&lock_file);
521    result?;
522    write_result?;
523    Ok(())
524}
525
526/// Test-only helpers. Lives outside `tests` mod so other modules' tests
527/// can share the same WIRE_HOME isolation. Tests run in-process and share
528/// process-wide env state, so all WIRE_HOME mutators must use this lock or
529/// they race each other.
530#[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        // Recover from poison so one failing test doesn't cascade-fail the rest.
538        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        // SAFETY: ENV_LOCK serializes all callers, so no concurrent env access.
541        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        // No path-traversal characters survive into the filename.
564        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        // P0.3 smoke: update_relay_state runs the modifier and persists the
641        // result. Doesn't exercise concurrent flock contention (that needs
642        // multi-process orchestration; deferred to an e2e test) but at least
643        // proves the happy path works end-to-end through the new lock
644        // wrapper.
645        with_temp_home(|| {
646            ensure_dirs().unwrap();
647            // Seed initial state.
648            let initial = json!({"self": null, "peers": {}});
649            write_relay_state(&initial).unwrap();
650            // Run an update.
651            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            // Verify persisted.
661            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        // Bug #3 regression: many writers hammering relay.json with
670        // alternating long/short bodies. With the old raw fs::write a
671        // concurrent reader caught torn bytes ("trailing characters") and
672        // failed to parse. The atomic tmp+rename + flock must guarantee every
673        // read sees a complete, parseable file. (Threads share one process +
674        // WIRE_HOME; the flock serializes them just as it would processes.)
675        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                            // Reader must ALWAYS parse — never a torn file.
689                            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        // P0.3 contract: if the modifier returns Err, the state on disk
704        // must NOT be overwritten — partial work shouldn't half-land. The
705        // operator's prior state should survive the failed RMW.
706        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                // Trash the state mid-modifier...
712                state["self"] = json!({"relay_url": "https://NEVER_PERSIST"});
713                // ...then fail. Write must NOT happen.
714                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()); // card still missing
732            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        // Regression for issue #2 (v0.5.11 silent-fail): if a caller
741        // passes the FQDN form (`bob@relay.example`), the file MUST
742        // still land at `bob.jsonl` so `wire push` enumerates it.
743        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            // Both calls must land in the SAME file — the bare handle one.
747            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            // And the FQDN-named file MUST NOT exist.
753            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            // The bare file should have BOTH writes.
759            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}