mermaid-runtime 0.16.0

Daemon-safe runtime core for Mermaid
Documentation
#[cfg(any(unix, windows))]
use std::io::{BufRead, BufReader, Write};
use std::path::PathBuf;

use anyhow::{Context, Result};
use base64::{Engine as _, engine::general_purpose};
use serde::de::DeserializeOwned;
use sha2::{Digest, Sha256};

use crate::data_dir;

pub const DAEMON_TOKEN_ENV: &str = "MERMAID_DAEMON_TOKEN";

/// Default lifetime for a freshly minted pairing token. Tokens expire so a
/// leaked or forgotten token can't be replayed indefinitely; `--ttl-days 0`
/// opts out for long-lived automation.
pub const DEFAULT_PAIRING_TTL_DAYS: i64 = 30;

/// RFC3339 expiry `ttl_days` from now, or `None` when `ttl_days <= 0`
/// (never expires). Shared by the daemon `pair` command and the local CLI so
/// both honor the same TTL semantics.
pub fn pairing_expiry_from_now(ttl_days: i64) -> Option<String> {
    (ttl_days > 0).then(|| (chrono::Utc::now() + chrono::Duration::days(ttl_days)).to_rfc3339())
}

/// Clamp a *client-supplied* pairing TTL so a daemon socket caller can't mint a
/// never-expiring token by sending `ttl_days <= 0`: non-positive input becomes
/// the default TTL, positive values pass through. The local `mermaid pair` CLI
/// deliberately does **not** call this — its `--ttl-days 0` "never expires"
/// opt-out is an owner-only choice with no privilege boundary (#65).
pub fn clamp_pairing_ttl_days(ttl_days: i64) -> i64 {
    if ttl_days <= 0 {
        DEFAULT_PAIRING_TTL_DAYS
    } else {
        ttl_days
    }
}

pub fn daemon_socket_path() -> Result<PathBuf> {
    Ok(data_dir()?.join("mermaidd.sock"))
}

pub fn generate_pairing_token() -> Result<(String, String)> {
    let mut bytes = [0_u8; 32];
    getrandom::fill(&mut bytes)
        .map_err(|err| anyhow::anyhow!("failed to generate pairing token: {}", err))?;
    let token = format!("mermaid_{}", general_purpose::URL_SAFE_NO_PAD.encode(bytes));
    let hash = hash_pairing_token(&token);
    Ok((token, hash))
}

pub fn hash_pairing_token(token: &str) -> String {
    let digest = Sha256::digest(token.as_bytes());
    crate::hex_lower(&digest)
}

pub fn request_daemon_json(mut body: serde_json::Value) -> Result<serde_json::Value> {
    if body.get("auth").is_none()
        && let Ok(token) = std::env::var(DAEMON_TOKEN_ENV)
        && !token.trim().is_empty()
    {
        body["auth"] = serde_json::json!({ "token": token });
    }
    request_daemon_text(&body.to_string())
}

pub fn request_daemon_text(line: &str) -> Result<serde_json::Value> {
    #[cfg(unix)]
    {
        use std::os::unix::net::UnixStream;

        let socket = daemon_socket_path()?;
        let mut stream = UnixStream::connect(&socket)
            .with_context(|| format!("failed to connect to {}", socket.display()))?;
        stream.write_all(line.as_bytes())?;
        stream.write_all(b"\n")?;
        stream.flush()?;

        let mut response = String::new();
        let mut reader = BufReader::new(stream);
        reader.read_line(&mut response)?;
        let value: serde_json::Value =
            serde_json::from_str(response.trim()).context("daemon returned invalid JSON")?;
        if value.get("ok").and_then(|v| v.as_bool()) == Some(false) {
            anyhow::bail!(
                "{}",
                value
                    .get("error")
                    .and_then(|v| v.as_str())
                    .unwrap_or("daemon request failed")
            );
        }
        Ok(value)
    }

    #[cfg(windows)]
    {
        let pipe_name = daemon_pipe_name()?;
        let stream = open_daemon_pipe(&pipe_name)?;
        let mut stream = stream;
        stream.write_all(line.as_bytes())?;
        stream.write_all(b"\n")?;
        stream.flush()?;

        // The response is exactly one JSON line followed by `\n`, written before
        // the server closes its end — so `read_line` completes on the newline and
        // never reaches the post-close read (which Windows surfaces as a
        // `BrokenPipe` error rather than a unix-style clean EOF).
        let mut response = String::new();
        let mut reader = BufReader::new(stream);
        reader.read_line(&mut response)?;
        let value: serde_json::Value =
            serde_json::from_str(response.trim()).context("daemon returned invalid JSON")?;
        if value.get("ok").and_then(|v| v.as_bool()) == Some(false) {
            anyhow::bail!(
                "{}",
                value
                    .get("error")
                    .and_then(|v| v.as_str())
                    .unwrap_or("daemon request failed")
            );
        }
        Ok(value)
    }

    #[cfg(not(any(unix, windows)))]
    {
        let _ = line;
        anyhow::bail!("daemon IPC supports Unix sockets and Windows named pipes only")
    }
}

