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/// Name of the per-user daemon control pipe for `sid`. Namespaced by the
134/// user's SID so two users on one machine get distinct pipes (the analog of
135/// the unix socket living in a per-user data dir) — the ACL from
136/// [`pipe_sddl`] then enforces that separation, rather than merely naming it.
137pub fn pipe_name_for_sid(sid: &str) -> String {
138    format!(r"\\.\pipe\mermaidd-{sid}")
139}
140
141/// SDDL for the daemon pipe's DACL: protected (`P`, no inherited ACEs),
142/// granting `GA` (generic all) to `SY` (LocalSystem) and to the owning user's
143/// SID — and to no one else, since a DACL denies anything it doesn't grant.
144/// This is the named-pipe analog of the 0600 unix socket + uid peer check
145/// (#66). Remote access is separately refused via
146/// `PIPE_REJECT_REMOTE_CLIENTS` on the server, not the DACL.
147pub fn pipe_sddl(sid: &str) -> String {
148    format!("D:P(A;;GA;;;SY)(A;;GA;;;{sid})")
149}
150
151/// String SID (`S-1-5-21-…`) of the user this process runs as, read from the
152/// process token. Both ends derive the pipe name from it, and the server bakes
153/// it into the pipe ACL.
154#[cfg(windows)]
155pub fn current_user_sid() -> Result<String> {
156    use windows_sys::Win32::Foundation::{CloseHandle, GetLastError, HANDLE, LocalFree};
157    use windows_sys::Win32::Security::Authorization::ConvertSidToStringSidW;
158    use windows_sys::Win32::Security::{GetTokenInformation, TOKEN_QUERY, TOKEN_USER, TokenUser};
159    use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
160
161    unsafe {
162        let mut token: HANDLE = std::ptr::null_mut();
163        if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) == 0 {
164            anyhow::bail!("OpenProcessToken failed (error {})", GetLastError());
165        }
166        // Everything after the token opens runs in a closure so the handle is
167        // closed on every path — success or bail.
168        let result = (|| {
169            let mut needed: u32 = 0;
170            GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut needed);
171            anyhow::ensure!(
172                needed > 0,
173                "GetTokenInformation sizing call failed (error {})",
174                GetLastError()
175            );
176            let mut buf = vec![0_u8; needed as usize];
177            if GetTokenInformation(
178                token,
179                TokenUser,
180                buf.as_mut_ptr().cast(),
181                needed,
182                &mut needed,
183            ) == 0
184            {
185                anyhow::bail!("GetTokenInformation failed (error {})", GetLastError());
186            }
187            let user = &*(buf.as_ptr() as *const TOKEN_USER);
188            let mut sid_w: *mut u16 = std::ptr::null_mut();
189            if ConvertSidToStringSidW(user.User.Sid, &mut sid_w) == 0 {
190                anyhow::bail!("ConvertSidToStringSidW failed (error {})", GetLastError());
191            }
192            let mut len = 0_usize;
193            while *sid_w.add(len) != 0 {
194                len += 1;
195            }
196            let sid = String::from_utf16_lossy(std::slice::from_raw_parts(sid_w, len));
197            LocalFree(sid_w.cast());
198            Ok(sid)
199        })();
200        CloseHandle(token);
201        result
202    }
203}
204
205/// Control-pipe name for the current user (see [`pipe_name_for_sid`]).
206#[cfg(windows)]
207pub fn daemon_pipe_name() -> Result<String> {
208    Ok(pipe_name_for_sid(&current_user_sid()?))
209}
210
211/// Owner-only pipe security for the daemon's listener. Owns the
212/// `LocalAlloc`ed security descriptor built from [`pipe_sddl`]; hand
213/// [`Self::attributes_ptr`] to `ServerOptions::create_with_security_attributes_raw`
214/// while this guard is alive.
215#[cfg(windows)]
216pub struct PipeSecurity {
217    descriptor: windows_sys::Win32::Security::PSECURITY_DESCRIPTOR,
218    attributes: windows_sys::Win32::Security::SECURITY_ATTRIBUTES,
219}
220
221#[cfg(windows)]
222impl PipeSecurity {
223    pub fn owner_only() -> Result<Self> {
224        use windows_sys::Win32::Foundation::GetLastError;
225        use windows_sys::Win32::Security::Authorization::{
226            ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
227        };
228        use windows_sys::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES};
229
230        let sddl = pipe_sddl(&current_user_sid()?);
231        let wide: Vec<u16> = sddl.encode_utf16().chain(std::iter::once(0)).collect();
232        let mut descriptor: PSECURITY_DESCRIPTOR = std::ptr::null_mut();
233        if unsafe {
234            ConvertStringSecurityDescriptorToSecurityDescriptorW(
235                wide.as_ptr(),
236                SDDL_REVISION_1,
237                &mut descriptor,
238                std::ptr::null_mut(),
239            )
240        } == 0
241        {
242            anyhow::bail!(
243                "failed to build pipe security descriptor from `{}` (error {})",
244                sddl,
245                unsafe { GetLastError() }
246            );
247        }
248        let attributes = SECURITY_ATTRIBUTES {
249            nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
250            lpSecurityDescriptor: descriptor,
251            bInheritHandle: 0,
252        };
253        Ok(Self {
254            descriptor,
255            attributes,
256        })
257    }
258
259    /// Pointer for `create_with_security_attributes_raw`. Taken fresh per call
260    /// so moving the guard between calls stays sound; the descriptor it points
261    /// at is heap-allocated and lives until the guard drops.
262    pub fn attributes_ptr(&mut self) -> *mut core::ffi::c_void {
263        (&raw mut self.attributes).cast()
264    }
265}
266
267#[cfg(windows)]
268impl Drop for PipeSecurity {
269    fn drop(&mut self) {
270        unsafe {
271            windows_sys::Win32::Foundation::LocalFree(self.descriptor.cast());
272        }
273    }
274}
275
276/// Open the daemon control pipe as an ordinary duplex file handle, retrying
277/// briefly on `ERROR_PIPE_BUSY` (all server instances momentarily taken — the
278/// server stands up the next instance right after each accept, so busy windows
279/// are tiny).
280#[cfg(windows)]
281fn open_daemon_pipe(pipe_name: &str) -> Result<std::fs::File> {
282    const ATTEMPTS: u32 = 5;
283    for attempt in 1..=ATTEMPTS {
284        match std::fs::OpenOptions::new()
285            .read(true)
286            .write(true)
287            .open(pipe_name)
288        {
289            Ok(file) => return Ok(file),
290            Err(err)
291                if err.raw_os_error()
292                    == Some(windows_sys::Win32::Foundation::ERROR_PIPE_BUSY as i32)
293                    && attempt < ATTEMPTS =>
294            {
295                std::thread::sleep(std::time::Duration::from_millis(50));
296            },
297            Err(err) => {
298                return Err(err).with_context(|| {
299                    format!("failed to connect to {pipe_name} (is mermaidd running?)")
300                });
301            },
302        }
303    }
304    anyhow::bail!("daemon pipe {pipe_name} stayed busy after {ATTEMPTS} attempts")
305}
306
307pub fn snapshot_field_from_daemon<T: DeserializeOwned>(field: &str) -> Result<T> {
308    let value = request_daemon_json(serde_json::json!({ "command": "snapshot" }))?;
309    let field_value = value
310        .get(field)
311        .cloned()
312        .with_context(|| format!("daemon snapshot missing `{}`", field))?;
313    serde_json::from_value(field_value)
314        .with_context(|| format!("daemon snapshot field `{}` had unexpected shape", field))
315}
316
317#[cfg(test)]
318mod tests {
319    use crate::*;
320
321    #[test]
322    fn pairing_token_hash_is_stable_and_not_plaintext() {
323        let hash = hash_pairing_token("mermaid_test");
324        assert_eq!(hash, hash_pairing_token("mermaid_test"));
325        assert_ne!(hash, "mermaid_test");
326        assert_eq!(hash.len(), 64);
327    }
328
329    #[test]
330    fn generated_pairing_token_hash_matches_token() {
331        let (token, hash) = generate_pairing_token().expect("token");
332        assert!(token.starts_with("mermaid_"));
333        assert_eq!(hash, hash_pairing_token(&token));
334    }
335
336    #[test]
337    fn clamp_pairing_ttl_days_forces_expiry_for_non_positive() {
338        assert_eq!(clamp_pairing_ttl_days(0), DEFAULT_PAIRING_TTL_DAYS);
339        assert_eq!(clamp_pairing_ttl_days(-5), DEFAULT_PAIRING_TTL_DAYS);
340        assert_eq!(clamp_pairing_ttl_days(7), 7);
341        // The #65 property: a clamped non-positive ttl yields a NON-NULL expiry,
342        // exactly as the daemon `pair` handler composes the two helpers.
343        assert!(pairing_expiry_from_now(clamp_pairing_ttl_days(0)).is_some());
344        assert!(pairing_expiry_from_now(clamp_pairing_ttl_days(-1)).is_some());
345    }
346
347    #[test]
348    fn pipe_name_and_sddl_embed_the_sid() {
349        let sid = "S-1-5-21-1-2-3-1000";
350        assert_eq!(
351            super::pipe_name_for_sid(sid),
352            r"\\.\pipe\mermaidd-S-1-5-21-1-2-3-1000"
353        );
354        let sddl = super::pipe_sddl(sid);
355        // Protected DACL granting only LocalSystem + the owner: exactly two
356        // allow-ACEs, no deny/inherit clutter for the parser to misread.
357        assert_eq!(sddl, "D:P(A;;GA;;;SY)(A;;GA;;;S-1-5-21-1-2-3-1000)");
358    }
359
360    // Windows-only: exercises the real token→SID→SDDL→descriptor FFI chain on
361    // the Windows CI runner — the part a Linux build can't validate at all.
362    #[cfg(windows)]
363    #[test]
364    fn current_user_sid_and_pipe_security_resolve() {
365        let sid = super::current_user_sid().expect("current_user_sid");
366        assert!(sid.starts_with("S-1-"), "unexpected SID shape: {sid}");
367        let mut security = super::PipeSecurity::owner_only().expect("PipeSecurity");
368        assert!(!security.attributes_ptr().is_null());
369        assert!(super::daemon_pipe_name().expect("pipe name").contains(&sid));
370    }
371}