Skip to main content

microsandbox_agentd/
config.rs

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