Skip to main content

microsandbox_agentd/
config.rs

1//! Agentd configuration, received once over the guest console at startup.
2//!
3//! Split into two structs with different lifetimes:
4//!
5//! - [`BootParams`] - one-shot bootstrap values consumed by [`init::init`] and
6//!   dropped once init completes.
7//! - [`AgentdConfig`] — runtime config that outlives init (currently just
8//!   the default guest user), passed by reference to the agent loop.
9//!
10//! The typed bootstrap is validated before full guest initialization. Legacy
11//! environment parsers remain local to this module for compatibility tests.
12//!
13//! [`init::init`]: crate::init::init
14
15use std::env;
16use std::ffi::OsString;
17use std::net::{Ipv4Addr, Ipv6Addr};
18use std::path::PathBuf;
19
20use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
21use microsandbox_protocol::{
22    ENV_BLOCK_ROOT, ENV_DIR_MOUNTS, ENV_DISK_MOUNTS, ENV_FILE_MOUNTS, ENV_HANDOFF_INIT,
23    ENV_HANDOFF_INIT_ARGS, ENV_HANDOFF_INIT_CWD, ENV_HANDOFF_INIT_ENV, ENV_HOST_ALIAS,
24    ENV_HOSTNAME, ENV_NET, ENV_NET_IPV4, ENV_NET_IPV6, ENV_RLIMITS, ENV_SECURITY_PROFILE,
25    ENV_TMPFS, ENV_USER, HANDOFF_INIT_AUTO,
26    bootstrap::{
27        BootstrapBlockRoot, BootstrapBlockRootUpper, BootstrapEnvVar, BootstrapHandoffInit,
28        BootstrapSecurityProfile, GuestBootstrap,
29    },
30    exec::ExecRlimit,
31};
32use serde::de::DeserializeOwned;
33
34use crate::error::{AgentdError, AgentdResult};
35use crate::rlimit;
36
37//--------------------------------------------------------------------------------------------------
38// Types
39//--------------------------------------------------------------------------------------------------
40
41/// One-shot bootstrap values consumed by [`init::init`] and dropped afterward.
42///
43/// Moved by value into init; owning the data (rather than borrowing) makes
44/// the "consumed once" lifetime explicit in the signature and prevents
45/// accidental reads after init completes.
46///
47/// [`init::init`]: crate::init::init
48#[derive(Debug)]
49pub struct BootParams {
50    /// Block device configuration for a rootfs switch.
51    pub(crate) block_root: Option<BlockRootSpec>,
52
53    /// Virtiofs directory mount specs (empty when unset).
54    pub(crate) dir_mounts: Vec<DirMountSpec>,
55
56    /// Virtiofs file mount specs (empty when unset).
57    pub(crate) file_mounts: Vec<FileMountSpec>,
58
59    /// Disk-image mount specs (empty when unset).
60    pub(crate) disk_mounts: Vec<DiskMountSpec>,
61
62    /// Tmpfs mount specs (empty when unset).
63    pub(crate) tmpfs: Vec<TmpfsSpec>,
64
65    /// In-guest security profile.
66    pub(crate) security_profile: SecurityProfile,
67
68    /// Guest hostname.
69    pub(crate) hostname: Option<String>,
70
71    /// DNS name (for example `host.microsandbox.internal`) the guest uses to
72    /// reach the sandbox host. Written into `/etc/hosts` at the gateway IPs.
73    pub(crate) host_alias: Option<String>,
74
75    /// Network interface configuration.
76    pub(crate) net: Option<NetSpec>,
77
78    /// IPv4 configuration.
79    pub(crate) net_ipv4: Option<NetIpv4Spec>,
80
81    /// IPv6 configuration.
82    pub(crate) net_ipv6: Option<NetIpv6Spec>,
83
84    /// Sandbox-wide resource limits applied to PID 1 so every guest process
85    /// inherits the raised baseline (empty when unset).
86    pub(crate) rlimits: Vec<ExecRlimit>,
87
88    /// Guest init binary to which agentd hands off PID 1 after `init::init()`.
89    /// `None` means agentd remains PID 1 (the default).
90    pub(crate) handoff_init: Option<HandoffInit>,
91}
92
93/// Parsed handoff-init specification.
94///
95/// When present in [`BootParams`], agentd performs setup, forks, the
96/// parent execs `cmd` (becoming the new PID 1), and the child
97/// continues as the agent loop.
98#[derive(Debug)]
99pub struct HandoffInit {
100    /// Absolute path inside the guest rootfs, or the literal `"auto"`
101    /// (resolved via [`HANDOFF_INIT_AUTO_CANDIDATES`] in `do_handoff`).
102    pub(crate) cmd: PathBuf,
103
104    /// argv past `argv[0]` — i.e., the supplemental arguments. Empty
105    /// means the init is exec'd with `argv = [cmd]`.
106    pub(crate) argv: Vec<OsString>,
107
108    /// Working directory to enter before execing the init binary.
109    pub(crate) cwd: Option<PathBuf>,
110
111    /// Extra env vars merged on top of the inherited env. Empty means
112    /// inherit-only.
113    pub(crate) env: Vec<(OsString, OsString)>,
114}
115
116/// Runtime configuration surviving past init; referenced by the agent loop.
117///
118/// Holds runtime settings used after init, including the default guest user
119/// and security profile for exec sessions.
120#[derive(Debug)]
121pub struct AgentdConfig {
122    /// Default guest user for exec sessions, captured at startup.
123    pub(crate) user: Option<String>,
124
125    /// In-guest security profile for exec sessions.
126    pub(crate) security_profile: SecurityProfile,
127
128    /// Default working directory for requests that omit one.
129    pub(crate) default_cwd: Option<String>,
130
131    /// Baseline image/user environment plus host-generated placeholders.
132    pub(crate) default_env: Vec<BootstrapEnvVar>,
133}
134
135/// In-guest security profile.
136#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
137pub enum SecurityProfile {
138    /// Preserve normal guest-root behavior.
139    #[default]
140    Default,
141
142    /// Set `no_new_privs`, drop `CAP_SYS_ADMIN`, and force `nosuid,nodev` mounts.
143    Restricted,
144}
145
146/// Parsed tmpfs mount specification.
147#[derive(Debug)]
148pub(crate) struct TmpfsSpec {
149    pub path: String,
150    pub size_mib: Option<u32>,
151    pub mode: Option<u32>,
152    pub noexec: bool,
153    pub nosuid: bool,
154    pub nodev: bool,
155    pub readonly: bool,
156}
157
158/// Parsed block-device root specification with kind-based dispatch.
159#[derive(Debug)]
160pub(crate) enum BlockRootSpec {
161    /// Single disk image.
162    DiskImage {
163        device: String,
164        fstype: Option<String>,
165    },
166    /// OCI EROFS: merged EROFS lower + writable upper + guest overlayfs.
167    OciErofs {
168        lower: String,
169        upper: BlockRootUpper,
170    },
171}
172
173/// Writable upper backing for the OCI EROFS root.
174///
175/// `upper=<device>` carries a filesystem on a virtio-blk device (managed
176/// ext4 or a user disk image); `upper=tmpfs` means no upper device is
177/// attached and agentd assembles a RAM-backed upper itself.
178#[derive(Debug)]
179pub(crate) enum BlockRootUpper {
180    /// Writable filesystem on a block device (`upper_fstype` required).
181    Device { device: String, fstype: String },
182    /// RAM-backed tmpfs sized by `upper_size_mib` (kernel default when absent).
183    Tmpfs { size_mib: Option<u32> },
184}
185
186/// Parsed virtiofs directory volume mount specification.
187#[derive(Debug)]
188pub(crate) struct DirMountSpec {
189    pub tag: String,
190    pub guest_path: String,
191    pub readonly: bool,
192    pub noexec: bool,
193    pub nosuid: bool,
194    pub nodev: bool,
195}
196
197/// Parsed virtiofs file volume mount specification.
198#[derive(Debug)]
199pub(crate) struct FileMountSpec {
200    pub tag: String,
201    pub filename: String,
202    pub guest_path: String,
203    pub readonly: bool,
204    pub noexec: bool,
205    pub nosuid: bool,
206    pub nodev: bool,
207}
208
209/// Parsed disk-image volume mount specification.
210///
211/// Each entry corresponds to one extra virtio-blk device attached by the
212/// VMM. Agentd resolves the device node from `id` via
213/// `/dev/disk/by-id/virtio-<id>` and mounts it at `guest_path`.
214#[derive(Debug)]
215pub(crate) struct DiskMountSpec {
216    pub id: String,
217    pub guest_path: String,
218    /// Inner filesystem type. `None` triggers an autodetect walk over
219    /// `/proc/filesystems` in agentd's init path.
220    pub fstype: Option<String>,
221    pub readonly: bool,
222    pub noexec: bool,
223    pub nosuid: bool,
224    pub nodev: bool,
225}
226
227/// Parsed common volume mount option block.
228#[derive(Debug, Default)]
229struct ParsedMountOptions {
230    readonly: bool,
231    noexec: bool,
232    nosuid: bool,
233    nodev: bool,
234    fstype: Option<String>,
235    size_mib: Option<u32>,
236    mode: Option<u32>,
237}
238
239/// Which keyed options are valid for a specific mount environment variable.
240#[derive(Debug, Clone, Copy, Default)]
241struct MountOptionSupport {
242    fstype: bool,
243    size: bool,
244    mode: bool,
245}
246
247/// Parsed `MSB_NET` specification.
248#[derive(Debug)]
249pub(crate) struct NetSpec {
250    pub iface: String,
251    pub mac: [u8; 6],
252    pub mtu: u16,
253}
254
255/// Parsed `MSB_NET_IPV4` specification.
256#[derive(Debug)]
257pub(crate) struct NetIpv4Spec {
258    pub address: Ipv4Addr,
259    pub prefix_len: u8,
260    pub gateway: Ipv4Addr,
261    pub dns: Option<Ipv4Addr>,
262}
263
264/// Parsed `MSB_NET_IPV6` specification.
265#[derive(Debug)]
266pub(crate) struct NetIpv6Spec {
267    pub address: Ipv6Addr,
268    pub prefix_len: u8,
269    pub gateway: Ipv6Addr,
270    pub dns: Option<Ipv6Addr>,
271}
272
273/// Bundled network configuration: interface + IPv4 + IPv6.
274///
275/// Borrows the three `MSB_NET*` specs so they can travel as one parameter.
276#[derive(Debug)]
277pub(crate) struct NetConfig<'a> {
278    pub net: Option<&'a NetSpec>,
279    pub ipv4: Option<&'a NetIpv4Spec>,
280    pub ipv6: Option<&'a NetIpv6Spec>,
281}
282
283//--------------------------------------------------------------------------------------------------
284// Implementations
285//--------------------------------------------------------------------------------------------------
286
287impl BootParams {
288    /// Validate and convert the typed console bootstrap into agentd's init and
289    /// long-lived runtime configuration.
290    pub fn from_bootstrap(bootstrap: GuestBootstrap) -> AgentdResult<(Self, AgentdConfig)> {
291        validate_guest_bootstrap(&bootstrap)?;
292
293        let GuestBootstrap {
294            init_failure_ack: _,
295            block_root,
296            dir_mounts,
297            file_mounts,
298            disk_mounts,
299            tmpfs_mounts,
300            hostname,
301            host_alias,
302            network,
303            rlimits,
304            user,
305            default_cwd,
306            default_env,
307            security_profile,
308            handoff_init,
309        } = bootstrap;
310
311        let security_profile = match security_profile {
312            BootstrapSecurityProfile::Default => SecurityProfile::Default,
313            BootstrapSecurityProfile::Restricted => SecurityProfile::Restricted,
314        };
315        let block_root = block_root.map(|root| match root {
316            BootstrapBlockRoot::DiskImage { device, fstype } => {
317                BlockRootSpec::DiskImage { device, fstype }
318            }
319            BootstrapBlockRoot::OciErofs { lower, upper } => BlockRootSpec::OciErofs {
320                lower,
321                upper: match upper {
322                    BootstrapBlockRootUpper::Device { device, fstype } => {
323                        BlockRootUpper::Device { device, fstype }
324                    }
325                    BootstrapBlockRootUpper::Tmpfs { size_mib } => {
326                        BlockRootUpper::Tmpfs { size_mib }
327                    }
328                },
329            },
330        });
331        let dir_mounts = dir_mounts
332            .into_iter()
333            .map(|mount| {
334                let flags = mount.flags;
335                DirMountSpec {
336                    tag: mount.tag,
337                    guest_path: mount.guest_path,
338                    readonly: flags.readonly,
339                    noexec: flags.noexec,
340                    nosuid: flags.nosuid,
341                    nodev: flags.nodev,
342                }
343            })
344            .collect();
345        let file_mounts = file_mounts
346            .into_iter()
347            .map(|mount| {
348                let flags = mount.flags;
349                FileMountSpec {
350                    tag: mount.tag,
351                    filename: mount.filename,
352                    guest_path: mount.guest_path,
353                    readonly: flags.readonly,
354                    noexec: flags.noexec,
355                    nosuid: flags.nosuid,
356                    nodev: flags.nodev,
357                }
358            })
359            .collect();
360        let disk_mounts = disk_mounts
361            .into_iter()
362            .map(|mount| {
363                let flags = mount.flags;
364                DiskMountSpec {
365                    id: mount.id,
366                    guest_path: mount.guest_path,
367                    fstype: mount.fstype,
368                    readonly: flags.readonly,
369                    noexec: flags.noexec,
370                    nosuid: flags.nosuid,
371                    nodev: flags.nodev,
372                }
373            })
374            .collect();
375        let tmpfs = tmpfs_mounts
376            .into_iter()
377            .map(|mount| {
378                let flags = mount.flags;
379                TmpfsSpec {
380                    path: mount.path,
381                    size_mib: mount.size_mib,
382                    mode: mount.mode,
383                    noexec: flags.noexec,
384                    nosuid: flags.nosuid,
385                    nodev: flags.nodev,
386                    readonly: flags.readonly,
387                }
388            })
389            .collect();
390        let (net, net_ipv4, net_ipv6) = match network {
391            Some(network) => {
392                let net = NetSpec {
393                    iface: network.interface,
394                    mac: network.mac,
395                    mtu: network.mtu,
396                };
397                let ipv4 = network.ipv4.map(|ipv4| NetIpv4Spec {
398                    address: ipv4.address,
399                    prefix_len: ipv4.prefix_len,
400                    gateway: ipv4.gateway,
401                    dns: ipv4.dns,
402                });
403                let ipv6 = network.ipv6.map(|ipv6| NetIpv6Spec {
404                    address: ipv6.address,
405                    prefix_len: ipv6.prefix_len,
406                    gateway: ipv6.gateway,
407                    dns: ipv6.dns,
408                });
409                (Some(net), ipv4, ipv6)
410            }
411            None => (None, None, None),
412        };
413        let handoff_init = handoff_init.map(convert_bootstrap_handoff).transpose()?;
414
415        Ok((
416            Self {
417                block_root,
418                dir_mounts,
419                file_mounts,
420                disk_mounts,
421                tmpfs,
422                security_profile,
423                hostname,
424                host_alias,
425                net,
426                net_ipv4,
427                net_ipv6,
428                rlimits,
429                handoff_init,
430            },
431            AgentdConfig {
432                user,
433                security_profile,
434                default_cwd,
435                default_env,
436            },
437        ))
438    }
439
440    /// Reads and parses the boot-time `MSB_*` environment variables.
441    ///
442    /// Empty or whitespace-only values are treated as absent (`None`).
443    /// Returns an error if any present value fails to parse.
444    pub fn from_env() -> AgentdResult<Self> {
445        Ok(Self {
446            block_root: read_env(ENV_BLOCK_ROOT)
447                .map(|v| parse_block_root(&v))
448                .transpose()?,
449            dir_mounts: read_env(ENV_DIR_MOUNTS)
450                .map(|v| parse_dir_mounts(&v))
451                .transpose()?
452                .unwrap_or_default(),
453            file_mounts: read_env(ENV_FILE_MOUNTS)
454                .map(|v| parse_file_mounts(&v))
455                .transpose()?
456                .unwrap_or_default(),
457            disk_mounts: read_env(ENV_DISK_MOUNTS)
458                .map(|v| parse_disk_mounts(&v))
459                .transpose()?
460                .unwrap_or_default(),
461            tmpfs: read_env(ENV_TMPFS)
462                .map(|v| parse_tmpfs_mounts(&v))
463                .transpose()?
464                .unwrap_or_default(),
465            hostname: read_env(ENV_HOSTNAME),
466            host_alias: read_env(ENV_HOST_ALIAS),
467            net: read_env(ENV_NET).map(|v| parse_net(&v)).transpose()?,
468            net_ipv4: read_env(ENV_NET_IPV4)
469                .map(|v| parse_net_ipv4(&v))
470                .transpose()?,
471            net_ipv6: read_env(ENV_NET_IPV6)
472                .map(|v| parse_net_ipv6(&v))
473                .transpose()?,
474            rlimits: read_env(ENV_RLIMITS)
475                .map(|v| parse_rlimits(&v))
476                .transpose()?
477                .unwrap_or_default(),
478            security_profile: read_env(ENV_SECURITY_PROFILE)
479                .map(|v| parse_security_profile(&v))
480                .transpose()?
481                .unwrap_or_default(),
482            handoff_init: parse_handoff_init()?,
483        })
484    }
485
486    /// Take the handoff-init spec out of the boot params.
487    ///
488    /// Used by `bin/main.rs` before `init::init` consumes `BootParams`
489    /// by value, since the handoff hook fires after init returns.
490    pub fn take_handoff_init(&mut self) -> Option<HandoffInit> {
491        self.handoff_init.take()
492    }
493
494    /// Borrows the three `MSB_NET*` specs as a single bundle.
495    pub(crate) fn network(&self) -> NetConfig<'_> {
496        NetConfig {
497            net: self.net.as_ref(),
498            ipv4: self.net_ipv4.as_ref(),
499            ipv6: self.net_ipv6.as_ref(),
500        }
501    }
502}
503
504impl AgentdConfig {
505    /// Returns the configured default guest user, if any.
506    pub fn user(&self) -> Option<&str> {
507        self.user.as_deref()
508    }
509
510    /// Return the sandbox working directory used when an exec request omits one.
511    pub fn default_cwd(&self) -> Option<&str> {
512        self.default_cwd.as_deref()
513    }
514
515    /// Install the baseline workload environment before any guest child is created.
516    pub fn install_default_env(&self) {
517        for variable in &self.default_env {
518            // SAFETY: agentd is still single-threaded during bootstrap, and
519            // `validate_guest_bootstrap` rejected NUL bytes in both fields.
520            unsafe { env::set_var(&variable.key, &variable.value) };
521        }
522
523        // The former libkrun environment transport always installed this
524        // prefix for both agent execs and PID 1 handoff. Preserve that
525        // inheritance without relying on agentd's ambient boot environment.
526        let configured_path = self
527            .default_env
528            .iter()
529            .rev()
530            .find(|variable| variable.key == "PATH")
531            .map(|variable| variable.value.as_str());
532        // SAFETY: `scripts_path` constructs a NUL-free key and value while
533        // agentd is still single-threaded during bootstrap.
534        unsafe { env::set_var("PATH", scripts_path(configured_path)) };
535    }
536
537    /// Reads the runtime-config `MSB_*` environment variables.
538    ///
539    /// Empty or whitespace-only values are treated as absent (`None`).
540    pub fn from_env() -> AgentdResult<Self> {
541        Ok(Self {
542            user: read_env(ENV_USER),
543            security_profile: read_env(ENV_SECURITY_PROFILE)
544                .map(|v| parse_security_profile(&v))
545                .transpose()?
546                .unwrap_or_default(),
547            default_cwd: None,
548            default_env: Vec::new(),
549        })
550    }
551}
552
553//--------------------------------------------------------------------------------------------------
554// Functions: Runtime Environment
555//--------------------------------------------------------------------------------------------------
556
557/// Return a guest PATH with the runtime scripts directory present exactly once.
558pub(crate) fn scripts_path(existing: Option<&str>) -> String {
559    const DEFAULT_GUEST_PATH: &str = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
560
561    let existing = existing.unwrap_or(DEFAULT_GUEST_PATH);
562    if existing
563        .split(':')
564        .any(|segment| segment == microsandbox_protocol::SCRIPTS_PATH)
565    {
566        existing.to_string()
567    } else {
568        format!("{}:{existing}", microsandbox_protocol::SCRIPTS_PATH)
569    }
570}
571
572//--------------------------------------------------------------------------------------------------
573// Parse Functions: Block Root / Volume Mounts / Tmpfs
574//--------------------------------------------------------------------------------------------------
575
576fn parse_security_profile(value: &str) -> AgentdResult<SecurityProfile> {
577    match value {
578        "default" => Ok(SecurityProfile::Default),
579        "restricted" => Ok(SecurityProfile::Restricted),
580        other => Err(AgentdError::Config(format!(
581            "{ENV_SECURITY_PROFILE} unknown value: {other}"
582        ))),
583    }
584}
585
586/// Parses `MSB_BLOCK_ROOT` into a kind-based spec.
587///
588/// Supports:
589/// - `kind=disk-image,device=/dev/vda[,fstype=ext4]`
590/// - `kind=oci-erofs,lower=/dev/vdb,upper=/dev/vdc,upper_fstype=ext4`
591fn parse_block_root(val: &str) -> AgentdResult<BlockRootSpec> {
592    let mut kv: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
593    for part in val.split(',') {
594        let Some((k, v)) = part.split_once('=') else {
595            continue;
596        };
597        if kv.insert(k, v).is_some() {
598            return Err(AgentdError::Config(format!(
599                "MSB_BLOCK_ROOT duplicate key '{k}'"
600            )));
601        }
602    }
603
604    let get = |key: &str| -> AgentdResult<String> {
605        kv.get(key)
606            .filter(|v| !v.is_empty())
607            .map(|v| v.to_string())
608            .ok_or_else(|| AgentdError::Config(format!("MSB_BLOCK_ROOT missing '{key}'")))
609    };
610
611    match kv.get("kind").copied() {
612        Some("disk-image") => {
613            let device = get("device")?;
614            let fstype = kv
615                .get("fstype")
616                .filter(|v| !v.is_empty())
617                .map(|v| v.to_string());
618            Ok(BlockRootSpec::DiskImage { device, fstype })
619        }
620        Some("oci-erofs") => {
621            let lower = get("lower")?;
622            let upper = if kv.get("upper").copied() == Some("tmpfs") {
623                let size_mib = kv
624                    .get("upper_size_mib")
625                    .map(|v| {
626                        v.parse::<u32>().map_err(|e| {
627                            AgentdError::Config(format!(
628                                "MSB_BLOCK_ROOT invalid upper_size_mib '{v}': {e}"
629                            ))
630                        })
631                    })
632                    .transpose()?;
633                if kv.contains_key("upper_fstype") {
634                    return Err(AgentdError::Config(
635                        "MSB_BLOCK_ROOT upper_fstype is not valid with upper=tmpfs".into(),
636                    ));
637                }
638                BlockRootUpper::Tmpfs { size_mib }
639            } else {
640                BlockRootUpper::Device {
641                    device: get("upper")?,
642                    fstype: get("upper_fstype")?,
643                }
644            };
645            Ok(BlockRootSpec::OciErofs { lower, upper })
646        }
647        Some(other) => Err(AgentdError::Config(format!(
648            "MSB_BLOCK_ROOT unknown kind: {other}"
649        ))),
650        None => Err(AgentdError::Config(
651            "MSB_BLOCK_ROOT missing 'kind' key".into(),
652        )),
653    }
654}
655
656/// Parse a comma-separated volume mount option block.
657fn parse_mount_options(
658    env_name: &str,
659    opts: Option<&str>,
660    support: MountOptionSupport,
661) -> AgentdResult<ParsedMountOptions> {
662    let mut parsed = ParsedMountOptions::default();
663    let mut seen_access = false;
664    let mut seen_noexec = false;
665    let mut seen_nosuid = false;
666    let mut seen_nodev = false;
667    let mut seen_fstype = false;
668    let mut seen_size = false;
669    let mut seen_mode = false;
670
671    let Some(opts) = opts else {
672        return Ok(parsed);
673    };
674
675    for opt in opts.split(',') {
676        let opt = opt.trim();
677        if opt.is_empty() {
678            continue;
679        }
680        match opt {
681            "ro" | "rw" => {
682                if seen_access {
683                    return Err(AgentdError::Config(format!(
684                        "{env_name} option 'ro'/'rw' specified more than once"
685                    )));
686                }
687                seen_access = true;
688                parsed.readonly = opt == "ro";
689            }
690            "noexec" => {
691                if seen_noexec {
692                    return Err(AgentdError::Config(format!(
693                        "{env_name} option 'noexec' specified more than once"
694                    )));
695                }
696                seen_noexec = true;
697                parsed.noexec = true;
698            }
699            "nosuid" => {
700                if seen_nosuid {
701                    return Err(AgentdError::Config(format!(
702                        "{env_name} option 'nosuid' specified more than once"
703                    )));
704                }
705                seen_nosuid = true;
706                parsed.nosuid = true;
707            }
708            "nodev" => {
709                if seen_nodev {
710                    return Err(AgentdError::Config(format!(
711                        "{env_name} option 'nodev' specified more than once"
712                    )));
713                }
714                seen_nodev = true;
715                parsed.nodev = true;
716            }
717            "suid" | "exec" | "dev" => {
718                return Err(AgentdError::Config(format!(
719                    "{env_name} unsupported mount option '{opt}'"
720                )));
721            }
722            _ => {
723                let (key, value) = opt.split_once('=').ok_or_else(|| {
724                    AgentdError::Config(format!("{env_name} unknown mount option '{opt}'"))
725                })?;
726                if value.is_empty() {
727                    return Err(AgentdError::Config(format!(
728                        "{env_name} option '{key}' must not be empty"
729                    )));
730                }
731                match key {
732                    "fstype" if support.fstype => {
733                        if seen_fstype {
734                            return Err(AgentdError::Config(format!(
735                                "{env_name} option 'fstype' specified more than once"
736                            )));
737                        }
738                        seen_fstype = true;
739                        if value.chars().any(|c| matches!(c, ',' | ';' | ':' | '=')) {
740                            return Err(AgentdError::Config(format!(
741                                "{env_name} fstype must not contain ',', ';', ':', or '=': {value}"
742                            )));
743                        }
744                        parsed.fstype = Some(value.to_string());
745                    }
746                    "size" if support.size => {
747                        if seen_size {
748                            return Err(AgentdError::Config(format!(
749                                "{env_name} option 'size' specified more than once"
750                            )));
751                        }
752                        seen_size = true;
753                        parsed.size_mib = Some(value.parse::<u32>().map_err(|_| {
754                            AgentdError::Config(format!("{env_name} invalid tmpfs size: {value}"))
755                        })?);
756                    }
757                    "mode" if support.mode => {
758                        if seen_mode {
759                            return Err(AgentdError::Config(format!(
760                                "{env_name} option 'mode' specified more than once"
761                            )));
762                        }
763                        seen_mode = true;
764                        parsed.mode = Some(u32::from_str_radix(value, 8).map_err(|_| {
765                            AgentdError::Config(format!(
766                                "{env_name} invalid octal tmpfs mode: {value}"
767                            ))
768                        })?);
769                    }
770                    "fstype" | "size" | "mode" => {
771                        return Err(AgentdError::Config(format!(
772                            "{env_name} option '{key}' is not valid for this mount kind"
773                        )));
774                    }
775                    other => {
776                        return Err(AgentdError::Config(format!(
777                            "{env_name} unknown mount option '{other}'"
778                        )));
779                    }
780                }
781            }
782        }
783    }
784
785    Ok(parsed)
786}
787
788/// Parses semicolon-separated directory mount entries.
789fn parse_dir_mounts(val: &str) -> AgentdResult<Vec<DirMountSpec>> {
790    val.split(';')
791        .filter(|e| !e.is_empty())
792        .map(parse_dir_mount_entry)
793        .collect()
794}
795
796/// Parses a single virtiofs directory volume mount entry: `tag:guest_path[:opts]`.
797fn parse_dir_mount_entry(entry: &str) -> AgentdResult<DirMountSpec> {
798    let mut parts = entry.splitn(3, ':');
799    let Some(tag) = parts.next() else {
800        unreachable!("splitn always yields at least one part");
801    };
802    let guest_path = parts.next().ok_or_else(|| {
803        AgentdError::Config(format!(
804            "MSB_DIR_MOUNTS entry must be tag:path[:opts], got: {entry}"
805        ))
806    })?;
807    let options = parse_mount_options(ENV_DIR_MOUNTS, parts.next(), MountOptionSupport::default())?;
808
809    if tag.is_empty() {
810        return Err(AgentdError::Config(
811            "MSB_DIR_MOUNTS entry has empty tag".into(),
812        ));
813    }
814    if guest_path.is_empty() || !guest_path.starts_with('/') {
815        return Err(AgentdError::Config(format!(
816            "MSB_DIR_MOUNTS guest path must be absolute: {guest_path}"
817        )));
818    }
819
820    Ok(DirMountSpec {
821        tag: tag.to_string(),
822        guest_path: guest_path.to_string(),
823        readonly: options.readonly,
824        noexec: options.noexec,
825        nosuid: options.nosuid,
826        nodev: options.nodev,
827    })
828}
829
830/// Parses semicolon-separated file mount entries.
831fn parse_file_mounts(val: &str) -> AgentdResult<Vec<FileMountSpec>> {
832    val.split(';')
833        .filter(|e| !e.is_empty())
834        .map(parse_file_mount_entry)
835        .collect()
836}
837
838/// Parses a single virtiofs file volume mount entry: `tag:filename:guest_path[:opts]`.
839fn parse_file_mount_entry(entry: &str) -> AgentdResult<FileMountSpec> {
840    let mut parts = entry.splitn(4, ':');
841    let Some(tag) = parts.next() else {
842        unreachable!("splitn always yields at least one part");
843    };
844    let filename = parts.next().ok_or_else(|| {
845        AgentdError::Config(format!(
846            "MSB_FILE_MOUNTS entry must be tag:filename:path[:opts], got: {entry}"
847        ))
848    })?;
849    let guest_path = parts.next().ok_or_else(|| {
850        AgentdError::Config(format!(
851            "MSB_FILE_MOUNTS entry must be tag:filename:path[:opts], got: {entry}"
852        ))
853    })?;
854    let options =
855        parse_mount_options(ENV_FILE_MOUNTS, parts.next(), MountOptionSupport::default())?;
856
857    if tag.is_empty() {
858        return Err(AgentdError::Config(
859            "MSB_FILE_MOUNTS entry has empty tag".into(),
860        ));
861    }
862    if filename.is_empty() {
863        return Err(AgentdError::Config(
864            "MSB_FILE_MOUNTS entry has empty filename".into(),
865        ));
866    }
867    if guest_path.is_empty() || !guest_path.starts_with('/') {
868        return Err(AgentdError::Config(format!(
869            "MSB_FILE_MOUNTS guest path must be absolute: {guest_path}"
870        )));
871    }
872
873    Ok(FileMountSpec {
874        tag: tag.to_string(),
875        filename: filename.to_string(),
876        guest_path: guest_path.to_string(),
877        readonly: options.readonly,
878        noexec: options.noexec,
879        nosuid: options.nosuid,
880        nodev: options.nodev,
881    })
882}
883
884/// Parses semicolon-separated disk-image mount entries.
885fn parse_disk_mounts(val: &str) -> AgentdResult<Vec<DiskMountSpec>> {
886    val.split(';')
887        .filter(|e| !e.is_empty())
888        .map(parse_disk_mount_entry)
889        .collect()
890}
891
892/// Parses a single disk-image mount entry: `id:guest_path[:opts]`.
893fn parse_disk_mount_entry(entry: &str) -> AgentdResult<DiskMountSpec> {
894    let mut parts = entry.splitn(3, ':');
895    let Some(id) = parts.next() else {
896        unreachable!("splitn always yields at least one part");
897    };
898    let guest_path = parts.next().ok_or_else(|| {
899        AgentdError::Config(format!(
900            "MSB_DISK_MOUNTS entry must be id:guest_path[:opts], got: {entry}"
901        ))
902    })?;
903    let options = parse_mount_options(
904        ENV_DISK_MOUNTS,
905        parts.next(),
906        MountOptionSupport {
907            fstype: true,
908            ..MountOptionSupport::default()
909        },
910    )?;
911
912    if id.is_empty() {
913        return Err(AgentdError::Config(
914            "MSB_DISK_MOUNTS entry has empty id".into(),
915        ));
916    }
917    if guest_path.is_empty() || !guest_path.starts_with('/') {
918        return Err(AgentdError::Config(format!(
919            "MSB_DISK_MOUNTS guest path must be absolute: {guest_path}"
920        )));
921    }
922
923    Ok(DiskMountSpec {
924        id: id.to_string(),
925        guest_path: guest_path.to_string(),
926        fstype: options.fstype,
927        readonly: options.readonly,
928        noexec: options.noexec,
929        nosuid: options.nosuid,
930        nodev: options.nodev,
931    })
932}
933
934/// Parses semicolon-separated tmpfs mount entries.
935fn parse_tmpfs_mounts(val: &str) -> AgentdResult<Vec<TmpfsSpec>> {
936    val.split(';')
937        .filter(|e| !e.is_empty())
938        .map(parse_tmpfs_entry)
939        .collect()
940}
941
942/// Parses a single tmpfs entry: `path[:opts]`.
943///
944/// Supported options are `size=N`, `mode=N`, `ro`, `rw`, `nosuid`, `nodev`, and `noexec`.
945/// Mode is parsed as octal (e.g. `mode=1777`).
946fn parse_tmpfs_entry(entry: &str) -> AgentdResult<TmpfsSpec> {
947    let (path, opts) = match entry.split_once(':') {
948        Some((path, opts)) => (path, Some(opts)),
949        None => {
950            if entry.contains(',') {
951                return Err(AgentdError::Config(
952                    "MSB_TMPFS options must use path:opts syntax".into(),
953                ));
954            }
955            (entry, None)
956        }
957    };
958
959    if path.is_empty() {
960        return Err(AgentdError::Config("tmpfs entry has empty path".into()));
961    }
962
963    let options = parse_mount_options(
964        ENV_TMPFS,
965        opts,
966        MountOptionSupport {
967            size: true,
968            mode: true,
969            ..MountOptionSupport::default()
970        },
971    )?;
972
973    Ok(TmpfsSpec {
974        path: path.to_string(),
975        size_mib: options.size_mib,
976        mode: options.mode,
977        noexec: options.noexec,
978        nosuid: options.nosuid,
979        nodev: options.nodev,
980        readonly: options.readonly,
981    })
982}
983
984//--------------------------------------------------------------------------------------------------
985// Parse Functions: Rlimits
986//--------------------------------------------------------------------------------------------------
987
988/// Parses `MSB_RLIMITS` value: semicolon-separated `resource=soft[:hard]` entries.
989///
990/// Rejects unknown resource names and duplicate resources at startup so
991/// misspellings and overrides fail loud rather than silently last-winning
992/// during PID 1 init.
993fn parse_rlimits(val: &str) -> AgentdResult<Vec<ExecRlimit>> {
994    let mut seen: Vec<String> = Vec::new();
995    val.split(';')
996        .filter(|entry| !entry.is_empty())
997        .map(|entry| {
998            let rlimit = entry.parse::<ExecRlimit>().map_err(|err| {
999                AgentdError::Config(format!("{ENV_RLIMITS} entry {entry}: {err}"))
1000            })?;
1001            if rlimit::parse_rlimit_resource(&rlimit.resource).is_none() {
1002                return Err(AgentdError::Config(format!(
1003                    "{ENV_RLIMITS} unknown resource: {}",
1004                    rlimit.resource
1005                )));
1006            }
1007            if seen.iter().any(|name| name == &rlimit.resource) {
1008                return Err(AgentdError::Config(format!(
1009                    "{ENV_RLIMITS} duplicate resource: {}",
1010                    rlimit.resource
1011                )));
1012            }
1013            seen.push(rlimit.resource.clone());
1014            Ok(rlimit)
1015        })
1016        .collect()
1017}
1018
1019//--------------------------------------------------------------------------------------------------
1020// Parse Functions: Network
1021//--------------------------------------------------------------------------------------------------
1022
1023/// Parses `MSB_NET` value: `iface=NAME,mac=AA:BB:CC:DD:EE:FF,mtu=N`
1024fn parse_net(val: &str) -> AgentdResult<NetSpec> {
1025    let mut iface = None;
1026    let mut mac = None;
1027    let mut mtu = 1500u16;
1028
1029    for part in val.split(',') {
1030        if let Some(v) = part.strip_prefix("iface=") {
1031            iface = Some(v.to_string());
1032        } else if let Some(v) = part.strip_prefix("mac=") {
1033            mac = Some(parse_mac(v)?);
1034        } else if let Some(v) = part.strip_prefix("mtu=") {
1035            mtu = v
1036                .parse()
1037                .map_err(|_| AgentdError::Config(format!("invalid MTU: {v}")))?;
1038        } else {
1039            return Err(AgentdError::Config(format!(
1040                "unknown MSB_NET option: {part}"
1041            )));
1042        }
1043    }
1044
1045    let iface = iface.ok_or_else(|| AgentdError::Config("MSB_NET missing iface=".into()))?;
1046    let mac = mac.ok_or_else(|| AgentdError::Config("MSB_NET missing mac=".into()))?;
1047
1048    Ok(NetSpec { iface, mac, mtu })
1049}
1050
1051/// Parses `MSB_NET_IPV4` value: `addr=A.B.C.D/N,gw=A.B.C.D[,dns=A.B.C.D]`
1052fn parse_net_ipv4(val: &str) -> AgentdResult<NetIpv4Spec> {
1053    let mut address = None;
1054    let mut prefix_len = None;
1055    let mut gateway = None;
1056    let mut dns = None;
1057
1058    for part in val.split(',') {
1059        if let Some(v) = part.strip_prefix("addr=") {
1060            let (addr, prefix) = parse_cidr_v4(v)?;
1061            address = Some(addr);
1062            prefix_len = Some(prefix);
1063        } else if let Some(v) = part.strip_prefix("gw=") {
1064            gateway = Some(
1065                v.parse::<Ipv4Addr>()
1066                    .map_err(|_| AgentdError::Config(format!("invalid IPv4 gateway: {v}")))?,
1067            );
1068        } else if let Some(v) = part.strip_prefix("dns=") {
1069            dns = Some(
1070                v.parse::<Ipv4Addr>()
1071                    .map_err(|_| AgentdError::Config(format!("invalid IPv4 DNS: {v}")))?,
1072            );
1073        } else {
1074            return Err(AgentdError::Config(format!(
1075                "unknown MSB_NET_IPV4 option: {part}"
1076            )));
1077        }
1078    }
1079
1080    let address =
1081        address.ok_or_else(|| AgentdError::Config("MSB_NET_IPV4 missing addr=".into()))?;
1082    let prefix_len =
1083        prefix_len.ok_or_else(|| AgentdError::Config("MSB_NET_IPV4 missing addr=".into()))?;
1084    let gateway = gateway.ok_or_else(|| AgentdError::Config("MSB_NET_IPV4 missing gw=".into()))?;
1085
1086    Ok(NetIpv4Spec {
1087        address,
1088        prefix_len,
1089        gateway,
1090        dns,
1091    })
1092}
1093
1094/// Parses `MSB_NET_IPV6` value: `addr=ADDR/N,gw=ADDR[,dns=ADDR]`
1095fn parse_net_ipv6(val: &str) -> AgentdResult<NetIpv6Spec> {
1096    let mut address = None;
1097    let mut prefix_len = None;
1098    let mut gateway = None;
1099    let mut dns = None;
1100
1101    for part in val.split(',') {
1102        if let Some(v) = part.strip_prefix("addr=") {
1103            let (addr, prefix) = parse_cidr_v6(v)?;
1104            address = Some(addr);
1105            prefix_len = Some(prefix);
1106        } else if let Some(v) = part.strip_prefix("gw=") {
1107            gateway = Some(
1108                v.parse::<Ipv6Addr>()
1109                    .map_err(|_| AgentdError::Config(format!("invalid IPv6 gateway: {v}")))?,
1110            );
1111        } else if let Some(v) = part.strip_prefix("dns=") {
1112            dns = Some(
1113                v.parse::<Ipv6Addr>()
1114                    .map_err(|_| AgentdError::Config(format!("invalid IPv6 DNS: {v}")))?,
1115            );
1116        } else {
1117            return Err(AgentdError::Config(format!(
1118                "unknown MSB_NET_IPV6 option: {part}"
1119            )));
1120        }
1121    }
1122
1123    let address =
1124        address.ok_or_else(|| AgentdError::Config("MSB_NET_IPV6 missing addr=".into()))?;
1125    let prefix_len =
1126        prefix_len.ok_or_else(|| AgentdError::Config("MSB_NET_IPV6 missing addr=".into()))?;
1127    let gateway = gateway.ok_or_else(|| AgentdError::Config("MSB_NET_IPV6 missing gw=".into()))?;
1128
1129    Ok(NetIpv6Spec {
1130        address,
1131        prefix_len,
1132        gateway,
1133        dns,
1134    })
1135}
1136
1137/// Parses a MAC address string like `02:5a:7b:13:01:02`.
1138fn parse_mac(s: &str) -> AgentdResult<[u8; 6]> {
1139    let mut mac = [0u8; 6];
1140    let mut len = 0usize;
1141    for (i, part) in s.split(':').enumerate() {
1142        if i >= 6 {
1143            return Err(AgentdError::Config(format!("invalid MAC address: {s}")));
1144        }
1145        mac[i] = u8::from_str_radix(part, 16)
1146            .map_err(|_| AgentdError::Config(format!("invalid MAC octet: {part}")))?;
1147        len = i + 1;
1148    }
1149    if len != 6 {
1150        return Err(AgentdError::Config(format!("invalid MAC address: {s}")));
1151    }
1152    Ok(mac)
1153}
1154
1155/// Parses an IPv4 CIDR like `100.96.1.2/30`.
1156fn parse_cidr_v4(s: &str) -> AgentdResult<(Ipv4Addr, u8)> {
1157    let (addr_str, prefix_str) = s
1158        .split_once('/')
1159        .ok_or_else(|| AgentdError::Config(format!("invalid IPv4 CIDR (missing /): {s}")))?;
1160    let addr = addr_str
1161        .parse::<Ipv4Addr>()
1162        .map_err(|_| AgentdError::Config(format!("invalid IPv4 address: {addr_str}")))?;
1163    let prefix = prefix_str
1164        .parse::<u8>()
1165        .map_err(|_| AgentdError::Config(format!("invalid IPv4 prefix length: {prefix_str}")))?;
1166    if prefix > 32 {
1167        return Err(AgentdError::Config(format!(
1168            "IPv4 prefix length out of range (0-32): {prefix}"
1169        )));
1170    }
1171    Ok((addr, prefix))
1172}
1173
1174/// Parses an IPv6 CIDR like `fd42:6d73:62:2a::2/64`.
1175fn parse_cidr_v6(s: &str) -> AgentdResult<(Ipv6Addr, u8)> {
1176    let (addr_str, prefix_str) = s
1177        .rsplit_once('/')
1178        .ok_or_else(|| AgentdError::Config(format!("invalid IPv6 CIDR (missing /): {s}")))?;
1179    let addr = addr_str
1180        .parse::<Ipv6Addr>()
1181        .map_err(|_| AgentdError::Config(format!("invalid IPv6 address: {addr_str}")))?;
1182    let prefix = prefix_str
1183        .parse::<u8>()
1184        .map_err(|_| AgentdError::Config(format!("invalid IPv6 prefix length: {prefix_str}")))?;
1185    if prefix > 128 {
1186        return Err(AgentdError::Config(format!(
1187            "IPv6 prefix length out of range (0-128): {prefix}"
1188        )));
1189    }
1190    Ok((addr, prefix))
1191}
1192
1193//--------------------------------------------------------------------------------------------------
1194// Parse Functions: Handoff Init
1195//--------------------------------------------------------------------------------------------------
1196
1197/// Reads `MSB_HANDOFF_INIT[_ARGS|_ENV]` and assembles a [`HandoffInit`].
1198///
1199/// Returns `Ok(None)` when `MSB_HANDOFF_INIT` is unset/empty (the
1200/// default no-handoff path). Returns `Err` when the cmd path is
1201/// not absolute, or when `MSB_HANDOFF_INIT_ARGS` / `MSB_HANDOFF_INIT_ENV`
1202/// contain invalid base64url JSON. The args/env payloads are the one
1203/// structured exception to the delimiter-based `MSB_*` boot envs because
1204/// they carry exact process argv/env strings.
1205fn parse_handoff_init() -> AgentdResult<Option<HandoffInit>> {
1206    let Some(cmd_str) = read_env_raw(ENV_HANDOFF_INIT) else {
1207        return Ok(None);
1208    };
1209    if cmd_str.trim().is_empty() {
1210        return Ok(None);
1211    }
1212
1213    let cmd = PathBuf::from(&cmd_str);
1214    // The sentinel `auto` is resolved lazily in `handoff::do_handoff`
1215    // by probing `HANDOFF_INIT_AUTO_CANDIDATES`; everything else must
1216    // be an absolute path.
1217    if cmd_str != HANDOFF_INIT_AUTO && !cmd.is_absolute() {
1218        return Err(AgentdError::Config(format!(
1219            "{ENV_HANDOFF_INIT} must be an absolute path or `auto`, got: {cmd_str}"
1220        )));
1221    }
1222
1223    let argv = match read_env_raw(ENV_HANDOFF_INIT_ARGS) {
1224        Some(val) if !val.is_empty() => {
1225            decode_handoff_json::<Vec<String>>(ENV_HANDOFF_INIT_ARGS, &val)?
1226                .into_iter()
1227                .enumerate()
1228                .map(|(index, arg)| parse_handoff_arg(index, arg))
1229                .collect::<AgentdResult<Vec<_>>>()?
1230        }
1231        _ => Vec::new(),
1232    };
1233
1234    let cwd = match read_env_raw(ENV_HANDOFF_INIT_CWD) {
1235        Some(val) if !val.is_empty() => {
1236            let cwd = PathBuf::from(&val);
1237            if !cwd.is_absolute() {
1238                return Err(AgentdError::Config(format!(
1239                    "{ENV_HANDOFF_INIT_CWD} must be an absolute path, got: {val}"
1240                )));
1241            }
1242            Some(cwd)
1243        }
1244        _ => None,
1245    };
1246
1247    let env = match read_env_raw(ENV_HANDOFF_INIT_ENV) {
1248        Some(val) if !val.is_empty() => {
1249            let entries = decode_handoff_json::<Vec<(String, String)>>(ENV_HANDOFF_INIT_ENV, &val)?;
1250            entries
1251                .into_iter()
1252                .map(|(key, value)| parse_handoff_env_pair(key, value))
1253                .collect::<AgentdResult<Vec<_>>>()?
1254        }
1255        _ => Vec::new(),
1256    };
1257
1258    Ok(Some(HandoffInit {
1259        cmd,
1260        argv,
1261        cwd,
1262        env,
1263    }))
1264}
1265
1266fn decode_handoff_json<T: DeserializeOwned>(env_name: &str, value: &str) -> AgentdResult<T> {
1267    let json = URL_SAFE_NO_PAD.decode(value).map_err(|e| {
1268        AgentdError::Config(format!("{env_name} must be base64url-no-padding JSON: {e}"))
1269    })?;
1270    serde_json::from_slice(&json)
1271        .map_err(|e| AgentdError::Config(format!("{env_name} contains invalid JSON: {e}")))
1272}
1273
1274fn parse_handoff_arg(index: usize, arg: String) -> AgentdResult<OsString> {
1275    if arg.contains('\0') {
1276        return Err(AgentdError::Config(format!(
1277            "{ENV_HANDOFF_INIT_ARGS} entry #{index} must not contain NUL"
1278        )));
1279    }
1280    Ok(OsString::from(arg))
1281}
1282
1283fn parse_handoff_env_pair(key: String, value: String) -> AgentdResult<(OsString, OsString)> {
1284    if key.is_empty() {
1285        return Err(AgentdError::Config(format!(
1286            "{ENV_HANDOFF_INIT_ENV} entry has empty key"
1287        )));
1288    }
1289    if key.contains('=') {
1290        return Err(AgentdError::Config(format!(
1291            "{ENV_HANDOFF_INIT_ENV} key {key:?} must not contain '='"
1292        )));
1293    }
1294    if key.contains('\0') {
1295        return Err(AgentdError::Config(format!(
1296            "{ENV_HANDOFF_INIT_ENV} key {key:?} must not contain NUL"
1297        )));
1298    }
1299    if value.contains('\0') {
1300        return Err(AgentdError::Config(format!(
1301            "{ENV_HANDOFF_INIT_ENV} value for {key:?} must not contain NUL"
1302        )));
1303    }
1304    Ok((OsString::from(key), OsString::from(value)))
1305}
1306
1307//--------------------------------------------------------------------------------------------------
1308// Helper Functions
1309//--------------------------------------------------------------------------------------------------
1310
1311fn validate_guest_bootstrap(bootstrap: &GuestBootstrap) -> AgentdResult<()> {
1312    if let Some(root) = &bootstrap.block_root {
1313        match root {
1314            BootstrapBlockRoot::DiskImage { device, fstype } => {
1315                validate_absolute_guest_path("bootstrap block-root device", device)?;
1316                if let Some(fstype) = fstype {
1317                    validate_nonempty_bootstrap_string("bootstrap block-root fstype", fstype)?;
1318                }
1319            }
1320            BootstrapBlockRoot::OciErofs { lower, upper } => {
1321                validate_absolute_guest_path("bootstrap EROFS lower device", lower)?;
1322                match upper {
1323                    BootstrapBlockRootUpper::Device { device, fstype } => {
1324                        validate_absolute_guest_path("bootstrap upper device", device)?;
1325                        validate_nonempty_bootstrap_string("bootstrap upper fstype", fstype)?;
1326                    }
1327                    BootstrapBlockRootUpper::Tmpfs { .. } => {}
1328                }
1329            }
1330        }
1331    }
1332
1333    for mount in &bootstrap.dir_mounts {
1334        validate_nonempty_bootstrap_string("bootstrap directory mount tag", &mount.tag)?;
1335        validate_absolute_guest_path("bootstrap directory mount path", &mount.guest_path)?;
1336    }
1337    for mount in &bootstrap.file_mounts {
1338        validate_nonempty_bootstrap_string("bootstrap file mount tag", &mount.tag)?;
1339        validate_nonempty_bootstrap_string("bootstrap file mount filename", &mount.filename)?;
1340        validate_absolute_guest_path("bootstrap file mount path", &mount.guest_path)?;
1341    }
1342    for mount in &bootstrap.disk_mounts {
1343        validate_nonempty_bootstrap_string("bootstrap disk mount id", &mount.id)?;
1344        validate_absolute_guest_path("bootstrap disk mount path", &mount.guest_path)?;
1345        if let Some(fstype) = &mount.fstype {
1346            validate_nonempty_bootstrap_string("bootstrap disk mount fstype", fstype)?;
1347        }
1348    }
1349    for mount in &bootstrap.tmpfs_mounts {
1350        validate_absolute_guest_path("bootstrap tmpfs path", &mount.path)?;
1351    }
1352
1353    if let Some(hostname) = &bootstrap.hostname {
1354        validate_nonempty_bootstrap_string("bootstrap hostname", hostname)?;
1355    }
1356    if let Some(host_alias) = &bootstrap.host_alias {
1357        validate_nonempty_bootstrap_string("bootstrap host alias", host_alias)?;
1358    }
1359    if let Some(network) = &bootstrap.network {
1360        validate_nonempty_bootstrap_string("bootstrap network interface", &network.interface)?;
1361        if let Some(ipv4) = network.ipv4
1362            && ipv4.prefix_len > 32
1363        {
1364            return Err(AgentdError::Config(format!(
1365                "bootstrap IPv4 prefix length out of range: {}",
1366                ipv4.prefix_len
1367            )));
1368        }
1369        if let Some(ipv6) = network.ipv6
1370            && ipv6.prefix_len > 128
1371        {
1372            return Err(AgentdError::Config(format!(
1373                "bootstrap IPv6 prefix length out of range: {}",
1374                ipv6.prefix_len
1375            )));
1376        }
1377    }
1378
1379    let mut seen_rlimits = Vec::new();
1380    for rlimit in &bootstrap.rlimits {
1381        if rlimit::parse_rlimit_resource(&rlimit.resource).is_none() {
1382            return Err(AgentdError::Config(format!(
1383                "bootstrap rlimits contains unknown resource: {}",
1384                rlimit.resource
1385            )));
1386        }
1387        if rlimit.soft > rlimit.hard {
1388            return Err(AgentdError::Config(format!(
1389                "bootstrap rlimit {} has soft limit above hard limit",
1390                rlimit.resource
1391            )));
1392        }
1393        if seen_rlimits.iter().any(|name| name == &rlimit.resource) {
1394            return Err(AgentdError::Config(format!(
1395                "bootstrap rlimits contains duplicate resource: {}",
1396                rlimit.resource
1397            )));
1398        }
1399        seen_rlimits.push(rlimit.resource.clone());
1400    }
1401
1402    if let Some(user) = &bootstrap.user {
1403        validate_nonempty_bootstrap_string("bootstrap user", user)?;
1404    }
1405    if let Some(cwd) = &bootstrap.default_cwd {
1406        validate_nonempty_bootstrap_string("bootstrap default cwd", cwd)?;
1407    }
1408    for variable in &bootstrap.default_env {
1409        validate_bootstrap_env("bootstrap default env", variable)?;
1410    }
1411    if let Some(handoff) = &bootstrap.handoff_init {
1412        validate_bootstrap_handoff(handoff)?;
1413    }
1414
1415    Ok(())
1416}
1417
1418fn validate_bootstrap_handoff(handoff: &BootstrapHandoffInit) -> AgentdResult<()> {
1419    if handoff.cmd.contains('\0') {
1420        return Err(AgentdError::Config(
1421            "bootstrap handoff command must not contain NUL".into(),
1422        ));
1423    }
1424    if handoff.cmd != HANDOFF_INIT_AUTO && !handoff.cmd.starts_with('/') {
1425        return Err(AgentdError::Config(format!(
1426            "bootstrap handoff command must be absolute or `auto`: {}",
1427            handoff.cmd
1428        )));
1429    }
1430    for (index, arg) in handoff.args.iter().enumerate() {
1431        if arg.contains('\0') {
1432            return Err(AgentdError::Config(format!(
1433                "bootstrap handoff argument #{index} must not contain NUL"
1434            )));
1435        }
1436    }
1437    if let Some(cwd) = &handoff.cwd {
1438        validate_absolute_guest_path("bootstrap handoff cwd", cwd)?;
1439    }
1440    for variable in &handoff.env {
1441        validate_bootstrap_env("bootstrap handoff env", variable)?;
1442    }
1443    Ok(())
1444}
1445
1446fn validate_bootstrap_env(label: &str, variable: &BootstrapEnvVar) -> AgentdResult<()> {
1447    if variable.key.is_empty() {
1448        return Err(AgentdError::Config(format!(
1449            "{label} contains an empty key"
1450        )));
1451    }
1452    if variable.key.contains('=') || variable.key.contains('\0') {
1453        return Err(AgentdError::Config(format!(
1454            "{label} key {:?} must not contain '=' or NUL",
1455            variable.key
1456        )));
1457    }
1458    if variable.value.contains('\0') {
1459        return Err(AgentdError::Config(format!(
1460            "{label} value for {:?} must not contain NUL",
1461            variable.key
1462        )));
1463    }
1464    Ok(())
1465}
1466
1467fn validate_absolute_guest_path(label: &str, value: &str) -> AgentdResult<()> {
1468    validate_nonempty_bootstrap_string(label, value)?;
1469    if !value.starts_with('/') {
1470        return Err(AgentdError::Config(format!(
1471            "{label} must be an absolute Linux path: {value}"
1472        )));
1473    }
1474    Ok(())
1475}
1476
1477fn validate_nonempty_bootstrap_string(label: &str, value: &str) -> AgentdResult<()> {
1478    if value.is_empty() || value.contains('\0') {
1479        return Err(AgentdError::Config(format!(
1480            "{label} must be non-empty and must not contain NUL"
1481        )));
1482    }
1483    Ok(())
1484}
1485
1486fn convert_bootstrap_handoff(handoff: BootstrapHandoffInit) -> AgentdResult<HandoffInit> {
1487    Ok(HandoffInit {
1488        cmd: PathBuf::from(handoff.cmd),
1489        argv: handoff.args.into_iter().map(OsString::from).collect(),
1490        cwd: handoff.cwd.map(PathBuf::from),
1491        env: handoff
1492            .env
1493            .into_iter()
1494            .map(|variable| (OsString::from(variable.key), OsString::from(variable.value)))
1495            .collect(),
1496    })
1497}
1498
1499/// Reads a single environment variable, returning `None` for missing or empty values.
1500fn read_env(key: &str) -> Option<String> {
1501    env::var(key)
1502        .ok()
1503        .map(|v| v.trim().to_string())
1504        .filter(|v| !v.is_empty())
1505}
1506
1507/// Reads a single environment variable without trimming whitespace.
1508///
1509/// Used for the handoff-init vars where argv content is sensitive to
1510/// byte-exact preservation.
1511fn read_env_raw(key: &str) -> Option<String> {
1512    env::var(key).ok().filter(|v| !v.is_empty())
1513}
1514
1515//--------------------------------------------------------------------------------------------------
1516// Tests
1517//--------------------------------------------------------------------------------------------------
1518
1519#[cfg(test)]
1520mod tests {
1521    use super::*;
1522
1523    // ---------------------------------------------------------------------------------------------
1524    // Typed Bootstrap
1525    // ---------------------------------------------------------------------------------------------
1526
1527    #[test]
1528    fn test_scripts_path_uses_stable_default_and_avoids_duplicates() {
1529        assert_eq!(
1530            scripts_path(None),
1531            "/.msb/scripts:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
1532        );
1533        assert_eq!(
1534            scripts_path(Some("/custom/bin:/bin")),
1535            "/.msb/scripts:/custom/bin:/bin"
1536        );
1537        assert_eq!(
1538            scripts_path(Some("/bin:/.msb/scripts:/usr/bin")),
1539            "/bin:/.msb/scripts:/usr/bin"
1540        );
1541    }
1542
1543    #[test]
1544    fn test_bootstrap_preserves_structured_environment_and_handoff_values() {
1545        let bootstrap = GuestBootstrap {
1546            hostname: Some("quoted-host".to_string()),
1547            network: Some(microsandbox_protocol::bootstrap::BootstrapNetwork {
1548                interface: "eth0".to_string(),
1549                mac: [0x02, 0x5a, 0x7b, 0x13, 0x01, 0x02],
1550                mtu: 1500,
1551                ipv4: None,
1552                ipv6: None,
1553            }),
1554            rlimits: vec![ExecRlimit {
1555                resource: "nofile".to_string(),
1556                soft: 4096,
1557                hard: 65_535,
1558            }],
1559            user: Some("1000:1000".to_string()),
1560            default_cwd: Some("/workspace".to_string()),
1561            default_env: vec![BootstrapEnvVar {
1562                key: "APP_CONFIG".to_string(),
1563                value: "{\"message\":\"hello\",\"unicode\":\"lambda λ\"}\nnext\tline=a=b"
1564                    .to_string(),
1565            }],
1566            security_profile: BootstrapSecurityProfile::Restricted,
1567            handoff_init: Some(BootstrapHandoffInit {
1568                cmd: "/sbin/init".to_string(),
1569                args: vec!["--label=\"hello world\"".to_string()],
1570                cwd: Some("/workspace".to_string()),
1571                env: vec![BootstrapEnvVar {
1572                    key: "INIT_CONFIG".to_string(),
1573                    value: "{\"enabled\":true}".to_string(),
1574                }],
1575            }),
1576            ..GuestBootstrap::default()
1577        };
1578
1579        let (boot, config) = BootParams::from_bootstrap(bootstrap).unwrap();
1580
1581        assert_eq!(boot.hostname.as_deref(), Some("quoted-host"));
1582        assert_eq!(boot.rlimits[0].hard, 65_535);
1583        assert!(matches!(boot.security_profile, SecurityProfile::Restricted));
1584        assert_eq!(config.user.as_deref(), Some("1000:1000"));
1585        assert_eq!(config.default_cwd.as_deref(), Some("/workspace"));
1586        assert_eq!(
1587            config.default_env[0].value,
1588            "{\"message\":\"hello\",\"unicode\":\"lambda λ\"}\nnext\tline=a=b"
1589        );
1590
1591        let handoff = boot.handoff_init.expect("handoff bootstrap");
1592        assert_eq!(
1593            handoff.argv,
1594            vec![OsString::from("--label=\"hello world\"")]
1595        );
1596        assert_eq!(
1597            handoff.env,
1598            vec![(
1599                OsString::from("INIT_CONFIG"),
1600                OsString::from("{\"enabled\":true}")
1601            )]
1602        );
1603    }
1604
1605    #[test]
1606    fn test_bootstrap_accepts_relative_default_cwd() {
1607        let bootstrap = GuestBootstrap {
1608            default_cwd: Some("workspace".to_string()),
1609            ..GuestBootstrap::default()
1610        };
1611
1612        let (_, config) = BootParams::from_bootstrap(bootstrap).unwrap();
1613
1614        assert_eq!(config.default_cwd.as_deref(), Some("workspace"));
1615    }
1616
1617    #[test]
1618    fn test_bootstrap_rejects_duplicate_rlimits() {
1619        let rlimit = ExecRlimit {
1620            resource: "nofile".to_string(),
1621            soft: 1024,
1622            hard: 1024,
1623        };
1624        let bootstrap = GuestBootstrap {
1625            rlimits: vec![rlimit.clone(), rlimit],
1626            ..GuestBootstrap::default()
1627        };
1628
1629        let error = BootParams::from_bootstrap(bootstrap).unwrap_err();
1630        assert!(error.to_string().contains("duplicate resource"));
1631    }
1632
1633    #[test]
1634    fn test_bootstrap_rejects_invalid_environment_key() {
1635        let bootstrap = GuestBootstrap {
1636            default_env: vec![BootstrapEnvVar {
1637                key: "BAD=KEY".to_string(),
1638                value: "value".to_string(),
1639            }],
1640            ..GuestBootstrap::default()
1641        };
1642
1643        let error = BootParams::from_bootstrap(bootstrap).unwrap_err();
1644        assert!(error.to_string().contains("must not contain '=' or NUL"));
1645    }
1646
1647    // ── Block Root ────────────────────────────────────────────────────
1648
1649    #[test]
1650    fn test_parse_block_root_disk_image() {
1651        let spec = parse_block_root("kind=disk-image,device=/dev/vda,fstype=ext4").unwrap();
1652        let BlockRootSpec::DiskImage { device, fstype } = spec else {
1653            panic!("expected DiskImage");
1654        };
1655        assert_eq!(device, "/dev/vda");
1656        assert_eq!(fstype.as_deref(), Some("ext4"));
1657    }
1658
1659    #[test]
1660    fn test_parse_block_root_disk_image_no_fstype() {
1661        let spec = parse_block_root("kind=disk-image,device=/dev/vda").unwrap();
1662        let BlockRootSpec::DiskImage { device, fstype } = spec else {
1663            panic!("expected DiskImage");
1664        };
1665        assert_eq!(device, "/dev/vda");
1666        assert_eq!(fstype, None);
1667    }
1668
1669    #[test]
1670    fn test_parse_block_root_oci_erofs() {
1671        let spec =
1672            parse_block_root("kind=oci-erofs,lower=/dev/vda,upper=/dev/vdb,upper_fstype=ext4")
1673                .unwrap();
1674        let BlockRootSpec::OciErofs { lower, upper } = spec else {
1675            panic!("expected OciErofs");
1676        };
1677        assert_eq!(lower, "/dev/vda");
1678        let BlockRootUpper::Device { device, fstype } = upper else {
1679            panic!("expected Device upper");
1680        };
1681        assert_eq!(device, "/dev/vdb");
1682        assert_eq!(fstype, "ext4");
1683    }
1684
1685    #[test]
1686    fn test_parse_block_root_oci_erofs_tmpfs_upper() {
1687        let spec =
1688            parse_block_root("kind=oci-erofs,lower=/dev/vda,upper=tmpfs,upper_size_mib=2048")
1689                .unwrap();
1690        let BlockRootSpec::OciErofs { lower, upper } = spec else {
1691            panic!("expected OciErofs");
1692        };
1693        assert_eq!(lower, "/dev/vda");
1694        let BlockRootUpper::Tmpfs { size_mib } = upper else {
1695            panic!("expected Tmpfs upper");
1696        };
1697        assert_eq!(size_mib, Some(2048));
1698    }
1699
1700    #[test]
1701    fn test_parse_block_root_oci_erofs_tmpfs_upper_no_size() {
1702        let spec = parse_block_root("kind=oci-erofs,lower=/dev/vda,upper=tmpfs").unwrap();
1703        let BlockRootSpec::OciErofs {
1704            upper: BlockRootUpper::Tmpfs { size_mib: None },
1705            ..
1706        } = spec
1707        else {
1708            panic!("expected Tmpfs upper without size");
1709        };
1710    }
1711
1712    #[test]
1713    fn test_parse_block_root_oci_erofs_tmpfs_upper_rejects_fstype() {
1714        let err = parse_block_root("kind=oci-erofs,lower=/dev/vda,upper=tmpfs,upper_fstype=ext4")
1715            .unwrap_err();
1716        assert!(err.to_string().contains("not valid with upper=tmpfs"));
1717    }
1718
1719    #[test]
1720    fn test_parse_block_root_oci_erofs_tmpfs_upper_invalid_size_errors() {
1721        let err = parse_block_root("kind=oci-erofs,lower=/dev/vda,upper=tmpfs,upper_size_mib=big")
1722            .unwrap_err();
1723        assert!(err.to_string().contains("invalid upper_size_mib"));
1724    }
1725
1726    #[test]
1727    fn test_parse_block_root_unknown_kind_errors() {
1728        let err = parse_block_root("kind=bogus,device=/dev/vda").unwrap_err();
1729        assert!(err.to_string().contains("unknown kind"));
1730    }
1731
1732    #[test]
1733    fn test_parse_block_root_missing_kind_errors() {
1734        let err = parse_block_root("/dev/vda").unwrap_err();
1735        assert!(err.to_string().contains("missing 'kind' key"));
1736    }
1737
1738    #[test]
1739    fn test_parse_block_root_disk_image_missing_device_errors() {
1740        let err = parse_block_root("kind=disk-image").unwrap_err();
1741        assert!(err.to_string().contains("missing 'device'"));
1742    }
1743
1744    #[test]
1745    fn test_parse_block_root_oci_erofs_missing_upper_errors() {
1746        let err = parse_block_root("kind=oci-erofs,lower=/dev/vda,upper_fstype=ext4").unwrap_err();
1747        assert!(err.to_string().contains("missing 'upper'"));
1748    }
1749
1750    #[test]
1751    fn test_parse_block_root_duplicate_key_errors() {
1752        let err = parse_block_root("kind=disk-image,device=/dev/vda,device=/dev/vdb").unwrap_err();
1753        assert!(err.to_string().contains("duplicate key 'device'"));
1754    }
1755
1756    // ── File Mounts ────────────────────────────────────────────────────
1757
1758    #[test]
1759    fn test_parse_file_mount_entry_basic() {
1760        let spec = parse_file_mount_entry("fm_config:app.conf:/etc/app.conf").unwrap();
1761        assert_eq!(spec.tag, "fm_config");
1762        assert_eq!(spec.filename, "app.conf");
1763        assert_eq!(spec.guest_path, "/etc/app.conf");
1764        assert!(!spec.readonly);
1765        assert!(!spec.noexec);
1766    }
1767
1768    #[test]
1769    fn test_parse_file_mount_entry_readonly() {
1770        let spec = parse_file_mount_entry("fm_config:app.conf:/etc/app.conf:ro,noexec").unwrap();
1771        assert!(spec.readonly);
1772        assert!(spec.noexec);
1773    }
1774
1775    #[test]
1776    fn test_parse_file_mount_entry_too_few_parts() {
1777        assert!(parse_file_mount_entry("fm_config:/etc/app.conf").is_err());
1778    }
1779
1780    #[test]
1781    fn test_parse_file_mount_entry_empty_filename() {
1782        assert!(parse_file_mount_entry("fm_config::/etc/app.conf").is_err());
1783    }
1784
1785    #[test]
1786    fn test_parse_file_mount_entry_relative_path() {
1787        assert!(parse_file_mount_entry("fm_config:app.conf:relative/path").is_err());
1788    }
1789
1790    #[test]
1791    fn test_parse_file_mount_entry_too_many_parts() {
1792        assert!(parse_file_mount_entry("fm_config:app.conf:/etc/app.conf:ro:extra").is_err());
1793    }
1794
1795    #[test]
1796    fn test_parse_file_mount_entry_unknown_flag() {
1797        assert!(parse_file_mount_entry("fm_config:app.conf:/etc/app.conf:exec").is_err());
1798    }
1799
1800    #[test]
1801    fn test_parse_file_mount_entry_empty_tag() {
1802        assert!(parse_file_mount_entry(":app.conf:/etc/app.conf").is_err());
1803    }
1804
1805    // ── Tmpfs ─────────────────────────────────────────────────────────
1806
1807    #[test]
1808    fn test_parse_path_only() {
1809        let spec = parse_tmpfs_entry("/tmp").unwrap();
1810        assert_eq!(spec.path, "/tmp");
1811        assert_eq!(spec.size_mib, None);
1812        assert_eq!(spec.mode, None);
1813        assert!(!spec.noexec);
1814    }
1815
1816    #[test]
1817    fn test_parse_with_size() {
1818        let spec = parse_tmpfs_entry("/tmp:size=256").unwrap();
1819        assert_eq!(spec.path, "/tmp");
1820        assert_eq!(spec.size_mib, Some(256));
1821    }
1822
1823    #[test]
1824    fn test_parse_with_noexec() {
1825        let spec = parse_tmpfs_entry("/tmp:noexec").unwrap();
1826        assert_eq!(spec.path, "/tmp");
1827        assert!(spec.noexec);
1828    }
1829
1830    // ── Disk Mounts ───────────────────────────────────────────────────
1831
1832    #[test]
1833    fn test_parse_disk_mount_entry_basic() {
1834        let spec = parse_disk_mount_entry("data_abc:/data:fstype=ext4").unwrap();
1835        assert_eq!(spec.id, "data_abc");
1836        assert_eq!(spec.guest_path, "/data");
1837        assert_eq!(spec.fstype.as_deref(), Some("ext4"));
1838        assert!(!spec.readonly);
1839        assert!(!spec.noexec);
1840    }
1841
1842    #[test]
1843    fn test_parse_disk_mount_entry_readonly() {
1844        let spec = parse_disk_mount_entry("seed_7f:/seed:ro,noexec,fstype=ext4").unwrap();
1845        assert!(spec.readonly);
1846        assert!(spec.noexec);
1847        assert_eq!(spec.fstype.as_deref(), Some("ext4"));
1848    }
1849
1850    #[test]
1851    fn test_parse_disk_mount_entry_no_fstype_means_autodetect() {
1852        let spec = parse_disk_mount_entry("probe_1:/data:ro").unwrap();
1853        assert!(spec.fstype.is_none());
1854        assert!(spec.readonly);
1855    }
1856
1857    #[test]
1858    fn test_parse_disk_mount_entry_autodetect_no_ro() {
1859        let spec = parse_disk_mount_entry("probe_1:/data").unwrap();
1860        assert!(spec.fstype.is_none());
1861        assert!(!spec.readonly);
1862    }
1863
1864    #[test]
1865    fn test_parse_disk_mount_entry_rejects_unknown_flag() {
1866        let err = parse_disk_mount_entry("id:/data:exec").unwrap_err();
1867        assert!(err.to_string().contains("unsupported mount option"));
1868    }
1869
1870    #[test]
1871    fn test_parse_disk_mount_entry_rejects_relative_path() {
1872        assert!(parse_disk_mount_entry("id:relative").is_err());
1873    }
1874
1875    #[test]
1876    fn test_parse_disk_mount_entry_rejects_empty_id() {
1877        assert!(parse_disk_mount_entry(":/data:fstype=ext4").is_err());
1878    }
1879
1880    #[test]
1881    fn test_parse_disk_mount_entry_rejects_too_many_parts() {
1882        assert!(parse_disk_mount_entry("id:/data:fstype=ext4:extra").is_err());
1883    }
1884
1885    #[test]
1886    fn test_parse_disk_mounts_multiple_entries() {
1887        let specs =
1888            parse_disk_mounts("data_1:/data:fstype=ext4;seed_2:/seed:ro;probe_3:/p").unwrap();
1889        assert_eq!(specs.len(), 3);
1890        assert_eq!(specs[0].guest_path, "/data");
1891        assert!(specs[1].readonly);
1892        assert!(specs[2].fstype.is_none());
1893    }
1894
1895    #[test]
1896    fn test_parse_with_ro() {
1897        let spec = parse_tmpfs_entry("/seed:size=64,ro").unwrap();
1898        assert_eq!(spec.path, "/seed");
1899        assert_eq!(spec.size_mib, Some(64));
1900        assert!(spec.readonly);
1901        assert!(!spec.noexec);
1902    }
1903
1904    #[test]
1905    fn test_parse_ro_defaults_to_false_when_absent() {
1906        let spec = parse_tmpfs_entry("/tmp:size=256").unwrap();
1907        assert!(!spec.readonly);
1908    }
1909
1910    #[test]
1911    fn test_parse_with_octal_mode() {
1912        let spec = parse_tmpfs_entry("/tmp:mode=1777").unwrap();
1913        assert_eq!(spec.mode, Some(0o1777));
1914
1915        let spec = parse_tmpfs_entry("/data:mode=755").unwrap();
1916        assert_eq!(spec.mode, Some(0o755));
1917    }
1918
1919    #[test]
1920    fn test_parse_multi_options() {
1921        let spec = parse_tmpfs_entry("/tmp:size=256,mode=1777,noexec").unwrap();
1922        assert_eq!(spec.path, "/tmp");
1923        assert_eq!(spec.size_mib, Some(256));
1924        assert_eq!(spec.mode, Some(0o1777));
1925        assert!(spec.noexec);
1926    }
1927
1928    #[test]
1929    fn test_parse_unknown_option_errors() {
1930        let err = parse_tmpfs_entry("/tmp:bogus=42").unwrap_err();
1931        assert!(err.to_string().contains("unknown mount option"));
1932    }
1933
1934    #[test]
1935    fn test_parse_invalid_size_errors() {
1936        let err = parse_tmpfs_entry("/tmp:size=abc").unwrap_err();
1937        assert!(err.to_string().contains("invalid tmpfs size"));
1938    }
1939
1940    #[test]
1941    fn test_parse_invalid_mode_errors() {
1942        let err = parse_tmpfs_entry("/tmp:mode=zzz").unwrap_err();
1943        assert!(err.to_string().contains("invalid octal tmpfs mode"));
1944    }
1945
1946    #[test]
1947    fn test_parse_empty_path_errors() {
1948        let err = parse_tmpfs_entry(":size=256").unwrap_err();
1949        assert!(err.to_string().contains("empty path"));
1950    }
1951
1952    // ── Network ───────────────────────────────────────────────────────
1953
1954    #[test]
1955    fn test_parse_net_full() {
1956        let spec = parse_net("iface=eth0,mac=02:5a:7b:13:01:02,mtu=1500").unwrap();
1957        assert_eq!(spec.iface, "eth0");
1958        assert_eq!(spec.mac, [0x02, 0x5a, 0x7b, 0x13, 0x01, 0x02]);
1959        assert_eq!(spec.mtu, 1500);
1960    }
1961
1962    #[test]
1963    fn test_parse_net_default_mtu() {
1964        let spec = parse_net("iface=eth0,mac=02:00:00:00:00:01").unwrap();
1965        assert_eq!(spec.mtu, 1500);
1966    }
1967
1968    #[test]
1969    fn test_parse_net_missing_iface() {
1970        assert!(parse_net("mac=02:00:00:00:00:01").is_err());
1971    }
1972
1973    #[test]
1974    fn test_parse_net_missing_mac() {
1975        assert!(parse_net("iface=eth0").is_err());
1976    }
1977
1978    #[test]
1979    fn test_parse_net_unknown_option() {
1980        assert!(parse_net("iface=eth0,mac=02:00:00:00:00:01,bogus=42").is_err());
1981    }
1982
1983    #[test]
1984    fn test_parse_net_ipv4() {
1985        let spec = parse_net_ipv4("addr=100.96.1.2/30,gw=100.96.1.1,dns=100.96.1.1").unwrap();
1986        assert_eq!(spec.address, Ipv4Addr::new(100, 96, 1, 2));
1987        assert_eq!(spec.prefix_len, 30);
1988        assert_eq!(spec.gateway, Ipv4Addr::new(100, 96, 1, 1));
1989        assert_eq!(spec.dns, Some(Ipv4Addr::new(100, 96, 1, 1)));
1990    }
1991
1992    #[test]
1993    fn test_parse_net_ipv4_no_dns() {
1994        let spec = parse_net_ipv4("addr=10.0.0.2/24,gw=10.0.0.1").unwrap();
1995        assert_eq!(spec.dns, None);
1996    }
1997
1998    #[test]
1999    fn test_parse_net_ipv4_missing_addr() {
2000        assert!(parse_net_ipv4("gw=10.0.0.1").is_err());
2001    }
2002
2003    #[test]
2004    fn test_parse_net_ipv6() {
2005        let spec = parse_net_ipv6(
2006            "addr=fd42:6d73:62:2a::2/64,gw=fd42:6d73:62:2a::1,dns=fd42:6d73:62:2a::1",
2007        )
2008        .unwrap();
2009        assert_eq!(
2010            spec.address,
2011            "fd42:6d73:62:2a::2".parse::<Ipv6Addr>().unwrap()
2012        );
2013        assert_eq!(spec.prefix_len, 64);
2014        assert_eq!(
2015            spec.gateway,
2016            "fd42:6d73:62:2a::1".parse::<Ipv6Addr>().unwrap()
2017        );
2018        assert!(spec.dns.is_some());
2019    }
2020
2021    #[test]
2022    fn test_parse_mac_valid() {
2023        let mac = parse_mac("02:5a:7b:13:01:02").unwrap();
2024        assert_eq!(mac, [0x02, 0x5a, 0x7b, 0x13, 0x01, 0x02]);
2025    }
2026
2027    #[test]
2028    fn test_parse_mac_invalid() {
2029        assert!(parse_mac("02:5a:7b").is_err());
2030        assert!(parse_mac("zz:00:00:00:00:00").is_err());
2031    }
2032
2033    #[test]
2034    fn test_parse_cidr_v4() {
2035        let (addr, prefix) = parse_cidr_v4("100.96.1.2/30").unwrap();
2036        assert_eq!(addr, Ipv4Addr::new(100, 96, 1, 2));
2037        assert_eq!(prefix, 30);
2038    }
2039
2040    #[test]
2041    fn test_parse_cidr_v6() {
2042        let (addr, prefix) = parse_cidr_v6("fd42:6d73:62:2a::2/64").unwrap();
2043        assert_eq!(addr, "fd42:6d73:62:2a::2".parse::<Ipv6Addr>().unwrap());
2044        assert_eq!(prefix, 64);
2045    }
2046
2047    // ── Rlimits ───────────────────────────────────────────────────────
2048
2049    #[test]
2050    fn test_parse_rlimits_happy_path() {
2051        let rlimits = parse_rlimits("nofile=65535;nproc=4096:8192").unwrap();
2052        assert_eq!(rlimits.len(), 2);
2053        assert_eq!(rlimits[0].resource, "nofile");
2054        assert_eq!(rlimits[0].soft, 65535);
2055        assert_eq!(rlimits[0].hard, 65535);
2056        assert_eq!(rlimits[1].resource, "nproc");
2057        assert_eq!(rlimits[1].soft, 4096);
2058        assert_eq!(rlimits[1].hard, 8192);
2059    }
2060
2061    #[test]
2062    fn test_parse_rlimits_ignores_empty_entries() {
2063        let rlimits = parse_rlimits("nofile=1024;").unwrap();
2064        assert_eq!(rlimits.len(), 1);
2065        assert_eq!(rlimits[0].resource, "nofile");
2066    }
2067
2068    #[test]
2069    fn test_parse_rlimits_rejects_unknown_resource() {
2070        let err = parse_rlimits("bogus=1024").unwrap_err();
2071        assert!(
2072            matches!(err, AgentdError::Config(msg) if msg.contains("unknown resource: bogus")),
2073            "unexpected error shape"
2074        );
2075    }
2076
2077    #[test]
2078    fn test_parse_rlimits_rejects_duplicate_resource() {
2079        let err = parse_rlimits("nofile=1024;nofile=65535").unwrap_err();
2080        assert!(
2081            matches!(err, AgentdError::Config(msg) if msg.contains("duplicate resource: nofile")),
2082            "unexpected error shape"
2083        );
2084    }
2085
2086    #[test]
2087    fn test_parse_rlimits_rejects_malformed_entry() {
2088        assert!(parse_rlimits("nofile").is_err());
2089        assert!(parse_rlimits("nofile=abc").is_err());
2090        assert!(parse_rlimits("nofile=65535:1024").is_err()); // soft > hard
2091    }
2092
2093    // ── Handoff Init ──────────────────────────────────────────────────
2094
2095    /// Mutex serialising tests that touch `MSB_HANDOFF_INIT*` env vars,
2096    /// since `parse_handoff_init` reads them from the process env.
2097    static HANDOFF_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2098
2099    fn with_handoff_env<R>(
2100        cmd: Option<&str>,
2101        args: Option<&str>,
2102        cwd: Option<&str>,
2103        env_var: Option<&str>,
2104        f: impl FnOnce() -> R,
2105    ) -> R {
2106        let _guard = HANDOFF_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2107        unsafe {
2108            match cmd {
2109                Some(v) => env::set_var(ENV_HANDOFF_INIT, v),
2110                None => env::remove_var(ENV_HANDOFF_INIT),
2111            }
2112            match args {
2113                Some(v) => env::set_var(ENV_HANDOFF_INIT_ARGS, v),
2114                None => env::remove_var(ENV_HANDOFF_INIT_ARGS),
2115            }
2116            match cwd {
2117                Some(v) => env::set_var(ENV_HANDOFF_INIT_CWD, v),
2118                None => env::remove_var(ENV_HANDOFF_INIT_CWD),
2119            }
2120            match env_var {
2121                Some(v) => env::set_var(ENV_HANDOFF_INIT_ENV, v),
2122                None => env::remove_var(ENV_HANDOFF_INIT_ENV),
2123            }
2124        }
2125        let out = f();
2126        unsafe {
2127            env::remove_var(ENV_HANDOFF_INIT);
2128            env::remove_var(ENV_HANDOFF_INIT_ARGS);
2129            env::remove_var(ENV_HANDOFF_INIT_CWD);
2130            env::remove_var(ENV_HANDOFF_INIT_ENV);
2131        }
2132        out
2133    }
2134
2135    fn encode_handoff_json<T: serde::Serialize>(value: &T) -> String {
2136        use base64::Engine as _;
2137
2138        let json = serde_json::to_vec(value).unwrap();
2139        base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json)
2140    }
2141
2142    #[test]
2143    fn test_parse_handoff_init_unset_returns_none() {
2144        let res = with_handoff_env(None, None, None, None, parse_handoff_init).unwrap();
2145        assert!(res.is_none());
2146    }
2147
2148    #[test]
2149    fn test_parse_handoff_init_empty_returns_none() {
2150        let res = with_handoff_env(Some(""), None, None, None, parse_handoff_init).unwrap();
2151        assert!(res.is_none());
2152    }
2153
2154    #[test]
2155    fn test_parse_handoff_init_cmd_only() {
2156        let res = with_handoff_env(
2157            Some("/lib/systemd/systemd"),
2158            None,
2159            None,
2160            None,
2161            parse_handoff_init,
2162        )
2163        .unwrap()
2164        .unwrap();
2165        assert_eq!(res.cmd, PathBuf::from("/lib/systemd/systemd"));
2166        assert!(res.argv.is_empty());
2167        assert!(res.env.is_empty());
2168    }
2169
2170    #[test]
2171    fn test_parse_handoff_init_with_argv() {
2172        let argv = encode_handoff_json(&vec!["--unit=multi-user.target", "--log-level=warning"]);
2173        let res = with_handoff_env(
2174            Some("/lib/systemd/systemd"),
2175            Some(&argv),
2176            None,
2177            None,
2178            parse_handoff_init,
2179        )
2180        .unwrap()
2181        .unwrap();
2182        assert_eq!(
2183            res.argv,
2184            vec![
2185                OsString::from("--unit=multi-user.target"),
2186                OsString::from("--log-level=warning"),
2187            ]
2188        );
2189    }
2190
2191    #[test]
2192    fn test_parse_handoff_init_with_env() {
2193        let envs = encode_handoff_json(&vec![("container", "microsandbox"), ("LANG", "C.UTF-8")]);
2194        let res = with_handoff_env(
2195            Some("/sbin/init"),
2196            None,
2197            None,
2198            Some(&envs),
2199            parse_handoff_init,
2200        )
2201        .unwrap()
2202        .unwrap();
2203        assert_eq!(
2204            res.env,
2205            vec![
2206                (OsString::from("container"), OsString::from("microsandbox")),
2207                (OsString::from("LANG"), OsString::from("C.UTF-8")),
2208            ]
2209        );
2210    }
2211
2212    #[test]
2213    fn test_parse_handoff_init_with_cwd() {
2214        let res = with_handoff_env(
2215            Some("/sbin/init"),
2216            None,
2217            Some("/opt/hermes"),
2218            None,
2219            parse_handoff_init,
2220        )
2221        .unwrap()
2222        .unwrap();
2223        assert_eq!(res.cwd, Some(PathBuf::from("/opt/hermes")));
2224    }
2225
2226    #[test]
2227    fn test_parse_handoff_init_argv_with_spaces_preserved() {
2228        let argv = encode_handoff_json(&vec![
2229            "--label=hello world",
2230            "--config=/etc/foo;bar",
2231            "old\x1fseparator",
2232        ]);
2233        let res = with_handoff_env(
2234            Some("/sbin/init"),
2235            Some(&argv),
2236            None,
2237            None,
2238            parse_handoff_init,
2239        )
2240        .unwrap()
2241        .unwrap();
2242        assert_eq!(
2243            res.argv,
2244            vec![
2245                OsString::from("--label=hello world"),
2246                OsString::from("--config=/etc/foo;bar"),
2247                OsString::from("old\x1fseparator"),
2248            ]
2249        );
2250    }
2251
2252    #[test]
2253    fn test_parse_handoff_init_rejects_relative_path() {
2254        let err =
2255            with_handoff_env(Some("sbin/init"), None, None, None, parse_handoff_init).unwrap_err();
2256        assert!(err.to_string().contains("absolute path"));
2257    }
2258
2259    #[test]
2260    fn test_parse_handoff_init_env_rejects_invalid_base64() {
2261        let err = with_handoff_env(
2262            Some("/sbin/init"),
2263            None,
2264            None,
2265            Some("not base64!"),
2266            parse_handoff_init,
2267        )
2268        .unwrap_err();
2269        assert!(err.to_string().contains("base64url-no-padding JSON"));
2270    }
2271
2272    #[test]
2273    fn test_parse_handoff_init_cwd_rejects_relative_path() {
2274        let err = with_handoff_env(
2275            Some("/sbin/init"),
2276            None,
2277            Some("opt/hermes"),
2278            None,
2279            parse_handoff_init,
2280        )
2281        .unwrap_err();
2282        assert!(err.to_string().contains("absolute path"));
2283    }
2284
2285    #[test]
2286    fn test_parse_handoff_init_env_entry_empty_key_rejected() {
2287        let envs = encode_handoff_json(&vec![("", "value")]);
2288        let err = with_handoff_env(
2289            Some("/sbin/init"),
2290            None,
2291            None,
2292            Some(&envs),
2293            parse_handoff_init,
2294        )
2295        .unwrap_err();
2296        assert!(err.to_string().contains("empty key"));
2297    }
2298
2299    #[test]
2300    fn test_parse_handoff_init_arg_rejects_nul() {
2301        let argv = encode_handoff_json(&vec!["ok", "bad\0arg"]);
2302        let err = with_handoff_env(
2303            Some("/sbin/init"),
2304            Some(&argv),
2305            None,
2306            None,
2307            parse_handoff_init,
2308        )
2309        .unwrap_err();
2310        assert!(err.to_string().contains("entry #1"));
2311        assert!(err.to_string().contains("NUL"));
2312    }
2313
2314    #[test]
2315    fn test_parse_handoff_init_env_key_rejects_equals() {
2316        let envs = encode_handoff_json(&vec![("BAD=KEY", "value")]);
2317        let err = with_handoff_env(
2318            Some("/sbin/init"),
2319            None,
2320            None,
2321            Some(&envs),
2322            parse_handoff_init,
2323        )
2324        .unwrap_err();
2325        assert!(err.to_string().contains("must not contain '='"));
2326    }
2327
2328    #[test]
2329    fn test_parse_handoff_init_env_key_rejects_nul() {
2330        let envs = encode_handoff_json(&vec![("BAD\0KEY", "value")]);
2331        let err = with_handoff_env(
2332            Some("/sbin/init"),
2333            None,
2334            None,
2335            Some(&envs),
2336            parse_handoff_init,
2337        )
2338        .unwrap_err();
2339        assert!(err.to_string().contains("key"));
2340        assert!(err.to_string().contains("NUL"));
2341    }
2342
2343    #[test]
2344    fn test_parse_handoff_init_env_value_rejects_nul() {
2345        let envs = encode_handoff_json(&vec![("KEY", "bad\0value")]);
2346        let err = with_handoff_env(
2347            Some("/sbin/init"),
2348            None,
2349            None,
2350            Some(&envs),
2351            parse_handoff_init,
2352        )
2353        .unwrap_err();
2354        assert!(err.to_string().contains("value for"));
2355        assert!(err.to_string().contains("NUL"));
2356    }
2357
2358    #[test]
2359    fn test_parse_handoff_init_env_value_with_equals_is_value() {
2360        let envs = encode_handoff_json(&vec![("PATH", "/a:/b=/c")]);
2361        let res = with_handoff_env(
2362            Some("/sbin/init"),
2363            None,
2364            None,
2365            Some(&envs),
2366            parse_handoff_init,
2367        )
2368        .unwrap()
2369        .unwrap();
2370        assert_eq!(
2371            res.env,
2372            vec![(OsString::from("PATH"), OsString::from("/a:/b=/c"))]
2373        );
2374    }
2375}