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