/// Name of the per-user daemon control pipe for `sid`. Namespaced by the
/// user's SID so two users on one machine get distinct pipes (the analog of
/// the unix socket living in a per-user data dir) — the ACL from
/// [`pipe_sddl`] then enforces that separation, rather than merely naming it.
pub fn pipe_name_for_sid(sid: &str) -> String {
    format!(r"\\.\pipe\mermaidd-{sid}")
}

/// SDDL for the daemon pipe's DACL: protected (`P`, no inherited ACEs),
/// granting `GA` (generic all) to `SY` (LocalSystem) and to the owning user's
/// SID — and to no one else, since a DACL denies anything it doesn't grant.
/// This is the named-pipe analog of the 0600 unix socket + uid peer check
/// (#66). Remote access is separately refused via
/// `PIPE_REJECT_REMOTE_CLIENTS` on the server, not the DACL.
pub fn pipe_sddl(sid: &str) -> String {
    format!("D:P(A;;GA;;;SY)(A;;GA;;;{sid})")
}

/// String SID (`S-1-5-21-…`) of the user this process runs as, read from the
/// process token. Both ends derive the pipe name from it, and the server bakes
/// it into the pipe ACL.
#[cfg(windows)]
pub fn current_user_sid() -> Result<String> {
    use windows_sys::Win32::Foundation::{CloseHandle, GetLastError, HANDLE, LocalFree};
    use windows_sys::Win32::Security::Authorization::ConvertSidToStringSidW;
    use windows_sys::Win32::Security::{GetTokenInformation, TOKEN_QUERY, TOKEN_USER, TokenUser};
    use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};

    unsafe {
        let mut token: HANDLE = std::ptr::null_mut();
        if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) == 0 {
            anyhow::bail!("OpenProcessToken failed (error {})", GetLastError());
        }
        // Everything after the token opens runs in a closure so the handle is
        // closed on every path — success or bail.
        let result = (|| {
            let mut needed: u32 = 0;
            GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut needed);
            anyhow::ensure!(
                needed > 0,
                "GetTokenInformation sizing call failed (error {})",
                GetLastError()
            );
            let mut buf = vec![0_u8; needed as usize];
            if GetTokenInformation(
                token,
                TokenUser,
                buf.as_mut_ptr().cast(),
                needed,
                &mut needed,
            ) == 0
            {
                anyhow::bail!("GetTokenInformation failed (error {})", GetLastError());
            }
            let user = &*(buf.as_ptr() as *const TOKEN_USER);
            let mut sid_w: *mut u16 = std::ptr::null_mut();
            if ConvertSidToStringSidW(user.User.Sid, &mut sid_w) == 0 {
                anyhow::bail!("ConvertSidToStringSidW failed (error {})", GetLastError());
            }
            let mut len = 0_usize;
            while *sid_w.add(len) != 0 {
                len += 1;
            }
            let sid = String::from_utf16_lossy(std::slice::from_raw_parts(sid_w, len));
            LocalFree(sid_w.cast());
            Ok(sid)
        })();
        CloseHandle(token);
        result
    }
}

/// Control-pipe name for the current user (see [`pipe_name_for_sid`]).
#[cfg(windows)]
pub fn daemon_pipe_name() -> Result<String> {
    Ok(pipe_name_for_sid(&current_user_sid()?))
}

/// Owner-only pipe security for the daemon's listener. Owns the
/// `LocalAlloc`ed security descriptor built from [`pipe_sddl`]; hand
/// [`Self::attributes_ptr`] to `ServerOptions::create_with_security_attributes_raw`
/// while this guard is alive.
#[cfg(windows)]
pub struct PipeSecurity {
    descriptor: windows_sys::Win32::Security::PSECURITY_DESCRIPTOR,
    attributes: windows_sys::Win32::Security::SECURITY_ATTRIBUTES,
}

#[cfg(windows)]
impl PipeSecurity {
    pub fn owner_only() -> Result<Self> {
        use windows_sys::Win32::Foundation::GetLastError;
        use windows_sys::Win32::Security::Authorization::{
            ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
        };
        use windows_sys::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES};

        let sddl = pipe_sddl(&current_user_sid()?);
        let wide: Vec<u16> = sddl.encode_utf16().chain(std::iter::once(0)).collect();
        let mut descriptor: PSECURITY_DESCRIPTOR = std::ptr::null_mut();
        if unsafe {
            ConvertStringSecurityDescriptorToSecurityDescriptorW(
                wide.as_ptr(),
                SDDL_REVISION_1,
                &mut descriptor,
                std::ptr::null_mut(),
            )
        } == 0
        {
            anyhow::bail!(
                "failed to build pipe security descriptor from `{}` (error {})",
                sddl,
                unsafe { GetLastError() }
            );
        }
        let attributes = SECURITY_ATTRIBUTES {
            nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
            lpSecurityDescriptor: descriptor,
            bInheritHandle: 0,
        };
        Ok(Self {
            descriptor,
            attributes,
        })
    }

    /// Pointer for `create_with_security_attributes_raw`. Taken fresh per call
    /// so moving the guard between calls stays sound; the descriptor it points
    /// at is heap-allocated and lives until the guard drops.
    pub fn attributes_ptr(&mut self) -> *mut core::ffi::c_void {
        (&raw mut self.attributes).cast()
    }
}

