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