Skip to main content

mermaid_runtime/
daemon.rs

1#[cfg(unix)]
2use std::io::{BufRead, BufReader, Write};
3use std::path::PathBuf;
4
5use anyhow::{Context, Result};
6use base64::{Engine as _, engine::general_purpose};
7use serde::de::DeserializeOwned;
8use sha2::{Digest, Sha256};
9
10use crate::data_dir;
11
12pub const DAEMON_TOKEN_ENV: &str = "MERMAID_DAEMON_TOKEN";
13
14/// Default lifetime for a freshly minted pairing token. Tokens expire so a
15/// leaked or forgotten token can't be replayed indefinitely; `--ttl-days 0`
16/// opts out for long-lived automation.
17pub const DEFAULT_PAIRING_TTL_DAYS: i64 = 30;
18
19/// RFC3339 expiry `ttl_days` from now, or `None` when `ttl_days <= 0`
20/// (never expires). Shared by the daemon `pair` command and the local CLI so
21/// both honor the same TTL semantics.
22pub fn pairing_expiry_from_now(ttl_days: i64) -> Option<String> {
23    (ttl_days > 0).then(|| (chrono::Utc::now() + chrono::Duration::days(ttl_days)).to_rfc3339())
24}
25
26/// Clamp a *client-supplied* pairing TTL so a daemon socket caller can't mint a
27/// never-expiring token by sending `ttl_days <= 0`: non-positive input becomes
28/// the default TTL, positive values pass through. The local `mermaid pair` CLI
29/// deliberately does **not** call this — its `--ttl-days 0` "never expires"
30/// opt-out is an owner-only choice with no privilege boundary (#65).
31pub fn clamp_pairing_ttl_days(ttl_days: i64) -> i64 {
32    if ttl_days <= 0 {
33        DEFAULT_PAIRING_TTL_DAYS
34    } else {
35        ttl_days
36    }
37}
38
39pub fn daemon_socket_path() -> Result<PathBuf> {
40    Ok(data_dir()?.join("mermaidd.sock"))
41}
42
43pub fn generate_pairing_token() -> Result<(String, String)> {
44    let mut bytes = [0_u8; 32];
45    getrandom::fill(&mut bytes)
46        .map_err(|err| anyhow::anyhow!("failed to generate pairing token: {}", err))?;
47    let token = format!("mermaid_{}", general_purpose::URL_SAFE_NO_PAD.encode(bytes));
48    let hash = hash_pairing_token(&token);
49    Ok((token, hash))
50}
51
52pub fn hash_pairing_token(token: &str) -> String {
53    let digest = Sha256::digest(token.as_bytes());
54    crate::hex_lower(&digest)
55}
56
57pub fn request_daemon_json(mut body: serde_json::Value) -> Result<serde_json::Value> {
58    if body.get("auth").is_none()
59        && let Ok(token) = std::env::var(DAEMON_TOKEN_ENV)
60        && !token.trim().is_empty()
61    {
62        body["auth"] = serde_json::json!({ "token": token });
63    }
64    request_daemon_text(&body.to_string())
65}
66
67pub fn request_daemon_text(line: &str) -> Result<serde_json::Value> {
68    #[cfg(unix)]
69    {
70        use std::os::unix::net::UnixStream;
71
72        let socket = daemon_socket_path()?;
73        let mut stream = UnixStream::connect(&socket)
74            .with_context(|| format!("failed to connect to {}", socket.display()))?;
75        stream.write_all(line.as_bytes())?;
76        stream.write_all(b"\n")?;
77        stream.flush()?;
78
79        let mut response = String::new();
80        let mut reader = BufReader::new(stream);
81        reader.read_line(&mut response)?;
82        let value: serde_json::Value =
83            serde_json::from_str(response.trim()).context("daemon returned invalid JSON")?;
84        if value.get("ok").and_then(|v| v.as_bool()) == Some(false) {
85            anyhow::bail!(
86                "{}",
87                value
88                    .get("error")
89                    .and_then(|v| v.as_str())
90                    .unwrap_or("daemon request failed")
91            );
92        }
93        Ok(value)
94    }
95
96    #[cfg(not(unix))]
97    {
98        let _ = line;
99        anyhow::bail!("daemon IPC currently supports Unix sockets only")
100    }
101}
102
103pub fn snapshot_field_from_daemon<T: DeserializeOwned>(field: &str) -> Result<T> {
104    let value = request_daemon_json(serde_json::json!({ "command": "snapshot" }))?;
105    let field_value = value
106        .get(field)
107        .cloned()
108        .with_context(|| format!("daemon snapshot missing `{}`", field))?;
109    serde_json::from_value(field_value)
110        .with_context(|| format!("daemon snapshot field `{}` had unexpected shape", field))
111}
112
113#[cfg(test)]
114mod tests {
115    use crate::*;
116
117    #[test]
118    fn pairing_token_hash_is_stable_and_not_plaintext() {
119        let hash = hash_pairing_token("mermaid_test");
120        assert_eq!(hash, hash_pairing_token("mermaid_test"));
121        assert_ne!(hash, "mermaid_test");
122        assert_eq!(hash.len(), 64);
123    }
124
125    #[test]
126    fn generated_pairing_token_hash_matches_token() {
127        let (token, hash) = generate_pairing_token().expect("token");
128        assert!(token.starts_with("mermaid_"));
129        assert_eq!(hash, hash_pairing_token(&token));
130    }
131
132    #[test]
133    fn clamp_pairing_ttl_days_forces_expiry_for_non_positive() {
134        assert_eq!(clamp_pairing_ttl_days(0), DEFAULT_PAIRING_TTL_DAYS);
135        assert_eq!(clamp_pairing_ttl_days(-5), DEFAULT_PAIRING_TTL_DAYS);
136        assert_eq!(clamp_pairing_ttl_days(7), 7);
137        // The #65 property: a clamped non-positive ttl yields a NON-NULL expiry,
138        // exactly as the daemon `pair` handler composes the two helpers.
139        assert!(pairing_expiry_from_now(clamp_pairing_ttl_days(0)).is_some());
140        assert!(pairing_expiry_from_now(clamp_pairing_ttl_days(-1)).is_some());
141    }
142}