Skip to main content

wire/
retire.rs

1//! retire — decommission a wire identity you're done with, reversibly.
2//!
3//! The daemon supervisor keeps a daemon alive for *every* real identity (one
4//! with a `private.key`) so it can still receive mail — so an idle throwaway
5//! identity's daemon can't just be killed: the supervisor respawns it within
6//! one poll. "Retiring" writes a durable `.retired` marker that makes the
7//! supervisor treat the home as ineligible (it kills the child and never
8//! respawns — see `daemon_supervisor::supervisor_eligible`), then stops the
9//! running daemon directly.
10//!
11//! Reversible by construction: the marker is the ONLY state change. The home,
12//! identity keypair, relay slot, and pull cursor are all kept, so `revive`
13//! (remove the marker) brings the identity back intact and it drains any mail
14//! that arrived while retired (relay slots never expire; mail is retained).
15//!
16//! `is_retired` is a pure existence check so a torn write can never flip an
17//! identity back to "not retired". CLI-only: an agent must not retire another
18//! identity's daemon unsupervised (mirrors `wire nuke`).
19
20use anyhow::{Result, bail};
21use serde::{Deserialize, Serialize};
22use std::path::{Path, PathBuf};
23use std::time::Duration;
24
25pub const MARKER_SCHEMA: &str = "wire-retired-v1";
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct RetiredMarker {
29    pub schema: String,
30    pub retired_at_unix: u64,
31    #[serde(default)]
32    pub reason: String,
33}
34
35/// `<home>/state/wire/retired.json`.
36pub fn marker_path(home: &Path) -> PathBuf {
37    home.join("state").join("wire").join("retired.json")
38}
39
40/// Pure existence check — never parses the body, so a partial write can't
41/// read as "not retired". The supervisor eligibility filter keys on this.
42pub fn is_retired(home: &Path) -> bool {
43    marker_path(home).exists()
44}
45
46/// Best-effort read of the marker body for display. A parse error is treated
47/// as retired-with-unknown-details (fail closed), never as not-retired.
48pub fn read_marker(home: &Path) -> Option<RetiredMarker> {
49    let bytes = std::fs::read(marker_path(home)).ok()?;
50    serde_json::from_slice(&bytes).ok()
51}
52
53fn write_marker(home: &Path, reason: &str, now_unix: u64) -> Result<()> {
54    let dir = home.join("state").join("wire");
55    std::fs::create_dir_all(&dir)?;
56    let m = RetiredMarker {
57        schema: MARKER_SCHEMA.to_string(),
58        retired_at_unix: now_unix,
59        reason: reason.to_string(),
60    };
61    let tmp = dir.join("retired.json.tmp");
62    std::fs::write(&tmp, serde_json::to_vec_pretty(&m)?)?;
63    std::fs::rename(&tmp, marker_path(home))?; // atomic
64    Ok(())
65}
66
67fn remove_marker(home: &Path) -> Result<()> {
68    let p = marker_path(home);
69    if p.exists() {
70        std::fs::remove_file(&p)?;
71    }
72    Ok(())
73}
74
75/// Retire a session home: write the marker FIRST (so the supervisor won't
76/// respawn), then stop its daemon via the injected `stop` fn. Returns the pid
77/// that was stopped, if any. Idempotent — re-retiring just rewrites the marker.
78///
79/// Marker-before-kill is load-bearing: reverse it and the supervisor can
80/// respawn the daemon in the window between kill and marker.
81pub fn retire_session<F>(home: &Path, reason: &str, now_unix: u64, stop: F) -> Result<Option<u32>>
82where
83    F: Fn(u32) -> bool,
84{
85    write_marker(home, reason, now_unix)?;
86    let pid = crate::session::session_daemon_pid(home);
87    if let Some(p) = pid {
88        stop(p);
89    }
90    Ok(pid)
91}
92
93/// Bring a retired identity back: remove the marker; the supervisor respawns
94/// its daemon on the next poll. No-op if not retired.
95pub fn revive_session(home: &Path) -> Result<()> {
96    remove_marker(home)
97}
98
99/// Stop a daemon by pid, graceful then force. Mirrors the `wire upgrade` fix:
100/// a bare SIGTERM / `taskkill /PID` (no `/F`) is a no-op for a headless daemon
101/// on Windows, so escalate to SIGKILL / `/F` if it's still alive after a grace.
102pub fn stop_daemon_graceful_then_force(pid: u32) -> bool {
103    crate::platform::kill_process(pid, false);
104    for _ in 0..10 {
105        if !crate::platform::process_alive(pid) {
106            return true;
107        }
108        std::thread::sleep(Duration::from_millis(100));
109    }
110    crate::platform::kill_process(pid, true);
111    for _ in 0..6 {
112        if !crate::platform::process_alive(pid) {
113            return true;
114        }
115        std::thread::sleep(Duration::from_millis(100));
116    }
117    !crate::platform::process_alive(pid)
118}
119
120/// The session home THIS process resolves for itself (honoring WIRE_HOME /
121/// session-key). Used to guarantee we never retire the current identity —
122/// compared by canonical home path, since `resolve_session_key()` is `None`
123/// on a bare terminal. `None` if it can't be resolved (caller must fail closed).
124pub fn current_home() -> Option<PathBuf> {
125    let cfg = crate::config::config_dir().ok()?; // <home>/config/wire
126    let home = cfg.parent()?.parent()?; // <home>
127    // Fail closed: only claim a home we can confirm is a real identity home
128    // (has `config/wire/private.key`). Under a bare terminal with no WIRE_HOME
129    // set, `config_dir()` is only one level deep (`dirs_config/wire`), so
130    // `parent().parent()` walks PAST the config root into an unrelated ancestor
131    // — the private.key check rejects that bogus path and returns None (the
132    // caller then fails closed) instead of a plausible-looking wrong home.
133    if !home
134        .join("config")
135        .join("wire")
136        .join("private.key")
137        .exists()
138    {
139        return None;
140    }
141    Some(std::fs::canonicalize(home).unwrap_or_else(|_| home.to_path_buf()))
142}
143
144/// True iff `home` is the current process's own identity home.
145pub fn is_current(home: &Path) -> bool {
146    match current_home() {
147        Some(cur) => {
148            let h = std::fs::canonicalize(home).unwrap_or_else(|_| home.to_path_buf());
149            h == cur
150        }
151        None => false,
152    }
153}
154
155/// True iff the home has any pending inbound pair request awaiting `wire accept`
156/// (`state/wire/pending-inbound-pairs/*.json`). Such a home has 0 pinned peers
157/// but is NOT idle — a peer is actively trying to reach it — so the bulk sweep
158/// must never retire it.
159pub fn has_pending_inbound(home: &Path) -> bool {
160    let dir = home
161        .join("state")
162        .join("wire")
163        .join("pending-inbound-pairs");
164    match std::fs::read_dir(&dir) {
165        Ok(mut entries) => entries.any(|e| {
166            e.ok()
167                .and_then(|e| e.path().extension().map(|x| x == "json"))
168                .unwrap_or(false)
169        }),
170        Err(_) => false,
171    }
172}
173
174/// Seconds since this identity was created — the mtime of
175/// `config/wire/private.key`, written exactly once at keygen and never
176/// rewritten. This is the honest "how old is this throwaway" signal: unlike
177/// `daemon.pid` (which resets to now on every supervisor respawn) or
178/// `last_sync.json` (which a running daemon refreshes every heartbeat), the
179/// key's mtime tracks the identity's actual age, so a freshly-created sibling
180/// session is never swept. `None` if no key (not a real identity).
181pub fn identity_age_s(home: &Path) -> Option<u64> {
182    let p = home.join("config").join("wire").join("private.key");
183    let mtime = std::fs::metadata(&p).ok()?.modified().ok()?;
184    mtime.elapsed().ok().map(|d| d.as_secs())
185}
186
187/// Resolve `<handle|fingerprint|key>` to exactly one local session, box-wide
188/// over `list_sessions()`. Many idle homes never claimed a handle, so the key
189/// (by-key dir name) and fingerprint are also accepted. Errors on zero or
190/// multiple matches — never guesses.
191pub fn resolve_target(arg: &str) -> Result<crate::session::SessionInfo> {
192    let a = arg.trim();
193    if a.is_empty() {
194        bail!("empty identity — pass a handle, fingerprint, or session key");
195    }
196    let sessions = crate::session::list_sessions()?;
197    let matched: Vec<crate::session::SessionInfo> = sessions
198        .into_iter()
199        .filter(|s| {
200            s.handle.as_deref() == Some(a)
201                || s.name == a
202                || s.did
203                    .as_deref()
204                    .and_then(crate::dash::fingerprint_from_did)
205                    .as_deref()
206                    == Some(a)
207        })
208        .collect();
209    match matched.len() {
210        0 => bail!("no wire identity matches '{a}' (try a handle, fingerprint, or `wire dash`)"),
211        1 => Ok(matched.into_iter().next().unwrap()),
212        n => {
213            let names: Vec<String> = matched
214                .iter()
215                .map(|s| s.handle.clone().unwrap_or_else(|| s.name.clone()))
216                .collect();
217            bail!(
218                "'{a}' is ambiguous — {n} identities match: {}",
219                names.join(", ")
220            )
221        }
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn marker_roundtrip_and_is_retired() {
231        let dir = tempfile::tempdir().unwrap();
232        let home = dir.path();
233        assert!(!is_retired(home));
234        write_marker(home, "test", 1_700_000_000).unwrap();
235        assert!(is_retired(home), "marker present ⇒ retired");
236        let m = read_marker(home).unwrap();
237        assert_eq!(m.schema, MARKER_SCHEMA);
238        assert_eq!(m.retired_at_unix, 1_700_000_000);
239        assert_eq!(m.reason, "test");
240        remove_marker(home).unwrap();
241        assert!(!is_retired(home), "marker removed ⇒ not retired");
242    }
243
244    #[test]
245    fn is_retired_is_pure_existence_not_content() {
246        // A garbage (unparseable) marker still reads as retired — fail closed.
247        let dir = tempfile::tempdir().unwrap();
248        let home = dir.path();
249        std::fs::create_dir_all(home.join("state").join("wire")).unwrap();
250        std::fs::write(marker_path(home), b"{ this is not json").unwrap();
251        assert!(
252            is_retired(home),
253            "corrupt marker must still read as retired"
254        );
255        assert!(
256            read_marker(home).is_none(),
257            "corrupt body → None, but still retired"
258        );
259    }
260
261    #[test]
262    fn retire_writes_marker_before_kill() {
263        // The stop closure asserts the marker already exists when it runs.
264        let dir = tempfile::tempdir().unwrap();
265        let home = dir.path().to_path_buf();
266        // No daemon.pid → stop never called, but marker still written.
267        let called = std::cell::Cell::new(false);
268        let pid = retire_session(&home, "r", 1, |_p| {
269            called.set(true);
270            true
271        })
272        .unwrap();
273        assert_eq!(pid, None, "no pid file ⇒ nothing to stop");
274        assert!(!called.get());
275        assert!(is_retired(&home), "marker written even with no daemon");
276    }
277
278    #[test]
279    fn revive_is_noop_when_not_retired() {
280        let dir = tempfile::tempdir().unwrap();
281        revive_session(dir.path()).unwrap();
282        assert!(!is_retired(dir.path()));
283    }
284}