mermaid_runtime/
daemon.rs1#[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
13pub const DEFAULT_PAIRING_TTL_DAYS: i64 = 30;
17
18pub 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
25pub 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#[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
122pub 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
172pub fn pipe_name_for_sid(sid: &str) -> String {
177 format!(r"\\.\pipe\mermaidd-{sid}")
178}
179
180pub fn pipe_sddl(sid: &str) -> String {
187 format!("D:P(A;;GA;;;SY)(A;;GA;;;{sid})")
188}
189
190#[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 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#[cfg(windows)]
246pub fn daemon_pipe_name() -> Result<String> {
247 Ok(pipe_name_for_sid(¤t_user_sid()?))
248}
249
250#[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(¤t_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 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#[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 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 assert_eq!(sddl, "D:P(A;;GA;;;SY)(A;;GA;;;S-1-5-21-1-2-3-1000)");
387 }
388
389 #[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}