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