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