Skip to main content

microsandbox_agentd/
config.rs

1//! Agentd configuration, read once from environment variables at startup.
2//!
3//! Split into two structs with different lifetimes:
4//!
5//! - [`BootParams`] — one-shot MSB_* env vars 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//! Each struct owns its own [`from_env`](BootParams::from_env) constructor
11//! so reading is centralised and validation failures abort boot with a
12//! single clean error before any side effects begin.
13//!
14//! [`init::init`]: crate::init::init
15
16use std::env;
17use std::ffi::OsString;
18use std::net::{Ipv4Addr, Ipv6Addr};
19use std::path::PathBuf;
20
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_ENV, ENV_HOST_ALIAS, ENV_HOSTNAME, ENV_NET,
24    ENV_NET_IPV4, ENV_NET_IPV6, ENV_RLIMITS, ENV_SECURITY_PROFILE, ENV_TMPFS, ENV_USER,
25    HANDOFF_INIT_AUTO, HANDOFF_INIT_SEP, exec::ExecRlimit,
26};
27
28use crate::error::{AgentdError, AgentdResult};
29use crate::rlimit;
30
31//--------------------------------------------------------------------------------------------------
32// Types
33//--------------------------------------------------------------------------------------------------
34
35/// One-shot MSB_* env vars consumed by [`init::init`] and dropped afterward.
36///
37/// Moved by value into init; owning the data (rather than borrowing) makes
38/// the "consumed once" lifetime explicit in the signature and prevents
39/// accidental reads after init completes.
40///
41/// [`init::init`]: crate::init::init
42#[derive(Debug)]
43pub struct BootParams {
44    /// Parsed `MSB_BLOCK_ROOT` — block device for rootfs switch.
45    pub(crate) block_root: Option<BlockRootSpec>,
46
47    /// Parsed `MSB_DIR_MOUNTS` — virtiofs directory mount specs (empty when unset).
48    pub(crate) dir_mounts: Vec<DirMountSpec>,
49
50    /// Parsed `MSB_FILE_MOUNTS` — virtiofs file mount specs (empty when unset).
51    pub(crate) file_mounts: Vec<FileMountSpec>,
52
53    /// Parsed `MSB_DISK_MOUNTS` — disk-image mount specs (empty when unset).
54    pub(crate) disk_mounts: Vec<DiskMountSpec>,
55
56    /// Parsed `MSB_TMPFS` — tmpfs mount specs (empty when unset).
57    pub(crate) tmpfs: Vec<TmpfsSpec>,
58
59    /// Parsed `MSB_SECURITY_PROFILE` — in-guest security profile.
60    pub(crate) security_profile: SecurityProfile,
61
62    /// `MSB_HOSTNAME` — guest hostname.
63    pub(crate) hostname: Option<String>,
64
65    /// `MSB_HOST_ALIAS` — DNS name (e.g. `host.microsandbox.internal`)
66    /// the guest uses to reach the sandbox host. Written into
67    /// `/etc/hosts` pointing at the gateway IPs.
68    pub(crate) host_alias: Option<String>,
69
70    /// Parsed `MSB_NET` — network interface config.
71    pub(crate) net: Option<NetSpec>,
72
73    /// Parsed `MSB_NET_IPV4` — IPv4 config.
74    pub(crate) net_ipv4: Option<NetIpv4Spec>,
75
76    /// Parsed `MSB_NET_IPV6` — IPv6 config.
77    pub(crate) net_ipv6: Option<NetIpv6Spec>,
78
79    /// Parsed `MSB_RLIMITS` — sandbox-wide resource limits applied to PID 1
80    /// so every guest process inherits the raised baseline (empty when unset).
81    pub(crate) rlimits: Vec<ExecRlimit>,
82
83    /// Parsed `MSB_HANDOFF_INIT[_ARGS|_ENV]` — guest init binary to which
84    /// agentd hands off PID 1 after `init::init()`. `None` means agentd
85    /// remains PID 1 (the default).
86    pub(crate) handoff_init: Option<HandoffInit>,
87}
88
89/// Parsed handoff-init specification.
90///
91/// When present in [`BootParams`], agentd performs setup, forks, the
92/// parent execs `cmd` (becoming the new PID 1), and the child
93/// continues as the agent loop.
94#[derive(Debug)]
95pub struct HandoffInit {
96    /// Absolute path inside the guest rootfs, or the literal `"auto"`
97    /// (resolved via [`HANDOFF_INIT_AUTO_CANDIDATES`] in `do_handoff`).
98    pub(crate) cmd: PathBuf,
99
100    /// argv past `argv[0]` — i.e., the supplemental arguments. Empty
101    /// means the init is exec'd with `argv = [cmd]`.
102    pub(crate) argv: Vec<OsString>,
103
104    /// Extra env vars merged on top of the inherited env. Empty means
105    /// inherit-only.
106    pub(crate) env: Vec<(OsString, OsString)>,
107}
108
109/// Runtime configuration surviving past init; referenced by the agent loop.
110///
111/// Holds runtime settings used after init, including the default guest user
112/// and security profile for exec sessions.
113#[derive(Debug)]
114pub struct AgentdConfig {
115    /// `MSB_USER` — default guest user for exec sessions.
116    ///
117    /// Captured at startup; changes to `MSB_USER` afterward are not observed.
118    pub(crate) user: Option<String>,
119
120    /// In-guest security profile for exec sessions.
121    pub(crate) security_profile: SecurityProfile,
122}
123
124/// In-guest security profile.
125#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
126pub enum SecurityProfile {
127    /// Preserve normal guest-root behavior.
128    #[default]
129    Default,
130
131    /// Set `no_new_privs`, drop `CAP_SYS_ADMIN`, and force `nosuid,nodev` mounts.
132    Restricted,
133}
134
135/// Parsed tmpfs mount specification.
136#[derive(Debug)]
137pub(crate) struct TmpfsSpec {
138    pub path: String,
139    pub size_mib: Option<u32>,
140    pub mode: Option<u32>,
141    pub noexec: bool,
142    pub nosuid: bool,
143    pub nodev: bool,
144    pub readonly: bool,
145}
146
147/// Parsed block-device root specification with kind-based dispatch.
148#[derive(Debug)]
149pub(crate) enum BlockRootSpec {
150    /// Single disk image.
151    DiskImage {
152        device: String,
153        fstype: Option<String>,
154    },
155    /// OCI EROFS: merged EROFS lower + writable upper + guest overlayfs.
156    OciErofs {
157        lower: String,
158        upper: String,
159        upper_fstype: String,
160    },
161}
162
163/// Parsed virtiofs directory volume mount specification.
164#[derive(Debug)]
165pub(crate) struct DirMountSpec {
166    pub tag: String,
167    pub guest_path: String,
168    pub readonly: bool,
169    pub noexec: bool,
170    pub nosuid: bool,
171    pub nodev: bool,
172}
173
174/// Parsed virtiofs file volume mount specification.
175#[derive(Debug)]
176pub(crate) struct FileMountSpec {
177    pub tag: String,
178    pub filename: String,
179    pub guest_path: String,
180    pub readonly: bool,
181    pub noexec: bool,
182    pub nosuid: bool,
183    pub nodev: bool,
184}
185
186/// Parsed disk-image volume mount specification.
187///
188/// Each entry corresponds to one extra virtio-blk device attached by the
189/// VMM. Agentd resolves the device node from `id` via
190/// `/dev/disk/by-id/virtio-<id>` and mounts it at `guest_path`.
191#[derive(Debug)]
192pub(crate) struct DiskMountSpec {
193    pub id: String,
194    pub guest_path: String,
195    /// Inner filesystem type. `None` triggers an autodetect walk over
196    /// `/proc/filesystems` in agentd's init path.
197    pub fstype: Option<String>,
198    pub readonly: bool,
199    pub noexec: bool,
200    pub nosuid: bool,
201    pub nodev: bool,
202}
203
204/// Parsed common volume mount option block.
205#[derive(Debug, Default)]
206struct ParsedMountOptions {
207    readonly: bool,
208    noexec: bool,
209    nosuid: bool,
210    nodev: bool,
211    fstype: Option<String>,
212    size_mib: Option<u32>,
213    mode: Option<u32>,
214}
215
216/// Which keyed options are valid for a specific mount environment variable.
217#[derive(Debug, Clone, Copy, Default)]
218struct MountOptionSupport {
219    fstype: bool,
220    size: bool,
221    mode: bool,
222}
223
224/// Parsed `MSB_NET` specification.
225#[derive(Debug)]
226pub(crate) struct NetSpec {
227    pub iface: String,
228    pub mac: [u8; 6],
229    pub mtu: u16,
230}
231
232/// Parsed `MSB_NET_IPV4` specification.
233#[derive(Debug)]
234pub(crate) struct NetIpv4Spec {
235    pub address: Ipv4Addr,
236    pub prefix_len: u8,
237    pub gateway: Ipv4Addr,
238    pub dns: Option<Ipv4Addr>,
239}
240
241/// Parsed `MSB_NET_IPV6` specification.
242#[derive(Debug)]
243pub(crate) struct NetIpv6Spec {
244    pub address: Ipv6Addr,
245    pub prefix_len: u8,
246    pub gateway: Ipv6Addr,
247    pub dns: Option<Ipv6Addr>,
248}
249
250/// Bundled network configuration: interface + IPv4 + IPv6.
251///
252/// Borrows the three `MSB_NET*` specs so they can travel as one parameter.
253#[derive(Debug)]
254pub(crate) struct NetConfig<'a> {
255    pub net: Option<&'a NetSpec>,
256    pub ipv4: Option<&'a NetIpv4Spec>,
257    pub ipv6: Option<&'a NetIpv6Spec>,
258}
259
260//--------------------------------------------------------------------------------------------------
261// Implementations
262//--------------------------------------------------------------------------------------------------
263
264impl BootParams {
265    /// Reads and parses the boot-time `MSB_*` environment variables.
266    ///
267    /// Empty or whitespace-only values are treated as absent (`None`).
268    /// Returns an error if any present value fails to parse.
269    pub fn from_env() -> AgentdResult<Self> {
270        Ok(Self {
271            block_root: read_env(ENV_BLOCK_ROOT)
272                .map(|v| parse_block_root(&v))
273                .transpose()?,
274            dir_mounts: read_env(ENV_DIR_MOUNTS)
275                .map(|v| parse_dir_mounts(&v))
276                .transpose()?
277                .unwrap_or_default(),
278            file_mounts: read_env(ENV_FILE_MOUNTS)
279                .map(|v| parse_file_mounts(&v))
280                .transpose()?
281                .unwrap_or_default(),
282            disk_mounts: read_env(ENV_DISK_MOUNTS)
283                .map(|v| parse_disk_mounts(&v))
284                .transpose()?
285                .unwrap_or_default(),
286            tmpfs: read_env(ENV_TMPFS)
287                .map(|v| parse_tmpfs_mounts(&v))
288                .transpose()?
289                .unwrap_or_default(),
290            hostname: read_env(ENV_HOSTNAME),
291            host_alias: read_env(ENV_HOST_ALIAS),
292            net: read_env(ENV_NET).map(|v| parse_net(&v)).transpose()?,
293            net_ipv4: read_env(ENV_NET_IPV4)
294                .map(|v| parse_net_ipv4(&v))
295                .transpose()?,
296            net_ipv6: read_env(ENV_NET_IPV6)
297                .map(|v| parse_net_ipv6(&v))
298                .transpose()?,
299            rlimits: read_env(ENV_RLIMITS)
300                .map(|v| parse_rlimits(&v))
301                .transpose()?
302                .unwrap_or_default(),
303            security_profile: read_env(ENV_SECURITY_PROFILE)
304                .map(|v| parse_security_profile(&v))
305                .transpose()?
306                .unwrap_or_default(),
307            handoff_init: parse_handoff_init()?,
308        })
309    }
310
311    /// Take the handoff-init spec out of the boot params.
312    ///
313    /// Used by `bin/main.rs` before `init::init` consumes `BootParams`
314    /// by value, since the handoff hook fires after init returns.
315    pub fn take_handoff_init(&mut self) -> Option<HandoffInit> {
316        self.handoff_init.take()
317    }
318
319    /// Borrows the three `MSB_NET*` specs as a single bundle.
320    pub(crate) fn network(&self) -> NetConfig<'_> {
321        NetConfig {
322            net: self.net.as_ref(),
323            ipv4: self.net_ipv4.as_ref(),
324            ipv6: self.net_ipv6.as_ref(),
325        }
326    }
327}
328
329impl AgentdConfig {
330    /// Returns the configured default guest user, if any.
331    pub fn user(&self) -> Option<&str> {
332        self.user.as_deref()
333    }
334
335    /// Reads the runtime-config `MSB_*` environment variables.
336    ///
337    /// Empty or whitespace-only values are treated as absent (`None`).
338    pub fn from_env() -> AgentdResult<Self> {
339        Ok(Self {
340            user: read_env(ENV_USER),
341            security_profile: read_env(ENV_SECURITY_PROFILE)
342                .map(|v| parse_security_profile(&v))
343                .transpose()?
344                .unwrap_or_default(),
345        })
346    }
347}
348
349//--------------------------------------------------------------------------------------------------
350// Parse Functions: Block Root / Volume Mounts / Tmpfs
351//--------------------------------------------------------------------------------------------------
352
353fn parse_security_profile(value: &str) -> AgentdResult<SecurityProfile> {
354    match value {
355        "default" => Ok(SecurityProfile::Default),
356        "restricted" => Ok(SecurityProfile::Restricted),
357        other => Err(AgentdError::Config(format!(
358            "{ENV_SECURITY_PROFILE} unknown value: {other}"
359        ))),
360    }
361}
362
363/// Parses `MSB_BLOCK_ROOT` into a kind-based spec.
364///
365/// Supports:
366/// - `kind=disk-image,device=/dev/vda[,fstype=ext4]`
367/// - `kind=oci-erofs,lower=/dev/vdb,upper=/dev/vdc,upper_fstype=ext4`
368fn parse_block_root(val: &str) -> AgentdResult<BlockRootSpec> {
369    let mut kv: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
370    for part in val.split(',') {
371        let Some((k, v)) = part.split_once('=') else {
372            continue;
373        };
374        if kv.insert(k, v).is_some() {
375            return Err(AgentdError::Config(format!(
376                "MSB_BLOCK_ROOT duplicate key '{k}'"
377            )));
378        }
379    }
380
381    let get = |key: &str| -> AgentdResult<String> {
382        kv.get(key)
383            .filter(|v| !v.is_empty())
384            .map(|v| v.to_string())
385            .ok_or_else(|| AgentdError::Config(format!("MSB_BLOCK_ROOT missing '{key}'")))
386    };
387
388    match kv.get("kind").copied() {
389        Some("disk-image") => {
390            let device = get("device")?;
391            let fstype = kv
392                .get("fstype")
393                .filter(|v| !v.is_empty())
394                .map(|v| v.to_string());
395            Ok(BlockRootSpec::DiskImage { device, fstype })
396        }
397        Some("oci-erofs") => {
398            let lower = get("lower")?;
399            let upper = get("upper")?;
400            let upper_fstype = get("upper_fstype")?;
401            Ok(BlockRootSpec::OciErofs {
402                lower,
403                upper,
404                upper_fstype,
405            })
406        }
407        Some(other) => Err(AgentdError::Config(format!(
408            "MSB_BLOCK_ROOT unknown kind: {other}"
409        ))),
410        None => Err(AgentdError::Config(
411            "MSB_BLOCK_ROOT missing 'kind' key".into(),
412        )),
413    }
414}
415
416/// Parse a comma-separated volume mount option block.
417fn parse_mount_options(
418    env_name: &str,
419    opts: Option<&str>,
420    support: MountOptionSupport,
421) -> AgentdResult<ParsedMountOptions> {
422    let mut parsed = ParsedMountOptions::default();
423    let mut seen_access = false;
424    let mut seen_noexec = false;
425    let mut seen_nosuid = false;
426    let mut seen_nodev = false;
427    let mut seen_fstype = false;
428    let mut seen_size = false;
429    let mut seen_mode = false;
430
431    let Some(opts) = opts else {
432        return Ok(parsed);
433    };
434
435    for opt in opts.split(',') {
436        let opt = opt.trim();
437        if opt.is_empty() {
438            continue;
439        }
440        match opt {
441            "ro" | "rw" => {
442                if seen_access {
443                    return Err(AgentdError::Config(format!(
444                        "{env_name} option 'ro'/'rw' specified more than once"
445                    )));
446                }
447                seen_access = true;
448                parsed.readonly = opt == "ro";
449            }
450            "noexec" => {
451                if seen_noexec {
452                    return Err(AgentdError::Config(format!(
453                        "{env_name} option 'noexec' specified more than once"
454                    )));
455                }
456                seen_noexec = true;
457                parsed.noexec = true;
458            }
459            "nosuid" => {
460                if seen_nosuid {
461                    return Err(AgentdError::Config(format!(
462                        "{env_name} option 'nosuid' specified more than once"
463                    )));
464                }
465                seen_nosuid = true;
466                parsed.nosuid = true;
467            }
468            "nodev" => {
469                if seen_nodev {
470                    return Err(AgentdError::Config(format!(
471                        "{env_name} option 'nodev' specified more than once"
472                    )));
473                }
474                seen_nodev = true;
475                parsed.nodev = true;
476            }
477            "suid" | "exec" | "dev" => {
478                return Err(AgentdError::Config(format!(
479                    "{env_name} unsupported mount option '{opt}'"
480                )));
481            }
482            _ => {
483                let (key, value) = opt.split_once('=').ok_or_else(|| {
484                    AgentdError::Config(format!("{env_name} unknown mount option '{opt}'"))
485                })?;
486                if value.is_empty() {
487                    return Err(AgentdError::Config(format!(
488                        "{env_name} option '{key}' must not be empty"
489                    )));
490                }
491                match key {
492                    "fstype" if support.fstype => {
493                        if seen_fstype {
494                            return Err(AgentdError::Config(format!(
495                                "{env_name} option 'fstype' specified more than once"
496                            )));
497                        }
498                        seen_fstype = true;
499                        if value.chars().any(|c| matches!(c, ',' | ';' | ':' | '=')) {
500                            return Err(AgentdError::Config(format!(
501                                "{env_name} fstype must not contain ',', ';', ':', or '=': {value}"
502                            )));
503                        }
504                        parsed.fstype = Some(value.to_string());
505                    }
506                    "size" if support.size => {
507                        if seen_size {
508                            return Err(AgentdError::Config(format!(
509                                "{env_name} option 'size' specified more than once"
510                            )));
511                        }
512                        seen_size = true;
513                        parsed.size_mib = Some(value.parse::<u32>().map_err(|_| {
514                            AgentdError::Config(format!("{env_name} invalid tmpfs size: {value}"))
515                        })?);
516                    }
517                    "mode" if support.mode => {
518                        if seen_mode {
519                            return Err(AgentdError::Config(format!(
520                                "{env_name} option 'mode' specified more than once"
521                            )));
522                        }
523                        seen_mode = true;
524                        parsed.mode = Some(u32::from_str_radix(value, 8).map_err(|_| {
525                            AgentdError::Config(format!(
526                                "{env_name} invalid octal tmpfs mode: {value}"
527                            ))
528                        })?);
529                    }
530                    "fstype" | "size" | "mode" => {
531                        return Err(AgentdError::Config(format!(
532                            "{env_name} option '{key}' is not valid for this mount kind"
533                        )));
534                    }
535                    other => {
536                        return Err(AgentdError::Config(format!(
537                            "{env_name} unknown mount option '{other}'"
538                        )));
539                    }
540                }
541            }
542        }
543    }
544
545    Ok(parsed)
546}
547
548/// Parses semicolon-separated directory mount entries.
549fn parse_dir_mounts(val: &str) -> AgentdResult<Vec<DirMountSpec>> {
550    val.split(';')
551        .filter(|e| !e.is_empty())
552        .map(parse_dir_mount_entry)
553        .collect()
554}
555
556/// Parses a single virtiofs directory volume mount entry: `tag:guest_path[:opts]`.
557fn parse_dir_mount_entry(entry: &str) -> AgentdResult<DirMountSpec> {
558    let mut parts = entry.splitn(3, ':');
559    let Some(tag) = parts.next() else {
560        unreachable!("splitn always yields at least one part");
561    };
562    let guest_path = parts.next().ok_or_else(|| {
563        AgentdError::Config(format!(
564            "MSB_DIR_MOUNTS entry must be tag:path[:opts], got: {entry}"
565        ))
566    })?;
567    let options = parse_mount_options(ENV_DIR_MOUNTS, parts.next(), MountOptionSupport::default())?;
568
569    if tag.is_empty() {
570        return Err(AgentdError::Config(
571            "MSB_DIR_MOUNTS entry has empty tag".into(),
572        ));
573    }
574    if guest_path.is_empty() || !guest_path.starts_with('/') {
575        return Err(AgentdError::Config(format!(
576            "MSB_DIR_MOUNTS guest path must be absolute: {guest_path}"
577        )));
578    }
579
580    Ok(DirMountSpec {
581        tag: tag.to_string(),
582        guest_path: guest_path.to_string(),
583        readonly: options.readonly,
584        noexec: options.noexec,
585        nosuid: options.nosuid,
586        nodev: options.nodev,
587    })
588}
589
590/// Parses semicolon-separated file mount entries.
591fn parse_file_mounts(val: &str) -> AgentdResult<Vec<FileMountSpec>> {
592    val.split(';')
593        .filter(|e| !e.is_empty())
594        .map(parse_file_mount_entry)
595        .collect()
596}
597
598/// Parses a single virtiofs file volume mount entry: `tag:filename:guest_path[:opts]`.
599fn parse_file_mount_entry(entry: &str) -> AgentdResult<FileMountSpec> {
600    let mut parts = entry.splitn(4, ':');
601    let Some(tag) = parts.next() else {
602        unreachable!("splitn always yields at least one part");
603    };
604    let filename = parts.next().ok_or_else(|| {
605        AgentdError::Config(format!(
606            "MSB_FILE_MOUNTS entry must be tag:filename:path[:opts], got: {entry}"
607        ))
608    })?;
609    let guest_path = parts.next().ok_or_else(|| {
610        AgentdError::Config(format!(
611            "MSB_FILE_MOUNTS entry must be tag:filename:path[:opts], got: {entry}"
612        ))
613    })?;
614    let options =
615        parse_mount_options(ENV_FILE_MOUNTS, parts.next(), MountOptionSupport::default())?;
616
617    if tag.is_empty() {
618        return Err(AgentdError::Config(
619            "MSB_FILE_MOUNTS entry has empty tag".into(),
620        ));
621    }
622    if filename.is_empty() {
623        return Err(AgentdError::Config(
624            "MSB_FILE_MOUNTS entry has empty filename".into(),
625        ));
626    }
627    if guest_path.is_empty() || !guest_path.starts_with('/') {
628        return Err(AgentdError::Config(format!(
629            "MSB_FILE_MOUNTS guest path must be absolute: {guest_path}"
630        )));
631    }
632
633    Ok(FileMountSpec {
634        tag: tag.to_string(),
635        filename: filename.to_string(),
636        guest_path: guest_path.to_string(),
637        readonly: options.readonly,
638        noexec: options.noexec,
639        nosuid: options.nosuid,
640        nodev: options.nodev,
641    })
642}
643
644/// Parses semicolon-separated disk-image mount entries.
645fn parse_disk_mounts(val: &str) -> AgentdResult<Vec<DiskMountSpec>> {
646    val.split(';')
647        .filter(|e| !e.is_empty())
648        .map(parse_disk_mount_entry)
649        .collect()
650}
651
652/// Parses a single disk-image mount entry: `id:guest_path[:opts]`.
653fn parse_disk_mount_entry(entry: &str) -> AgentdResult<DiskMountSpec> {
654    let mut parts = entry.splitn(3, ':');
655    let Some(id) = parts.next() else {
656        unreachable!("splitn always yields at least one part");
657    };
658    let guest_path = parts.next().ok_or_else(|| {
659        AgentdError::Config(format!(
660            "MSB_DISK_MOUNTS entry must be id:guest_path[:opts], got: {entry}"
661        ))
662    })?;
663    let options = parse_mount_options(
664        ENV_DISK_MOUNTS,
665        parts.next(),
666        MountOptionSupport {
667            fstype: true,
668            ..MountOptionSupport::default()
669        },
670    )?;
671
672    if id.is_empty() {
673        return Err(AgentdError::Config(
674            "MSB_DISK_MOUNTS entry has empty id".into(),
675        ));
676    }
677    if guest_path.is_empty() || !guest_path.starts_with('/') {
678        return Err(AgentdError::Config(format!(
679            "MSB_DISK_MOUNTS guest path must be absolute: {guest_path}"
680        )));
681    }
682
683    Ok(DiskMountSpec {
684        id: id.to_string(),
685        guest_path: guest_path.to_string(),
686        fstype: options.fstype,
687        readonly: options.readonly,
688        noexec: options.noexec,
689        nosuid: options.nosuid,
690        nodev: options.nodev,
691    })
692}
693
694/// Parses semicolon-separated tmpfs mount entries.
695fn parse_tmpfs_mounts(val: &str) -> AgentdResult<Vec<TmpfsSpec>> {
696    val.split(';')
697        .filter(|e| !e.is_empty())
698        .map(parse_tmpfs_entry)
699        .collect()
700}
701
702/// Parses a single tmpfs entry: `path[:opts]`.
703///
704/// Supported options are `size=N`, `mode=N`, `ro`, `rw`, `nosuid`, `nodev`, and `noexec`.
705/// Mode is parsed as octal (e.g. `mode=1777`).
706fn parse_tmpfs_entry(entry: &str) -> AgentdResult<TmpfsSpec> {
707    let (path, opts) = match entry.split_once(':') {
708        Some((path, opts)) => (path, Some(opts)),
709        None => {
710            if entry.contains(',') {
711                return Err(AgentdError::Config(
712                    "MSB_TMPFS options must use path:opts syntax".into(),
713                ));
714            }
715            (entry, None)
716        }
717    };
718
719    if path.is_empty() {
720        return Err(AgentdError::Config("tmpfs entry has empty path".into()));
721    }
722
723    let options = parse_mount_options(
724        ENV_TMPFS,
725        opts,
726        MountOptionSupport {
727            size: true,
728            mode: true,
729            ..MountOptionSupport::default()
730        },
731    )?;
732
733    Ok(TmpfsSpec {
734        path: path.to_string(),
735        size_mib: options.size_mib,
736        mode: options.mode,
737        noexec: options.noexec,
738        nosuid: options.nosuid,
739        nodev: options.nodev,
740        readonly: options.readonly,
741    })
742}
743
744//--------------------------------------------------------------------------------------------------
745// Parse Functions: Rlimits
746//--------------------------------------------------------------------------------------------------
747
748/// Parses `MSB_RLIMITS` value: semicolon-separated `resource=soft[:hard]` entries.
749///
750/// Rejects unknown resource names and duplicate resources at startup so
751/// misspellings and overrides fail loud rather than silently last-winning
752/// during PID 1 init.
753fn parse_rlimits(val: &str) -> AgentdResult<Vec<ExecRlimit>> {
754    let mut seen: Vec<String> = Vec::new();
755    val.split(';')
756        .filter(|entry| !entry.is_empty())
757        .map(|entry| {
758            let rlimit = entry.parse::<ExecRlimit>().map_err(|err| {
759                AgentdError::Config(format!("{ENV_RLIMITS} entry {entry}: {err}"))
760            })?;
761            if rlimit::parse_rlimit_resource(&rlimit.resource).is_none() {
762                return Err(AgentdError::Config(format!(
763                    "{ENV_RLIMITS} unknown resource: {}",
764                    rlimit.resource
765                )));
766            }
767            if seen.iter().any(|name| name == &rlimit.resource) {
768                return Err(AgentdError::Config(format!(
769                    "{ENV_RLIMITS} duplicate resource: {}",
770                    rlimit.resource
771                )));
772            }
773            seen.push(rlimit.resource.clone());
774            Ok(rlimit)
775        })
776        .collect()
777}
778
779//--------------------------------------------------------------------------------------------------
780// Parse Functions: Network
781//--------------------------------------------------------------------------------------------------
782
783/// Parses `MSB_NET` value: `iface=NAME,mac=AA:BB:CC:DD:EE:FF,mtu=N`
784fn parse_net(val: &str) -> AgentdResult<NetSpec> {
785    let mut iface = None;
786    let mut mac = None;
787    let mut mtu = 1500u16;
788
789    for part in val.split(',') {
790        if let Some(v) = part.strip_prefix("iface=") {
791            iface = Some(v.to_string());
792        } else if let Some(v) = part.strip_prefix("mac=") {
793            mac = Some(parse_mac(v)?);
794        } else if let Some(v) = part.strip_prefix("mtu=") {
795            mtu = v
796                .parse()
797                .map_err(|_| AgentdError::Config(format!("invalid MTU: {v}")))?;
798        } else {
799            return Err(AgentdError::Config(format!(
800                "unknown MSB_NET option: {part}"
801            )));
802        }
803    }
804
805    let iface = iface.ok_or_else(|| AgentdError::Config("MSB_NET missing iface=".into()))?;
806    let mac = mac.ok_or_else(|| AgentdError::Config("MSB_NET missing mac=".into()))?;
807
808    Ok(NetSpec { iface, mac, mtu })
809}
810
811/// Parses `MSB_NET_IPV4` value: `addr=A.B.C.D/N,gw=A.B.C.D[,dns=A.B.C.D]`
812fn parse_net_ipv4(val: &str) -> AgentdResult<NetIpv4Spec> {
813    let mut address = None;
814    let mut prefix_len = None;
815    let mut gateway = None;
816    let mut dns = None;
817
818    for part in val.split(',') {
819        if let Some(v) = part.strip_prefix("addr=") {
820            let (addr, prefix) = parse_cidr_v4(v)?;
821            address = Some(addr);
822            prefix_len = Some(prefix);
823        } else if let Some(v) = part.strip_prefix("gw=") {
824            gateway = Some(
825                v.parse::<Ipv4Addr>()
826                    .map_err(|_| AgentdError::Config(format!("invalid IPv4 gateway: {v}")))?,
827            );
828        } else if let Some(v) = part.strip_prefix("dns=") {
829            dns = Some(
830                v.parse::<Ipv4Addr>()
831                    .map_err(|_| AgentdError::Config(format!("invalid IPv4 DNS: {v}")))?,
832            );
833        } else {
834            return Err(AgentdError::Config(format!(
835                "unknown MSB_NET_IPV4 option: {part}"
836            )));
837        }
838    }
839
840    let address =
841        address.ok_or_else(|| AgentdError::Config("MSB_NET_IPV4 missing addr=".into()))?;
842    let prefix_len =
843        prefix_len.ok_or_else(|| AgentdError::Config("MSB_NET_IPV4 missing addr=".into()))?;
844    let gateway = gateway.ok_or_else(|| AgentdError::Config("MSB_NET_IPV4 missing gw=".into()))?;
845
846    Ok(NetIpv4Spec {
847        address,
848        prefix_len,
849        gateway,
850        dns,
851    })
852}
853
854/// Parses `MSB_NET_IPV6` value: `addr=ADDR/N,gw=ADDR[,dns=ADDR]`
855fn parse_net_ipv6(val: &str) -> AgentdResult<NetIpv6Spec> {
856    let mut address = None;
857    let mut prefix_len = None;
858    let mut gateway = None;
859    let mut dns = None;
860
861    for part in val.split(',') {
862        if let Some(v) = part.strip_prefix("addr=") {
863            let (addr, prefix) = parse_cidr_v6(v)?;
864            address = Some(addr);
865            prefix_len = Some(prefix);
866        } else if let Some(v) = part.strip_prefix("gw=") {
867            gateway = Some(
868                v.parse::<Ipv6Addr>()
869                    .map_err(|_| AgentdError::Config(format!("invalid IPv6 gateway: {v}")))?,
870            );
871        } else if let Some(v) = part.strip_prefix("dns=") {
872            dns = Some(
873                v.parse::<Ipv6Addr>()
874                    .map_err(|_| AgentdError::Config(format!("invalid IPv6 DNS: {v}")))?,
875            );
876        } else {
877            return Err(AgentdError::Config(format!(
878                "unknown MSB_NET_IPV6 option: {part}"
879            )));
880        }
881    }
882
883    let address =
884        address.ok_or_else(|| AgentdError::Config("MSB_NET_IPV6 missing addr=".into()))?;
885    let prefix_len =
886        prefix_len.ok_or_else(|| AgentdError::Config("MSB_NET_IPV6 missing addr=".into()))?;
887    let gateway = gateway.ok_or_else(|| AgentdError::Config("MSB_NET_IPV6 missing gw=".into()))?;
888
889    Ok(NetIpv6Spec {
890        address,
891        prefix_len,
892        gateway,
893        dns,
894    })
895}
896
897/// Parses a MAC address string like `02:5a:7b:13:01:02`.
898fn parse_mac(s: &str) -> AgentdResult<[u8; 6]> {
899    let mut mac = [0u8; 6];
900    let mut len = 0usize;
901    for (i, part) in s.split(':').enumerate() {
902        if i >= 6 {
903            return Err(AgentdError::Config(format!("invalid MAC address: {s}")));
904        }
905        mac[i] = u8::from_str_radix(part, 16)
906            .map_err(|_| AgentdError::Config(format!("invalid MAC octet: {part}")))?;
907        len = i + 1;
908    }
909    if len != 6 {
910        return Err(AgentdError::Config(format!("invalid MAC address: {s}")));
911    }
912    Ok(mac)
913}
914
915/// Parses an IPv4 CIDR like `100.96.1.2/30`.
916fn parse_cidr_v4(s: &str) -> AgentdResult<(Ipv4Addr, u8)> {
917    let (addr_str, prefix_str) = s
918        .split_once('/')
919        .ok_or_else(|| AgentdError::Config(format!("invalid IPv4 CIDR (missing /): {s}")))?;
920    let addr = addr_str
921        .parse::<Ipv4Addr>()
922        .map_err(|_| AgentdError::Config(format!("invalid IPv4 address: {addr_str}")))?;
923    let prefix = prefix_str
924        .parse::<u8>()
925        .map_err(|_| AgentdError::Config(format!("invalid IPv4 prefix length: {prefix_str}")))?;
926    if prefix > 32 {
927        return Err(AgentdError::Config(format!(
928            "IPv4 prefix length out of range (0-32): {prefix}"
929        )));
930    }
931    Ok((addr, prefix))
932}
933
934/// Parses an IPv6 CIDR like `fd42:6d73:62:2a::2/64`.
935fn parse_cidr_v6(s: &str) -> AgentdResult<(Ipv6Addr, u8)> {
936    let (addr_str, prefix_str) = s
937        .rsplit_once('/')
938        .ok_or_else(|| AgentdError::Config(format!("invalid IPv6 CIDR (missing /): {s}")))?;
939    let addr = addr_str
940        .parse::<Ipv6Addr>()
941        .map_err(|_| AgentdError::Config(format!("invalid IPv6 address: {addr_str}")))?;
942    let prefix = prefix_str
943        .parse::<u8>()
944        .map_err(|_| AgentdError::Config(format!("invalid IPv6 prefix length: {prefix_str}")))?;
945    if prefix > 128 {
946        return Err(AgentdError::Config(format!(
947            "IPv6 prefix length out of range (0-128): {prefix}"
948        )));
949    }
950    Ok((addr, prefix))
951}
952
953//--------------------------------------------------------------------------------------------------
954// Parse Functions: Handoff Init
955//--------------------------------------------------------------------------------------------------
956
957/// Reads `MSB_HANDOFF_INIT[_ARGS|_ENV]` and assembles a [`HandoffInit`].
958///
959/// Returns `Ok(None)` when `MSB_HANDOFF_INIT` is unset/empty (the
960/// default no-handoff path). Returns `Err` when the cmd path is
961/// not absolute, or when `MSB_HANDOFF_INIT_ENV` contains an entry
962/// without an `=`.
963fn parse_handoff_init() -> AgentdResult<Option<HandoffInit>> {
964    let Some(cmd_str) = read_env_raw(ENV_HANDOFF_INIT) else {
965        return Ok(None);
966    };
967    if cmd_str.trim().is_empty() {
968        return Ok(None);
969    }
970
971    let cmd = PathBuf::from(&cmd_str);
972    // The sentinel `auto` is resolved lazily in `handoff::do_handoff`
973    // by probing `HANDOFF_INIT_AUTO_CANDIDATES`; everything else must
974    // be an absolute path.
975    if cmd_str != HANDOFF_INIT_AUTO && !cmd.is_absolute() {
976        return Err(AgentdError::Config(format!(
977            "{ENV_HANDOFF_INIT} must be an absolute path or `auto`, got: {cmd_str}"
978        )));
979    }
980
981    let argv = match read_env_raw(ENV_HANDOFF_INIT_ARGS) {
982        Some(val) if !val.is_empty() => val.split(HANDOFF_INIT_SEP).map(OsString::from).collect(),
983        _ => Vec::new(),
984    };
985
986    let env = match read_env_raw(ENV_HANDOFF_INIT_ENV) {
987        Some(val) if !val.is_empty() => val
988            .split(HANDOFF_INIT_SEP)
989            .map(|entry| {
990                let (k, v) = entry.split_once('=').ok_or_else(|| {
991                    AgentdError::Config(format!(
992                        "{ENV_HANDOFF_INIT_ENV} entry missing '=': {entry}"
993                    ))
994                })?;
995                if k.is_empty() {
996                    return Err(AgentdError::Config(format!(
997                        "{ENV_HANDOFF_INIT_ENV} entry has empty key: {entry}"
998                    )));
999                }
1000                Ok((OsString::from(k), OsString::from(v)))
1001            })
1002            .collect::<AgentdResult<Vec<_>>>()?,
1003        _ => Vec::new(),
1004    };
1005
1006    Ok(Some(HandoffInit { cmd, argv, env }))
1007}
1008
1009//--------------------------------------------------------------------------------------------------
1010// Helper Functions
1011//--------------------------------------------------------------------------------------------------
1012
1013/// Reads a single environment variable, returning `None` for missing or empty values.
1014fn read_env(key: &str) -> Option<String> {
1015    env::var(key)
1016        .ok()
1017        .map(|v| v.trim().to_string())
1018        .filter(|v| !v.is_empty())
1019}
1020
1021/// Reads a single environment variable without trimming whitespace.
1022///
1023/// Used for the handoff-init vars where the `\x1f` separator and argv
1024/// content are sensitive to byte-exact preservation.
1025fn read_env_raw(key: &str) -> Option<String> {
1026    env::var(key).ok().filter(|v| !v.is_empty())
1027}
1028
1029//--------------------------------------------------------------------------------------------------
1030// Tests
1031//--------------------------------------------------------------------------------------------------
1032
1033#[cfg(test)]
1034mod tests {
1035    use super::*;
1036
1037    // ── Block Root ────────────────────────────────────────────────────
1038
1039    #[test]
1040    fn test_parse_block_root_disk_image() {
1041        let spec = parse_block_root("kind=disk-image,device=/dev/vda,fstype=ext4").unwrap();
1042        let BlockRootSpec::DiskImage { device, fstype } = spec else {
1043            panic!("expected DiskImage");
1044        };
1045        assert_eq!(device, "/dev/vda");
1046        assert_eq!(fstype.as_deref(), Some("ext4"));
1047    }
1048
1049    #[test]
1050    fn test_parse_block_root_disk_image_no_fstype() {
1051        let spec = parse_block_root("kind=disk-image,device=/dev/vda").unwrap();
1052        let BlockRootSpec::DiskImage { device, fstype } = spec else {
1053            panic!("expected DiskImage");
1054        };
1055        assert_eq!(device, "/dev/vda");
1056        assert_eq!(fstype, None);
1057    }
1058
1059    #[test]
1060    fn test_parse_block_root_oci_erofs() {
1061        let spec =
1062            parse_block_root("kind=oci-erofs,lower=/dev/vda,upper=/dev/vdb,upper_fstype=ext4")
1063                .unwrap();
1064        let BlockRootSpec::OciErofs {
1065            lower,
1066            upper,
1067            upper_fstype,
1068        } = spec
1069        else {
1070            panic!("expected OciErofs");
1071        };
1072        assert_eq!(lower, "/dev/vda");
1073        assert_eq!(upper, "/dev/vdb");
1074        assert_eq!(upper_fstype, "ext4");
1075    }
1076
1077    #[test]
1078    fn test_parse_block_root_unknown_kind_errors() {
1079        let err = parse_block_root("kind=bogus,device=/dev/vda").unwrap_err();
1080        assert!(err.to_string().contains("unknown kind"));
1081    }
1082
1083    #[test]
1084    fn test_parse_block_root_missing_kind_errors() {
1085        let err = parse_block_root("/dev/vda").unwrap_err();
1086        assert!(err.to_string().contains("missing 'kind' key"));
1087    }
1088
1089    #[test]
1090    fn test_parse_block_root_disk_image_missing_device_errors() {
1091        let err = parse_block_root("kind=disk-image").unwrap_err();
1092        assert!(err.to_string().contains("missing 'device'"));
1093    }
1094
1095    #[test]
1096    fn test_parse_block_root_oci_erofs_missing_upper_errors() {
1097        let err = parse_block_root("kind=oci-erofs,lower=/dev/vda,upper_fstype=ext4").unwrap_err();
1098        assert!(err.to_string().contains("missing 'upper'"));
1099    }
1100
1101    #[test]
1102    fn test_parse_block_root_duplicate_key_errors() {
1103        let err = parse_block_root("kind=disk-image,device=/dev/vda,device=/dev/vdb").unwrap_err();
1104        assert!(err.to_string().contains("duplicate key 'device'"));
1105    }
1106
1107    // ── File Mounts ────────────────────────────────────────────────────
1108
1109    #[test]
1110    fn test_parse_file_mount_entry_basic() {
1111        let spec = parse_file_mount_entry("fm_config:app.conf:/etc/app.conf").unwrap();
1112        assert_eq!(spec.tag, "fm_config");
1113        assert_eq!(spec.filename, "app.conf");
1114        assert_eq!(spec.guest_path, "/etc/app.conf");
1115        assert!(!spec.readonly);
1116        assert!(!spec.noexec);
1117    }
1118
1119    #[test]
1120    fn test_parse_file_mount_entry_readonly() {
1121        let spec = parse_file_mount_entry("fm_config:app.conf:/etc/app.conf:ro,noexec").unwrap();
1122        assert!(spec.readonly);
1123        assert!(spec.noexec);
1124    }
1125
1126    #[test]
1127    fn test_parse_file_mount_entry_too_few_parts() {
1128        assert!(parse_file_mount_entry("fm_config:/etc/app.conf").is_err());
1129    }
1130
1131    #[test]
1132    fn test_parse_file_mount_entry_empty_filename() {
1133        assert!(parse_file_mount_entry("fm_config::/etc/app.conf").is_err());
1134    }
1135
1136    #[test]
1137    fn test_parse_file_mount_entry_relative_path() {
1138        assert!(parse_file_mount_entry("fm_config:app.conf:relative/path").is_err());
1139    }
1140
1141    #[test]
1142    fn test_parse_file_mount_entry_too_many_parts() {
1143        assert!(parse_file_mount_entry("fm_config:app.conf:/etc/app.conf:ro:extra").is_err());
1144    }
1145
1146    #[test]
1147    fn test_parse_file_mount_entry_unknown_flag() {
1148        assert!(parse_file_mount_entry("fm_config:app.conf:/etc/app.conf:exec").is_err());
1149    }
1150
1151    #[test]
1152    fn test_parse_file_mount_entry_empty_tag() {
1153        assert!(parse_file_mount_entry(":app.conf:/etc/app.conf").is_err());
1154    }
1155
1156    // ── Tmpfs ─────────────────────────────────────────────────────────
1157
1158    #[test]
1159    fn test_parse_path_only() {
1160        let spec = parse_tmpfs_entry("/tmp").unwrap();
1161        assert_eq!(spec.path, "/tmp");
1162        assert_eq!(spec.size_mib, None);
1163        assert_eq!(spec.mode, None);
1164        assert!(!spec.noexec);
1165    }
1166
1167    #[test]
1168    fn test_parse_with_size() {
1169        let spec = parse_tmpfs_entry("/tmp:size=256").unwrap();
1170        assert_eq!(spec.path, "/tmp");
1171        assert_eq!(spec.size_mib, Some(256));
1172    }
1173
1174    #[test]
1175    fn test_parse_with_noexec() {
1176        let spec = parse_tmpfs_entry("/tmp:noexec").unwrap();
1177        assert_eq!(spec.path, "/tmp");
1178        assert!(spec.noexec);
1179    }
1180
1181    // ── Disk Mounts ───────────────────────────────────────────────────
1182
1183    #[test]
1184    fn test_parse_disk_mount_entry_basic() {
1185        let spec = parse_disk_mount_entry("data_abc:/data:fstype=ext4").unwrap();
1186        assert_eq!(spec.id, "data_abc");
1187        assert_eq!(spec.guest_path, "/data");
1188        assert_eq!(spec.fstype.as_deref(), Some("ext4"));
1189        assert!(!spec.readonly);
1190        assert!(!spec.noexec);
1191    }
1192
1193    #[test]
1194    fn test_parse_disk_mount_entry_readonly() {
1195        let spec = parse_disk_mount_entry("seed_7f:/seed:ro,noexec,fstype=ext4").unwrap();
1196        assert!(spec.readonly);
1197        assert!(spec.noexec);
1198        assert_eq!(spec.fstype.as_deref(), Some("ext4"));
1199    }
1200
1201    #[test]
1202    fn test_parse_disk_mount_entry_no_fstype_means_autodetect() {
1203        let spec = parse_disk_mount_entry("probe_1:/data:ro").unwrap();
1204        assert!(spec.fstype.is_none());
1205        assert!(spec.readonly);
1206    }
1207
1208    #[test]
1209    fn test_parse_disk_mount_entry_autodetect_no_ro() {
1210        let spec = parse_disk_mount_entry("probe_1:/data").unwrap();
1211        assert!(spec.fstype.is_none());
1212        assert!(!spec.readonly);
1213    }
1214
1215    #[test]
1216    fn test_parse_disk_mount_entry_rejects_unknown_flag() {
1217        let err = parse_disk_mount_entry("id:/data:exec").unwrap_err();
1218        assert!(err.to_string().contains("unsupported mount option"));
1219    }
1220
1221    #[test]
1222    fn test_parse_disk_mount_entry_rejects_relative_path() {
1223        assert!(parse_disk_mount_entry("id:relative").is_err());
1224    }
1225
1226    #[test]
1227    fn test_parse_disk_mount_entry_rejects_empty_id() {
1228        assert!(parse_disk_mount_entry(":/data:fstype=ext4").is_err());
1229    }
1230
1231    #[test]
1232    fn test_parse_disk_mount_entry_rejects_too_many_parts() {
1233        assert!(parse_disk_mount_entry("id:/data:fstype=ext4:extra").is_err());
1234    }
1235
1236    #[test]
1237    fn test_parse_disk_mounts_multiple_entries() {
1238        let specs =
1239            parse_disk_mounts("data_1:/data:fstype=ext4;seed_2:/seed:ro;probe_3:/p").unwrap();
1240        assert_eq!(specs.len(), 3);
1241        assert_eq!(specs[0].guest_path, "/data");
1242        assert!(specs[1].readonly);
1243        assert!(specs[2].fstype.is_none());
1244    }
1245
1246    #[test]
1247    fn test_parse_with_ro() {
1248        let spec = parse_tmpfs_entry("/seed:size=64,ro").unwrap();
1249        assert_eq!(spec.path, "/seed");
1250        assert_eq!(spec.size_mib, Some(64));
1251        assert!(spec.readonly);
1252        assert!(!spec.noexec);
1253    }
1254
1255    #[test]
1256    fn test_parse_ro_defaults_to_false_when_absent() {
1257        let spec = parse_tmpfs_entry("/tmp:size=256").unwrap();
1258        assert!(!spec.readonly);
1259    }
1260
1261    #[test]
1262    fn test_parse_with_octal_mode() {
1263        let spec = parse_tmpfs_entry("/tmp:mode=1777").unwrap();
1264        assert_eq!(spec.mode, Some(0o1777));
1265
1266        let spec = parse_tmpfs_entry("/data:mode=755").unwrap();
1267        assert_eq!(spec.mode, Some(0o755));
1268    }
1269
1270    #[test]
1271    fn test_parse_multi_options() {
1272        let spec = parse_tmpfs_entry("/tmp:size=256,mode=1777,noexec").unwrap();
1273        assert_eq!(spec.path, "/tmp");
1274        assert_eq!(spec.size_mib, Some(256));
1275        assert_eq!(spec.mode, Some(0o1777));
1276        assert!(spec.noexec);
1277    }
1278
1279    #[test]
1280    fn test_parse_unknown_option_errors() {
1281        let err = parse_tmpfs_entry("/tmp:bogus=42").unwrap_err();
1282        assert!(err.to_string().contains("unknown mount option"));
1283    }
1284
1285    #[test]
1286    fn test_parse_invalid_size_errors() {
1287        let err = parse_tmpfs_entry("/tmp:size=abc").unwrap_err();
1288        assert!(err.to_string().contains("invalid tmpfs size"));
1289    }
1290
1291    #[test]
1292    fn test_parse_invalid_mode_errors() {
1293        let err = parse_tmpfs_entry("/tmp:mode=zzz").unwrap_err();
1294        assert!(err.to_string().contains("invalid octal tmpfs mode"));
1295    }
1296
1297    #[test]
1298    fn test_parse_empty_path_errors() {
1299        let err = parse_tmpfs_entry(":size=256").unwrap_err();
1300        assert!(err.to_string().contains("empty path"));
1301    }
1302
1303    // ── Network ───────────────────────────────────────────────────────
1304
1305    #[test]
1306    fn test_parse_net_full() {
1307        let spec = parse_net("iface=eth0,mac=02:5a:7b:13:01:02,mtu=1500").unwrap();
1308        assert_eq!(spec.iface, "eth0");
1309        assert_eq!(spec.mac, [0x02, 0x5a, 0x7b, 0x13, 0x01, 0x02]);
1310        assert_eq!(spec.mtu, 1500);
1311    }
1312
1313    #[test]
1314    fn test_parse_net_default_mtu() {
1315        let spec = parse_net("iface=eth0,mac=02:00:00:00:00:01").unwrap();
1316        assert_eq!(spec.mtu, 1500);
1317    }
1318
1319    #[test]
1320    fn test_parse_net_missing_iface() {
1321        assert!(parse_net("mac=02:00:00:00:00:01").is_err());
1322    }
1323
1324    #[test]
1325    fn test_parse_net_missing_mac() {
1326        assert!(parse_net("iface=eth0").is_err());
1327    }
1328
1329    #[test]
1330    fn test_parse_net_unknown_option() {
1331        assert!(parse_net("iface=eth0,mac=02:00:00:00:00:01,bogus=42").is_err());
1332    }
1333
1334    #[test]
1335    fn test_parse_net_ipv4() {
1336        let spec = parse_net_ipv4("addr=100.96.1.2/30,gw=100.96.1.1,dns=100.96.1.1").unwrap();
1337        assert_eq!(spec.address, Ipv4Addr::new(100, 96, 1, 2));
1338        assert_eq!(spec.prefix_len, 30);
1339        assert_eq!(spec.gateway, Ipv4Addr::new(100, 96, 1, 1));
1340        assert_eq!(spec.dns, Some(Ipv4Addr::new(100, 96, 1, 1)));
1341    }
1342
1343    #[test]
1344    fn test_parse_net_ipv4_no_dns() {
1345        let spec = parse_net_ipv4("addr=10.0.0.2/24,gw=10.0.0.1").unwrap();
1346        assert_eq!(spec.dns, None);
1347    }
1348
1349    #[test]
1350    fn test_parse_net_ipv4_missing_addr() {
1351        assert!(parse_net_ipv4("gw=10.0.0.1").is_err());
1352    }
1353
1354    #[test]
1355    fn test_parse_net_ipv6() {
1356        let spec = parse_net_ipv6(
1357            "addr=fd42:6d73:62:2a::2/64,gw=fd42:6d73:62:2a::1,dns=fd42:6d73:62:2a::1",
1358        )
1359        .unwrap();
1360        assert_eq!(
1361            spec.address,
1362            "fd42:6d73:62:2a::2".parse::<Ipv6Addr>().unwrap()
1363        );
1364        assert_eq!(spec.prefix_len, 64);
1365        assert_eq!(
1366            spec.gateway,
1367            "fd42:6d73:62:2a::1".parse::<Ipv6Addr>().unwrap()
1368        );
1369        assert!(spec.dns.is_some());
1370    }
1371
1372    #[test]
1373    fn test_parse_mac_valid() {
1374        let mac = parse_mac("02:5a:7b:13:01:02").unwrap();
1375        assert_eq!(mac, [0x02, 0x5a, 0x7b, 0x13, 0x01, 0x02]);
1376    }
1377
1378    #[test]
1379    fn test_parse_mac_invalid() {
1380        assert!(parse_mac("02:5a:7b").is_err());
1381        assert!(parse_mac("zz:00:00:00:00:00").is_err());
1382    }
1383
1384    #[test]
1385    fn test_parse_cidr_v4() {
1386        let (addr, prefix) = parse_cidr_v4("100.96.1.2/30").unwrap();
1387        assert_eq!(addr, Ipv4Addr::new(100, 96, 1, 2));
1388        assert_eq!(prefix, 30);
1389    }
1390
1391    #[test]
1392    fn test_parse_cidr_v6() {
1393        let (addr, prefix) = parse_cidr_v6("fd42:6d73:62:2a::2/64").unwrap();
1394        assert_eq!(addr, "fd42:6d73:62:2a::2".parse::<Ipv6Addr>().unwrap());
1395        assert_eq!(prefix, 64);
1396    }
1397
1398    // ── Rlimits ───────────────────────────────────────────────────────
1399
1400    #[test]
1401    fn test_parse_rlimits_happy_path() {
1402        let rlimits = parse_rlimits("nofile=65535;nproc=4096:8192").unwrap();
1403        assert_eq!(rlimits.len(), 2);
1404        assert_eq!(rlimits[0].resource, "nofile");
1405        assert_eq!(rlimits[0].soft, 65535);
1406        assert_eq!(rlimits[0].hard, 65535);
1407        assert_eq!(rlimits[1].resource, "nproc");
1408        assert_eq!(rlimits[1].soft, 4096);
1409        assert_eq!(rlimits[1].hard, 8192);
1410    }
1411
1412    #[test]
1413    fn test_parse_rlimits_ignores_empty_entries() {
1414        let rlimits = parse_rlimits("nofile=1024;").unwrap();
1415        assert_eq!(rlimits.len(), 1);
1416        assert_eq!(rlimits[0].resource, "nofile");
1417    }
1418
1419    #[test]
1420    fn test_parse_rlimits_rejects_unknown_resource() {
1421        let err = parse_rlimits("bogus=1024").unwrap_err();
1422        assert!(
1423            matches!(err, AgentdError::Config(msg) if msg.contains("unknown resource: bogus")),
1424            "unexpected error shape"
1425        );
1426    }
1427
1428    #[test]
1429    fn test_parse_rlimits_rejects_duplicate_resource() {
1430        let err = parse_rlimits("nofile=1024;nofile=65535").unwrap_err();
1431        assert!(
1432            matches!(err, AgentdError::Config(msg) if msg.contains("duplicate resource: nofile")),
1433            "unexpected error shape"
1434        );
1435    }
1436
1437    #[test]
1438    fn test_parse_rlimits_rejects_malformed_entry() {
1439        assert!(parse_rlimits("nofile").is_err());
1440        assert!(parse_rlimits("nofile=abc").is_err());
1441        assert!(parse_rlimits("nofile=65535:1024").is_err()); // soft > hard
1442    }
1443
1444    // ── Handoff Init ──────────────────────────────────────────────────
1445
1446    /// Mutex serialising tests that touch `MSB_HANDOFF_INIT*` env vars,
1447    /// since `parse_handoff_init` reads them from the process env.
1448    static HANDOFF_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1449
1450    fn with_handoff_env<R>(
1451        cmd: Option<&str>,
1452        args: Option<&str>,
1453        env_var: Option<&str>,
1454        f: impl FnOnce() -> R,
1455    ) -> R {
1456        let _guard = HANDOFF_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1457        unsafe {
1458            match cmd {
1459                Some(v) => env::set_var(ENV_HANDOFF_INIT, v),
1460                None => env::remove_var(ENV_HANDOFF_INIT),
1461            }
1462            match args {
1463                Some(v) => env::set_var(ENV_HANDOFF_INIT_ARGS, v),
1464                None => env::remove_var(ENV_HANDOFF_INIT_ARGS),
1465            }
1466            match env_var {
1467                Some(v) => env::set_var(ENV_HANDOFF_INIT_ENV, v),
1468                None => env::remove_var(ENV_HANDOFF_INIT_ENV),
1469            }
1470        }
1471        let out = f();
1472        unsafe {
1473            env::remove_var(ENV_HANDOFF_INIT);
1474            env::remove_var(ENV_HANDOFF_INIT_ARGS);
1475            env::remove_var(ENV_HANDOFF_INIT_ENV);
1476        }
1477        out
1478    }
1479
1480    #[test]
1481    fn test_parse_handoff_init_unset_returns_none() {
1482        let res = with_handoff_env(None, None, None, parse_handoff_init).unwrap();
1483        assert!(res.is_none());
1484    }
1485
1486    #[test]
1487    fn test_parse_handoff_init_empty_returns_none() {
1488        let res = with_handoff_env(Some(""), None, None, parse_handoff_init).unwrap();
1489        assert!(res.is_none());
1490    }
1491
1492    #[test]
1493    fn test_parse_handoff_init_cmd_only() {
1494        let res = with_handoff_env(Some("/lib/systemd/systemd"), None, None, parse_handoff_init)
1495            .unwrap()
1496            .unwrap();
1497        assert_eq!(res.cmd, PathBuf::from("/lib/systemd/systemd"));
1498        assert!(res.argv.is_empty());
1499        assert!(res.env.is_empty());
1500    }
1501
1502    #[test]
1503    fn test_parse_handoff_init_with_argv() {
1504        let argv = format!("--unit=multi-user.target{HANDOFF_INIT_SEP}--log-level=warning");
1505        let res = with_handoff_env(
1506            Some("/lib/systemd/systemd"),
1507            Some(&argv),
1508            None,
1509            parse_handoff_init,
1510        )
1511        .unwrap()
1512        .unwrap();
1513        assert_eq!(
1514            res.argv,
1515            vec![
1516                OsString::from("--unit=multi-user.target"),
1517                OsString::from("--log-level=warning"),
1518            ]
1519        );
1520    }
1521
1522    #[test]
1523    fn test_parse_handoff_init_with_env() {
1524        let envs = format!("container=microsandbox{HANDOFF_INIT_SEP}LANG=C.UTF-8");
1525        let res = with_handoff_env(Some("/sbin/init"), None, Some(&envs), parse_handoff_init)
1526            .unwrap()
1527            .unwrap();
1528        assert_eq!(
1529            res.env,
1530            vec![
1531                (OsString::from("container"), OsString::from("microsandbox")),
1532                (OsString::from("LANG"), OsString::from("C.UTF-8")),
1533            ]
1534        );
1535    }
1536
1537    #[test]
1538    fn test_parse_handoff_init_argv_with_spaces_preserved() {
1539        // Argv entries can contain any characters except '\x1f' and NUL.
1540        let argv = format!("--label=hello world{HANDOFF_INIT_SEP}--config=/etc/foo;bar");
1541        let res = with_handoff_env(Some("/sbin/init"), Some(&argv), None, parse_handoff_init)
1542            .unwrap()
1543            .unwrap();
1544        assert_eq!(
1545            res.argv,
1546            vec![
1547                OsString::from("--label=hello world"),
1548                OsString::from("--config=/etc/foo;bar"),
1549            ]
1550        );
1551    }
1552
1553    #[test]
1554    fn test_parse_handoff_init_rejects_relative_path() {
1555        let err = with_handoff_env(Some("sbin/init"), None, None, parse_handoff_init).unwrap_err();
1556        assert!(err.to_string().contains("absolute path"));
1557    }
1558
1559    #[test]
1560    fn test_parse_handoff_init_env_entry_missing_equals() {
1561        let envs = format!("KEY=value{HANDOFF_INIT_SEP}NOEQUALS");
1562        let err = with_handoff_env(Some("/sbin/init"), None, Some(&envs), parse_handoff_init)
1563            .unwrap_err();
1564        assert!(err.to_string().contains("missing '='"));
1565    }
1566
1567    #[test]
1568    fn test_parse_handoff_init_env_entry_empty_key_rejected() {
1569        // `=value` (empty key) used to silently produce a nameless env entry.
1570        let envs = "=value".to_string();
1571        let err = with_handoff_env(Some("/sbin/init"), None, Some(&envs), parse_handoff_init)
1572            .unwrap_err();
1573        assert!(err.to_string().contains("empty key"));
1574    }
1575
1576    #[test]
1577    fn test_parse_handoff_init_env_value_with_equals_is_value() {
1578        // Only the first '=' splits; the rest is part of the value.
1579        let envs = "PATH=/a:/b=/c".to_string();
1580        let res = with_handoff_env(Some("/sbin/init"), None, Some(&envs), parse_handoff_init)
1581            .unwrap()
1582            .unwrap();
1583        assert_eq!(
1584            res.env,
1585            vec![(OsString::from("PATH"), OsString::from("/a:/b=/c"))]
1586        );
1587    }
1588}