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