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    hex_encode(&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
113fn hex_encode(bytes: &[u8]) -> String {
114    const HEX: &[u8; 16] = b"0123456789abcdef";
115    let mut out = String::with_capacity(bytes.len() * 2);
116    for byte in bytes {
117        out.push(HEX[(byte >> 4) as usize] as char);
118        out.push(HEX[(byte & 0x0f) as usize] as char);
119    }
120    out
121}
122
123#[cfg(test)]
124mod tests {
125    use crate::*;
126
127    #[test]
128    fn pairing_token_hash_is_stable_and_not_plaintext() {
129        let hash = hash_pairing_token("mermaid_test");
130        assert_eq!(hash, hash_pairing_token("mermaid_test"));
131        assert_ne!(hash, "mermaid_test");
132        assert_eq!(hash.len(), 64);
133    }
134
135    #[test]
136    fn generated_pairing_token_hash_matches_token() {
137        let (token, hash) = generate_pairing_token().expect("token");
138        assert!(token.starts_with("mermaid_"));
139        assert_eq!(hash, hash_pairing_token(&token));
140    }
141
142    #[test]
143    fn clamp_pairing_ttl_days_forces_expiry_for_non_positive() {
144        assert_eq!(clamp_pairing_ttl_days(0), DEFAULT_PAIRING_TTL_DAYS);
145        assert_eq!(clamp_pairing_ttl_days(-5), DEFAULT_PAIRING_TTL_DAYS);
146        assert_eq!(clamp_pairing_ttl_days(7), 7);
147        // The #65 property: a clamped non-positive ttl yields a NON-NULL expiry,
148        // exactly as the daemon `pair` handler composes the two helpers.
149        assert!(pairing_expiry_from_now(clamp_pairing_ttl_days(0)).is_some());
150        assert!(pairing_expiry_from_now(clamp_pairing_ttl_days(-1)).is_some());
151    }
152}