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