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