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