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/// A D-Bus signal parsed from a data frame.
176///
177/// cockpit `dbus-json3` delivers a signal as `{"signal": [path, iface,
178/// member, [args]]}` on the channel, with no `id` member (it is not a reply).
179/// PackageKit's transaction results arrive entirely as such signals, so the
180/// signal-collecting receive loop
181/// ([`crate::protocol::client::BridgeClient::dbus_call_collect`]) decodes them
182/// through this type. Request/reply callers never see it.
183#[derive(Debug, Deserialize)]
184pub struct DbusSignal {
185    /// The `[path, iface, member, [args]]` tuple, when this frame is a signal.
186    #[serde(default)]
187    pub signal: Option<Vec<Value>>,
188}
189
190impl DbusSignal {
191    /// The signal member name (e.g. `Package`, `Finished`), if present.
192    pub fn member(&self) -> Option<&str> {
193        self.signal
194            .as_ref()
195            .and_then(|s| s.get(2))
196            .and_then(|v| v.as_str())
197    }
198    /// The emitting object path, if present.
199    pub fn path(&self) -> Option<&str> {
200        self.signal
201            .as_ref()
202            .and_then(|s| s.first())
203            .and_then(|v| v.as_str())
204    }
205    /// The signal argument array, if present.
206    pub fn args(&self) -> Option<&Vec<Value>> {
207        self.signal
208            .as_ref()
209            .and_then(|s| s.get(3))
210            .and_then(|v| v.as_array())
211    }
212}
213
214/// Control messages we RECEIVE (permissive).
215#[derive(Debug, Deserialize)]
216pub struct IncomingControl {
217    /// The control command name (e.g. `close`, `done`, `init`).
218    pub command: String,
219    /// Channel the command refers to, if any.
220    #[serde(default)]
221    pub channel: Option<String>,
222    /// Problem kind when the command reports a failure.
223    #[serde(default)]
224    pub problem: Option<String>,
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use serde_json::json;
231
232    #[test]
233    fn parses_dbus_signal() {
234        let v = serde_json::json!({
235            "signal": [
236                "/1234_abcd",
237                "org.freedesktop.PackageKit.Transaction",
238                "Package",
239                [8, "htop;3.4.1-3.fc44;x86_64;fedora", "Interactive process viewer"]
240            ]
241        });
242        let sig: DbusSignal = serde_json::from_value(v).unwrap();
243        assert_eq!(sig.member(), Some("Package"));
244        assert_eq!(sig.path(), Some("/1234_abcd"));
245        let args = sig.args().unwrap();
246        assert_eq!(args[0].as_u64(), Some(8));
247        assert_eq!(args[1].as_str(), Some("htop;3.4.1-3.fc44;x86_64;fedora"));
248    }
249
250    #[test]
251    fn dbus_reply_is_not_a_signal() {
252        // A normal reply frame has no "signal" member.
253        let v = serde_json::json!({ "reply": [[]], "id": "7" });
254        let sig: DbusSignal = serde_json::from_value(v).unwrap();
255        assert!(sig.member().is_none());
256    }
257
258    #[test]
259    fn serializes_init_without_superuser() {
260        let v = serde_json::to_value(Control::Init {
261            version: 1,
262            host: "localhost".into(),
263            superuser: None,
264        })
265        .unwrap();
266        // Omitted entirely when None so we never imply a privileged session.
267        assert_eq!(v, json!({"command":"init","version":1,"host":"localhost"}));
268    }
269
270    #[test]
271    fn serializes_init_superuser_none() {
272        // fez defers escalation by sending superuser:"none" (a string), not a
273        // mechanism-pinning object. Escalation happens later via Start.
274        let v = serde_json::to_value(Control::Init {
275            version: 1,
276            host: "localhost".into(),
277            superuser: Some(json!("none")),
278        })
279        .unwrap();
280        assert_eq!(
281            v,
282            json!({
283                "command":"init","version":1,"host":"localhost",
284                "superuser":"none"
285            })
286        );
287    }
288
289    #[test]
290    fn serializes_open_with_options() {
291        let open = Control::open("ch1", "dbus-json3")
292            .opt("bus", json!("system"))
293            .opt("name", json!("org.freedesktop.systemd1"));
294        let v = serde_json::to_value(open).unwrap();
295        assert_eq!(
296            v,
297            json!({
298                "command":"open","channel":"ch1","payload":"dbus-json3",
299                "bus":"system","name":"org.freedesktop.systemd1"
300            })
301        );
302    }
303
304    #[test]
305    fn serializes_internal_bus_open() {
306        // The internal-bus open (used to reach cockpit.Superuser) carries
307        // bus:"internal", no name, and is never privileged.
308        let open = Control::open("ch9", "dbus-json3").opt("bus", json!("internal"));
309        let v = serde_json::to_value(open).unwrap();
310        assert_eq!(
311            v,
312            json!({
313                "command":"open","channel":"ch9","payload":"dbus-json3",
314                "bus":"internal"
315            })
316        );
317        let obj = v.as_object().unwrap();
318        assert!(!obj.contains_key("name"));
319        assert!(!obj.contains_key("superuser"));
320    }
321
322    #[test]
323    fn serializes_dbus_call() {
324        let call = DbusCall::new(
325            "ch1",
326            "/org/freedesktop/systemd1",
327            "org.freedesktop.systemd1.Manager",
328            "ListUnits",
329            json!([]),
330        );
331        let v = serde_json::to_value(&call.body).unwrap();
332        assert_eq!(
333            v,
334            json!({
335                "call":["/org/freedesktop/systemd1","org.freedesktop.systemd1.Manager","ListUnits",[]],
336                "id": call.id
337            })
338        );
339    }
340
341    #[test]
342    fn parses_dbus_reply() {
343        let msg: DbusResponse = serde_json::from_value(json!({
344            "reply":[[ [["sshd.service","OpenSSH","loaded","active","running"]] ]],
345            "id":"7"
346        }))
347        .unwrap();
348        assert_eq!(msg.id.as_deref(), Some("7"));
349        assert!(msg.error.is_none());
350        assert!(msg.reply.is_some());
351    }
352
353    #[test]
354    fn parses_dbus_error() {
355        let msg: DbusResponse = serde_json::from_value(json!({
356            "error":["org.freedesktop.DBus.Error.UnknownMethod",["nope"]],
357            "id":"7"
358        }))
359        .unwrap();
360        assert_eq!(
361            msg.dbus_error_name(),
362            Some("org.freedesktop.DBus.Error.UnknownMethod")
363        );
364    }
365
366    #[test]
367    fn parses_incoming_control() {
368        let c: IncomingControl = serde_json::from_value(json!({
369            "command":"close","channel":"ch1","problem":"not-found"
370        }))
371        .unwrap();
372        assert_eq!(c.command, "close");
373        assert_eq!(c.channel.as_deref(), Some("ch1"));
374        assert_eq!(c.problem.as_deref(), Some("not-found"));
375    }
376
377    #[test]
378    fn serializes_done_control() {
379        let v = serde_json::to_value(Control::Done {
380            channel: "ch1".into(),
381        })
382        .unwrap();
383        assert_eq!(v, json!({"command":"done","channel":"ch1"}));
384    }
385
386    #[test]
387    fn serializes_close_control_with_and_without_problem() {
388        let with = serde_json::to_value(Control::Close {
389            channel: "ch1".into(),
390            problem: Some("not-found".into()),
391        })
392        .unwrap();
393        assert_eq!(
394            with,
395            json!({"command":"close","channel":"ch1","problem":"not-found"})
396        );
397
398        let without = serde_json::to_value(Control::Close {
399            channel: "ch2".into(),
400            problem: None,
401        })
402        .unwrap();
403        assert_eq!(without, json!({"command":"close","channel":"ch2"}));
404    }
405
406    #[test]
407    fn control_to_json_round_trips() {
408        let bytes = Control::Done {
409            channel: "ch1".into(),
410        }
411        .to_json();
412        let v: Value = serde_json::from_slice(&bytes).unwrap();
413        assert_eq!(v, json!({"command":"done","channel":"ch1"}));
414    }
415
416    #[test]
417    fn dbus_response_out_args_none_when_empty() {
418        let resp: DbusResponse = serde_json::from_value(json!({"id":"1"})).unwrap();
419        assert!(resp.out_args().is_none());
420        assert!(resp.dbus_error_name().is_none());
421        assert!(resp.dbus_error_message().is_none());
422    }
423}