Skip to main content

fez/protocol/
message.rs

1//! Control and D-Bus message types exchanged with the bridge.
2use serde::{Deserialize, Serialize};
3use serde_json::{Map, Value};
4
5/// Control messages we SEND (empty-channel JSON, tagged by `command`).
6#[derive(Debug, Serialize)]
7#[serde(tag = "command", rename_all = "kebab-case")]
8pub enum Control {
9    /// Initial handshake message.
10    Init {
11        /// Protocol version.
12        version: u32,
13        /// Host label.
14        host: String,
15        /// Superuser negotiation mode at init time. fez sends `"none"` to
16        /// defer escalation: the bridge brings up no root peer at init, and fez
17        /// later selects a working mechanism itself via
18        /// `cockpit.Superuser.Start` (sudo, falling through to polkit). Omitted
19        /// entirely when `None`.
20        #[serde(skip_serializing_if = "Option::is_none")]
21        superuser: Option<Value>,
22    },
23    /// Open a new channel.
24    Open {
25        /// Channel id to allocate.
26        channel: String,
27        /// Channel payload type (e.g. `dbus-json3`, `stream`).
28        payload: String,
29        /// Additional open options (bus, name, spawn, ...).
30        #[serde(flatten)]
31        options: Map<String, Value>,
32    },
33    /// Signal end of input on a channel.
34    Done {
35        /// Channel being finished.
36        channel: String,
37    },
38    /// Close a channel, optionally reporting a problem.
39    Close {
40        /// Channel to close.
41        channel: String,
42        /// Problem kind if the close is abnormal.
43        #[serde(skip_serializing_if = "Option::is_none")]
44        problem: Option<String>,
45    },
46}
47
48impl Control {
49    /// Build an `Open` control for `channel` with the given payload type.
50    pub fn open(channel: &str, payload: &str) -> Control {
51        Control::Open {
52            channel: channel.into(),
53            payload: payload.into(),
54            options: Map::new(),
55        }
56    }
57    /// Builder helper for Open options (bus, name, spawn, ...).
58    pub fn opt(mut self, key: &str, value: Value) -> Control {
59        if let Control::Open {
60            ref mut options, ..
61        } = self
62        {
63            options.insert(key.to_string(), value);
64        }
65        self
66    }
67    /// Serialize this control message to JSON bytes.
68    ///
69    /// Returns a safe-close frame on serialization failure so the bridge sees a
70    /// well-formed command rather than receiving nothing.
71    pub fn to_json(&self) -> Vec<u8> {
72        serde_json::to_vec(self).unwrap_or_else(|_| {
73            serde_json::json!({"command":"close","channel":"","problem":"internal-error"})
74                .to_string()
75                .into_bytes()
76        })
77    }
78}
79
80/// A dbus method call plus the channel it rides on.
81#[derive(Debug)]
82pub struct DbusCall {
83    /// Channel the call is sent on.
84    pub channel: String,
85    /// Per-call cookie correlating the response.
86    pub id: String,
87    /// The serializable call body.
88    pub body: DbusCallBody,
89}
90
91/// The wire body of a D-Bus call: `(path, interface, method, args)` plus an id.
92#[derive(Debug, Serialize)]
93pub struct DbusCallBody {
94    /// Tuple of object path, interface, method name, and argument array.
95    pub call: (String, String, String, Value),
96    /// Correlation id echoed in the response.
97    pub id: String,
98}
99
100impl DbusCall {
101    /// Build a call to `method` on `iface` at `path` with `args`, assigning a
102    /// fresh correlation cookie.
103    pub fn new(channel: &str, path: &str, iface: &str, method: &str, args: Value) -> DbusCall {
104        // A per-call cookie. Monotonic-enough for one short-lived connection.
105        let id = format!("{}", next_cookie());
106        DbusCall {
107            channel: channel.into(),
108            id: id.clone(),
109            body: DbusCallBody {
110                call: (path.into(), iface.into(), method.into(), args),
111                id,
112            },
113        }
114    }
115    /// Serialize the call body to JSON bytes.
116    ///
117    /// Returns a valid no-op DbusCallBody on serialization failure so the
118    /// bridge receives well-formed JSON instead of malformed bytes.
119    pub fn to_json(&self) -> Vec<u8> {
120        serde_json::to_vec(&self.body).unwrap_or_else(|_| {
121            serde_json::json!({
122                "call": ["", "", "", []],
123                "id": "serialize-error"
124            })
125            .to_string()
126            .into_bytes()
127        })
128    }
129}
130
131fn next_cookie() -> u64 {
132    use std::sync::atomic::{AtomicU64, Ordering};
133    static N: AtomicU64 = AtomicU64::new(1);
134    N.fetch_add(1, Ordering::Relaxed)
135}
136
137/// A dbus response (reply or error) parsed from a data frame.
138#[derive(Debug, Deserialize)]
139pub struct DbusResponse {
140    /// Reply arguments when the call succeeded.
141    #[serde(default)]
142    pub reply: Option<Vec<Value>>,
143    /// Error tuple when the call failed.
144    #[serde(default)]
145    pub error: Option<Vec<Value>>,
146    /// Correlation id matching the originating call.
147    #[serde(default)]
148    pub id: Option<String>,
149}
150
151impl DbusResponse {
152    /// The out-argument array (`reply[0]`), if present.
153    pub fn out_args(&self) -> Option<&Value> {
154        self.reply.as_ref().and_then(|r| r.first())
155    }
156    /// The D-Bus error name, if this response is an error.
157    pub fn dbus_error_name(&self) -> Option<&str> {
158        self.error
159            .as_ref()
160            .and_then(|e| e.first())
161            .and_then(|v| v.as_str())
162    }
163    /// The human-readable D-Bus error message, if present.
164    pub fn dbus_error_message(&self) -> Option<String> {
165        self.error
166            .as_ref()
167            .and_then(|e| e.get(1))
168            .and_then(|v| v.as_array())
169            .and_then(|a| a.first())
170            .and_then(|v| v.as_str())
171            .map(|s| s.to_string())
172    }
173}
174
175/// Control messages we RECEIVE (permissive).
176#[derive(Debug, Deserialize)]
177pub struct IncomingControl {
178    /// The control command name (e.g. `close`, `done`, `init`).
179    pub command: String,
180    /// Channel the command refers to, if any.
181    #[serde(default)]
182    pub channel: Option<String>,
183    /// Problem kind when the command reports a failure.
184    #[serde(default)]
185    pub problem: Option<String>,
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use serde_json::json;
192
193    #[test]
194    fn serializes_init_without_superuser() {
195        let v = serde_json::to_value(Control::Init {
196            version: 1,
197            host: "localhost".into(),
198            superuser: None,
199        })
200        .unwrap();
201        // Omitted entirely when None so we never imply a privileged session.
202        assert_eq!(v, json!({"command":"init","version":1,"host":"localhost"}));
203    }
204
205    #[test]
206    fn serializes_init_superuser_none() {
207        // fez defers escalation by sending superuser:"none" (a string), not a
208        // mechanism-pinning object. Escalation happens later via Start.
209        let v = serde_json::to_value(Control::Init {
210            version: 1,
211            host: "localhost".into(),
212            superuser: Some(json!("none")),
213        })
214        .unwrap();
215        assert_eq!(
216            v,
217            json!({
218                "command":"init","version":1,"host":"localhost",
219                "superuser":"none"
220            })
221        );
222    }
223
224    #[test]
225    fn serializes_open_with_options() {
226        let open = Control::open("ch1", "dbus-json3")
227            .opt("bus", json!("system"))
228            .opt("name", json!("org.freedesktop.systemd1"));
229        let v = serde_json::to_value(open).unwrap();
230        assert_eq!(
231            v,
232            json!({
233                "command":"open","channel":"ch1","payload":"dbus-json3",
234                "bus":"system","name":"org.freedesktop.systemd1"
235            })
236        );
237    }
238
239    #[test]
240    fn serializes_internal_bus_open() {
241        // The internal-bus open (used to reach cockpit.Superuser) carries
242        // bus:"internal", no name, and is never privileged.
243        let open = Control::open("ch9", "dbus-json3").opt("bus", json!("internal"));
244        let v = serde_json::to_value(open).unwrap();
245        assert_eq!(
246            v,
247            json!({
248                "command":"open","channel":"ch9","payload":"dbus-json3",
249                "bus":"internal"
250            })
251        );
252        let obj = v.as_object().unwrap();
253        assert!(!obj.contains_key("name"));
254        assert!(!obj.contains_key("superuser"));
255    }
256
257    #[test]
258    fn serializes_dbus_call() {
259        let call = DbusCall::new(
260            "ch1",
261            "/org/freedesktop/systemd1",
262            "org.freedesktop.systemd1.Manager",
263            "ListUnits",
264            json!([]),
265        );
266        let v = serde_json::to_value(&call.body).unwrap();
267        assert_eq!(
268            v,
269            json!({
270                "call":["/org/freedesktop/systemd1","org.freedesktop.systemd1.Manager","ListUnits",[]],
271                "id": call.id
272            })
273        );
274    }
275
276    #[test]
277    fn parses_dbus_reply() {
278        let msg: DbusResponse = serde_json::from_value(json!({
279            "reply":[[ [["sshd.service","OpenSSH","loaded","active","running"]] ]],
280            "id":"7"
281        }))
282        .unwrap();
283        assert_eq!(msg.id.as_deref(), Some("7"));
284        assert!(msg.error.is_none());
285        assert!(msg.reply.is_some());
286    }
287
288    #[test]
289    fn parses_dbus_error() {
290        let msg: DbusResponse = serde_json::from_value(json!({
291            "error":["org.freedesktop.DBus.Error.UnknownMethod",["nope"]],
292            "id":"7"
293        }))
294        .unwrap();
295        assert_eq!(
296            msg.dbus_error_name(),
297            Some("org.freedesktop.DBus.Error.UnknownMethod")
298        );
299    }
300
301    #[test]
302    fn parses_incoming_control() {
303        let c: IncomingControl = serde_json::from_value(json!({
304            "command":"close","channel":"ch1","problem":"not-found"
305        }))
306        .unwrap();
307        assert_eq!(c.command, "close");
308        assert_eq!(c.channel.as_deref(), Some("ch1"));
309        assert_eq!(c.problem.as_deref(), Some("not-found"));
310    }
311
312    #[test]
313    fn serializes_done_control() {
314        let v = serde_json::to_value(Control::Done {
315            channel: "ch1".into(),
316        })
317        .unwrap();
318        assert_eq!(v, json!({"command":"done","channel":"ch1"}));
319    }
320
321    #[test]
322    fn serializes_close_control_with_and_without_problem() {
323        let with = serde_json::to_value(Control::Close {
324            channel: "ch1".into(),
325            problem: Some("not-found".into()),
326        })
327        .unwrap();
328        assert_eq!(
329            with,
330            json!({"command":"close","channel":"ch1","problem":"not-found"})
331        );
332
333        let without = serde_json::to_value(Control::Close {
334            channel: "ch2".into(),
335            problem: None,
336        })
337        .unwrap();
338        assert_eq!(without, json!({"command":"close","channel":"ch2"}));
339    }
340
341    #[test]
342    fn control_to_json_round_trips() {
343        let bytes = Control::Done {
344            channel: "ch1".into(),
345        }
346        .to_json();
347        let v: Value = serde_json::from_slice(&bytes).unwrap();
348        assert_eq!(v, json!({"command":"done","channel":"ch1"}));
349    }
350
351    #[test]
352    fn dbus_response_out_args_none_when_empty() {
353        let resp: DbusResponse = serde_json::from_value(json!({"id":"1"})).unwrap();
354        assert!(resp.out_args().is_none());
355        assert!(resp.dbus_error_name().is_none());
356        assert!(resp.dbus_error_message().is_none());
357    }
358}