mermaid_runtime/
daemon.rs1#[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
14pub const DEFAULT_PAIRING_TTL_DAYS: i64 = 30;
18
19pub 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
26pub 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 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
133pub fn subscribe_daemon_lines(
140 mut body: serde_json::Value,
141) -> Result<impl Iterator<Item = Result<String>>> {
142 if body.get("auth").is_none()
143 && let Ok(token) = std::env::var(DAEMON_TOKEN_ENV)
144 && !token.trim().is_empty()
145 {
146 body["auth"] = serde_json::json!({ "token": token });
147 }
148 let line = body.to_string();
149
150 #[cfg(unix)]
151 {
152 use std::os::unix::net::UnixStream;
153 let socket = daemon_socket_path()?;
154 let mut stream = UnixStream::connect(&socket)
155 .with_context(|| format!("failed to connect to {}", socket.display()))?;
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(windows)]
164 {
165 let pipe_name = daemon_pipe_name()?;
166 let mut stream = open_daemon_pipe(&pipe_name)?;
167 stream.write_all(line.as_bytes())?;
168 stream.write_all(b"\n")?;
169 stream.flush()?;
170 let reader = BufReader::new(stream);
171 Ok(reader.lines().map(|l| l.map_err(anyhow::Error::from)))
172 }
173
174 #[cfg(not(any(unix, windows)))]
175 {
176 let _ = line;
177 anyhow::bail!("daemon IPC supports Unix sockets and Windows named pipes only");
178 #[allow(unreachable_code)]
179 Ok(std::iter::empty().map(|(): ()| unreachable!()))
180 }
181}
182
183pub fn pipe_name_for_sid(sid: &str) -> String {
188 format!(r"\\.\pipe\mermaidd-{sid}")
189}
190
191pub fn pipe_sddl(sid: &str) -> String {
198 format!("D:P(A;;GA;;;SY)(A;;GA;;;{sid})")
199}
200
201#[cfg(windows)]
205pub fn current_user_sid() -> Result<String> {
206 use windows_sys::Win32::Foundation::{CloseHandle, GetLastError, HANDLE, LocalFree};
207 use windows_sys::Win32::Security::Authorization::ConvertSidToStringSidW;
208 use windows_sys::Win32::Security::{GetTokenInformation, TOKEN_QUERY, TOKEN_USER, TokenUser};
209 use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
210
211 unsafe {
212 let mut token: HANDLE = std::ptr::null_mut();
213 if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) == 0 {
214 anyhow::bail!("OpenProcessToken failed (error {})", GetLastError());
215 }
216 let result = (|| {
219 let mut needed: u32 = 0;
220 GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut needed);
221 anyhow::ensure!(
222 needed > 0,
223 "GetTokenInformation sizing call failed (error {})",
224 GetLastError()
225 );
226 let mut buf = vec![0_u8; needed as usize];
227 if GetTokenInformation(
228 token,
229 TokenUser,
230 buf.as_mut_ptr().cast(),
231 needed,
232 &mut needed,
233 ) == 0
234 {
235 anyhow::bail!("GetTokenInformation failed (error {})", GetLastError());
236 }
237 let user = &*(buf.as_ptr() as *const TOKEN_USER);
238 let mut sid_w: *mut u16 = std::ptr::null_mut();
239 if ConvertSidToStringSidW(user.User.Sid, &mut sid_w) == 0 {
240 anyhow::bail!("ConvertSidToStringSidW failed (error {})", GetLastError());
241 }
242 let mut len = 0_usize;
243 while *sid_w.add(len) != 0 {
244 len += 1;
245 }
246 let sid = String::from_utf16_lossy(std::slice::from_raw_parts(sid_w, len));
247 LocalFree(sid_w.cast());
248 Ok(sid)
249 })();
250 CloseHandle(token);
251 result
252 }
253}
254
255#[cfg(windows)]
257pub fn daemon_pipe_name() -> Result<String> {
258 Ok(pipe_name_for_sid(¤t_user_sid()?))
259}
260
261#[cfg(windows)]
266pub struct PipeSecurity {
267 descriptor: windows_sys::Win32::Security::PSECURITY_DESCRIPTOR,
268 attributes: windows_sys::Win32::Security::SECURITY_ATTRIBUTES,
269}
270
271#[cfg(windows)]
272impl PipeSecurity {
273 pub fn owner_only() -> Result<Self> {
274 use windows_sys::Win32::Foundation::GetLastError;
275 use windows_sys::Win32::Security::Authorization::{
276 ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
277 };
278 use windows_sys::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES};
279
280 let sddl = pipe_sddl(¤t_user_sid()?);
281 let wide: Vec<u16> = sddl.encode_utf16().chain(std::iter::once(0)).collect();
282 let mut descriptor: PSECURITY_DESCRIPTOR = std::ptr::null_mut();
283 if unsafe {
284 ConvertStringSecurityDescriptorToSecurityDescriptorW(
285 wide.as_ptr(),
286 SDDL_REVISION_1,
287 &mut descriptor,
288 std::ptr::null_mut(),
289 )
290 } == 0
291 {
292 anyhow::bail!(
293 "failed to build pipe security descriptor from `{}` (error {})",
294 sddl,
295 unsafe { GetLastError() }
296 );
297 }
298 let attributes = SECURITY_ATTRIBUTES {
299 nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
300 lpSecurityDescriptor: descriptor,
301 bInheritHandle: 0,
302 };
303 Ok(Self {
304 descriptor,
305 attributes,
306 })
307 }
308
309 pub fn attributes_ptr(&mut self) -> *mut core::ffi::c_void {
313 (&raw mut self.attributes).cast()
314 }
315}
316
317#[cfg(windows)]
318impl Drop for PipeSecurity {
319 fn drop(&mut self) {
320 unsafe {
321 windows_sys::Win32::Foundation::LocalFree(self.descriptor.cast());
322 }
323 }
324}
325
326#[cfg(windows)]
331fn open_daemon_pipe(pipe_name: &str) -> Result<std::fs::File> {
332 const ATTEMPTS: u32 = 5;
333 for attempt in 1..=ATTEMPTS {
334 match std::fs::OpenOptions::new()
335 .read(true)
336 .write(true)
337 .open(pipe_name)
338 {
339 Ok(file) => return Ok(file),
340 Err(err)
341 if err.raw_os_error()
342 == Some(windows_sys::Win32::Foundation::ERROR_PIPE_BUSY as i32)
343 && attempt < ATTEMPTS =>
344 {
345 std::thread::sleep(std::time::Duration::from_millis(50));
346 },
347 Err(err) => {
348 return Err(err).with_context(|| {
349 format!("failed to connect to {pipe_name} (is mermaidd running?)")
350 });
351 },
352 }
353 }
354 anyhow::bail!("daemon pipe {pipe_name} stayed busy after {ATTEMPTS} attempts")
355}
356
357pub fn snapshot_field_from_daemon<T: DeserializeOwned>(field: &str) -> Result<T> {
358 let value = request_daemon_json(serde_json::json!({ "command": "snapshot" }))?;
359 let field_value = value
360 .get(field)
361 .cloned()
362 .with_context(|| format!("daemon snapshot missing `{}`", field))?;
363 serde_json::from_value(field_value)
364 .with_context(|| format!("daemon snapshot field `{}` had unexpected shape", field))
365}
366
367#[cfg(test)]
368mod tests {
369 use crate::*;
370
371 #[test]
372 fn pairing_token_hash_is_stable_and_not_plaintext() {
373 let hash = hash_pairing_token("mermaid_test");
374 assert_eq!(hash, hash_pairing_token("mermaid_test"));
375 assert_ne!(hash, "mermaid_test");
376 assert_eq!(hash.len(), 64);
377 }
378
379 #[test]
380 fn generated_pairing_token_hash_matches_token() {
381 let (token, hash) = generate_pairing_token().expect("token");
382 assert!(token.starts_with("mermaid_"));
383 assert_eq!(hash, hash_pairing_token(&token));
384 }
385
386 #[test]
387 fn clamp_pairing_ttl_days_forces_expiry_for_non_positive() {
388 assert_eq!(clamp_pairing_ttl_days(0), DEFAULT_PAIRING_TTL_DAYS);
389 assert_eq!(clamp_pairing_ttl_days(-5), DEFAULT_PAIRING_TTL_DAYS);
390 assert_eq!(clamp_pairing_ttl_days(7), 7);
391 assert!(pairing_expiry_from_now(clamp_pairing_ttl_days(0)).is_some());
394 assert!(pairing_expiry_from_now(clamp_pairing_ttl_days(-1)).is_some());
395 }
396
397 #[test]
398 fn pipe_name_and_sddl_embed_the_sid() {
399 let sid = "S-1-5-21-1-2-3-1000";
400 assert_eq!(
401 super::pipe_name_for_sid(sid),
402 r"\\.\pipe\mermaidd-S-1-5-21-1-2-3-1000"
403 );
404 let sddl = super::pipe_sddl(sid);
405 assert_eq!(sddl, "D:P(A;;GA;;;SY)(A;;GA;;;S-1-5-21-1-2-3-1000)");
408 }
409
410 #[cfg(windows)]
413 #[test]
414 fn current_user_sid_and_pipe_security_resolve() {
415 let sid = super::current_user_sid().expect("current_user_sid");
416 assert!(sid.starts_with("S-1-"), "unexpected SID shape: {sid}");
417 let mut security = super::PipeSecurity::owner_only().expect("PipeSecurity");
418 assert!(!security.attributes_ptr().is_null());
419 assert!(super::daemon_pipe_name().expect("pipe name").contains(&sid));
420 }
421}