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