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 pipe_name_for_sid(sid: &str) -> String {
138 format!(r"\\.\pipe\mermaidd-{sid}")
139}
140
141pub fn pipe_sddl(sid: &str) -> String {
148 format!("D:P(A;;GA;;;SY)(A;;GA;;;{sid})")
149}
150
151#[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 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#[cfg(windows)]
207pub fn daemon_pipe_name() -> Result<String> {
208 Ok(pipe_name_for_sid(¤t_user_sid()?))
209}
210
211#[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(¤t_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 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#[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 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 assert_eq!(sddl, "D:P(A;;GA;;;SY)(A;;GA;;;S-1-5-21-1-2-3-1000)");
358 }
359
360 #[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}