1use 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
48pub 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#[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 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(¤t, &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
292fn 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 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 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 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 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 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 let current = Settings::default();
639 let next = apply_settings_key(
640 ¤t,
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 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 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 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 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 const {
825 assert!(MAX_REQUEST_BYTES >= 4 * 1024);
826 assert!(MAX_REQUEST_BYTES <= 256 * 1024);
827 }
828 }
829
830 #[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 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 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 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 let oversize = "A".repeat((MAX_REQUEST_BYTES as usize) + 1);
953 let mut s = UnixStream::connect(&sock).expect("connect");
954 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 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 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 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 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 drop(handle);
1021 let _ = std::fs::remove_dir_all(&dir);
1022 }
1023 }
1024}