#[cfg(windows)]
impl Drop for PipeSecurity {
    fn drop(&mut self) {
        unsafe {
            windows_sys::Win32::Foundation::LocalFree(self.descriptor.cast());
        }
    }
}

/// Open the daemon control pipe as an ordinary duplex file handle, retrying
/// briefly on `ERROR_PIPE_BUSY` (all server instances momentarily taken — the
/// server stands up the next instance right after each accept, so busy windows
/// are tiny).
#[cfg(windows)]
fn open_daemon_pipe(pipe_name: &str) -> Result<std::fs::File> {
    const ATTEMPTS: u32 = 5;
    for attempt in 1..=ATTEMPTS {
        match std::fs::OpenOptions::new()
            .read(true)
            .write(true)
            .open(pipe_name)
        {
            Ok(file) => return Ok(file),
            Err(err)
                if err.raw_os_error()
                    == Some(windows_sys::Win32::Foundation::ERROR_PIPE_BUSY as i32)
                    && attempt < ATTEMPTS =>
            {
                std::thread::sleep(std::time::Duration::from_millis(50));
            },
            Err(err) => {
                return Err(err).with_context(|| {
                    format!("failed to connect to {pipe_name} (is mermaidd running?)")
                });
            },
        }
    }
    anyhow::bail!("daemon pipe {pipe_name} stayed busy after {ATTEMPTS} attempts")
}

pub fn snapshot_field_from_daemon<T: DeserializeOwned>(field: &str) -> Result<T> {
    let value = request_daemon_json(serde_json::json!({ "command": "snapshot" }))?;
    let field_value = value
        .get(field)
        .cloned()
        .with_context(|| format!("daemon snapshot missing `{}`", field))?;
    serde_json::from_value(field_value)
        .with_context(|| format!("daemon snapshot field `{}` had unexpected shape", field))
}

#[cfg(test)]
mod tests {
    use crate::*;

    #[test]
    fn pairing_token_hash_is_stable_and_not_plaintext() {
        let hash = hash_pairing_token("mermaid_test");
        assert_eq!(hash, hash_pairing_token("mermaid_test"));
        assert_ne!(hash, "mermaid_test");
        assert_eq!(hash.len(), 64);
    }

    #[test]
    fn generated_pairing_token_hash_matches_token() {
        let (token, hash) = generate_pairing_token().expect("token");
        assert!(token.starts_with("mermaid_"));
        assert_eq!(hash, hash_pairing_token(&token));
    }

    #[test]
    fn clamp_pairing_ttl_days_forces_expiry_for_non_positive() {
        assert_eq!(clamp_pairing_ttl_days(0), DEFAULT_PAIRING_TTL_DAYS);
        assert_eq!(clamp_pairing_ttl_days(-5), DEFAULT_PAIRING_TTL_DAYS);
        assert_eq!(clamp_pairing_ttl_days(7), 7);
        // The #65 property: a clamped non-positive ttl yields a NON-NULL expiry,
        // exactly as the daemon `pair` handler composes the two helpers.
        assert!(pairing_expiry_from_now(clamp_pairing_ttl_days(0)).is_some());
        assert!(pairing_expiry_from_now(clamp_pairing_ttl_days(-1)).is_some());
    }

    #[test]
    fn pipe_name_and_sddl_embed_the_sid() {
        let sid = "S-1-5-21-1-2-3-1000";
        assert_eq!(
            super::pipe_name_for_sid(sid),
            r"\\.\pipe\mermaidd-S-1-5-21-1-2-3-1000"
        );
        let sddl = super::pipe_sddl(sid);
        // Protected DACL granting only LocalSystem + the owner: exactly two
        // allow-ACEs, no deny/inherit clutter for the parser to misread.
        assert_eq!(sddl, "D:P(A;;GA;;;SY)(A;;GA;;;S-1-5-21-1-2-3-1000)");
    }

    // Windows-only: exercises the real token→SID→SDDL→descriptor FFI chain on
    // the Windows CI runner — the part a Linux build can't validate at all.
    #[cfg(windows)]
    #[test]
    fn current_user_sid_and_pipe_security_resolve() {
        let sid = super::current_user_sid().expect("current_user_sid");
        assert!(sid.starts_with("S-1-"), "unexpected SID shape: {sid}");
        let mut security = super::PipeSecurity::owner_only().expect("PipeSecurity");
        assert!(!security.attributes_ptr().is_null());
        assert!(super::daemon_pipe_name().expect("pipe name").contains(&sid));
    }
}