Skip to main content

microsandbox_protocol/
bootstrap.rs

1//! Typed host-to-guest configuration delivered before agent initialization.
2
3use std::net::{Ipv4Addr, Ipv6Addr};
4
5use serde::{Deserialize, Serialize};
6
7use crate::exec::ExecRlimit;
8
9//--------------------------------------------------------------------------------------------------
10// Types
11//--------------------------------------------------------------------------------------------------
12
13/// Complete one-shot configuration consumed by agentd during guest boot.
14///
15/// The runtime preloads this payload into the agent console before the VM
16/// starts. The surrounding protocol envelope supplies the schema generation.
17#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
18pub struct GuestBootstrap {
19    /// Block-backed root filesystem assembly, when required.
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub block_root: Option<BootstrapBlockRoot>,
22
23    /// Virtiofs directory mounts installed inside the guest.
24    #[serde(default, skip_serializing_if = "Vec::is_empty")]
25    pub dir_mounts: Vec<BootstrapDirMount>,
26
27    /// Virtiofs file mounts installed inside the guest.
28    #[serde(default, skip_serializing_if = "Vec::is_empty")]
29    pub file_mounts: Vec<BootstrapFileMount>,
30
31    /// Additional block-device mounts installed inside the guest.
32    #[serde(default, skip_serializing_if = "Vec::is_empty")]
33    pub disk_mounts: Vec<BootstrapDiskMount>,
34
35    /// Tmpfs mounts installed inside the guest.
36    #[serde(default, skip_serializing_if = "Vec::is_empty")]
37    pub tmpfs_mounts: Vec<BootstrapTmpfsMount>,
38
39    /// Guest hostname.
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub hostname: Option<String>,
42
43    /// Host alias written into the guest's hosts file.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub host_alias: Option<String>,
46
47    /// Guest network interface and address configuration.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub network: Option<BootstrapNetwork>,
50
51    /// Sandbox-wide resource limits inherited by guest workloads.
52    #[serde(default, skip_serializing_if = "Vec::is_empty")]
53    pub rlimits: Vec<ExecRlimit>,
54
55    /// Default guest user for command execution.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub user: Option<String>,
58
59    /// Default working directory for requests that omit one.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub default_cwd: Option<String>,
62
63    /// Environment inherited by requests that do not override a key.
64    ///
65    /// Secret entries contain guest-visible placeholders, never host secret
66    /// values. Explicit exec and handoff environment entries take precedence.
67    #[serde(default, skip_serializing_if = "Vec::is_empty")]
68    pub default_env: Vec<BootstrapEnvVar>,
69
70    /// In-guest security policy.
71    #[serde(default)]
72    pub security_profile: BootstrapSecurityProfile,
73
74    /// Optional PID 1 handoff after agentd finishes guest initialization.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub handoff_init: Option<BootstrapHandoffInit>,
77}
78
79/// Block-backed root filesystem configuration.
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(tag = "kind", rename_all = "kebab-case")]
82pub enum BootstrapBlockRoot {
83    /// A single filesystem image mounted as the guest root.
84    DiskImage {
85        /// Guest block-device path.
86        device: String,
87
88        /// Filesystem type, or `None` to probe inside the guest.
89        #[serde(default, skip_serializing_if = "Option::is_none")]
90        fstype: Option<String>,
91    },
92
93    /// An EROFS lower filesystem combined with a writable overlay upper.
94    OciErofs {
95        /// Read-only EROFS block-device path.
96        lower: String,
97
98        /// Writable overlay backing.
99        upper: BootstrapBlockRootUpper,
100    },
101}
102
103/// Writable backing for an OCI EROFS root.
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(tag = "kind", rename_all = "kebab-case")]
106pub enum BootstrapBlockRootUpper {
107    /// Writable filesystem supplied by a guest block device.
108    Device {
109        /// Guest block-device path.
110        device: String,
111
112        /// Filesystem type on the device.
113        fstype: String,
114    },
115
116    /// RAM-backed writable upper.
117    Tmpfs {
118        /// Optional maximum size in MiB.
119        #[serde(default, skip_serializing_if = "Option::is_none")]
120        size_mib: Option<u32>,
121    },
122}
123
124/// Common guest mount flags.
125#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
126pub struct BootstrapMountFlags {
127    /// Mount read-only.
128    #[serde(default)]
129    pub readonly: bool,
130
131    /// Disallow execution from the mount.
132    #[serde(default)]
133    pub noexec: bool,
134
135    /// Ignore set-user-ID and set-group-ID bits.
136    #[serde(default)]
137    pub nosuid: bool,
138
139    /// Disallow device nodes.
140    #[serde(default)]
141    pub nodev: bool,
142}
143
144/// Guest-side virtiofs directory mount.
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146pub struct BootstrapDirMount {
147    /// Virtiofs device tag.
148    pub tag: String,
149
150    /// Absolute guest mount path.
151    pub guest_path: String,
152
153    /// Guest mount flags.
154    #[serde(default)]
155    pub flags: BootstrapMountFlags,
156}
157
158/// Guest-side virtiofs file mount.
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160pub struct BootstrapFileMount {
161    /// Virtiofs device tag.
162    pub tag: String,
163
164    /// Filename inside the staged virtiofs directory.
165    pub filename: String,
166
167    /// Absolute guest file path.
168    pub guest_path: String,
169
170    /// Guest mount flags.
171    #[serde(default)]
172    pub flags: BootstrapMountFlags,
173}
174
175/// Guest-side block-device mount.
176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
177pub struct BootstrapDiskMount {
178    /// Virtio block-device identifier.
179    pub id: String,
180
181    /// Absolute guest mount path.
182    pub guest_path: String,
183
184    /// Filesystem type, or `None` to probe inside the guest.
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub fstype: Option<String>,
187
188    /// Guest mount flags.
189    #[serde(default)]
190    pub flags: BootstrapMountFlags,
191}
192
193/// Guest-side tmpfs mount.
194#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
195pub struct BootstrapTmpfsMount {
196    /// Absolute guest mount path.
197    pub path: String,
198
199    /// Optional maximum size in MiB.
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub size_mib: Option<u32>,
202
203    /// Optional Unix mode applied to the tmpfs root.
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub mode: Option<u32>,
206
207    /// Guest mount flags.
208    #[serde(default)]
209    pub flags: BootstrapMountFlags,
210}
211
212/// Guest network interface and address configuration.
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214pub struct BootstrapNetwork {
215    /// Guest interface name.
216    pub interface: String,
217
218    /// Guest interface MAC address.
219    pub mac: [u8; 6],
220
221    /// Guest interface MTU.
222    pub mtu: u16,
223
224    /// IPv4 address configuration when IPv4 is active.
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub ipv4: Option<BootstrapIpv4>,
227
228    /// IPv6 address configuration when IPv6 is active.
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub ipv6: Option<BootstrapIpv6>,
231}
232
233/// Guest IPv4 address configuration.
234#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
235pub struct BootstrapIpv4 {
236    /// Guest IPv4 address.
237    pub address: Ipv4Addr,
238
239    /// CIDR prefix length.
240    pub prefix_len: u8,
241
242    /// Default gateway.
243    pub gateway: Ipv4Addr,
244
245    /// DNS resolver address.
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub dns: Option<Ipv4Addr>,
248}
249
250/// Guest IPv6 address configuration.
251#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
252pub struct BootstrapIpv6 {
253    /// Guest IPv6 address.
254    pub address: Ipv6Addr,
255
256    /// CIDR prefix length.
257    pub prefix_len: u8,
258
259    /// Default gateway.
260    pub gateway: Ipv6Addr,
261
262    /// DNS resolver address.
263    #[serde(default, skip_serializing_if = "Option::is_none")]
264    pub dns: Option<Ipv6Addr>,
265}
266
267/// A baseline guest environment entry.
268#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
269pub struct BootstrapEnvVar {
270    /// Environment variable name.
271    pub key: String,
272
273    /// Environment variable value.
274    pub value: String,
275}
276
277/// In-guest security profile selected for a sandbox.
278#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
279#[serde(rename_all = "snake_case")]
280pub enum BootstrapSecurityProfile {
281    /// Preserve normal guest-root behavior.
282    #[default]
283    Default,
284
285    /// Restrict mount and process privileges inside the guest.
286    Restricted,
287}
288
289/// Optional PID 1 handoff after agentd completes initialization.
290#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
291pub struct BootstrapHandoffInit {
292    /// Absolute init path inside the guest, or the `auto` sentinel.
293    pub cmd: String,
294
295    /// Arguments following `argv[0]`.
296    #[serde(default, skip_serializing_if = "Vec::is_empty")]
297    pub args: Vec<String>,
298
299    /// Working directory entered before the handoff.
300    #[serde(default, skip_serializing_if = "Option::is_none")]
301    pub cwd: Option<String>,
302
303    /// Environment merged over the inherited runtime environment.
304    #[serde(default, skip_serializing_if = "Vec::is_empty")]
305    pub env: Vec<BootstrapEnvVar>,
306}
307
308//--------------------------------------------------------------------------------------------------
309// Tests
310//--------------------------------------------------------------------------------------------------
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use crate::{
316        codec,
317        message::{Message, MessageType, PROTOCOL_VERSION},
318    };
319
320    #[test]
321    fn guest_bootstrap_round_trips_transport_sensitive_values() {
322        let bootstrap = GuestBootstrap {
323            block_root: Some(BootstrapBlockRoot::OciErofs {
324                lower: "/dev/vda".to_string(),
325                upper: BootstrapBlockRootUpper::Tmpfs {
326                    size_mib: Some(512),
327                },
328            }),
329            dir_mounts: vec![BootstrapDirMount {
330                tag: "workspace".to_string(),
331                guest_path: "/workspace:with separators".to_string(),
332                flags: BootstrapMountFlags {
333                    noexec: true,
334                    ..BootstrapMountFlags::default()
335                },
336            }],
337            file_mounts: vec![BootstrapFileMount {
338                tag: "config".to_string(),
339                filename: "app.json".to_string(),
340                guest_path: "/etc/app.json".to_string(),
341                flags: BootstrapMountFlags {
342                    readonly: true,
343                    ..BootstrapMountFlags::default()
344                },
345            }],
346            disk_mounts: vec![BootstrapDiskMount {
347                id: "data".to_string(),
348                guest_path: "/data".to_string(),
349                fstype: Some("ext4".to_string()),
350                flags: BootstrapMountFlags::default(),
351            }],
352            tmpfs_mounts: vec![BootstrapTmpfsMount {
353                path: "/tmp".to_string(),
354                size_mib: Some(64),
355                mode: Some(0o1777),
356                flags: BootstrapMountFlags::default(),
357            }],
358            hostname: Some("quoted-env-test".to_string()),
359            host_alias: Some("host.microsandbox.internal".to_string()),
360            network: Some(BootstrapNetwork {
361                interface: "eth0".to_string(),
362                mac: [0x02, 0x00, 0x00, 0x00, 0x00, 0x02],
363                mtu: 1500,
364                ipv4: Some(BootstrapIpv4 {
365                    address: "172.16.0.2".parse().unwrap(),
366                    prefix_len: 30,
367                    gateway: "172.16.0.1".parse().unwrap(),
368                    dns: Some("172.16.0.1".parse().unwrap()),
369                }),
370                ipv6: Some(BootstrapIpv6 {
371                    address: "fd42:6d73:62::2".parse().unwrap(),
372                    prefix_len: 64,
373                    gateway: "fd42:6d73:62::1".parse().unwrap(),
374                    dns: Some("fd42:6d73:62::1".parse().unwrap()),
375                }),
376            }),
377            rlimits: vec![ExecRlimit {
378                resource: "nofile".to_string(),
379                soft: 1024,
380                hard: 4096,
381            }],
382            user: Some("1000:1000".to_string()),
383            default_cwd: Some("/workspace with spaces".to_string()),
384            default_env: vec![
385                BootstrapEnvVar {
386                    key: "APP_CONFIG".to_string(),
387                    value: "{\"message\":\"hello\"}".to_string(),
388                },
389                BootstrapEnvVar {
390                    key: "UNICODE".to_string(),
391                    value: "snowman: \u{2603}\nnext\tcolumn".to_string(),
392                },
393                BootstrapEnvVar {
394                    key: "EMPTY".to_string(),
395                    value: String::new(),
396                },
397            ],
398            security_profile: BootstrapSecurityProfile::Restricted,
399            handoff_init: Some(BootstrapHandoffInit {
400                cmd: "/sbin/init".to_string(),
401                args: vec!["--unit=multi user.target".to_string()],
402                cwd: Some("/workspace with spaces".to_string()),
403                env: vec![BootstrapEnvVar {
404                    key: "HANDOFF_JSON".to_string(),
405                    value: "{\"enabled\":true}".to_string(),
406                }],
407            }),
408        };
409
410        let message = Message::with_payload(MessageType::Bootstrap, 0, &bootstrap).unwrap();
411        assert_eq!(message.v, PROTOCOL_VERSION);
412        let mut frame = Vec::new();
413        codec::encode_to_buf(&message, &mut frame).unwrap();
414        let decoded = codec::decode_message_frame(&frame).unwrap();
415        assert_eq!(decoded.payload::<GuestBootstrap>().unwrap(), bootstrap);
416    }
417}