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    },
76    ClipboardData {
77        text: String,
78    },
79    HostExecStdout {
80        data: String,
81    },
82    HostExecStderr {
83        data: String,
84    },
85    HostExecDone {
86        exit_code: i32,
87    },
88    NotifyActionResult {
89        notification_id: u32,
90        action_key: String,
91    },
92    Ping,
93    Shutdown,
94    /// Sent by the host when all CLI sessions have ended; guest responds
95    /// with `IdleTimeout` or `Busy` after scanning `/proc`.
96    CheckIdle,
97    /// Sent by the host when a guest message is rejected (missing `Hello`
98    /// negotiation, or a capability that was not accepted).
99    Error {
100        reason: String,
101    },
102}
103
104/// Write a length-prefixed JSON frame.
105pub fn write_frame<W: Write>(w: &mut W, msg: &impl Serialize) -> io::Result<()> {
106    let json = serde_json::to_vec(msg)?;
107    let len = u32::try_from(json.len())
108        .expect("frame payload exceeds 4 GiB")
109        .to_be_bytes();
110    w.write_all(&len)?;
111    w.write_all(&json)?;
112    w.flush()?;
113    Ok(())
114}
115
116/// Maximum frame size: 16 MiB.
117const MAX_FRAME_SIZE: usize = 16 * 1024 * 1024;
118
119/// Read a length-prefixed JSON frame.
120pub fn read_frame<R: Read>(r: &mut R) -> io::Result<Option<Vec<u8>>> {
121    let mut len_buf = [0u8; 4];
122    match r.read_exact(&mut len_buf) {
123        Ok(()) => {}
124        Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
125        Err(e) => return Err(e),
126    }
127    let len = u32::from_be_bytes(len_buf) as usize;
128    if len > MAX_FRAME_SIZE {
129        return Err(io::Error::new(
130            io::ErrorKind::InvalidData,
131            format!("frame too large: {len} bytes (max {MAX_FRAME_SIZE})"),
132        ));
133    }
134    let mut buf = vec![0u8; len];
135    r.read_exact(&mut buf)?;
136    Ok(Some(buf))
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn hello_serializes_with_type_tag() {
145        let msg = GuestMessage::Hello {
146            protocol_version: 1,
147            guest_version: "0.2.0".into(),
148            container: "myenv".into(),
149            capabilities: vec!["notify".into()],
150        };
151        let json = serde_json::to_string(&msg).unwrap();
152        assert!(json.contains("\"type\":\"hello\""));
153    }
154
155    #[test]
156    fn frame_length_prefix_matches_payload() {
157        let msg = GuestMessage::ClipboardGet;
158        let mut buf = Vec::new();
159        write_frame(&mut buf, &msg).unwrap();
160        let len = u32::from_be_bytes(buf[..4].try_into().unwrap()) as usize;
161        assert_eq!(len, buf[4..].len());
162    }
163
164    #[test]
165    fn roundtrip_notify_message() {
166        let msg = GuestMessage::Notify {
167            summary: "hello".into(),
168            body: "world".into(),
169            urgency: "normal".into(),
170            actions: vec![],
171            app_name: String::new(),
172        };
173        let mut buf = Vec::new();
174        write_frame(&mut buf, &msg).unwrap();
175
176        let payload = read_frame(&mut &buf[..]).unwrap().unwrap();
177        let decoded: GuestMessage = serde_json::from_slice(&payload).unwrap();
178        match decoded {
179            GuestMessage::Notify {
180                summary,
181                body,
182                urgency,
183                actions,
184                app_name: _,
185            } => {
186                assert_eq!(summary, "hello");
187                assert_eq!(body, "world");
188                assert_eq!(urgency, "normal");
189                assert!(actions.is_empty());
190            }
191            _ => panic!("wrong message type"),
192        }
193    }
194
195    #[test]
196    fn roundtrip_clipboard_set() {
197        let msg = GuestMessage::ClipboardSet {
198            text: "clipboard content".into(),
199        };
200        let mut buf = Vec::new();
201        write_frame(&mut buf, &msg).unwrap();
202
203        let payload = read_frame(&mut &buf[..]).unwrap().unwrap();
204        let decoded: GuestMessage = serde_json::from_slice(&payload).unwrap();
205        match decoded {
206            GuestMessage::ClipboardSet { text } => {
207                assert_eq!(text, "clipboard content");
208            }
209            _ => panic!("wrong message type"),
210        }
211    }
212}