Skip to main content

mermaid_runtime/
daemon.rs

1#[cfg(any(unix, windows))]
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(windows)]
97    {
98        let pipe_name = daemon_pipe_name()?;
99        let stream = open_daemon_pipe(&pipe_name)?;
100        let mut stream = stream;
101        stream.write_all(line.as_bytes())?;
102        stream.write_all(b"\n")?;
103        stream.flush()?;
104
105        // The response is exactly one JSON line followed by `\n`, written before
106        // the server closes its end — so `read_line` completes on the newline and
107        // never reaches the post-close read (which Windows surfaces as a
108        // `BrokenPipe` error rather than a unix-style clean EOF).
109        let mut response = String::new();
110        let mut reader = BufReader::new(stream);
111        reader.read_line(&mut response)?;
112        let value: serde_json::Value =
113            serde_json::from_str(response.trim()).context("daemon returned invalid JSON")?;
114        if value.get("ok").and_then(|v| v.as_bool()) == Some(false) {
115            anyhow::bail!(
116                "{}",
117                value
118                    .get("error")
119                    .and_then(|v| v.as_str())
120                    .unwrap_or("daemon request failed")
121            );
122        }
123        Ok(value)
124    }
125
126    #[cfg(not(any(unix, windows)))]
127    {
128        let _ = line;
129        anyhow::bail!("daemon IPC supports Unix sockets and Windows named pipes only")
130    }
131}
132
133/// Open a STREAMING daemon connection: send one JSON line, return a
134/// line-iterator over the responses. Used by `subscribe_task`, whose
135/// connection stays open (ack line, then NDJSON events until the terminal
136/// `result`) — `request_daemon_json` reads exactly one line and closes.
137/// `auth.token` is injected from `MERMAID_DAEMON_TOKEN` like the one-shot
138/// path.
139pub fn subscribe_daemon_lines(
140    mut body: serde_json::Value,
141) -> Result<impl Iterator<Item = Result<String>>> {
142    if body.get("auth").is_none()
143        && let Ok(token) = std::env::var(DAEMON_TOKEN_ENV)
144        && !token.trim().is_empty()
145    {
146        body["auth"] = serde_json::json!({ "token": token });
147    }
148    let line = body.to_string();
149
150    #[cfg(unix)]
151    {
152        use std::os::unix::net::UnixStream;
153        let socket = daemon_socket_path()?;
154        let mut stream = UnixStream::connect(&socket)
155            .with_context(|| format!("failed to connect to {}", socket.display()))?;
156        stream.write_all(line.as_bytes())?;
157        stream.write_all(b"\n")?;
158        stream.flush()?;
159        let reader = BufReader::new(stream);
160        Ok(reader.lines().map(|l| l.map_err(anyhow::Error::from)))
161    }
162
163    #[cfg(windows)]
164    {
165        let pipe_name = daemon_pipe_name()?;
166        let mut stream = open_daemon_pipe(&pipe_name)?;
167        stream.write_all(line.as_bytes())?;
168        stream.write_all(b"\n")?;
169        stream.flush()?;
170        let reader = BufReader::new(stream);
171        Ok(reader.lines().map(|l| l.map_err(anyhow::Error::from)))
172    }
173
174    #[cfg(not(any(unix, windows)))]
175    {
176        let _ = line;
177        anyhow::bail!("daemon IPC supports Unix sockets and Windows named pipes only");
178        #[allow(unreachable_code)]
179        Ok(std::iter::empty().map(|(): ()| unreachable!()))
180    }
181}
182
183/// Name of the per-user daemon control pipe for `sid`. Namespaced by the
184/// user's SID so two users on one machine get distinct pipes (the analog of
185/// the unix socket living in a per-user data dir) — the ACL from
186/// [`pipe_sddl`] then enforces that separation, rather than merely naming it.
187pub fn pipe_name_for_sid(sid: &str) -> String {
188    format!(r"\\.\pipe\mermaidd-{sid}")
189}
190
191/// SDDL for the daemon pipe's DACL: protected (`P`, no inherited ACEs),
192/// granting `GA` (generic all) to `SY` (LocalSystem) and to the owning user's
193/// SID — and to no one else, since a DACL denies anything it doesn't grant.
194/// This is the named-pipe analog of the 0600 unix socket + uid peer check
195/// (#66). Remote access is separately refused via
196/// `PIPE_REJECT_REMOTE_CLIENTS` on the server, not the DACL.
197pub fn pipe_sddl(sid: &str) -> String {
198    format!("D:P(A;;GA;;;SY)(A;;GA;;;{sid})")
199}
200
201/// String SID (`S-1-5-21-…`) of the user this process runs as, read from the
202/// process token. Both ends derive the pipe name from it, and the server bakes
203/// it into the pipe ACL.
204#[cfg(windows)]
205pub fn current_user_sid() -> Result<String> {
206    use windows_sys::Win32::Foundation::{CloseHandle, GetLastError, HANDLE, LocalFree};
207    use windows_sys::Win32::Security::Authorization::ConvertSidToStringSidW;
208    use windows_sys::Win32::Security::{GetTokenInformation, TOKEN_QUERY, TOKEN_USER, TokenUser};
209    use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
210
211    unsafe {
212        let mut token: HANDLE = std::ptr::null_mut();
213        if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) == 0 {
214            anyhow::bail!("OpenProcessToken failed (error {})", GetLastError());
215        }
216        // Everything after the token opens runs in a closure so the handle is
217        // closed on every path — success or bail.
218        let result = (|| {
219            let mut needed: u32 = 0;
220            GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut needed);
221            anyhow::ensure!(
222                needed > 0,
223                "GetTokenInformation sizing call failed (error {})",
224                GetLastError()
225            );
226            let mut buf = vec![0_u8; needed as usize];
227            if GetTokenInformation(
228                token,
229                TokenUser,
230                buf.as_mut_ptr().cast(),
231                needed,
232                &mut needed,
233            ) == 0
234            {
235                anyhow::bail!("GetTokenInformation failed (error {})", GetLastError());
236            }
237            let user = &*(buf.as_ptr() as *const TOKEN_USER);
238            let mut sid_w: *mut u16 = std::ptr::null_mut();
239            if ConvertSidToStringSidW(user.User.Sid, &mut sid_w) == 0 {
240                anyhow::bail!("ConvertSidToStringSidW failed (error {})", GetLastError());
241            }
242            let mut len = 0_usize;
243            while *sid_w.add(len) != 0 {
244                len += 1;
245            }
246            let sid = String::from_utf16_lossy(std::slice::from_raw_parts(sid_w, len));
247            LocalFree(sid_w.cast());
248            Ok(sid)
249        })();
250        CloseHandle(token);
251        result
252    }
253}
254
255/// Control-pipe name for the current user (see [`pipe_name_for_sid`]).
256#[cfg(windows)]
257pub fn daemon_pipe_name() -> Result<String> {
258    Ok(pipe_name_for_sid(&current_user_sid()?))
259}
260
261/// Owner-only pipe security for the daemon's listener. Owns the
262/// `LocalAlloc`ed security descriptor built from [`pipe_sddl`]; hand
263/// [`Self::attributes_ptr`] to `ServerOptions::create_with_security_attributes_raw`
264/// while this guard is alive.
265#[cfg(windows)]
266pub struct PipeSecurity {
267    descriptor: windows_sys::Win32::Security::PSECURITY_DESCRIPTOR,
268    attributes: windows_sys::Win32::Security::SECURITY_ATTRIBUTES,
269}
270
271#[cfg(windows)]
272impl PipeSecurity {
273    pub fn owner_only() -> Result<Self> {
274        use windows_sys::Win32::Foundation::GetLastError;
275        use windows_sys::Win32::Security::Authorization::{
276            ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
277        };
278        use windows_sys::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES};
279
280        let sddl = pipe_sddl(&current_user_sid()?);
281        let wide: Vec<u16> = sddl.encode_utf16().chain(std::iter::once(0)).collect();
282        let mut descriptor: PSECURITY_DESCRIPTOR = std::ptr::null_mut();
283        if unsafe {
284            ConvertStringSecurityDescriptorToSecurityDescriptorW(
285                wide.as_ptr(),
286                SDDL_REVISION_1,
287                &mut descriptor,
288                std::ptr::null_mut(),
289            )
290        } == 0
291        {
292            anyhow::bail!(
293                "failed to build pipe security descriptor from `{}` (error {})",
294                sddl,
295                unsafe { GetLastError() }
296            );
297        }
298        let attributes = SECURITY_ATTRIBUTES {
299            nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
300            lpSecurityDescriptor: descriptor,
301            bInheritHandle: 0,
302        };
303        Ok(Self {
304            descriptor,
305            attributes,
306        })
307    }
308
309    /// Pointer for `create_with_security_attributes_raw`. Taken fresh per call
310    /// so moving the guard between calls stays sound; the descriptor it points
311    /// at is heap-allocated and lives until the guard drops.
312    pub fn attributes_ptr(&mut self) -> *mut core::ffi::c_void {
313        (&raw mut self.attributes).cast()
314    }
315}
316
317#[cfg(windows)]
318impl Drop for PipeSecurity {
319    fn drop(&mut self) {
320        unsafe {
321            windows_sys::Win32::Foundation::LocalFree(self.descriptor.cast());
322        }
323    }
324}
325
326/// Open the daemon control pipe as an ordinary duplex file handle, retrying
327/// briefly on `ERROR_PIPE_BUSY` (all server instances momentarily taken — the
328/// server stands up the next instance right after each accept, so busy windows
329/// are tiny).
330#[cfg(windows)]
331fn open_daemon_pipe(pipe_name: &str) -> Result<std::fs::File> {
332    const ATTEMPTS: u32 = 5;
333    for attempt in 1..=ATTEMPTS {
334        match std::fs::OpenOptions::new()
335            .read(true)
336            .write(true)
337            .open(pipe_name)
338        {
339            Ok(file) => return Ok(file),
340            Err(err)
341                if err.raw_os_error()
342                    == Some(windows_sys::Win32::Foundation::ERROR_PIPE_BUSY as i32)
343                    && attempt < ATTEMPTS =>
344            {
345                std::thread::sleep(std::time::Duration::from_millis(50));
346            },
347            Err(err) => {
348                return Err(err).with_context(|| {
349                    format!("failed to connect to {pipe_name} (is mermaidd running?)")
350                });
351            },
352        }
353    }
354    anyhow::bail!("daemon pipe {pipe_name} stayed busy after {ATTEMPTS} attempts")
355}
356
357pub fn snapshot_field_from_daemon<T: DeserializeOwned>(field: &str) -> Result<T> {
358    let value = request_daemon_json(serde_json::json!({ "command": "snapshot" }))?;
359    let field_value = value
360        .get(field)
361        .cloned()
362        .with_context(|| format!("daemon snapshot missing `{}`", field))?;
363    serde_json::from_value(field_value)
364        .with_context(|| format!("daemon snapshot field `{}` had unexpected shape", field))
365}
366
367#[cfg(test)]
368mod tests {
369    use crate::*;
370
371    #[test]
372    fn pairing_token_hash_is_stable_and_not_plaintext() {
373        let hash = hash_pairing_token("mermaid_test");
374        assert_eq!(hash, hash_pairing_token("mermaid_test"));
375        assert_ne!(hash, "mermaid_test");
376        assert_eq!(hash.len(), 64);
377    }
378
379    #[test]
380    fn generated_pairing_token_hash_matches_token() {
381        let (token, hash) = generate_pairing_token().expect("token");
382        assert!(token.starts_with("mermaid_"));
383        assert_eq!(hash, hash_pairing_token(&token));
384    }
385
386    #[test]
387    fn clamp_pairing_ttl_days_forces_expiry_for_non_positive() {
388        assert_eq!(clamp_pairing_ttl_days(0), DEFAULT_PAIRING_TTL_DAYS);
389        assert_eq!(clamp_pairing_ttl_days(-5), DEFAULT_PAIRING_TTL_DAYS);
390        assert_eq!(clamp_pairing_ttl_days(7), 7);
391        // The #65 property: a clamped non-positive ttl yields a NON-NULL expiry,
392        // exactly as the daemon `pair` handler composes the two helpers.
393        assert!(pairing_expiry_from_now(clamp_pairing_ttl_days(0)).is_some());
394        assert!(pairing_expiry_from_now(clamp_pairing_ttl_days(-1)).is_some());
395    }
396
397    #[test]
398    fn pipe_name_and_sddl_embed_the_sid() {
399        let sid = "S-1-5-21-1-2-3-1000";
400        assert_eq!(
401            super::pipe_name_for_sid(sid),
402            r"\\.\pipe\mermaidd-S-1-5-21-1-2-3-1000"
403        );
404        let sddl = super::pipe_sddl(sid);
405        // Protected DACL granting only LocalSystem + the owner: exactly two
406        // allow-ACEs, no deny/inherit clutter for the parser to misread.
407        assert_eq!(sddl, "D:P(A;;GA;;;SY)(A;;GA;;;S-1-5-21-1-2-3-1000)");
408    }
409
410    // Windows-only: exercises the real token→SID→SDDL→descriptor FFI chain on
411    // the Windows CI runner — the part a Linux build can't validate at all.
412    #[cfg(windows)]
413    #[test]
414    fn current_user_sid_and_pipe_security_resolve() {
415        let sid = super::current_user_sid().expect("current_user_sid");
416        assert!(sid.starts_with("S-1-"), "unexpected SID shape: {sid}");
417        let mut security = super::PipeSecurity::owner_only().expect("PipeSecurity");
418        assert!(!security.attributes_ptr().is_null());
419        assert!(super::daemon_pipe_name().expect("pipe name").contains(&sid));
420    }
421}