use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::PathBuf;
use std::sync::mpsc;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SplitDirection {
Horizontal,
Vertical,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "cmd", rename_all = "snake_case")]
pub enum IpcRequest {
Split {
direction: SplitDirection,
pane: Option<usize>,
},
Close {
pane: usize,
},
Focus {
pane: usize,
},
Equalize,
List,
Layout {
spec: String,
},
Exec {
pane: usize,
command: String,
},
Save {
path: String,
},
Load {
path: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "cmd", rename_all = "snake_case")]
pub enum IpcRequestExt {
LsTree {
#[serde(default, skip_serializing_if = "Option::is_none")]
session: Option<String>,
},
Dump {
pane: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
session: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
since: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
last: Option<usize>,
#[serde(default = "default_true")]
include_scrollback: bool,
#[serde(default)]
strip_ansi: bool,
},
SendKeys {
pane: usize,
text: String,
#[serde(default)]
await_prompt: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
timeout_ms: Option<u64>,
#[serde(default)]
no_newline: bool,
},
}
const EXT_CMD_TAGS: &[&str] = &["ls_tree", "dump", "send_keys"];
pub const SEND_KEYS_DEFAULT_TIMEOUT_MS: u64 = 30_000;
fn default_true() -> bool {
true
}
pub const DUMP_MAX_BYTES: usize = 16 * 1024 * 1024;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PaneInfo {
pub index: usize,
pub id: usize,
pub cols: u16,
pub rows: u16,
pub alive: bool,
pub active: bool,
pub command: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LsTree {
pub proto_version: String,
pub sessions: Vec<SessionTree>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionTree {
pub name: String,
pub created_at: f64,
pub clients: Vec<ClientInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub focused_tab: Option<usize>,
pub tabs: Vec<TabInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClientInfo {
pub socket: String,
pub size: [u16; 2],
pub mode: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TabInfo {
pub id: usize,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub focused_pane: Option<usize>,
pub layout: String,
pub panes: Vec<PaneTreeInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PaneTreeInfo {
pub id: usize,
pub command: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
pub size: [u16; 2],
#[serde(skip_serializing_if = "Option::is_none")]
pub pid: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reported_cwd: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
pub is_dead: bool,
pub is_focused: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DumpPayload {
pub pane: usize,
pub lines: Vec<String>,
pub total: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SendKeysOutcome {
pub status: SendKeysStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
pub waited_ms: u64,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SendKeysStatus {
PromptSeen,
Timeout,
DetectionUnavailable,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IpcResponse {
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub panes: Option<Vec<PaneInfo>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ls_tree: Option<LsTree>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dump: Option<DumpPayload>,
#[serde(skip_serializing_if = "Option::is_none")]
pub send_keys: Option<SendKeysOutcome>,
}
impl IpcResponse {
pub fn success(message: impl Into<String>) -> Self {
Self {
ok: true,
message: Some(message.into()),
error: None,
panes: None,
ls_tree: None,
dump: None,
send_keys: None,
}
}
pub fn with_panes(panes: Vec<PaneInfo>) -> Self {
Self {
ok: true,
message: None,
error: None,
panes: Some(panes),
ls_tree: None,
dump: None,
send_keys: None,
}
}
pub fn with_ls_tree(tree: LsTree) -> Self {
Self {
ok: true,
message: None,
error: None,
panes: None,
ls_tree: Some(tree),
dump: None,
send_keys: None,
}
}
pub fn with_dump(dump: DumpPayload) -> Self {
Self {
ok: true,
message: None,
error: None,
panes: None,
ls_tree: None,
dump: Some(dump),
send_keys: None,
}
}
pub fn with_send_keys(outcome: SendKeysOutcome) -> Self {
Self {
ok: true,
message: None,
error: None,
panes: None,
ls_tree: None,
dump: None,
send_keys: Some(outcome),
}
}
pub fn error(message: impl Into<String>) -> Self {
Self {
ok: false,
message: None,
error: Some(message.into()),
panes: None,
ls_tree: None,
dump: None,
send_keys: None,
}
}
}
pub type ResponseSender = std::sync::mpsc::SyncSender<IpcResponse>;
pub fn socket_path() -> PathBuf {
socket_path_for_pid(std::process::id())
}
pub fn socket_path_for_pid(pid: u32) -> PathBuf {
let dir = std::env::var("EZPN_TEST_SOCKET_DIR")
.or_else(|_| std::env::var("XDG_RUNTIME_DIR"))
.unwrap_or_else(|_| "/tmp".to_string());
PathBuf::from(format!("{}/ezpn-{}.sock", dir, pid))
}
pub fn start_listener() -> anyhow::Result<mpsc::Receiver<(IpcRequest, ResponseSender)>> {
let path = socket_path();
if let Some(parent) = path.parent() {
crate::socket_security::harden_socket_dir(parent)?;
}
let _ = std::fs::remove_file(&path);
let prev_umask = unsafe { libc::umask(0o077) };
let bind_result = UnixListener::bind(&path);
unsafe {
libc::umask(prev_umask);
}
let listener = bind_result?;
crate::socket_security::fix_socket_permissions(&path)?;
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
for stream in listener.incoming() {
match stream {
Ok(stream) => {
let our_uid = unsafe { libc::getuid() };
match crate::socket_security::peer_uid(&stream) {
Ok(peer) if peer == our_uid => {}
Ok(peer) => {
tracing::warn!(
event = "ipc_ctl_peer_uid_mismatch",
peer_uid = peer,
expected_uid = our_uid,
"refusing cross-uid ctl connection"
);
continue;
}
Err(e) => {
tracing::warn!(
event = "ipc_ctl_peer_uid_error",
error = %e,
"could not read peer credentials, refusing ctl connection"
);
continue;
}
}
let tx = tx.clone();
std::thread::spawn(move || handle_client(stream, tx));
}
Err(_) => break,
}
}
});
Ok(rx)
}
pub fn cleanup() {
let _ = std::fs::remove_file(socket_path());
}
fn handle_client(stream: UnixStream, tx: mpsc::Sender<(IpcRequest, ResponseSender)>) {
let Ok(read_stream) = stream.try_clone() else {
return;
};
let reader = BufReader::new(read_stream);
let mut writer = stream;
for line in reader.lines() {
let line = match line {
Ok(line) => line,
Err(_) => break,
};
if line.trim().is_empty() {
continue;
}
let response = if is_ext_command(&line) {
handle_ext_request(&line)
} else {
match serde_json::from_str::<IpcRequest>(&line) {
Ok(request) => {
let (resp_tx, resp_rx) = mpsc::sync_channel(1);
if tx.send((request, resp_tx)).is_err() {
let response = IpcResponse::error("listener unavailable");
let _ = write_response(&mut writer, &response);
break;
}
resp_rx
.recv()
.unwrap_or_else(|_| IpcResponse::error("internal error"))
}
Err(error) => IpcResponse::error(format!("invalid request: {}", error)),
}
};
if write_response(&mut writer, &response).is_err() {
break;
}
}
}
fn is_ext_command(line: &str) -> bool {
let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
return false;
};
let Some(cmd) = value.get("cmd").and_then(|v| v.as_str()) else {
return false;
};
EXT_CMD_TAGS.contains(&cmd)
}
fn handle_ext_request(line: &str) -> IpcResponse {
let request = match serde_json::from_str::<IpcRequestExt>(line) {
Ok(req) => req,
Err(error) => return IpcResponse::error(format!("invalid request: {}", error)),
};
match request {
IpcRequestExt::LsTree { .. } => {
IpcResponse::error("ls --json: server-side handler not yet wired (issue #89)")
}
IpcRequestExt::Dump { .. } => {
IpcResponse::error("dump: server-side handler not yet wired (issue #88)")
}
IpcRequestExt::SendKeys { .. } => {
IpcResponse::error("send-keys: server-side handler not yet wired (issue #81)")
}
}
}
fn write_response(writer: &mut UnixStream, response: &IpcResponse) -> anyhow::Result<()> {
writeln!(writer, "{}", serde_json::to_string(response)?)?;
writer.flush()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ls_tree_roundtrip_matches_frozen_schema() {
let tree = LsTree {
proto_version: "1.0".to_string(),
sessions: vec![SessionTree {
name: "work".to_string(),
created_at: 1_745_800_000.0,
clients: vec![ClientInfo {
socket: "/run/ezpn-1.sock".to_string(),
size: [80, 24],
mode: "steal".to_string(),
}],
focused_tab: Some(0),
tabs: vec![TabInfo {
id: 0,
name: "editor".to_string(),
focused_pane: Some(1),
layout: "split-h:[1,2]".to_string(),
panes: vec![PaneTreeInfo {
id: 1,
command: "nvim".to_string(),
cwd: Some("/foo".to_string()),
size: [80, 24],
pid: Some(12345),
reported_cwd: Some("/foo/bar".to_string()),
exit_code: None,
is_dead: false,
is_focused: true,
title: Some("file.rs".to_string()),
}],
}],
}],
};
let json = serde_json::to_string(&tree).unwrap();
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(value["proto_version"], "1.0");
let pane = &value["sessions"][0]["tabs"][0]["panes"][0];
assert_eq!(pane["id"], 1);
assert_eq!(pane["command"], "nvim");
assert_eq!(pane["size"], serde_json::json!([80, 24]));
assert_eq!(pane["is_focused"], true);
assert_eq!(pane["is_dead"], false);
assert!(pane.get("exit_code").is_none(), "None must skip-serialize");
let back: LsTree = serde_json::from_str(&json).unwrap();
assert_eq!(back.proto_version, "1.0");
assert_eq!(back.sessions.len(), 1);
assert_eq!(back.sessions[0].tabs[0].panes[0].command, "nvim");
}
#[test]
fn ls_tree_request_uses_cmd_tag() {
let req = IpcRequestExt::LsTree {
session: Some("work".to_string()),
};
let json = serde_json::to_string(&req).unwrap();
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(value["cmd"], "ls_tree");
assert_eq!(value["session"], "work");
}
#[test]
fn ls_tree_request_omits_session_when_none() {
let req = IpcRequestExt::LsTree { session: None };
let json = serde_json::to_string(&req).unwrap();
assert!(!json.contains("session"));
}
#[test]
fn is_ext_command_classifies_v012_commands() {
assert!(is_ext_command(r#"{"cmd":"ls_tree"}"#));
assert!(!is_ext_command(r#"{"cmd":"list"}"#));
assert!(!is_ext_command(
r#"{"cmd":"split","direction":"horizontal"}"#
));
assert!(!is_ext_command("not json"));
}
#[test]
fn ls_tree_handler_is_parent_deferred() {
let response = handle_ext_request(r#"{"cmd":"ls_tree"}"#);
assert!(!response.ok);
let err = response.error.unwrap();
assert!(err.contains("#89"), "got: {err}");
assert!(err.contains("ls --json"), "got: {err}");
}
#[test]
fn ipc_response_carries_ls_tree() {
let tree = LsTree {
proto_version: "1.0".to_string(),
sessions: vec![],
};
let response = IpcResponse::with_ls_tree(tree);
let json = serde_json::to_string(&response).unwrap();
assert!(json.contains("\"proto_version\":\"1.0\""));
let back: IpcResponse = serde_json::from_str(&json).unwrap();
assert!(back.ok);
assert_eq!(back.ls_tree.unwrap().proto_version, "1.0");
}
#[test]
fn dump_request_defaults_match_cli_defaults() {
let json = r#"{"cmd":"dump","pane":3}"#;
let req: IpcRequestExt = serde_json::from_str(json).unwrap();
match req {
IpcRequestExt::Dump {
pane,
session,
since,
last,
include_scrollback,
strip_ansi,
} => {
assert_eq!(pane, 3);
assert!(session.is_none());
assert!(since.is_none());
assert!(last.is_none());
assert!(include_scrollback, "default must be true");
assert!(!strip_ansi);
}
_ => panic!("expected Dump variant"),
}
}
#[test]
fn is_ext_command_recognises_dump() {
assert!(is_ext_command(r#"{"cmd":"dump","pane":0}"#));
}
#[test]
fn dump_handler_is_parent_deferred() {
let response = handle_ext_request(r#"{"cmd":"dump","pane":0}"#);
assert!(!response.ok);
let err = response.error.unwrap();
assert!(err.contains("#88"), "got: {err}");
assert!(err.contains("dump"), "got: {err}");
}
#[test]
fn dump_payload_roundtrip_handles_escapes() {
let payload = DumpPayload {
pane: 2,
lines: vec![
"hello".to_string(),
"\x1b[31mred\x1b[0m".to_string(),
"café".to_string(),
],
total: 3,
};
let json = serde_json::to_string(&payload).unwrap();
let back: DumpPayload = serde_json::from_str(&json).unwrap();
assert_eq!(back.lines.len(), 3);
assert_eq!(back.lines[1], "\x1b[31mred\x1b[0m");
assert_eq!(back.lines[2], "café");
}
#[test]
fn dump_max_bytes_matches_wire_cap() {
assert_eq!(DUMP_MAX_BYTES, 16 * 1024 * 1024);
}
#[test]
fn send_keys_request_defaults() {
let json = r#"{"cmd":"send_keys","pane":1,"text":"echo hi"}"#;
let req: IpcRequestExt = serde_json::from_str(json).unwrap();
match req {
IpcRequestExt::SendKeys {
pane,
text,
await_prompt,
timeout_ms,
no_newline,
} => {
assert_eq!(pane, 1);
assert_eq!(text, "echo hi");
assert!(!await_prompt);
assert!(timeout_ms.is_none());
assert!(!no_newline);
}
_ => panic!("expected SendKeys variant"),
}
}
#[test]
fn is_ext_command_recognises_send_keys() {
assert!(is_ext_command(r#"{"cmd":"send_keys","pane":0,"text":"x"}"#));
}
#[test]
fn send_keys_handler_is_parent_deferred() {
let response =
handle_ext_request(r#"{"cmd":"send_keys","pane":0,"text":"echo","await_prompt":true}"#);
assert!(!response.ok);
let err = response.error.unwrap();
assert!(err.contains("#81"), "got: {err}");
assert!(err.contains("send-keys"), "got: {err}");
}
#[test]
fn send_keys_outcome_status_uses_snake_case() {
for (status, expected) in [
(SendKeysStatus::PromptSeen, "prompt_seen"),
(SendKeysStatus::Timeout, "timeout"),
(
SendKeysStatus::DetectionUnavailable,
"detection_unavailable",
),
] {
let outcome = SendKeysOutcome {
status,
exit_code: Some(0),
waited_ms: 12,
};
let json = serde_json::to_string(&outcome).unwrap();
assert!(
json.contains(&format!("\"{}\"", expected)),
"{} missing in {}",
expected,
json
);
let back: SendKeysOutcome = serde_json::from_str(&json).unwrap();
assert_eq!(back.status, status);
}
}
#[test]
fn ipc_response_with_send_keys_omits_exit_code_on_timeout() {
let response = IpcResponse::with_send_keys(SendKeysOutcome {
status: SendKeysStatus::Timeout,
exit_code: None,
waited_ms: 30_000,
});
let json = serde_json::to_string(&response).unwrap();
assert!(!json.contains("exit_code"), "got: {json}");
assert!(json.contains("\"timeout\""));
let back: IpcResponse = serde_json::from_str(&json).unwrap();
let outcome = back.send_keys.unwrap();
assert_eq!(outcome.status, SendKeysStatus::Timeout);
assert!(outcome.exit_code.is_none());
assert_eq!(outcome.waited_ms, 30_000);
}
#[test]
fn send_keys_default_timeout_is_30s() {
assert_eq!(SEND_KEYS_DEFAULT_TIMEOUT_MS, 30_000);
}
}