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