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
18#[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#[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
40pub fn daemon_socket_path() -> Result<PathBuf> {
48 Ok(data_dir()?.join("mermaidd.sock"))
49}
50
51pub 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
73pub 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#[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
120pub 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
155pub 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#[must_use]
217pub fn pipe_name_for_sid(sid: &str) -> String {
218 format!(r"\\.\pipe\mermaidd-{sid}")
219}
220
221#[must_use]
228pub fn pipe_sddl(sid: &str) -> String {
229 format!("D:P(A;;GA;;;SY)(A;;GA;;;{sid})")
230}
231
232#[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 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#[cfg(windows)]
300pub fn daemon_pipe_name() -> Result<String> {
301 Ok(pipe_name_for_sid(¤t_user_sid()?))
302}
303
304#[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 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(¤t_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 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#[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 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 assert_eq!(sddl, "D:P(A;;GA;;;SY)(A;;GA;;;S-1-5-21-1-2-3-1000)");
450 }
451
452 #[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}