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