Skip to main content

entracte_lib/
ipc.rs

1//! Local IPC channel between the running tray app and `entracte` CLI
2//! invocations.
3//!
4//! # Transport
5//!
6//! - **Unix (macOS + Linux):** AF_UNIX socket. The preferred location
7//!   is `<data_dir>/ipc.sock`, but `sockaddr_un.sun_path` is fixed at
8//!   104 bytes on macOS/BSD (108 on Linux, NUL included). Accounts
9//!   with long usernames can push the full path past that limit and
10//!   `bind`/`connect` fails with `ENAMETOOLONG`. When the data-dir
11//!   path would exceed [`MAX_SOCKET_PATH_LEN`] we fall back to
12//!   `$TMPDIR/entracte-<uid>.sock` (typically `/var/folders/...` on
13//!   macOS, `/tmp/...` on Linux), which stays well under any limit.
14//!   The chosen path is deterministic from `data_dir` so the CLI and
15//!   the tray agree without an extra discovery file. The socket file
16//!   is chmodded to `0o600` immediately after bind so other local
17//!   UIDs cannot `connect()`.
18//! - **Windows:** named pipe at `\\.\pipe\entracte-<sanitized-user>`.
19//!   The pipe is created with the default DACL, which grants access
20//!   to the current user's SID only. Pipe names cap at ~256 chars and
21//!   the per-user scheme stays well under that — no fallback needed.
22//!
23//! Both transports are user-scoped by the OS, so the threat model is
24//! "another process running as the same user", not "any local UID with
25//! the token". The token file (`<data_dir>/ipc-token`) stays in the
26//! data dir regardless of which socket path is chosen — only the
27//! socket may move. It is kept as a defense-in-depth secondary check —
28//! every request must still carry it and we still constant-time
29//! compare — but it's no longer the sole line of defense.
30//!
31//! # Wire protocol
32//!
33//! Newline-delimited JSON. Client sends one [`IpcEnvelope`] line,
34//! server replies with one [`IpcResponse`] line and closes the
35//! connection. Reads are bounded by [`MAX_REQUEST_BYTES`] so a hostile
36//! peer can't OOM the server with an unbounded frame.
37use std::path::{Path, PathBuf};
38use std::time::Duration;
39
40use serde::{Deserialize, Serialize};
41use subtle::ConstantTimeEq;
42use tauri::{AppHandle, Emitter, Manager};
43
44use crate::scheduler::{PauseState, Scheduler};
45use crate::secure_io::{ensure_user_only_dir, write_user_only};
46
47const SETTINGS_DENYLIST: &[&str] = &["hooks", "hooks_enabled"];
48
49/// Hard ceiling on a single IPC request frame. Anything larger is
50/// dropped — a CLI request is never bigger than a few hundred bytes,
51/// so 64 KiB is comfortably above the legitimate ceiling while still
52/// small enough to keep an attacker from exhausting memory.
53pub const MAX_REQUEST_BYTES: u64 = 64 * 1024;
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
56#[serde(tag = "cmd", rename_all = "snake_case")]
57pub enum IpcRequest {
58    Status,
59    ProfileList,
60    ProfileUse {
61        name: String,
62    },
63    SettingsGet {
64        key: String,
65    },
66    SettingsSet {
67        key: String,
68        value: serde_json::Value,
69    },
70    Pause {
71        duration_secs: Option<u64>,
72    },
73    Resume,
74    Trigger {
75        kind: String,
76    },
77    Skip {
78        kind: String,
79    },
80}
81
82#[derive(Debug, Serialize, Deserialize)]
83pub struct IpcEnvelope {
84    pub token: String,
85    pub request: IpcRequest,
86}
87
88#[derive(Debug, Serialize, Deserialize)]
89pub struct IpcResponse {
90    pub ok: bool,
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub data: Option<serde_json::Value>,
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub error: Option<String>,
95}
96
97impl IpcResponse {
98    pub fn ok(data: serde_json::Value) -> Self {
99        Self {
100            ok: true,
101            data: Some(data),
102            error: None,
103        }
104    }
105
106    pub fn err(msg: impl Into<String>) -> Self {
107        Self {
108            ok: false,
109            data: None,
110            error: Some(msg.into()),
111        }
112    }
113}
114
115pub fn token_file_path(data_dir: &Path) -> PathBuf {
116    data_dir.join("ipc-token")
117}
118
119/// Safe cushion below the smallest `sun_path` capacity we care about
120/// (104 bytes on macOS/BSD), leaving room for the trailing NUL and a
121/// couple of bytes of margin. If the preferred `<data_dir>/ipc.sock`
122/// path is longer than this we fall back to `$TMPDIR`.
123#[cfg(unix)]
124pub const MAX_SOCKET_PATH_LEN: usize = 100;
125
126#[cfg(unix)]
127pub fn socket_path(data_dir: &Path) -> PathBuf {
128    let preferred = data_dir.join("ipc.sock");
129    if preferred.as_os_str().len() <= MAX_SOCKET_PATH_LEN {
130        return preferred;
131    }
132    // SAFETY: `getuid` is async-signal-safe and always succeeds — no
133    // errno to check.
134    let uid = unsafe { libc::getuid() };
135    std::env::temp_dir().join(format!("entracte-{uid}.sock"))
136}
137
138#[cfg(windows)]
139pub fn pipe_name() -> String {
140    let raw = std::env::var("USERNAME").unwrap_or_else(|_| "default".to_string());
141    let sanitized: String = raw
142        .chars()
143        .map(|c| {
144            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
145                c
146            } else {
147                '_'
148            }
149        })
150        .collect();
151    let trimmed = if sanitized.is_empty() {
152        "default".to_string()
153    } else {
154        sanitized
155    };
156    format!(r"\\.\pipe\entracte-{trimmed}")
157}
158
159fn generate_token() -> std::io::Result<String> {
160    let mut bytes = [0u8; 32];
161    getrandom::getrandom(&mut bytes).map_err(|e| std::io::Error::other(e.to_string()))?;
162    Ok(hex::encode(bytes))
163}
164
165pub fn start_server(app: AppHandle, data_dir: PathBuf) -> std::io::Result<()> {
166    ensure_user_only_dir(&data_dir)?;
167    let token = generate_token()?;
168    let token_path = token_file_path(&data_dir);
169    write_user_only(&token_path, token.as_bytes())?;
170
171    #[cfg(unix)]
172    {
173        unix::spawn_server(app, data_dir, token)?;
174    }
175    #[cfg(windows)]
176    {
177        windows_pipe::spawn_server(app, token);
178        let _ = data_dir;
179    }
180    Ok(())
181}
182
183fn tokens_match(provided: &str, expected: &str) -> bool {
184    let a = provided.as_bytes();
185    let b = expected.as_bytes();
186    if a.len() != b.len() {
187        return false;
188    }
189    a.ct_eq(b).into()
190}
191
192async fn dispatch(app: &AppHandle, req: IpcRequest) -> IpcResponse {
193    let scheduler = match app.try_state::<Scheduler>() {
194        Some(s) => s.inner().clone(),
195        None => return IpcResponse::err("scheduler not ready"),
196    };
197    match req {
198        IpcRequest::Status => status_payload(&scheduler).await,
199        IpcRequest::ProfileList => {
200            let list: Vec<String> = scheduler
201                .profiles
202                .lock()
203                .await
204                .iter()
205                .map(|p| p.name.clone())
206                .collect();
207            IpcResponse::ok(serde_json::json!({"profiles": list}))
208        }
209        IpcRequest::ProfileUse { name } => {
210            match crate::scheduler::set_active_profile_impl(app, &scheduler, name).await {
211                Ok(()) => IpcResponse::ok(serde_json::json!({"ok": true})),
212                Err(e) => IpcResponse::err(e),
213            }
214        }
215        IpcRequest::SettingsGet { key } => {
216            let s = scheduler.settings.lock().await.clone();
217            let v = match serde_json::to_value(&s) {
218                Ok(v) => v,
219                Err(e) => return IpcResponse::err(format!("serialize: {e}")),
220            };
221            match v.get(&key).cloned() {
222                Some(value) => IpcResponse::ok(value),
223                None => IpcResponse::err(format!("unknown key: {key}")),
224            }
225        }
226        IpcRequest::Pause { duration_secs } => {
227            use std::time::Instant;
228            let until = duration_secs.map(|s| Instant::now() + Duration::from_secs(s));
229            *scheduler.pause_state.lock().await = crate::scheduler::PauseState::PausedUntil(until);
230            let _ = app.emit("pause:changed", true);
231            log::info!("ipc: pause {:?}", duration_secs);
232            IpcResponse::ok(serde_json::json!({"ok": true, "paused": true}))
233        }
234        IpcRequest::Resume => {
235            *scheduler.pause_state.lock().await = crate::scheduler::PauseState::Running;
236            let _ = app.emit("pause:changed", false);
237            log::info!("ipc: resume");
238            IpcResponse::ok(serde_json::json!({"ok": true, "paused": false}))
239        }
240        IpcRequest::Trigger { kind } => {
241            let break_kind = match kind.to_lowercase().as_str() {
242                "micro" => crate::scheduler::BreakKind::Micro,
243                "long" => crate::scheduler::BreakKind::Long,
244                other => return IpcResponse::err(format!("unknown kind: {other}")),
245            };
246            let secs = match break_kind {
247                crate::scheduler::BreakKind::Micro => {
248                    scheduler.settings.lock().await.micro_duration_secs
249                }
250                crate::scheduler::BreakKind::Long => {
251                    scheduler.settings.lock().await.long_duration_secs
252                }
253                crate::scheduler::BreakKind::Sleep => 0,
254            };
255            crate::scheduler::trigger_break_from_cli(app, &scheduler, break_kind, secs).await;
256            log::info!("ipc: trigger {:?}", kind);
257            IpcResponse::ok(serde_json::json!({"ok": true, "kind": kind}))
258        }
259        IpcRequest::Skip { kind } => {
260            let break_kind = match kind.to_lowercase().as_str() {
261                "micro" => crate::scheduler::BreakKind::Micro,
262                "long" => crate::scheduler::BreakKind::Long,
263                other => return IpcResponse::err(format!("unknown kind: {other}")),
264            };
265            if let Err(e) = crate::scheduler::skip_next_from_cli(app, &scheduler, break_kind).await
266            {
267                return IpcResponse::err(e);
268            }
269            log::info!("ipc: skip {:?}", kind);
270            IpcResponse::ok(serde_json::json!({"ok": true, "kind": kind}))
271        }
272        IpcRequest::SettingsSet { key, value } => {
273            if SETTINGS_DENYLIST.contains(&key.as_str()) {
274                return IpcResponse::err(format!("settings key '{key}' is not writable via IPC"));
275            }
276            let current = scheduler.settings.lock().await.clone();
277            let mut v = match serde_json::to_value(&current) {
278                Ok(v) => v,
279                Err(e) => return IpcResponse::err(format!("serialize: {e}")),
280            };
281            if v.get(&key).is_none() {
282                return IpcResponse::err(format!("unknown key: {key}"));
283            }
284            v[&key] = value;
285            let next: crate::scheduler::Settings = match serde_json::from_value(v) {
286                Ok(n) => n,
287                Err(e) => return IpcResponse::err(format!("type mismatch: {e}")),
288            };
289            *scheduler.settings.lock().await = next.clone();
290            {
291                let active = scheduler.active_profile_name.lock().await.clone();
292                let mut profiles = scheduler.profiles.lock().await;
293                if let Some(p) = profiles.iter_mut().find(|p| p.name == active) {
294                    p.settings = next.clone();
295                }
296            }
297            crate::scheduler::persist_profiles(&scheduler).await;
298            IpcResponse::ok(serde_json::json!({"ok": true, "key": key}))
299        }
300    }
301}
302
303async fn status_payload(scheduler: &Scheduler) -> IpcResponse {
304    let pause = scheduler.pause_state.lock().await.clone();
305    let active_profile = scheduler.active_profile_name.lock().await.clone();
306    let pause_json = match pause {
307        PauseState::Running => serde_json::json!({"paused": false}),
308        PauseState::PausedUntil(None) => serde_json::json!({"paused": true, "until": null}),
309        PauseState::PausedUntil(Some(deadline)) => {
310            let now = std::time::Instant::now();
311            let remaining = deadline.saturating_duration_since(now).as_secs();
312            serde_json::json!({"paused": true, "remaining_secs": remaining})
313        }
314    };
315    IpcResponse::ok(serde_json::json!({
316        "pause": pause_json,
317        "active_profile": active_profile,
318    }))
319}
320
321pub fn call(req: &IpcRequest, data_dir: &Path) -> Result<IpcResponse, String> {
322    let token_path = token_file_path(data_dir);
323    let token = std::fs::read_to_string(&token_path)
324        .map_err(|e| {
325            format!(
326                "can't read {}: {e}. Is Entracte running?",
327                token_path.display()
328            )
329        })?
330        .trim()
331        .to_string();
332    let envelope = IpcEnvelope {
333        token,
334        request: req.clone(),
335    };
336    let body = serde_json::to_string(&envelope).map_err(|e| e.to_string())?;
337
338    #[cfg(unix)]
339    {
340        unix::call(data_dir, &body)
341    }
342    #[cfg(windows)]
343    {
344        let _ = data_dir;
345        windows_pipe::call(&body)
346    }
347}
348
349pub fn ipc_data_dir() -> Option<PathBuf> {
350    const BUNDLE: &str = "app.entracte";
351    #[cfg(target_os = "macos")]
352    {
353        std::env::var_os("HOME").map(|h| {
354            PathBuf::from(h)
355                .join("Library/Application Support")
356                .join(BUNDLE)
357        })
358    }
359    #[cfg(target_os = "linux")]
360    {
361        let base = std::env::var_os("XDG_DATA_HOME")
362            .map(PathBuf::from)
363            .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share")));
364        base.map(|d| d.join(BUNDLE))
365    }
366    #[cfg(target_os = "windows")]
367    {
368        std::env::var_os("APPDATA").map(|d| PathBuf::from(d).join(BUNDLE))
369    }
370}
371
372#[cfg(unix)]
373mod unix {
374    use super::{dispatch, socket_path, tokens_match, IpcEnvelope, IpcResponse, MAX_REQUEST_BYTES};
375    use std::io::{BufRead, BufReader, Read, Write};
376    use std::os::unix::net::{UnixListener, UnixStream};
377    use std::path::{Path, PathBuf};
378    use std::time::Duration;
379    use tauri::AppHandle;
380
381    pub fn spawn_server(app: AppHandle, data_dir: PathBuf, token: String) -> std::io::Result<()> {
382        let sock = socket_path(&data_dir);
383        // A stale socket file (left over from a hard crash) blocks bind
384        // with EADDRINUSE — clear it before retrying.
385        if sock.exists() {
386            let _ = std::fs::remove_file(&sock);
387        }
388        let listener = UnixListener::bind(&sock)?;
389        {
390            use std::os::unix::fs::PermissionsExt;
391            std::fs::set_permissions(&sock, std::fs::Permissions::from_mode(0o600))?;
392        }
393        log::info!("ipc: listening on {}", sock.display());
394
395        std::thread::spawn(move || {
396            for stream in listener.incoming() {
397                match stream {
398                    Ok(s) => {
399                        let app = app.clone();
400                        let token = token.clone();
401                        tauri::async_runtime::spawn(async move {
402                            handle_client(s, app, token).await;
403                        });
404                    }
405                    Err(e) => log::warn!("ipc: accept failed: {e}"),
406                }
407            }
408        });
409        Ok(())
410    }
411
412    async fn handle_client(stream: UnixStream, app: AppHandle, expected_token: String) {
413        let _ = stream.set_read_timeout(Some(Duration::from_secs(5)));
414        let read_stream = match stream.try_clone() {
415            Ok(s) => s,
416            Err(e) => {
417                log::warn!("ipc: stream clone failed: {e}");
418                return;
419            }
420        };
421        let mut reader = BufReader::new(read_stream.take(MAX_REQUEST_BYTES));
422        let mut line = String::new();
423        let n = match reader.read_line(&mut line) {
424            Ok(n) => n,
425            Err(_) => return,
426        };
427        // If we filled the cap without seeing a newline, the peer is
428        // either lying about request size or maliciously holding the
429        // socket open — drop them.
430        if n as u64 == MAX_REQUEST_BYTES && !line.ends_with('\n') {
431            log::warn!("ipc: request exceeded {MAX_REQUEST_BYTES} bytes; dropping connection");
432            return;
433        }
434        let resp = match serde_json::from_str::<IpcEnvelope>(line.trim()) {
435            Ok(envelope) => {
436                if !tokens_match(&envelope.token, &expected_token) {
437                    log::warn!("ipc: rejected request with invalid token");
438                    IpcResponse::err("unauthorized")
439                } else {
440                    dispatch(&app, envelope.request).await
441                }
442            }
443            Err(e) => IpcResponse::err(format!("parse: {e}")),
444        };
445        let body = serde_json::to_string(&resp).unwrap_or_else(|_| "{}".to_string());
446        let mut w = stream;
447        let _ = writeln!(&mut w, "{body}");
448    }
449
450    pub fn call(data_dir: &Path, body: &str) -> Result<IpcResponse, String> {
451        let sock = socket_path(data_dir);
452        let mut stream = UnixStream::connect(&sock)
453            .map_err(|e| format!("connect {}: {e}. Is Entracte running?", sock.display()))?;
454        let _ = stream.set_read_timeout(Some(Duration::from_secs(5)));
455        let _ = stream.set_write_timeout(Some(Duration::from_secs(5)));
456        writeln!(&mut stream, "{body}").map_err(|e| e.to_string())?;
457        stream
458            .shutdown(std::net::Shutdown::Write)
459            .map_err(|e| e.to_string())?;
460        let mut buf = String::new();
461        stream
462            .take(MAX_REQUEST_BYTES)
463            .read_to_string(&mut buf)
464            .map_err(|e| e.to_string())?;
465        serde_json::from_str(buf.trim()).map_err(|e| format!("parse response: {e}: {buf}"))
466    }
467}
468
469#[cfg(windows)]
470mod windows_pipe {
471    use super::{dispatch, pipe_name, tokens_match, IpcEnvelope, IpcResponse, MAX_REQUEST_BYTES};
472    use std::time::Duration;
473    use tauri::AppHandle;
474    use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader as TokioBufReader};
475    use tokio::net::windows::named_pipe::{ClientOptions, NamedPipeServer, ServerOptions};
476    use tokio::time::timeout;
477
478    pub fn spawn_server(app: AppHandle, token: String) {
479        let name = pipe_name();
480        tauri::async_runtime::spawn(async move {
481            log::info!("ipc: listening on {name}");
482            // First instance uses `create` so the default DACL (current
483            // user only) is applied; subsequent instances reuse the same
484            // name to accept additional clients.
485            let mut first = true;
486            loop {
487                let server_res = if first {
488                    ServerOptions::new().first_pipe_instance(true).create(&name)
489                } else {
490                    ServerOptions::new().create(&name)
491                };
492                let server = match server_res {
493                    Ok(s) => s,
494                    Err(e) => {
495                        log::warn!("ipc: pipe create failed: {e}");
496                        return;
497                    }
498                };
499                first = false;
500                if let Err(e) = server.connect().await {
501                    log::warn!("ipc: pipe connect failed: {e}");
502                    continue;
503                }
504                let app = app.clone();
505                let token = token.clone();
506                tauri::async_runtime::spawn(async move {
507                    handle_client(server, app, token).await;
508                });
509            }
510        });
511    }
512
513    async fn handle_client(server: NamedPipeServer, app: AppHandle, expected_token: String) {
514        let (read_half, mut write_half) = tokio::io::split(server);
515        let mut reader = TokioBufReader::new(read_half.take(MAX_REQUEST_BYTES));
516        let mut line = String::new();
517        let read = timeout(Duration::from_secs(5), reader.read_line(&mut line)).await;
518        let n = match read {
519            Ok(Ok(n)) => n,
520            _ => return,
521        };
522        if n as u64 == MAX_REQUEST_BYTES && !line.ends_with('\n') {
523            log::warn!("ipc: request exceeded {MAX_REQUEST_BYTES} bytes; dropping connection");
524            return;
525        }
526        let resp = match serde_json::from_str::<IpcEnvelope>(line.trim()) {
527            Ok(envelope) => {
528                if !tokens_match(&envelope.token, &expected_token) {
529                    log::warn!("ipc: rejected request with invalid token");
530                    IpcResponse::err("unauthorized")
531                } else {
532                    dispatch(&app, envelope.request).await
533                }
534            }
535            Err(e) => IpcResponse::err(format!("parse: {e}")),
536        };
537        let body = serde_json::to_string(&resp).unwrap_or_else(|_| "{}".to_string());
538        let _ = write_half.write_all(body.as_bytes()).await;
539        let _ = write_half.write_all(b"\n").await;
540        let _ = write_half.shutdown().await;
541    }
542
543    pub fn call(body: &str) -> Result<IpcResponse, String> {
544        let name = pipe_name();
545        let mut last_err: Option<String> = None;
546        // Connecting can race with the server momentarily having no
547        // available instance — retry a few times.
548        for _ in 0..5 {
549            match ClientOptions::new().open(&name) {
550                Ok(stream) => {
551                    return blocking_round_trip(stream, body);
552                }
553                Err(e) => {
554                    last_err = Some(format!("connect {name}: {e}. Is Entracte running?"));
555                    std::thread::sleep(Duration::from_millis(50));
556                }
557            }
558        }
559        Err(last_err.unwrap_or_else(|| "named pipe connect failed".to_string()))
560    }
561
562    fn blocking_round_trip(
563        stream: tokio::net::windows::named_pipe::NamedPipeClient,
564        body: &str,
565    ) -> Result<IpcResponse, String> {
566        // The CLI process is sync — drive the async round-trip on a
567        // private runtime instead of dragging tokio through the caller.
568        let rt = tokio::runtime::Builder::new_current_thread()
569            .enable_all()
570            .build()
571            .map_err(|e| e.to_string())?;
572        rt.block_on(async move {
573            let (read_half, mut write_half) = tokio::io::split(stream);
574            write_half
575                .write_all(body.as_bytes())
576                .await
577                .map_err(|e| e.to_string())?;
578            write_half
579                .write_all(b"\n")
580                .await
581                .map_err(|e| e.to_string())?;
582            write_half.shutdown().await.map_err(|e| e.to_string())?;
583            let mut reader = TokioBufReader::new(read_half.take(MAX_REQUEST_BYTES));
584            let mut buf = String::new();
585            tokio::io::AsyncBufReadExt::read_line(&mut reader, &mut buf)
586                .await
587                .map_err(|e| e.to_string())?;
588            serde_json::from_str(buf.trim()).map_err(|e| format!("parse response: {e}: {buf}"))
589        })
590    }
591}
592
593#[cfg(test)]
594mod tests {
595    use super::*;
596
597    #[test]
598    fn ipc_response_skips_empty_fields() {
599        let r = IpcResponse::ok(serde_json::json!({"foo": 1}));
600        let s = serde_json::to_string(&r).unwrap();
601        assert!(s.contains("\"ok\":true"));
602        assert!(s.contains("\"data\""));
603        assert!(!s.contains("\"error\""));
604    }
605
606    #[test]
607    fn ipc_response_err_omits_data() {
608        let r = IpcResponse::err("nope");
609        let s = serde_json::to_string(&r).unwrap();
610        assert!(s.contains("\"ok\":false"));
611        assert!(!s.contains("\"data\""));
612        assert!(s.contains("\"error\":\"nope\""));
613    }
614
615    #[test]
616    fn ipc_request_round_trips_through_json() {
617        let req = IpcRequest::SettingsSet {
618            key: "micro_interval_secs".to_string(),
619            value: serde_json::json!(1800),
620        };
621        let s = serde_json::to_string(&req).unwrap();
622        let back: IpcRequest = serde_json::from_str(&s).unwrap();
623        match back {
624            IpcRequest::SettingsSet { key, value } => {
625                assert_eq!(key, "micro_interval_secs");
626                assert_eq!(value, serde_json::json!(1800));
627            }
628            _ => panic!("wrong variant"),
629        }
630    }
631
632    #[test]
633    fn ipc_envelope_round_trips_through_json() {
634        let env = IpcEnvelope {
635            token: "deadbeef".to_string(),
636            request: IpcRequest::Status,
637        };
638        let s = serde_json::to_string(&env).unwrap();
639        let back: IpcEnvelope = serde_json::from_str(&s).unwrap();
640        assert_eq!(back.token, "deadbeef");
641        assert!(matches!(back.request, IpcRequest::Status));
642    }
643
644    #[test]
645    fn token_file_path_uses_ipc_token_name() {
646        let p = token_file_path(Path::new("/tmp/x"));
647        assert_eq!(p, PathBuf::from("/tmp/x/ipc-token"));
648    }
649
650    #[cfg(unix)]
651    #[test]
652    fn socket_path_uses_ipc_sock_name() {
653        let p = socket_path(Path::new("/tmp/x"));
654        assert_eq!(p, PathBuf::from("/tmp/x/ipc.sock"));
655    }
656
657    #[cfg(unix)]
658    #[test]
659    fn socket_path_uses_data_dir_when_short() {
660        let p = socket_path(Path::new("/tmp/test-x"));
661        assert_eq!(p, PathBuf::from("/tmp/test-x/ipc.sock"));
662        assert!(p.as_os_str().len() <= MAX_SOCKET_PATH_LEN);
663    }
664
665    #[cfg(unix)]
666    #[test]
667    fn socket_path_falls_back_to_tmp_when_data_dir_too_long() {
668        let tmp = std::env::temp_dir();
669        let long = tmp.join("x".repeat(110));
670        let p = socket_path(&long);
671        let uid = unsafe { libc::getuid() };
672        assert!(
673            p.starts_with(&tmp),
674            "expected fallback under {}, got {}",
675            tmp.display(),
676            p.display(),
677        );
678        let name = p.file_name().and_then(|s| s.to_str()).unwrap_or_default();
679        assert_eq!(name, format!("entracte-{uid}.sock"));
680        assert!(
681            p.as_os_str().len() <= MAX_SOCKET_PATH_LEN,
682            "fallback path {} exceeds {} bytes",
683            p.display(),
684            MAX_SOCKET_PATH_LEN,
685        );
686    }
687
688    #[cfg(unix)]
689    #[test]
690    fn socket_path_client_and_server_agree() {
691        // Determinism is what lets the CLI find the server without a
692        // discovery file. Same input must yield byte-equal output on
693        // every call.
694        let short = Path::new("/tmp/test-agree");
695        assert_eq!(socket_path(short), socket_path(short));
696        let long = std::env::temp_dir().join("y".repeat(120));
697        assert_eq!(socket_path(&long), socket_path(&long));
698    }
699
700    #[cfg(windows)]
701    #[test]
702    fn pipe_name_has_entracte_prefix() {
703        let n = pipe_name();
704        assert!(n.starts_with(r"\\.\pipe\entracte-"), "got {n}");
705        // Tail is sanitized: only ascii alphanumerics + `-_`.
706        let tail = &n[r"\\.\pipe\entracte-".len()..];
707        assert!(
708            tail.chars()
709                .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
710            "unsanitized tail: {tail}",
711        );
712        assert!(!tail.is_empty());
713    }
714
715    #[test]
716    fn generate_token_is_64_hex_chars() {
717        let t = generate_token().expect("rng ok");
718        assert_eq!(t.len(), 64);
719        assert!(t.chars().all(|c| c.is_ascii_hexdigit()));
720    }
721
722    #[test]
723    fn generate_token_is_unique_per_call() {
724        let a = generate_token().unwrap();
725        let b = generate_token().unwrap();
726        assert_ne!(a, b);
727    }
728
729    #[test]
730    fn tokens_match_accepts_identical() {
731        assert!(tokens_match("abc123", "abc123"));
732    }
733
734    #[test]
735    fn tokens_match_rejects_different() {
736        assert!(!tokens_match("abc123", "abc124"));
737        assert!(!tokens_match("abc123", "abc12"));
738        assert!(!tokens_match("", "x"));
739    }
740
741    #[test]
742    fn settings_denylist_contains_hook_fields() {
743        assert!(SETTINGS_DENYLIST.contains(&"hooks"));
744        assert!(SETTINGS_DENYLIST.contains(&"hooks_enabled"));
745    }
746
747    #[test]
748    fn ipc_data_dir_contains_bundle_id() {
749        let d = ipc_data_dir().expect("resolves on test platform");
750        assert!(d.to_string_lossy().contains("app.entracte"));
751    }
752
753    #[test]
754    fn max_request_bytes_is_within_reason() {
755        // Sanity-checks that the constant isn't accidentally bumped to
756        // something absurd. 64 KiB is comfortably above any legit CLI
757        // request and well below "let attackers OOM us".
758        const {
759            assert!(MAX_REQUEST_BYTES >= 4 * 1024);
760            assert!(MAX_REQUEST_BYTES <= 256 * 1024);
761        }
762    }
763
764    // Integration-style server/client round trip. Unix-only because the
765    // Windows named-pipe path needs an AppHandle to dispatch, and we
766    // can't construct one from a unit test. The transport layer (bound
767    // reads, token check, transport-only access) is what we want to
768    // cover here, and that logic is the same on both platforms.
769    #[cfg(unix)]
770    mod transport {
771        use super::super::*;
772        use std::io::{BufRead, BufReader, Read, Write};
773        use std::os::unix::net::{UnixListener, UnixStream};
774        use std::path::PathBuf;
775        use std::thread;
776
777        fn unique_dir(label: &str) -> PathBuf {
778            // Keep this path short: AF_UNIX caps `sun_path` at SUN_LEN
779            // (~104 bytes on macOS), and we still need room for
780            // `/ipc.sock` on the end.
781            let pid = std::process::id();
782            let nanos = std::time::SystemTime::now()
783                .duration_since(std::time::UNIX_EPOCH)
784                .unwrap()
785                .subsec_nanos();
786            std::env::temp_dir().join(format!("ent-{label}-{pid}-{nanos:x}"))
787        }
788
789        // Echo-style server that mirrors handle_client's transport
790        // contract: bounded read, token check, JSON-line response. It
791        // doesn't dispatch to a real Scheduler — that's covered by the
792        // dispatch unit tests above. This is purely about the wire.
793        fn run_echo_server(sock: PathBuf, token: String) -> thread::JoinHandle<()> {
794            let listener = UnixListener::bind(&sock).expect("bind");
795            {
796                use std::os::unix::fs::PermissionsExt;
797                std::fs::set_permissions(&sock, std::fs::Permissions::from_mode(0o600)).unwrap();
798            }
799            thread::spawn(move || {
800                if let Ok((stream, _)) = listener.accept() {
801                    let read_stream = stream.try_clone().expect("clone");
802                    let mut reader = BufReader::new(read_stream.take(MAX_REQUEST_BYTES));
803                    let mut line = String::new();
804                    let n = reader.read_line(&mut line).unwrap_or(0);
805                    let resp = if n as u64 == MAX_REQUEST_BYTES && !line.ends_with('\n') {
806                        // Drop oversize requests without responding —
807                        // matches real server behaviour.
808                        return;
809                    } else {
810                        match serde_json::from_str::<IpcEnvelope>(line.trim()) {
811                            Ok(env) if tokens_match(&env.token, &token) => {
812                                IpcResponse::ok(serde_json::json!({"echo": true}))
813                            }
814                            Ok(_) => IpcResponse::err("unauthorized"),
815                            Err(e) => IpcResponse::err(format!("parse: {e}")),
816                        }
817                    };
818                    let body = serde_json::to_string(&resp).unwrap();
819                    let mut w = stream;
820                    let _ = writeln!(&mut w, "{body}");
821                }
822            })
823        }
824
825        fn round_trip(sock: &std::path::Path, line: &str) -> std::io::Result<String> {
826            let mut s = UnixStream::connect(sock)?;
827            writeln!(&mut s, "{line}")?;
828            s.shutdown(std::net::Shutdown::Write)?;
829            let mut buf = String::new();
830            s.read_to_string(&mut buf)?;
831            Ok(buf)
832        }
833
834        #[test]
835        fn authorized_request_round_trips_through_unix_socket() {
836            let dir = unique_dir("authz");
837            std::fs::create_dir_all(&dir).unwrap();
838            let sock = dir.join("ipc.sock");
839            let token = "good-token".to_string();
840            let handle = run_echo_server(sock.clone(), token.clone());
841
842            let env = IpcEnvelope {
843                token: token.clone(),
844                request: IpcRequest::Status,
845            };
846            let body = serde_json::to_string(&env).unwrap();
847            let resp_raw = round_trip(&sock, &body).expect("round trip");
848            let resp: IpcResponse = serde_json::from_str(resp_raw.trim()).unwrap();
849            assert!(resp.ok, "expected ok response, got {resp:?}");
850
851            handle.join().unwrap();
852            let _ = std::fs::remove_dir_all(&dir);
853        }
854
855        #[test]
856        fn unauthorized_request_is_rejected_by_server() {
857            let dir = unique_dir("unauthz");
858            std::fs::create_dir_all(&dir).unwrap();
859            let sock = dir.join("ipc.sock");
860            let handle = run_echo_server(sock.clone(), "expected".to_string());
861
862            let env = IpcEnvelope {
863                token: "wrong".to_string(),
864                request: IpcRequest::Status,
865            };
866            let body = serde_json::to_string(&env).unwrap();
867            let resp_raw = round_trip(&sock, &body).expect("round trip");
868            let resp: IpcResponse = serde_json::from_str(resp_raw.trim()).unwrap();
869            assert!(!resp.ok);
870            assert_eq!(resp.error.as_deref(), Some("unauthorized"));
871
872            handle.join().unwrap();
873            let _ = std::fs::remove_dir_all(&dir);
874        }
875
876        #[test]
877        fn oversize_request_is_dropped_without_oom() {
878            let dir = unique_dir("oversize");
879            std::fs::create_dir_all(&dir).unwrap();
880            let sock = dir.join("ipc.sock");
881            let handle = run_echo_server(sock.clone(), "any".to_string());
882
883            // Write `MAX_REQUEST_BYTES + 1` bytes with no newline so
884            // the server hits the cap, drops the connection, and never
885            // allocates the whole payload.
886            let oversize = "A".repeat((MAX_REQUEST_BYTES as usize) + 1);
887            let mut s = UnixStream::connect(&sock).expect("connect");
888            // The server may close mid-write — that's the expected
889            // signal, not an assertion failure.
890            let _ = s.write_all(oversize.as_bytes());
891            let _ = s.shutdown(std::net::Shutdown::Write);
892            let mut buf = String::new();
893            let _ = s.read_to_string(&mut buf);
894            // Server drops without responding to oversize frames.
895            assert!(
896                buf.is_empty() || !buf.contains("\"ok\":true"),
897                "server should not have echoed an ok response to oversize input, got: {buf:?}",
898            );
899
900            handle.join().unwrap();
901            let _ = std::fs::remove_dir_all(&dir);
902        }
903
904        #[test]
905        fn fallback_socket_path_round_trips_through_unix_socket() {
906            // Simulate a data_dir whose `<dir>/ipc.sock` would exceed
907            // the SUN_LEN cushion. `socket_path()` must pick the
908            // `$TMPDIR/entracte-<uid>.sock` fallback, and both server
909            // and client must agree on that choice without any extra
910            // discovery hop.
911            let long_data_dir = std::env::temp_dir().join("z".repeat(120));
912            let sock = socket_path(&long_data_dir);
913            assert!(
914                sock.starts_with(std::env::temp_dir()),
915                "expected fallback path, got {}",
916                sock.display(),
917            );
918            // Clean up any stale socket from a previous run before
919            // bind — the production server does the same.
920            let _ = std::fs::remove_file(&sock);
921
922            let token = "fallback-token".to_string();
923            let handle = run_echo_server(sock.clone(), token.clone());
924
925            // Resolve the path again the way `ipc::call` would, to
926            // prove client and server agree on the same byte string.
927            let client_sock = socket_path(&long_data_dir);
928            assert_eq!(client_sock, sock);
929
930            let env = IpcEnvelope {
931                token: token.clone(),
932                request: IpcRequest::Status,
933            };
934            let body = serde_json::to_string(&env).unwrap();
935            let resp_raw = round_trip(&client_sock, &body).expect("round trip");
936            let resp: IpcResponse = serde_json::from_str(resp_raw.trim()).unwrap();
937            assert!(resp.ok, "expected ok response, got {resp:?}");
938
939            handle.join().unwrap();
940            let _ = std::fs::remove_file(&sock);
941        }
942
943        #[test]
944        fn socket_file_is_chmodded_to_0600_after_bind() {
945            use std::os::unix::fs::PermissionsExt;
946            let dir = unique_dir("perms");
947            std::fs::create_dir_all(&dir).unwrap();
948            let sock = dir.join("ipc.sock");
949            let handle = run_echo_server(sock.clone(), "t".into());
950            let mode = std::fs::metadata(&sock).unwrap().permissions().mode() & 0o777;
951            assert_eq!(mode, 0o600);
952            // Close the listener so the server thread can exit
953            // cleanly when we drop it via remove_dir_all.
954            drop(handle);
955            let _ = std::fs::remove_dir_all(&dir);
956        }
957    }
958}