Skip to main content

podbox_protocol/
lib.rs

1use std::io::{self, Read, Write};
2
3use serde::{Deserialize, Serialize};
4
5/// Increment on breaking wire-format changes. Backwards-compatible
6/// additions (new optional message types) do NOT increment this.
7pub const PROTOCOL_VERSION: u32 = 1;
8
9/// Guest protocol capability identifiers.
10///
11/// Single source of truth — always use these constants in match arms,
12/// construction, and capability negotiation rather than inline strings.
13pub const CAP_NOTIFY: &str = "notify";
14pub const CAP_XDG_OPEN: &str = "xdg_open";
15pub const CAP_CLIPBOARD: &str = "clipboard";
16pub const CAP_HOST_EXEC: &str = "host_exec";
17
18/// All known capabilities in negotiation order.
19pub const ALL_CAPABILITIES: &[&str] = &[CAP_NOTIFY, CAP_XDG_OPEN, CAP_CLIPBOARD, CAP_HOST_EXEC];
20
21/// Messages sent from guest to host.
22#[derive(Debug, Serialize, Deserialize)]
23#[serde(tag = "type", rename_all = "snake_case")]
24pub enum GuestMessage {
25    Hello {
26        protocol_version: u32,
27        guest_version: String,
28        container: String,
29        capabilities: Vec<String>,
30    },
31    Notify {
32        summary: String,
33        body: String,
34        urgency: String,
35        #[serde(default)]
36        actions: Vec<NotifyAction>,
37        #[serde(default)]
38        app_name: String,
39    },
40    XdgOpen {
41        uri: String,
42    },
43    ClipboardSet {
44        text: String,
45    },
46    ClipboardGet,
47    HostExec {
48        cmd: String,
49        args: Vec<String>,
50    },
51    /// Sent by the host CLI to register a new terminal session.
52    /// The `pidfd` follows via `SCM_RIGHTS` on the same connection.
53    RegisterSession,
54    /// Sent by the guest daemon when user processes are still running.
55    Busy,
56    /// Sent by the guest daemon when no user processes remain.
57    IdleTimeout,
58}
59
60#[derive(Debug, Serialize, Deserialize, Clone)]
61pub struct NotifyAction {
62    pub key: String,
63    pub label: String,
64}
65
66/// Messages sent from host to guest.
67#[derive(Debug, Serialize, Deserialize)]
68#[serde(tag = "type", rename_all = "snake_case")]
69pub enum HostMessage {
70    HelloAck {
71        accepted: Vec<String>,
72        rejected: Vec<String>,
73        #[serde(default)]
74        idle_timeout_secs: u64,
75        /// List of command names for which the guest daemon should create PATH shims.
76        #[serde(default)]
77        host_exec_shims: Vec<String>,
78    },
79    ClipboardData {
80        text: String,
81    },
82    HostExecStdout {
83        data: String,
84    },
85    HostExecStderr {
86        data: String,
87    },
88    HostExecDone {
89        exit_code: i32,
90    },
91    NotifyActionResult {
92        notification_id: u32,
93        action_key: String,
94    },
95    Ping,
96    Shutdown,
97    /// Sent by the host when all CLI sessions have ended; guest responds
98    /// with `IdleTimeout` or `Busy` after scanning `/proc`.
99    CheckIdle,
100    /// Sent by the host when a guest message is rejected (missing `Hello`
101    /// negotiation, or a capability that was not accepted).
102    Error {
103        reason: String,
104    },
105}
106
107/// Write a length-prefixed JSON frame.
108pub fn write_frame<W: Write>(w: &mut W, msg: &impl Serialize) -> io::Result<()> {
109    let json = serde_json::to_vec(msg)?;
110    let len = u32::try_from(json.len())
111        .expect("frame payload exceeds 4 GiB")
112        .to_be_bytes();
113    w.write_all(&len)?;
114    w.write_all(&json)?;
115    w.flush()?;
116    Ok(())
117}
118
119/// Maximum frame size: 16 MiB.
120const MAX_FRAME_SIZE: usize = 16 * 1024 * 1024;
121
122/// Read a length-prefixed JSON frame.
123pub fn read_frame<R: Read>(r: &mut R) -> io::Result<Option<Vec<u8>>> {
124    let mut len_buf = [0u8; 4];
125    match r.read_exact(&mut len_buf) {
126        Ok(()) => {}
127        Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
128        Err(e) => return Err(e),
129    }
130    let len = u32::from_be_bytes(len_buf) as usize;
131    if len > MAX_FRAME_SIZE {
132        return Err(io::Error::new(
133            io::ErrorKind::InvalidData,
134            format!("frame too large: {len} bytes (max {MAX_FRAME_SIZE})"),
135        ));
136    }
137    let mut buf = vec![0u8; len];
138    r.read_exact(&mut buf)?;
139    Ok(Some(buf))
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn hello_serializes_with_type_tag() {
148        let msg = GuestMessage::Hello {
149            protocol_version: 1,
150            guest_version: "0.2.0".into(),
151            container: "myenv".into(),
152            capabilities: vec!["notify".into()],
153        };
154        let json = serde_json::to_string(&msg).unwrap();
155        assert!(json.contains("\"type\":\"hello\""));
156    }
157
158    #[test]
159    fn frame_length_prefix_matches_payload() {
160        let msg = GuestMessage::ClipboardGet;
161        let mut buf = Vec::new();
162        write_frame(&mut buf, &msg).unwrap();
163        let len = u32::from_be_bytes(buf[..4].try_into().unwrap()) as usize;
164        assert_eq!(len, buf[4..].len());
165    }
166
167    #[test]
168    fn roundtrip_notify_message() {
169        let msg = GuestMessage::Notify {
170            summary: "hello".into(),
171            body: "world".into(),
172            urgency: "normal".into(),
173            actions: vec![],
174            app_name: String::new(),
175        };
176        let mut buf = Vec::new();
177        write_frame(&mut buf, &msg).unwrap();
178
179        let payload = read_frame(&mut &buf[..]).unwrap().unwrap();
180        let decoded: GuestMessage = serde_json::from_slice(&payload).unwrap();
181        match decoded {
182            GuestMessage::Notify {
183                summary,
184                body,
185                urgency,
186                actions,
187                app_name: _,
188            } => {
189                assert_eq!(summary, "hello");
190                assert_eq!(body, "world");
191                assert_eq!(urgency, "normal");
192                assert!(actions.is_empty());
193            }
194            _ => panic!("wrong message type"),
195        }
196    }
197
198    #[test]
199    fn roundtrip_clipboard_set() {
200        let msg = GuestMessage::ClipboardSet {
201            text: "clipboard content".into(),
202        };
203        let mut buf = Vec::new();
204        write_frame(&mut buf, &msg).unwrap();
205
206        let payload = read_frame(&mut &buf[..]).unwrap().unwrap();
207        let decoded: GuestMessage = serde_json::from_slice(&payload).unwrap();
208        match decoded {
209            GuestMessage::ClipboardSet { text } => {
210                assert_eq!(text, "clipboard content");
211            }
212            _ => panic!("wrong message type"),
213        }
214    }
215
216    #[test]
217    fn hello_ack_with_shims_round_trips() {
218        let msg = HostMessage::HelloAck {
219            accepted: vec!["host_exec".into()],
220            rejected: vec![],
221            idle_timeout_secs: 10,
222            host_exec_shims: vec!["git".into(), "code".into()],
223        };
224        let mut buf = Vec::new();
225        write_frame(&mut buf, &msg).unwrap();
226        let payload = read_frame(&mut &buf[..]).unwrap().unwrap();
227        let decoded: HostMessage = serde_json::from_slice(&payload).unwrap();
228        match decoded {
229            HostMessage::HelloAck {
230                accepted,
231                host_exec_shims,
232                ..
233            } => {
234                assert_eq!(accepted, vec!["host_exec"]);
235                assert_eq!(host_exec_shims, vec!["git", "code"]);
236            }
237            _ => panic!("wrong message type"),
238        }
239    }
240
241    #[test]
242    fn hello_ack_backward_compat_missing_shims() {
243        let json = r#"{"type":"hello_ack","accepted":[],"rejected":[],"idle_timeout_secs":0}"#;
244        let msg: HostMessage = serde_json::from_slice(json.as_bytes()).unwrap();
245        match msg {
246            HostMessage::HelloAck {
247                host_exec_shims, ..
248            } => assert!(host_exec_shims.is_empty()),
249            _ => panic!("wrong message type"),
250        }
251    }
252}