Skip to main content

smolvm_protocol/
publish_socket.rs

1//! Shared spec for user-published host↔guest Unix-socket bridges.
2//!
3//! Generalizes the fixed Docker (guest→host) and SSH-agent (host→guest) bridges
4//! into a dynamic, user-specified set. The host allocates a vsock port per
5//! published socket and encodes the guest-relevant fields into the
6//! [`crate::guest_env::PUBLISH_SOCKETS`] env var; the guest agent decodes it and
7//! starts one relay per entry.
8
9use serde::{Deserialize, Serialize};
10use std::str::FromStr;
11
12/// Direction a published Unix socket is bridged.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "lowercase")]
15pub enum SocketDirection {
16    /// A guest process listens on the guest path; smolvm exposes it on the host.
17    /// Host clients connect in — the Docker-bridge pattern.
18    Expose,
19    /// A host process listens on the host path; smolvm makes it reachable inside
20    /// the guest at the guest path. Guest clients connect in — the SSH-agent
21    /// pattern.
22    Mount,
23}
24
25impl SocketDirection {
26    /// Wire/CLI token for this direction.
27    pub fn as_str(&self) -> &'static str {
28        match self {
29            SocketDirection::Expose => "expose",
30            SocketDirection::Mount => "mount",
31        }
32    }
33
34    /// The libkrun `krun_add_vsock_port2` `listen` flag for this direction.
35    ///
36    /// `Expose`: the guest serves on the vsock port and the host connects in, so
37    /// libkrun *listens* on the host-side socket (`true`). `Mount`: the guest
38    /// connects out to the vsock port and libkrun dials the host socket, so
39    /// libkrun does not listen (`false`).
40    pub fn host_listens(&self) -> bool {
41        matches!(self, SocketDirection::Expose)
42    }
43}
44
45impl FromStr for SocketDirection {
46    type Err = String;
47    fn from_str(s: &str) -> Result<Self, Self::Err> {
48        match s.to_ascii_lowercase().as_str() {
49            "expose" => Ok(SocketDirection::Expose),
50            "mount" => Ok(SocketDirection::Mount),
51            other => Err(format!(
52                "invalid socket direction '{other}' (expected 'expose' or 'mount')"
53            )),
54        }
55    }
56}
57
58/// One published socket, as the guest agent needs to know it: which vsock port
59/// carries it, which guest-side path to serve or create, and the direction.
60///
61/// The host-side path is intentionally absent — libkrun owns the host end, so
62/// the guest never needs it.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct PublishedSocket {
65    /// vsock port assigned to this bridge by the host launcher.
66    pub vsock_port: u32,
67    /// Guest-side socket path: the existing app socket to proxy to (`Expose`),
68    /// or the path to create a listener at (`Mount`).
69    pub guest_path: String,
70    /// Bridge direction.
71    pub direction: SocketDirection,
72}
73
74/// Encode a list of published sockets for the guest env var, as
75/// `port|dir|guest_path` entries joined by `;`. Paths cannot contain `;` or `|`
76/// in any realistic case; entries that would are rejected by the host before
77/// encoding (see the host-side validator).
78pub fn encode(sockets: &[PublishedSocket]) -> String {
79    sockets
80        .iter()
81        .map(|s| format!("{}|{}|{}", s.vsock_port, s.direction.as_str(), s.guest_path))
82        .collect::<Vec<_>>()
83        .join(";")
84}
85
86/// Decode the guest env var back into published-socket specs. Malformed entries
87/// are skipped rather than failing the whole parse, so one bad entry can't take
88/// down every bridge.
89pub fn decode(encoded: &str) -> Vec<PublishedSocket> {
90    encoded
91        .split(';')
92        .filter(|e| !e.is_empty())
93        .filter_map(|entry| {
94            let mut parts = entry.splitn(3, '|');
95            let vsock_port = parts.next()?.parse::<u32>().ok()?;
96            let direction = parts.next()?.parse::<SocketDirection>().ok()?;
97            let guest_path = parts.next()?.to_string();
98            if guest_path.is_empty() {
99                return None;
100            }
101            Some(PublishedSocket {
102                vsock_port,
103                guest_path,
104                direction,
105            })
106        })
107        .collect()
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn direction_roundtrips_and_maps_to_listen_flag() {
116        assert_eq!(
117            "expose".parse::<SocketDirection>().unwrap(),
118            SocketDirection::Expose
119        );
120        assert_eq!(
121            "MOUNT".parse::<SocketDirection>().unwrap(),
122            SocketDirection::Mount
123        );
124        assert!("bogus".parse::<SocketDirection>().is_err());
125        assert!(SocketDirection::Expose.host_listens());
126        assert!(!SocketDirection::Mount.host_listens());
127    }
128
129    #[test]
130    fn encode_decode_roundtrip() {
131        let socks = vec![
132            PublishedSocket {
133                vsock_port: 6100,
134                guest_path: "/var/run/app.sock".into(),
135                direction: SocketDirection::Expose,
136            },
137            PublishedSocket {
138                vsock_port: 6101,
139                guest_path: "/tmp/host.sock".into(),
140                direction: SocketDirection::Mount,
141            },
142        ];
143        let encoded = encode(&socks);
144        assert_eq!(
145            encoded,
146            "6100|expose|/var/run/app.sock;6101|mount|/tmp/host.sock"
147        );
148        assert_eq!(decode(&encoded), socks);
149    }
150
151    #[test]
152    fn decode_skips_malformed_entries() {
153        // empty string, missing fields, bad port, bad dir, empty path — all skipped
154        assert!(decode("").is_empty());
155        let mixed = "6100|expose|/ok.sock;garbage;9|nope|/x.sock;abc|expose|/y.sock;6102|mount|";
156        let out = decode(mixed);
157        assert_eq!(out.len(), 1);
158        assert_eq!(out[0].guest_path, "/ok.sock");
159    }
160}