Skip to main content

mermaid_runtime/
daemon.rs

1#[cfg(any(unix, windows))]
2use std::io::{BufRead, BufReader, Read, Write};
3use std::path::PathBuf;
4
5use anyhow::{Context, Result};
6use base64::{Engine as _, engine::general_purpose};
7use sha2::{Digest, Sha256};
8
9use crate::data_dir;
10
11pub const DAEMON_TOKEN_ENV: &str = "MERMAID_DAEMON_TOKEN";
12
13/// Default lifetime for a freshly minted pairing token. Tokens expire so a
14/// leaked or forgotten token can't be replayed indefinitely; `--ttl-days 0`
15/// opts out for long-lived automation.
16pub const DEFAULT_PAIRING_TTL_DAYS: i64 = 30;
17
18/// RFC3339 expiry `ttl_days` from now, or `None` when `ttl_days <= 0`
19/// (never expires). Shared by the daemon `pair` command and the local CLI so
20/// both honor the same TTL semantics.
21#[must_use]
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).
31#[must_use]
32pub fn clamp_pairing_ttl_days(ttl_days: i64) -> i64 {
33    if ttl_days <= 0 {
34        DEFAULT_PAIRING_TTL_DAYS
35    } else {
36        ttl_days
37    }
38}
39
40/// Path of the per-user daemon control socket.
41///
42/// # Errors
43///
44/// Only resolving the data dir. The socket itself is neither created nor
45/// checked for, so an `Ok` path says nothing about whether a daemon is
46/// listening.
47pub fn daemon_socket_path() -> Result<PathBuf> {
48    Ok(data_dir()?.join("mermaidd.sock"))
49}
50
51/// Mint a pairing token, returning `(token, sha256 hash)` — the caller shows
52/// the token once and stores only the hash.
53///
54/// # Errors
55///
56/// Only the OS random source refusing to fill 32 bytes. There is no fallback:
57/// a token from a weakened source would be worse than no token.
58pub fn generate_pairing_token() -> Result<(String, String)> {
59    let mut bytes = [0_u8; 32];
60    getrandom::fill(&mut bytes)
61        .map_err(|err| anyhow::anyhow!("failed to generate pairing token: {err}"))?;
62    let token = format!("mermaid_{}", general_purpose::URL_SAFE_NO_PAD.encode(bytes));
63    let hash = hash_pairing_token(&token);
64    Ok((token, hash))
65}
66
67#[must_use]
68pub fn hash_pairing_token(token: &str) -> String {
69    let digest = Sha256::digest(token.as_bytes());
70    crate::hex_lower(&digest)
71}
72
73/// Send one request to the daemon, injecting `auth.token` from
74/// `MERMAID_DAEMON_TOKEN` when `body` carries none.
75///
76/// # Errors
77///
78/// Exactly [`request_daemon_text`]'s.
79pub fn request_daemon_json(mut body: serde_json::Value) -> Result<serde_json::Value> {
80    if body.get("auth").is_none()
81        && let Ok(token) = std::env::var(DAEMON_TOKEN_ENV)
82        && !token.trim().is_empty()
83    {
84        body["auth"] = serde_json::json!({ "token": token });
85    }
86    request_daemon_text(&body.to_string())
87}
88
89/// Write one request line and read back the single JSON response line. The
90/// unix-socket and Windows named-pipe transports differ only in how the stream
91/// is opened, so the wire exchange itself lives here once.
92///
93/// The response is exactly one JSON line followed by `\n`, written before the
94/// server closes its end — so `read_line` completes on the newline and never
95/// reaches the post-close read (which Windows surfaces as a `BrokenPipe` error
96/// rather than a unix-style clean EOF).
97#[cfg(any(unix, windows))]
98fn daemon_exchange<S: Read + Write>(mut stream: S, line: &str) -> Result<serde_json::Value> {
99    stream.write_all(line.as_bytes())?;
100    stream.write_all(b"\n")?;
101    stream.flush()?;
102
103    let mut response = String::new();
104    let mut reader = BufReader::new(stream);
105    reader.read_line(&mut response)?;
106    let value: serde_json::Value =
107        serde_json::from_str(response.trim()).context("daemon returned invalid JSON")?;
108    if value.get("ok").and_then(|v| v.as_bool()) == Some(false) {
109        anyhow::bail!(
110            "{}",
111            value
112                .get("error")
113                .and_then(|v| v.as_str())
114                .unwrap_or("daemon request failed")
115        );
116    }
117    Ok(value)
118}
119
120/// Send `line` verbatim to the daemon and return its single JSON response.
121///
122/// # Errors
123///
124/// Connecting to the socket or pipe — which is what a daemon that is simply
125/// not running looks like, and callers with a local fallback treat it as such
126/// rather than as a failure. Then the write, the read, a response that is not
127/// JSON, and a well-formed response carrying `ok: false`, whose `error` string
128/// becomes the message. On a platform with neither Unix sockets nor Windows
129/// named pipes every call is an error.
130pub fn request_daemon_text(line: &str) -> Result<serde_json::Value> {
131    #[cfg(unix)]
132    {
133        use std::os::unix::net::UnixStream;
134
135        let socket = daemon_socket_path()?;
136        let stream = UnixStream::connect(&socket)
137            .with_context(|| format!("failed to connect to {}", socket.display()))?;
138        daemon_exchange(stream, line)
139    }
140
141    #[cfg(windows)]
142    {
143        let pipe_name = daemon_pipe_name()?;
144        let stream = open_daemon_pipe(&pipe_name)?;
145        daemon_exchange(stream, line)
146    }
147
148    #[cfg(not(any(unix, windows)))]
149    {
150        let _ = line;
151        anyhow::bail!("daemon IPC supports Unix sockets and Windows named pipes only")
152    }
153}
154
155/// Open a STREAMING daemon connection: send one JSON line, return a
156/// line-iterator over the responses. Used by `subscribe_task`, whose
157/// connection stays open (ack line, then NDJSON events until the terminal
158/// `result`) — `request_daemon_json` reads exactly one line and closes.
159/// `auth.token` is injected from `MERMAID_DAEMON_TOKEN` like the one-shot
160/// path.
161///
162/// # Errors
163///
164/// Connecting to the socket or pipe, and writing the request line. Nothing is
165/// read here, so a daemon that accepts the connection and then answers with an
166/// error still yields `Ok` — that shows up as the first item of the iterator,
167/// and each subsequent read can fail independently.
168pub fn subscribe_daemon_lines(
169    mut body: serde_json::Value,
170) -> Result<impl Iterator<Item = Result<String>>> {
171    if body.get("auth").is_none()
172        && let Ok(token) = std::env::var(DAEMON_TOKEN_ENV)
173        && !token.trim().is_empty()
174    {
175        body["auth"] = serde_json::json!({ "token": token });
176    }
177    let line = body.to_string();
178
179    #[cfg(unix)]
180    {
181        use std::os::unix::net::UnixStream;
182        let socket = daemon_socket_path()?;
183        let mut stream = UnixStream::connect(&socket)
184            .with_context(|| format!("failed to connect to {}", socket.display()))?;
185        stream.write_all(line.as_bytes())?;
186        stream.write_all(b"\n")?;
187        stream.flush()?;
188        let reader = BufReader::new(stream);
189        Ok(reader.lines().map(|l| l.map_err(anyhow::Error::from)))
190    }
191
192    #[cfg(windows)]
193    {
194        let pipe_name = daemon_pipe_name()?;
195        let mut stream = open_daemon_pipe(&pipe_name)?;
196        stream.write_all(line.as_bytes())?;
197        stream.write_all(b"\n")?;
198        stream.flush()?;
199        let reader = BufReader::new(stream);
200        Ok(reader.lines().map(|l| l.map_err(anyhow::Error::from)))
201    }
202
203    #[cfg(not(any(unix, windows)))]
204    {
205        let _ = line;
206        anyhow::bail!("daemon IPC supports Unix sockets and Windows named pipes only");
207        #[allow(unreachable_code)]
208        Ok(std::iter::empty().map(|(): ()| unreachable!()))
209    }
210}
211
212/// Name of the per-user daemon control pipe for `sid`. Namespaced by the
213/// user's SID so two users on one machine get distinct pipes (the analog of
214/// the unix socket living in a per-user data dir) — the ACL from
215/// [`pipe_sddl`] then enforces that separation, rather than merely naming it.
216#[must_use]
217pub fn pipe_name_for_sid(sid: &str) -> String {
218    format!(r"\\.\pipe\mermaidd-{sid}")
219}
220
221/// SDDL for the daemon pipe's DACL: protected (`P`, no inherited ACEs),
222/// granting `GA` (generic all) to `SY` (`LocalSystem`) and to the owning user's
223/// SID — and to no one else, since a DACL denies anything it doesn't grant.
224/// This is the named-pipe analog of the 0600 unix socket + uid peer check
225/// (#66). Remote access is separately refused via
226/// `PIPE_REJECT_REMOTE_CLIENTS` on the server, not the DACL.
227#[must_use]
228pub fn pipe_sddl(sid: &str) -> String {
229    format!("D:P(A;;GA;;;SY)(A;;GA;;;{sid})")
230}
231
232/// String SID (`S-1-5-21-…`) of the user this process runs as, read from the
233/// process token. Both ends derive the pipe name from it, and the server bakes
234/// it into the pipe ACL.
235///
236/// # Errors
237///
238/// Any of the four Win32 calls failing — `OpenProcessToken`, either
239/// `GetTokenInformation` (sizing and fetch), or `ConvertSidToStringSidW` —
240/// each reported with its `GetLastError` code. The process token is closed on
241/// every path, including these.
242#[cfg(windows)]
243pub fn current_user_sid() -> Result<String> {
244    use windows_sys::Win32::Foundation::{CloseHandle, GetLastError, HANDLE, LocalFree};
245    use windows_sys::Win32::Security::Authorization::ConvertSidToStringSidW;
246    use windows_sys::Win32::Security::{GetTokenInformation, TOKEN_QUERY, TOKEN_USER, TokenUser};
247    use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
248
249    unsafe {
250        let mut token: HANDLE = std::ptr::null_mut();
251        if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) == 0 {
252            anyhow::bail!("OpenProcessToken failed (error {})", GetLastError());
253        }
254        // Everything after the token opens runs in a closure so the handle is
255        // closed on every path — success or bail.
256        let result = (|| {
257            let mut needed: u32 = 0;
258            GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut needed);
259            anyhow::ensure!(
260                needed > 0,
261                "GetTokenInformation sizing call failed (error {})",
262                GetLastError()
263            );
264            let mut buf = vec![0_u8; needed as usize];
265            if GetTokenInformation(
266                token,
267                TokenUser,
268                buf.as_mut_ptr().cast(),
269                needed,
270                &mut needed,
271            ) == 0
272            {
273                anyhow::bail!("GetTokenInformation failed (error {})", GetLastError());
274            }
275            let user = &*(buf.as_ptr() as *const TOKEN_USER);
276            let mut sid_w: *mut u16 = std::ptr::null_mut();
277            if ConvertSidToStringSidW(user.User.Sid, &mut sid_w) == 0 {
278                anyhow::bail!("ConvertSidToStringSidW failed (error {})", GetLastError());
279            }
280            let mut len = 0_usize;
281            while *sid_w.add(len) != 0 {
282                len += 1;
283            }
284            let sid = String::from_utf16_lossy(std::slice::from_raw_parts(sid_w, len));
285            LocalFree(sid_w.cast());
286            Ok(sid)
287        })();
288        CloseHandle(token);
289        result
290    }
291}
292
293/// Control-pipe name for the current user (see [`pipe_name_for_sid`]).
294///
295/// # Errors
296///
297/// Only [`current_user_sid`]'s. The pipe is neither created nor opened here,
298/// so an `Ok` name says nothing about whether a daemon is listening.
299#[cfg(windows)]
300pub fn daemon_pipe_name() -> Result<String> {
301    Ok(pipe_name_for_sid(&current_user_sid()?))
302}
303
304/// Owner-only pipe security for the daemon's listener. Owns the
305/// `LocalAlloc`ed security descriptor built from [`pipe_sddl`]; hand
306/// [`Self::attributes_ptr`] to `ServerOptions::create_with_security_attributes_raw`
307/// while this guard is alive.
308#[cfg(windows)]
309pub struct PipeSecurity {
310    descriptor: windows_sys::Win32::Security::PSECURITY_DESCRIPTOR,
311    attributes: windows_sys::Win32::Security::SECURITY_ATTRIBUTES,
312}
313
314#[cfg(windows)]
315impl PipeSecurity {
316    /// Build the owner-only descriptor for the current user's SID.
317    ///
318    /// # Errors
319    ///
320    /// [`current_user_sid`]'s, and
321    /// `ConvertStringSecurityDescriptorToSecurityDescriptorW` rejecting the
322    /// SDDL — reported with its `GetLastError` code. The listener must not
323    /// fall back to a default descriptor on either: that would publish the
324    /// pipe with an inherited DACL.
325    pub fn owner_only() -> Result<Self> {
326        use windows_sys::Win32::Foundation::GetLastError;
327        use windows_sys::Win32::Security::Authorization::{
328            ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
329        };
330        use windows_sys::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES};
331
332        let sddl = pipe_sddl(&current_user_sid()?);
333        let wide: Vec<u16> = sddl.encode_utf16().chain(std::iter::once(0)).collect();
334        let mut descriptor: PSECURITY_DESCRIPTOR = std::ptr::null_mut();
335        if unsafe {
336            ConvertStringSecurityDescriptorToSecurityDescriptorW(
337                wide.as_ptr(),
338                SDDL_REVISION_1,
339                &mut descriptor,
340                std::ptr::null_mut(),
341            )
342        } == 0
343        {
344            anyhow::bail!(
345                "failed to build pipe security descriptor from `{}` (error {})",
346                sddl,
347                unsafe { GetLastError() }
348            );
349        }
350        let attributes = SECURITY_ATTRIBUTES {
351            nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
352            lpSecurityDescriptor: descriptor,
353            bInheritHandle: 0,
354        };
355        Ok(Self {
356            descriptor,
357            attributes,
358        })
359    }
360
361    /// Pointer for `create_with_security_attributes_raw`. Taken fresh per call
362    /// so moving the guard between calls stays sound; the descriptor it points
363    /// at is heap-allocated and lives until the guard drops.
364    pub fn attributes_ptr(&mut self) -> *mut core::ffi::c_void {
365        (&raw mut self.attributes).cast()
366    }
367}
368
369#[cfg(windows)]
370impl Drop for PipeSecurity {
371    fn drop(&mut self) {
372        unsafe {
373            windows_sys::Win32::Foundation::LocalFree(self.descriptor.cast());
374        }
375    }
376}
377
378/// Open the daemon control pipe as an ordinary duplex file handle, retrying
379/// briefly on `ERROR_PIPE_BUSY` (all server instances momentarily taken — the
380/// server stands up the next instance right after each accept, so busy windows
381/// are tiny).
382#[cfg(windows)]
383fn open_daemon_pipe(pipe_name: &str) -> Result<std::fs::File> {
384    const ATTEMPTS: u32 = 5;
385    for attempt in 1..=ATTEMPTS {
386        match std::fs::OpenOptions::new()
387            .read(true)
388            .write(true)
389            .open(pipe_name)
390        {
391            Ok(file) => return Ok(file),
392            Err(err)
393                if err.raw_os_error()
394                    == Some(windows_sys::Win32::Foundation::ERROR_PIPE_BUSY as i32)
395                    && attempt < ATTEMPTS =>
396            {
397                std::thread::sleep(std::time::Duration::from_millis(50));
398            },
399            Err(err) => {
400                return Err(err).with_context(|| {
401                    format!("failed to connect to {pipe_name} (is mermaidd running?)")
402                });
403            },
404        }
405    }
406    anyhow::bail!("daemon pipe {pipe_name} stayed busy after {ATTEMPTS} attempts")
407}
408
409#[cfg(test)]
410mod tests {
411    use crate::*;
412
413    #[test]
414    fn pairing_token_hash_is_stable_and_not_plaintext() {
415        let hash = hash_pairing_token("mermaid_test");
416        assert_eq!(hash, hash_pairing_token("mermaid_test"));
417        assert_ne!(hash, "mermaid_test");
418        assert_eq!(hash.len(), 64);
419    }
420
421    #[test]
422    fn generated_pairing_token_hash_matches_token() {
423        let (token, hash) = generate_pairing_token().expect("token");
424        assert!(token.starts_with("mermaid_"));
425        assert_eq!(hash, hash_pairing_token(&token));
426    }
427
428    #[test]
429    fn clamp_pairing_ttl_days_forces_expiry_for_non_positive() {
430        assert_eq!(clamp_pairing_ttl_days(0), DEFAULT_PAIRING_TTL_DAYS);
431        assert_eq!(clamp_pairing_ttl_days(-5), DEFAULT_PAIRING_TTL_DAYS);
432        assert_eq!(clamp_pairing_ttl_days(7), 7);
433        // The #65 property: a clamped non-positive ttl yields a NON-NULL expiry,
434        // exactly as the daemon `pair` handler composes the two helpers.
435        assert!(pairing_expiry_from_now(clamp_pairing_ttl_days(0)).is_some());
436        assert!(pairing_expiry_from_now(clamp_pairing_ttl_days(-1)).is_some());
437    }
438
439    #[test]
440    fn pipe_name_and_sddl_embed_the_sid() {
441        let sid = "S-1-5-21-1-2-3-1000";
442        assert_eq!(
443            super::pipe_name_for_sid(sid),
444            r"\\.\pipe\mermaidd-S-1-5-21-1-2-3-1000"
445        );
446        let sddl = super::pipe_sddl(sid);
447        // Protected DACL granting only LocalSystem + the owner: exactly two
448        // allow-ACEs, no deny/inherit clutter for the parser to misread.
449        assert_eq!(sddl, "D:P(A;;GA;;;SY)(A;;GA;;;S-1-5-21-1-2-3-1000)");
450    }
451
452    // Windows-only: exercises the real token→SID→SDDL→descriptor FFI chain on
453    // the Windows CI runner — the part a Linux build can't validate at all.
454    #[cfg(windows)]
455    #[test]
456    fn current_user_sid_and_pipe_security_resolve() {
457        let sid = super::current_user_sid().expect("current_user_sid");
458        assert!(sid.starts_with("S-1-"), "unexpected SID shape: {sid}");
459        let mut security = super::PipeSecurity::owner_only().expect("PipeSecurity");
460        assert!(!security.attributes_ptr().is_null());
461        assert!(super::daemon_pipe_name().expect("pipe name").contains(&sid));
462    }
463}