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    /// Guest reply to host `GetInfo`.
59    Info {
60        guest_version: String,
61        protocol_version: u32,
62    },
63}
64
65#[derive(Debug, Serialize, Deserialize, Clone)]
66pub struct NotifyAction {
67    pub key: String,
68    pub label: String,
69}
70
71/// Messages sent from host to guest.
72#[derive(Debug, Serialize, Deserialize)]
73#[serde(tag = "type", rename_all = "snake_case")]
74pub enum HostMessage {
75    HelloAck {
76        accepted: Vec<String>,
77        rejected: Vec<String>,
78        #[serde(default)]
79        idle_timeout_secs: u64,
80        /// List of command names for which the guest daemon should create PATH shims.
81        #[serde(default)]
82        host_exec_shims: Vec<String>,
83    },
84    ClipboardData {
85        text: String,
86    },
87    HostExecStdout {
88        data: String,
89    },
90    HostExecStderr {
91        data: String,
92    },
93    HostExecDone {
94        exit_code: i32,
95    },
96    NotifyActionResult {
97        notification_id: u32,
98        action_key: String,
99    },
100    Ping,
101    Shutdown,
102    /// Sent by the host when all CLI sessions have ended; guest responds
103    /// with `IdleTimeout` or `Busy` after scanning `/proc`.
104    CheckIdle,
105    /// Sent by the host when a guest message is rejected (missing `Hello`
106    /// negotiation, or a capability that was not accepted).
107    Error {
108        reason: String,
109    },
110    /// Host → guest version/info query (doctor).
111    GetInfo,
112}
113
114/// Write a length-prefixed JSON frame.
115pub fn write_frame<W: Write>(w: &mut W, msg: &impl Serialize) -> io::Result<()> {
116    let json = serde_json::to_vec(msg)?;
117    let len = u32::try_from(json.len())
118        .expect("frame payload exceeds 4 GiB")
119        .to_be_bytes();
120    w.write_all(&len)?;
121    w.write_all(&json)?;
122    w.flush()?;
123    Ok(())
124}
125
126/// Maximum frame size: 16 MiB.
127const MAX_FRAME_SIZE: usize = 16 * 1024 * 1024;
128
129/// Read a length-prefixed JSON frame.
130pub fn read_frame<R: Read>(r: &mut R) -> io::Result<Option<Vec<u8>>> {
131    let mut len_buf = [0u8; 4];
132    match r.read_exact(&mut len_buf) {
133        Ok(()) => {}
134        Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
135        Err(e) => return Err(e),
136    }
137    let len = u32::from_be_bytes(len_buf) as usize;
138    if len > MAX_FRAME_SIZE {
139        return Err(io::Error::new(
140            io::ErrorKind::InvalidData,
141            format!("frame too large: {len} bytes (max {MAX_FRAME_SIZE})"),
142        ));
143    }
144    let mut buf = vec![0u8; len];
145    r.read_exact(&mut buf)?;
146    Ok(Some(buf))
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn hello_serializes_with_type_tag() {
155        let msg = GuestMessage::Hello {
156            protocol_version: 1,
157            guest_version: "0.2.0".into(),
158            container: "myenv".into(),
159            capabilities: vec!["notify".into()],
160        };
161        let json = serde_json::to_string(&msg).unwrap();
162        assert!(json.contains("\"type\":\"hello\""));
163    }
164
165    #[test]
166    fn frame_length_prefix_matches_payload() {
167        let msg = GuestMessage::ClipboardGet;
168        let mut buf = Vec::new();
169        write_frame(&mut buf, &msg).unwrap();
170        let len = u32::from_be_bytes(buf[..4].try_into().unwrap()) as usize;
171        assert_eq!(len, buf[4..].len());
172    }
173
174    #[test]
175    fn roundtrip_notify_message() {
176        let msg = GuestMessage::Notify {
177            summary: "hello".into(),
178            body: "world".into(),
179            urgency: "normal".into(),
180            actions: vec![],
181            app_name: String::new(),
182        };
183        let mut buf = Vec::new();
184        write_frame(&mut buf, &msg).unwrap();
185
186        let payload = read_frame(&mut &buf[..]).unwrap().unwrap();
187        let decoded: GuestMessage = serde_json::from_slice(&payload).unwrap();
188        match decoded {
189            GuestMessage::Notify {
190                summary,
191                body,
192                urgency,
193                actions,
194                app_name: _,
195            } => {
196                assert_eq!(summary, "hello");
197                assert_eq!(body, "world");
198                assert_eq!(urgency, "normal");
199                assert!(actions.is_empty());
200            }
201            _ => panic!("wrong message type"),
202        }
203    }
204
205    #[test]
206    fn roundtrip_clipboard_set() {
207        let msg = GuestMessage::ClipboardSet {
208            text: "clipboard content".into(),
209        };
210        let mut buf = Vec::new();
211        write_frame(&mut buf, &msg).unwrap();
212
213        let payload = read_frame(&mut &buf[..]).unwrap().unwrap();
214        let decoded: GuestMessage = serde_json::from_slice(&payload).unwrap();
215        match decoded {
216            GuestMessage::ClipboardSet { text } => {
217                assert_eq!(text, "clipboard content");
218            }
219            _ => panic!("wrong message type"),
220        }
221    }
222
223    #[test]
224    fn hello_ack_with_shims_round_trips() {
225        let msg = HostMessage::HelloAck {
226            accepted: vec!["host_exec".into()],
227            rejected: vec![],
228            idle_timeout_secs: 10,
229            host_exec_shims: vec!["git".into(), "code".into()],
230        };
231        let mut buf = Vec::new();
232        write_frame(&mut buf, &msg).unwrap();
233        let payload = read_frame(&mut &buf[..]).unwrap().unwrap();
234        let decoded: HostMessage = serde_json::from_slice(&payload).unwrap();
235        match decoded {
236            HostMessage::HelloAck {
237                accepted,
238                host_exec_shims,
239                ..
240            } => {
241                assert_eq!(accepted, vec!["host_exec"]);
242                assert_eq!(host_exec_shims, vec!["git", "code"]);
243            }
244            _ => panic!("wrong message type"),
245        }
246    }
247
248    #[test]
249    fn hello_ack_backward_compat_missing_shims() {
250        let json = r#"{"type":"hello_ack","accepted":[],"rejected":[],"idle_timeout_secs":0}"#;
251        let msg: HostMessage = serde_json::from_slice(json.as_bytes()).unwrap();
252        match msg {
253            HostMessage::HelloAck {
254                host_exec_shims, ..
255            } => assert!(host_exec_shims.is_empty()),
256            _ => panic!("wrong message type"),
257        }
258    }
259}