use std::io::{self, BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::path::Path;
use std::time::Duration;
use serde::Deserialize;
pub const ATTACH_PROTO: u32 = 1;
#[derive(Debug, Clone, PartialEq)]
pub struct AttachRequest {
pub short: String,
pub auth: Option<String>,
pub cols: u32,
pub rows: u32,
}
impl AttachRequest {
pub fn new(short: impl Into<String>, auth: Option<String>, cols: u32, rows: u32) -> Self {
AttachRequest {
short: short.into(),
auth,
cols,
rows,
}
}
pub fn for_frame_stream(short: impl Into<String>, auth: Option<String>) -> Self {
Self::new(short, auth, 80, 24)
}
pub fn to_json_line(&self) -> String {
let mut obj = serde_json::Map::new();
obj.insert("proto".into(), ATTACH_PROTO.into());
obj.insert("op".into(), "attach".into());
obj.insert("short".into(), self.short.clone().into());
if let Some(a) = &self.auth {
obj.insert("auth".into(), a.clone().into());
}
obj.insert("cols".into(), self.cols.into());
obj.insert("rows".into(), self.rows.into());
obj.insert(
"caps".into(),
serde_json::json!({"terminal": null, "mux": null, "ssh": false}),
);
let mut line = serde_json::Value::Object(obj).to_string();
line.push('\n');
line
}
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct AttachOk {
#[serde(default)]
pub dec_modes: Vec<String>,
#[serde(default)]
pub via: Option<String>,
#[serde(default)]
pub tempo: Option<String>,
#[serde(default)]
pub state: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum AttachError {
Refused {
code: Option<String>,
detail: String,
},
Malformed(String),
}
impl std::fmt::Display for AttachError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AttachError::Refused { code, detail } => match code {
Some(c) => write!(f, "attach refused ({c}): {detail}"),
None => write!(f, "attach refused: {detail}"),
},
AttachError::Malformed(m) => write!(f, "malformed attach reply: {m}"),
}
}
}
impl std::error::Error for AttachError {}
pub fn parse_attach_reply(line: &str) -> Result<AttachOk, AttachError> {
let v: serde_json::Value = match serde_json::from_str(line.trim()) {
Ok(v) => v,
Err(e) => return Err(AttachError::Malformed(format!("{e}: {line:?}"))),
};
let ok = v
.get("ok")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
if ok {
let dec_modes = v
.get("decModes")
.and_then(serde_json::Value::as_array)
.map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
let str_field = |k: &str| {
v.get(k)
.and_then(serde_json::Value::as_str)
.map(str::to_string)
};
Ok(AttachOk {
dec_modes,
via: str_field("via"),
tempo: str_field("tempo"),
state: str_field("state"),
})
} else {
let code = v
.get("code")
.and_then(serde_json::Value::as_str)
.map(str::to_string);
let detail = v
.get("error")
.or_else(|| v.get("reason"))
.or_else(|| v.get("message"))
.and_then(serde_json::Value::as_str)
.unwrap_or("attach not accepted")
.to_string();
Err(AttachError::Refused { code, detail })
}
}
pub trait ControlTransport {
fn send_line(&mut self, line: &str) -> io::Result<()>;
fn recv_line(&mut self) -> io::Result<Option<String>>;
}
pub struct UnixControlTransport {
write: UnixStream,
read: BufReader<UnixStream>,
}
impl UnixControlTransport {
pub fn connect(path: &Path) -> io::Result<Self> {
let stream = UnixStream::connect(path)?;
stream.set_read_timeout(Some(Duration::from_secs(30)))?;
let read = BufReader::new(stream.try_clone()?);
Ok(UnixControlTransport {
write: stream,
read,
})
}
}
impl ControlTransport for UnixControlTransport {
fn send_line(&mut self, line: &str) -> io::Result<()> {
self.write.write_all(line.as_bytes())?;
self.write.flush()
}
fn recv_line(&mut self) -> io::Result<Option<String>> {
let mut buf = String::new();
let n = self.read.read_line(&mut buf)?;
if n == 0 {
return Ok(None); }
let len = buf.trim_end_matches(['\n', '\r']).len();
buf.truncate(len);
Ok(Some(buf))
}
}
pub fn perform_attach<T: ControlTransport>(
t: &mut T,
req: &AttachRequest,
) -> Result<AttachOk, AttachError> {
let line = req.to_json_line();
t.send_line(&line)
.map_err(|e| AttachError::Malformed(format!("send: {e}")))?;
match t.recv_line() {
Ok(Some(reply)) => parse_attach_reply(&reply),
Ok(None) => Err(AttachError::Refused {
code: Some("EOF".into()),
detail: "daemon closed before attach reply".into(),
}),
Err(e) => Err(AttachError::Malformed(format!("recv: {e}"))),
}
}
#[derive(Debug)]
pub struct FrameStream<R: io::Read> {
reader: BufReader<R>,
}
impl<R: io::Read> io::Read for FrameStream<R> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
self.reader.read(buf)
}
}
pub fn attach_for_frames<R: io::Read, W: Write>(
mut writer: W,
reader: R,
req: &AttachRequest,
) -> Result<(AttachOk, FrameStream<R>), AttachError> {
writer
.write_all(req.to_json_line().as_bytes())
.and_then(|()| writer.flush())
.map_err(|e| AttachError::Malformed(format!("send: {e}")))?;
let mut reader = BufReader::new(reader);
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) => Err(AttachError::Refused {
code: Some("EOF".into()),
detail: "daemon closed before attach reply".into(),
}),
Ok(_) if !line.ends_with('\n') => Err(AttachError::Refused {
code: Some("EOF".into()),
detail: "daemon closed before complete attach reply".into(),
}),
Ok(_) => {
let ok = parse_attach_reply(&line)?;
Ok((ok, FrameStream { reader }))
}
Err(e) => Err(AttachError::Malformed(format!("recv: {e}"))),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::VecDeque;
use std::io::{Cursor, Read};
struct FakeTransport {
replies: VecDeque<Option<String>>,
sent: Vec<String>,
recv_err: bool,
}
impl FakeTransport {
fn new(replies: Vec<Option<&str>>) -> Self {
FakeTransport {
replies: replies.into_iter().map(|r| r.map(str::to_string)).collect(),
sent: Vec::new(),
recv_err: false,
}
}
}
impl ControlTransport for FakeTransport {
fn send_line(&mut self, line: &str) -> io::Result<()> {
self.sent.push(line.to_string());
Ok(())
}
fn recv_line(&mut self) -> io::Result<Option<String>> {
if self.recv_err {
return Err(io::Error::other("boom"));
}
Ok(self.replies.pop_front().flatten())
}
}
#[test]
fn attach_request_serializes_to_pinned_schema() {
let req = AttachRequest::for_frame_stream("a1b2c3d4", Some("deadbeef".into()));
let line = req.to_json_line();
assert!(line.ends_with('\n'));
let v: serde_json::Value = serde_json::from_str(line.trim()).unwrap();
assert_eq!(v["proto"], 1);
assert_eq!(v["op"], "attach");
assert_eq!(v["short"], "a1b2c3d4");
assert_eq!(v["auth"], "deadbeef");
assert_eq!(v["cols"], 80);
assert_eq!(v["rows"], 24);
assert!(v["caps"]["terminal"].is_null());
assert!(v["caps"]["mux"].is_null());
assert_eq!(v["caps"]["ssh"], false);
assert!(v["caps"].get("colorLevel").is_none());
}
#[test]
fn attach_request_omits_auth_for_same_uid_path() {
let req = AttachRequest::for_frame_stream("a1b2c3d4", None);
let v: serde_json::Value = serde_json::from_str(req.to_json_line().trim()).unwrap();
assert!(v.get("auth").is_none(), "no-auth path must omit the key");
}
#[test]
fn parse_ok_reply() {
let ok = parse_attach_reply(
r#"{"ok":true,"op":"attach","decModes":["1049","2004"],"via":"spare","tempo":"active","state":"running"}"#,
)
.unwrap();
assert_eq!(ok.dec_modes, vec!["1049", "2004"]);
assert_eq!(ok.via.as_deref(), Some("spare"));
assert_eq!(ok.tempo.as_deref(), Some("active"));
assert_eq!(ok.state.as_deref(), Some("running"));
}
#[test]
fn parse_refused_reply_mines_code_and_reason() {
let err = parse_attach_reply(r#"{"ok":false,"code":"EPROTO","error":"restart claude"}"#)
.unwrap_err();
assert_eq!(
err,
AttachError::Refused {
code: Some("EPROTO".into()),
detail: "restart claude".into()
}
);
}
#[test]
fn parse_non_json_is_malformed() {
assert!(matches!(
parse_attach_reply("not a frame"),
Err(AttachError::Malformed(_))
));
}
#[test]
fn perform_attach_happy_path() {
let mut t =
FakeTransport::new(vec![Some(r#"{"ok":true,"op":"attach","state":"running"}"#)]);
let req = AttachRequest::for_frame_stream("a1b2c3d4", None);
let ok = perform_attach(&mut t, &req).unwrap();
assert_eq!(ok.state.as_deref(), Some("running"));
assert_eq!(t.sent.len(), 1);
assert!(t.sent[0].contains("\"op\":\"attach\""));
}
#[test]
fn perform_attach_eof_is_refused() {
let mut t = FakeTransport::new(vec![None]);
let req = AttachRequest::for_frame_stream("a1b2c3d4", None);
let err = perform_attach(&mut t, &req).unwrap_err();
assert!(matches!(err, AttachError::Refused { .. }));
}
#[test]
fn perform_attach_recv_error_is_malformed() {
let mut t = FakeTransport::new(vec![]);
t.recv_err = true;
let req = AttachRequest::for_frame_stream("a1b2c3d4", None);
let err = perform_attach(&mut t, &req).unwrap_err();
assert!(matches!(err, AttachError::Malformed(_)));
}
fn server_bytes(reply: &str, raw_tail: &[u8]) -> Cursor<Vec<u8>> {
let mut v = format!("{reply}\n").into_bytes();
v.extend_from_slice(raw_tail);
Cursor::new(v)
}
#[test]
fn attach_for_frames_returns_ok_then_raw_tail() {
let raw = b"\x1b[2J\x1b[Hhello world";
let reader = server_bytes(r#"{"ok":true,"op":"attach","state":"running"}"#, raw);
let mut writer: Vec<u8> = Vec::new();
let req = AttachRequest::for_frame_stream("a1b2c3d4", Some("k".into()));
let (ok, mut stream) = attach_for_frames(&mut writer, reader, &req).unwrap();
assert_eq!(ok.state.as_deref(), Some("running"));
assert!(String::from_utf8_lossy(&writer).contains("\"op\":\"attach\""));
let mut got = Vec::new();
stream.read_to_end(&mut got).unwrap();
assert_eq!(got, raw);
}
#[test]
fn attach_for_frames_keeps_tail_buffered_with_handshake() {
let raw = b"first-frame-bytes";
let reader = server_bytes(r#"{"ok":true,"op":"attach"}"#, raw);
let req = AttachRequest::for_frame_stream("a1b2c3d4", None);
let (_ok, mut stream) = attach_for_frames(Vec::new(), reader, &req).unwrap();
let mut got = Vec::new();
stream.read_to_end(&mut got).unwrap();
assert_eq!(got, raw);
}
#[test]
fn attach_for_frames_tail_with_embedded_newlines_is_not_split() {
let raw = b"line1\r\nline2\nline3";
let reader = server_bytes(r#"{"ok":true,"op":"attach"}"#, raw);
let req = AttachRequest::for_frame_stream("a1b2c3d4", None);
let (_ok, mut stream) = attach_for_frames(Vec::new(), reader, &req).unwrap();
let mut got = Vec::new();
stream.read_to_end(&mut got).unwrap();
assert_eq!(got, raw);
}
#[test]
fn attach_for_frames_refused_propagates() {
let reader = server_bytes(
r#"{"ok":false,"code":"EPROTO","error":"restart claude"}"#,
b"",
);
let req = AttachRequest::for_frame_stream("a1b2c3d4", None);
let err = attach_for_frames(Vec::new(), reader, &req).unwrap_err();
assert_eq!(
err,
AttachError::Refused {
code: Some("EPROTO".into()),
detail: "restart claude".into()
}
);
}
#[test]
fn attach_for_frames_eof_before_reply_is_refused() {
let reader = Cursor::new(Vec::new());
let req = AttachRequest::for_frame_stream("a1b2c3d4", None);
let err = attach_for_frames(Vec::new(), reader, &req).unwrap_err();
assert!(matches!(err, AttachError::Refused { .. }));
}
#[test]
fn attach_for_frames_truncated_reply_without_newline_is_refused() {
let reader = Cursor::new(br#"{"ok":true,"op":"attach","state":"running"}"#.to_vec());
let req = AttachRequest::for_frame_stream("a1b2c3d4", None);
let err = attach_for_frames(Vec::new(), reader, &req).unwrap_err();
assert!(matches!(err, AttachError::Refused { .. }));
}
}