1use 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
49pub 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#[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 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(¤t) {
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 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 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 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 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 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 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 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 const {
759 assert!(MAX_REQUEST_BYTES >= 4 * 1024);
760 assert!(MAX_REQUEST_BYTES <= 256 * 1024);
761 }
762 }
763
764 #[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 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 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 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 let oversize = "A".repeat((MAX_REQUEST_BYTES as usize) + 1);
887 let mut s = UnixStream::connect(&sock).expect("connect");
888 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 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 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 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 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 drop(handle);
955 let _ = std::fs::remove_dir_all(&dir);
956 }
957 }
958}