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