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