1use std::env;
16use std::ffi::OsString;
17use std::net::{Ipv4Addr, Ipv6Addr};
18use std::path::PathBuf;
19
20use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
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_CWD, ENV_HANDOFF_INIT_ENV, ENV_HOST_ALIAS,
24 ENV_HOSTNAME, ENV_NET, ENV_NET_IPV4, ENV_NET_IPV6, ENV_RLIMITS, ENV_SECURITY_PROFILE,
25 ENV_TMPFS, ENV_USER, HANDOFF_INIT_AUTO,
26 bootstrap::{
27 BootstrapBlockRoot, BootstrapBlockRootUpper, BootstrapEnvVar, BootstrapHandoffInit,
28 BootstrapSecurityProfile, GuestBootstrap,
29 },
30 exec::ExecRlimit,
31};
32use serde::de::DeserializeOwned;
33
34use crate::error::{AgentdError, AgentdResult};
35use crate::rlimit;
36
37#[derive(Debug)]
49pub struct BootParams {
50 pub(crate) block_root: Option<BlockRootSpec>,
52
53 pub(crate) dir_mounts: Vec<DirMountSpec>,
55
56 pub(crate) file_mounts: Vec<FileMountSpec>,
58
59 pub(crate) disk_mounts: Vec<DiskMountSpec>,
61
62 pub(crate) tmpfs: Vec<TmpfsSpec>,
64
65 pub(crate) security_profile: SecurityProfile,
67
68 pub(crate) hostname: Option<String>,
70
71 pub(crate) host_alias: Option<String>,
74
75 pub(crate) net: Option<NetSpec>,
77
78 pub(crate) net_ipv4: Option<NetIpv4Spec>,
80
81 pub(crate) net_ipv6: Option<NetIpv6Spec>,
83
84 pub(crate) rlimits: Vec<ExecRlimit>,
87
88 pub(crate) handoff_init: Option<HandoffInit>,
91}
92
93#[derive(Debug)]
99pub struct HandoffInit {
100 pub(crate) cmd: PathBuf,
103
104 pub(crate) argv: Vec<OsString>,
107
108 pub(crate) cwd: Option<PathBuf>,
110
111 pub(crate) env: Vec<(OsString, OsString)>,
114}
115
116#[derive(Debug)]
121pub struct AgentdConfig {
122 pub(crate) user: Option<String>,
124
125 pub(crate) security_profile: SecurityProfile,
127
128 pub(crate) default_cwd: Option<String>,
130
131 pub(crate) default_env: Vec<BootstrapEnvVar>,
133}
134
135#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
137pub enum SecurityProfile {
138 #[default]
140 Default,
141
142 Restricted,
144}
145
146#[derive(Debug)]
148pub(crate) struct TmpfsSpec {
149 pub path: String,
150 pub size_mib: Option<u32>,
151 pub mode: Option<u32>,
152 pub noexec: bool,
153 pub nosuid: bool,
154 pub nodev: bool,
155 pub readonly: bool,
156}
157
158#[derive(Debug)]
160pub(crate) enum BlockRootSpec {
161 DiskImage {
163 device: String,
164 fstype: Option<String>,
165 },
166 OciErofs {
168 lower: String,
169 upper: BlockRootUpper,
170 },
171}
172
173#[derive(Debug)]
179pub(crate) enum BlockRootUpper {
180 Device { device: String, fstype: String },
182 Tmpfs { size_mib: Option<u32> },
184}
185
186#[derive(Debug)]
188pub(crate) struct DirMountSpec {
189 pub tag: String,
190 pub guest_path: String,
191 pub readonly: bool,
192 pub noexec: bool,
193 pub nosuid: bool,
194 pub nodev: bool,
195}
196
197#[derive(Debug)]
199pub(crate) struct FileMountSpec {
200 pub tag: String,
201 pub filename: String,
202 pub guest_path: String,
203 pub readonly: bool,
204 pub noexec: bool,
205 pub nosuid: bool,
206 pub nodev: bool,
207}
208
209#[derive(Debug)]
215pub(crate) struct DiskMountSpec {
216 pub id: String,
217 pub guest_path: String,
218 pub fstype: Option<String>,
221 pub readonly: bool,
222 pub noexec: bool,
223 pub nosuid: bool,
224 pub nodev: bool,
225}
226
227#[derive(Debug, Default)]
229struct ParsedMountOptions {
230 readonly: bool,
231 noexec: bool,
232 nosuid: bool,
233 nodev: bool,
234 fstype: Option<String>,
235 size_mib: Option<u32>,
236 mode: Option<u32>,
237}
238
239#[derive(Debug, Clone, Copy, Default)]
241struct MountOptionSupport {
242 fstype: bool,
243 size: bool,
244 mode: bool,
245}
246
247#[derive(Debug)]
249pub(crate) struct NetSpec {
250 pub iface: String,
251 pub mac: [u8; 6],
252 pub mtu: u16,
253}
254
255#[derive(Debug)]
257pub(crate) struct NetIpv4Spec {
258 pub address: Ipv4Addr,
259 pub prefix_len: u8,
260 pub gateway: Ipv4Addr,
261 pub dns: Option<Ipv4Addr>,
262}
263
264#[derive(Debug)]
266pub(crate) struct NetIpv6Spec {
267 pub address: Ipv6Addr,
268 pub prefix_len: u8,
269 pub gateway: Ipv6Addr,
270 pub dns: Option<Ipv6Addr>,
271}
272
273#[derive(Debug)]
277pub(crate) struct NetConfig<'a> {
278 pub net: Option<&'a NetSpec>,
279 pub ipv4: Option<&'a NetIpv4Spec>,
280 pub ipv6: Option<&'a NetIpv6Spec>,
281}
282
283impl BootParams {
288 pub fn from_bootstrap(bootstrap: GuestBootstrap) -> AgentdResult<(Self, AgentdConfig)> {
291 validate_guest_bootstrap(&bootstrap)?;
292
293 let GuestBootstrap {
294 block_root,
295 dir_mounts,
296 file_mounts,
297 disk_mounts,
298 tmpfs_mounts,
299 hostname,
300 host_alias,
301 network,
302 rlimits,
303 user,
304 default_cwd,
305 default_env,
306 security_profile,
307 handoff_init,
308 } = bootstrap;
309
310 let security_profile = match security_profile {
311 BootstrapSecurityProfile::Default => SecurityProfile::Default,
312 BootstrapSecurityProfile::Restricted => SecurityProfile::Restricted,
313 };
314 let block_root = block_root.map(|root| match root {
315 BootstrapBlockRoot::DiskImage { device, fstype } => {
316 BlockRootSpec::DiskImage { device, fstype }
317 }
318 BootstrapBlockRoot::OciErofs { lower, upper } => BlockRootSpec::OciErofs {
319 lower,
320 upper: match upper {
321 BootstrapBlockRootUpper::Device { device, fstype } => {
322 BlockRootUpper::Device { device, fstype }
323 }
324 BootstrapBlockRootUpper::Tmpfs { size_mib } => {
325 BlockRootUpper::Tmpfs { size_mib }
326 }
327 },
328 },
329 });
330 let dir_mounts = dir_mounts
331 .into_iter()
332 .map(|mount| {
333 let flags = mount.flags;
334 DirMountSpec {
335 tag: mount.tag,
336 guest_path: mount.guest_path,
337 readonly: flags.readonly,
338 noexec: flags.noexec,
339 nosuid: flags.nosuid,
340 nodev: flags.nodev,
341 }
342 })
343 .collect();
344 let file_mounts = file_mounts
345 .into_iter()
346 .map(|mount| {
347 let flags = mount.flags;
348 FileMountSpec {
349 tag: mount.tag,
350 filename: mount.filename,
351 guest_path: mount.guest_path,
352 readonly: flags.readonly,
353 noexec: flags.noexec,
354 nosuid: flags.nosuid,
355 nodev: flags.nodev,
356 }
357 })
358 .collect();
359 let disk_mounts = disk_mounts
360 .into_iter()
361 .map(|mount| {
362 let flags = mount.flags;
363 DiskMountSpec {
364 id: mount.id,
365 guest_path: mount.guest_path,
366 fstype: mount.fstype,
367 readonly: flags.readonly,
368 noexec: flags.noexec,
369 nosuid: flags.nosuid,
370 nodev: flags.nodev,
371 }
372 })
373 .collect();
374 let tmpfs = tmpfs_mounts
375 .into_iter()
376 .map(|mount| {
377 let flags = mount.flags;
378 TmpfsSpec {
379 path: mount.path,
380 size_mib: mount.size_mib,
381 mode: mount.mode,
382 noexec: flags.noexec,
383 nosuid: flags.nosuid,
384 nodev: flags.nodev,
385 readonly: flags.readonly,
386 }
387 })
388 .collect();
389 let (net, net_ipv4, net_ipv6) = match network {
390 Some(network) => {
391 let net = NetSpec {
392 iface: network.interface,
393 mac: network.mac,
394 mtu: network.mtu,
395 };
396 let ipv4 = network.ipv4.map(|ipv4| NetIpv4Spec {
397 address: ipv4.address,
398 prefix_len: ipv4.prefix_len,
399 gateway: ipv4.gateway,
400 dns: ipv4.dns,
401 });
402 let ipv6 = network.ipv6.map(|ipv6| NetIpv6Spec {
403 address: ipv6.address,
404 prefix_len: ipv6.prefix_len,
405 gateway: ipv6.gateway,
406 dns: ipv6.dns,
407 });
408 (Some(net), ipv4, ipv6)
409 }
410 None => (None, None, None),
411 };
412 let handoff_init = handoff_init.map(convert_bootstrap_handoff).transpose()?;
413
414 Ok((
415 Self {
416 block_root,
417 dir_mounts,
418 file_mounts,
419 disk_mounts,
420 tmpfs,
421 security_profile,
422 hostname,
423 host_alias,
424 net,
425 net_ipv4,
426 net_ipv6,
427 rlimits,
428 handoff_init,
429 },
430 AgentdConfig {
431 user,
432 security_profile,
433 default_cwd,
434 default_env,
435 },
436 ))
437 }
438
439 pub fn from_env() -> AgentdResult<Self> {
444 Ok(Self {
445 block_root: read_env(ENV_BLOCK_ROOT)
446 .map(|v| parse_block_root(&v))
447 .transpose()?,
448 dir_mounts: read_env(ENV_DIR_MOUNTS)
449 .map(|v| parse_dir_mounts(&v))
450 .transpose()?
451 .unwrap_or_default(),
452 file_mounts: read_env(ENV_FILE_MOUNTS)
453 .map(|v| parse_file_mounts(&v))
454 .transpose()?
455 .unwrap_or_default(),
456 disk_mounts: read_env(ENV_DISK_MOUNTS)
457 .map(|v| parse_disk_mounts(&v))
458 .transpose()?
459 .unwrap_or_default(),
460 tmpfs: read_env(ENV_TMPFS)
461 .map(|v| parse_tmpfs_mounts(&v))
462 .transpose()?
463 .unwrap_or_default(),
464 hostname: read_env(ENV_HOSTNAME),
465 host_alias: read_env(ENV_HOST_ALIAS),
466 net: read_env(ENV_NET).map(|v| parse_net(&v)).transpose()?,
467 net_ipv4: read_env(ENV_NET_IPV4)
468 .map(|v| parse_net_ipv4(&v))
469 .transpose()?,
470 net_ipv6: read_env(ENV_NET_IPV6)
471 .map(|v| parse_net_ipv6(&v))
472 .transpose()?,
473 rlimits: read_env(ENV_RLIMITS)
474 .map(|v| parse_rlimits(&v))
475 .transpose()?
476 .unwrap_or_default(),
477 security_profile: read_env(ENV_SECURITY_PROFILE)
478 .map(|v| parse_security_profile(&v))
479 .transpose()?
480 .unwrap_or_default(),
481 handoff_init: parse_handoff_init()?,
482 })
483 }
484
485 pub fn take_handoff_init(&mut self) -> Option<HandoffInit> {
490 self.handoff_init.take()
491 }
492
493 pub(crate) fn network(&self) -> NetConfig<'_> {
495 NetConfig {
496 net: self.net.as_ref(),
497 ipv4: self.net_ipv4.as_ref(),
498 ipv6: self.net_ipv6.as_ref(),
499 }
500 }
501}
502
503impl AgentdConfig {
504 pub fn user(&self) -> Option<&str> {
506 self.user.as_deref()
507 }
508
509 pub fn default_cwd(&self) -> Option<&str> {
511 self.default_cwd.as_deref()
512 }
513
514 pub fn install_default_env(&self) {
516 for variable in &self.default_env {
517 unsafe { env::set_var(&variable.key, &variable.value) };
520 }
521
522 let configured_path = self
526 .default_env
527 .iter()
528 .rev()
529 .find(|variable| variable.key == "PATH")
530 .map(|variable| variable.value.as_str());
531 unsafe { env::set_var("PATH", scripts_path(configured_path)) };
534 }
535
536 pub fn from_env() -> AgentdResult<Self> {
540 Ok(Self {
541 user: read_env(ENV_USER),
542 security_profile: read_env(ENV_SECURITY_PROFILE)
543 .map(|v| parse_security_profile(&v))
544 .transpose()?
545 .unwrap_or_default(),
546 default_cwd: None,
547 default_env: Vec::new(),
548 })
549 }
550}
551
552pub(crate) fn scripts_path(existing: Option<&str>) -> String {
558 const DEFAULT_GUEST_PATH: &str = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
559
560 let existing = existing.unwrap_or(DEFAULT_GUEST_PATH);
561 if existing
562 .split(':')
563 .any(|segment| segment == microsandbox_protocol::SCRIPTS_PATH)
564 {
565 existing.to_string()
566 } else {
567 format!("{}:{existing}", microsandbox_protocol::SCRIPTS_PATH)
568 }
569}
570
571fn parse_security_profile(value: &str) -> AgentdResult<SecurityProfile> {
576 match value {
577 "default" => Ok(SecurityProfile::Default),
578 "restricted" => Ok(SecurityProfile::Restricted),
579 other => Err(AgentdError::Config(format!(
580 "{ENV_SECURITY_PROFILE} unknown value: {other}"
581 ))),
582 }
583}
584
585fn parse_block_root(val: &str) -> AgentdResult<BlockRootSpec> {
591 let mut kv: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
592 for part in val.split(',') {
593 let Some((k, v)) = part.split_once('=') else {
594 continue;
595 };
596 if kv.insert(k, v).is_some() {
597 return Err(AgentdError::Config(format!(
598 "MSB_BLOCK_ROOT duplicate key '{k}'"
599 )));
600 }
601 }
602
603 let get = |key: &str| -> AgentdResult<String> {
604 kv.get(key)
605 .filter(|v| !v.is_empty())
606 .map(|v| v.to_string())
607 .ok_or_else(|| AgentdError::Config(format!("MSB_BLOCK_ROOT missing '{key}'")))
608 };
609
610 match kv.get("kind").copied() {
611 Some("disk-image") => {
612 let device = get("device")?;
613 let fstype = kv
614 .get("fstype")
615 .filter(|v| !v.is_empty())
616 .map(|v| v.to_string());
617 Ok(BlockRootSpec::DiskImage { device, fstype })
618 }
619 Some("oci-erofs") => {
620 let lower = get("lower")?;
621 let upper = if kv.get("upper").copied() == Some("tmpfs") {
622 let size_mib = kv
623 .get("upper_size_mib")
624 .map(|v| {
625 v.parse::<u32>().map_err(|e| {
626 AgentdError::Config(format!(
627 "MSB_BLOCK_ROOT invalid upper_size_mib '{v}': {e}"
628 ))
629 })
630 })
631 .transpose()?;
632 if kv.contains_key("upper_fstype") {
633 return Err(AgentdError::Config(
634 "MSB_BLOCK_ROOT upper_fstype is not valid with upper=tmpfs".into(),
635 ));
636 }
637 BlockRootUpper::Tmpfs { size_mib }
638 } else {
639 BlockRootUpper::Device {
640 device: get("upper")?,
641 fstype: get("upper_fstype")?,
642 }
643 };
644 Ok(BlockRootSpec::OciErofs { lower, upper })
645 }
646 Some(other) => Err(AgentdError::Config(format!(
647 "MSB_BLOCK_ROOT unknown kind: {other}"
648 ))),
649 None => Err(AgentdError::Config(
650 "MSB_BLOCK_ROOT missing 'kind' key".into(),
651 )),
652 }
653}
654
655fn parse_mount_options(
657 env_name: &str,
658 opts: Option<&str>,
659 support: MountOptionSupport,
660) -> AgentdResult<ParsedMountOptions> {
661 let mut parsed = ParsedMountOptions::default();
662 let mut seen_access = false;
663 let mut seen_noexec = false;
664 let mut seen_nosuid = false;
665 let mut seen_nodev = false;
666 let mut seen_fstype = false;
667 let mut seen_size = false;
668 let mut seen_mode = false;
669
670 let Some(opts) = opts else {
671 return Ok(parsed);
672 };
673
674 for opt in opts.split(',') {
675 let opt = opt.trim();
676 if opt.is_empty() {
677 continue;
678 }
679 match opt {
680 "ro" | "rw" => {
681 if seen_access {
682 return Err(AgentdError::Config(format!(
683 "{env_name} option 'ro'/'rw' specified more than once"
684 )));
685 }
686 seen_access = true;
687 parsed.readonly = opt == "ro";
688 }
689 "noexec" => {
690 if seen_noexec {
691 return Err(AgentdError::Config(format!(
692 "{env_name} option 'noexec' specified more than once"
693 )));
694 }
695 seen_noexec = true;
696 parsed.noexec = true;
697 }
698 "nosuid" => {
699 if seen_nosuid {
700 return Err(AgentdError::Config(format!(
701 "{env_name} option 'nosuid' specified more than once"
702 )));
703 }
704 seen_nosuid = true;
705 parsed.nosuid = true;
706 }
707 "nodev" => {
708 if seen_nodev {
709 return Err(AgentdError::Config(format!(
710 "{env_name} option 'nodev' specified more than once"
711 )));
712 }
713 seen_nodev = true;
714 parsed.nodev = true;
715 }
716 "suid" | "exec" | "dev" => {
717 return Err(AgentdError::Config(format!(
718 "{env_name} unsupported mount option '{opt}'"
719 )));
720 }
721 _ => {
722 let (key, value) = opt.split_once('=').ok_or_else(|| {
723 AgentdError::Config(format!("{env_name} unknown mount option '{opt}'"))
724 })?;
725 if value.is_empty() {
726 return Err(AgentdError::Config(format!(
727 "{env_name} option '{key}' must not be empty"
728 )));
729 }
730 match key {
731 "fstype" if support.fstype => {
732 if seen_fstype {
733 return Err(AgentdError::Config(format!(
734 "{env_name} option 'fstype' specified more than once"
735 )));
736 }
737 seen_fstype = true;
738 if value.chars().any(|c| matches!(c, ',' | ';' | ':' | '=')) {
739 return Err(AgentdError::Config(format!(
740 "{env_name} fstype must not contain ',', ';', ':', or '=': {value}"
741 )));
742 }
743 parsed.fstype = Some(value.to_string());
744 }
745 "size" if support.size => {
746 if seen_size {
747 return Err(AgentdError::Config(format!(
748 "{env_name} option 'size' specified more than once"
749 )));
750 }
751 seen_size = true;
752 parsed.size_mib = Some(value.parse::<u32>().map_err(|_| {
753 AgentdError::Config(format!("{env_name} invalid tmpfs size: {value}"))
754 })?);
755 }
756 "mode" if support.mode => {
757 if seen_mode {
758 return Err(AgentdError::Config(format!(
759 "{env_name} option 'mode' specified more than once"
760 )));
761 }
762 seen_mode = true;
763 parsed.mode = Some(u32::from_str_radix(value, 8).map_err(|_| {
764 AgentdError::Config(format!(
765 "{env_name} invalid octal tmpfs mode: {value}"
766 ))
767 })?);
768 }
769 "fstype" | "size" | "mode" => {
770 return Err(AgentdError::Config(format!(
771 "{env_name} option '{key}' is not valid for this mount kind"
772 )));
773 }
774 other => {
775 return Err(AgentdError::Config(format!(
776 "{env_name} unknown mount option '{other}'"
777 )));
778 }
779 }
780 }
781 }
782 }
783
784 Ok(parsed)
785}
786
787fn parse_dir_mounts(val: &str) -> AgentdResult<Vec<DirMountSpec>> {
789 val.split(';')
790 .filter(|e| !e.is_empty())
791 .map(parse_dir_mount_entry)
792 .collect()
793}
794
795fn parse_dir_mount_entry(entry: &str) -> AgentdResult<DirMountSpec> {
797 let mut parts = entry.splitn(3, ':');
798 let Some(tag) = parts.next() else {
799 unreachable!("splitn always yields at least one part");
800 };
801 let guest_path = parts.next().ok_or_else(|| {
802 AgentdError::Config(format!(
803 "MSB_DIR_MOUNTS entry must be tag:path[:opts], got: {entry}"
804 ))
805 })?;
806 let options = parse_mount_options(ENV_DIR_MOUNTS, parts.next(), MountOptionSupport::default())?;
807
808 if tag.is_empty() {
809 return Err(AgentdError::Config(
810 "MSB_DIR_MOUNTS entry has empty tag".into(),
811 ));
812 }
813 if guest_path.is_empty() || !guest_path.starts_with('/') {
814 return Err(AgentdError::Config(format!(
815 "MSB_DIR_MOUNTS guest path must be absolute: {guest_path}"
816 )));
817 }
818
819 Ok(DirMountSpec {
820 tag: tag.to_string(),
821 guest_path: guest_path.to_string(),
822 readonly: options.readonly,
823 noexec: options.noexec,
824 nosuid: options.nosuid,
825 nodev: options.nodev,
826 })
827}
828
829fn parse_file_mounts(val: &str) -> AgentdResult<Vec<FileMountSpec>> {
831 val.split(';')
832 .filter(|e| !e.is_empty())
833 .map(parse_file_mount_entry)
834 .collect()
835}
836
837fn parse_file_mount_entry(entry: &str) -> AgentdResult<FileMountSpec> {
839 let mut parts = entry.splitn(4, ':');
840 let Some(tag) = parts.next() else {
841 unreachable!("splitn always yields at least one part");
842 };
843 let filename = parts.next().ok_or_else(|| {
844 AgentdError::Config(format!(
845 "MSB_FILE_MOUNTS entry must be tag:filename:path[:opts], got: {entry}"
846 ))
847 })?;
848 let guest_path = parts.next().ok_or_else(|| {
849 AgentdError::Config(format!(
850 "MSB_FILE_MOUNTS entry must be tag:filename:path[:opts], got: {entry}"
851 ))
852 })?;
853 let options =
854 parse_mount_options(ENV_FILE_MOUNTS, parts.next(), MountOptionSupport::default())?;
855
856 if tag.is_empty() {
857 return Err(AgentdError::Config(
858 "MSB_FILE_MOUNTS entry has empty tag".into(),
859 ));
860 }
861 if filename.is_empty() {
862 return Err(AgentdError::Config(
863 "MSB_FILE_MOUNTS entry has empty filename".into(),
864 ));
865 }
866 if guest_path.is_empty() || !guest_path.starts_with('/') {
867 return Err(AgentdError::Config(format!(
868 "MSB_FILE_MOUNTS guest path must be absolute: {guest_path}"
869 )));
870 }
871
872 Ok(FileMountSpec {
873 tag: tag.to_string(),
874 filename: filename.to_string(),
875 guest_path: guest_path.to_string(),
876 readonly: options.readonly,
877 noexec: options.noexec,
878 nosuid: options.nosuid,
879 nodev: options.nodev,
880 })
881}
882
883fn parse_disk_mounts(val: &str) -> AgentdResult<Vec<DiskMountSpec>> {
885 val.split(';')
886 .filter(|e| !e.is_empty())
887 .map(parse_disk_mount_entry)
888 .collect()
889}
890
891fn parse_disk_mount_entry(entry: &str) -> AgentdResult<DiskMountSpec> {
893 let mut parts = entry.splitn(3, ':');
894 let Some(id) = parts.next() else {
895 unreachable!("splitn always yields at least one part");
896 };
897 let guest_path = parts.next().ok_or_else(|| {
898 AgentdError::Config(format!(
899 "MSB_DISK_MOUNTS entry must be id:guest_path[:opts], got: {entry}"
900 ))
901 })?;
902 let options = parse_mount_options(
903 ENV_DISK_MOUNTS,
904 parts.next(),
905 MountOptionSupport {
906 fstype: true,
907 ..MountOptionSupport::default()
908 },
909 )?;
910
911 if id.is_empty() {
912 return Err(AgentdError::Config(
913 "MSB_DISK_MOUNTS entry has empty id".into(),
914 ));
915 }
916 if guest_path.is_empty() || !guest_path.starts_with('/') {
917 return Err(AgentdError::Config(format!(
918 "MSB_DISK_MOUNTS guest path must be absolute: {guest_path}"
919 )));
920 }
921
922 Ok(DiskMountSpec {
923 id: id.to_string(),
924 guest_path: guest_path.to_string(),
925 fstype: options.fstype,
926 readonly: options.readonly,
927 noexec: options.noexec,
928 nosuid: options.nosuid,
929 nodev: options.nodev,
930 })
931}
932
933fn parse_tmpfs_mounts(val: &str) -> AgentdResult<Vec<TmpfsSpec>> {
935 val.split(';')
936 .filter(|e| !e.is_empty())
937 .map(parse_tmpfs_entry)
938 .collect()
939}
940
941fn parse_tmpfs_entry(entry: &str) -> AgentdResult<TmpfsSpec> {
946 let (path, opts) = match entry.split_once(':') {
947 Some((path, opts)) => (path, Some(opts)),
948 None => {
949 if entry.contains(',') {
950 return Err(AgentdError::Config(
951 "MSB_TMPFS options must use path:opts syntax".into(),
952 ));
953 }
954 (entry, None)
955 }
956 };
957
958 if path.is_empty() {
959 return Err(AgentdError::Config("tmpfs entry has empty path".into()));
960 }
961
962 let options = parse_mount_options(
963 ENV_TMPFS,
964 opts,
965 MountOptionSupport {
966 size: true,
967 mode: true,
968 ..MountOptionSupport::default()
969 },
970 )?;
971
972 Ok(TmpfsSpec {
973 path: path.to_string(),
974 size_mib: options.size_mib,
975 mode: options.mode,
976 noexec: options.noexec,
977 nosuid: options.nosuid,
978 nodev: options.nodev,
979 readonly: options.readonly,
980 })
981}
982
983fn parse_rlimits(val: &str) -> AgentdResult<Vec<ExecRlimit>> {
993 let mut seen: Vec<String> = Vec::new();
994 val.split(';')
995 .filter(|entry| !entry.is_empty())
996 .map(|entry| {
997 let rlimit = entry.parse::<ExecRlimit>().map_err(|err| {
998 AgentdError::Config(format!("{ENV_RLIMITS} entry {entry}: {err}"))
999 })?;
1000 if rlimit::parse_rlimit_resource(&rlimit.resource).is_none() {
1001 return Err(AgentdError::Config(format!(
1002 "{ENV_RLIMITS} unknown resource: {}",
1003 rlimit.resource
1004 )));
1005 }
1006 if seen.iter().any(|name| name == &rlimit.resource) {
1007 return Err(AgentdError::Config(format!(
1008 "{ENV_RLIMITS} duplicate resource: {}",
1009 rlimit.resource
1010 )));
1011 }
1012 seen.push(rlimit.resource.clone());
1013 Ok(rlimit)
1014 })
1015 .collect()
1016}
1017
1018fn parse_net(val: &str) -> AgentdResult<NetSpec> {
1024 let mut iface = None;
1025 let mut mac = None;
1026 let mut mtu = 1500u16;
1027
1028 for part in val.split(',') {
1029 if let Some(v) = part.strip_prefix("iface=") {
1030 iface = Some(v.to_string());
1031 } else if let Some(v) = part.strip_prefix("mac=") {
1032 mac = Some(parse_mac(v)?);
1033 } else if let Some(v) = part.strip_prefix("mtu=") {
1034 mtu = v
1035 .parse()
1036 .map_err(|_| AgentdError::Config(format!("invalid MTU: {v}")))?;
1037 } else {
1038 return Err(AgentdError::Config(format!(
1039 "unknown MSB_NET option: {part}"
1040 )));
1041 }
1042 }
1043
1044 let iface = iface.ok_or_else(|| AgentdError::Config("MSB_NET missing iface=".into()))?;
1045 let mac = mac.ok_or_else(|| AgentdError::Config("MSB_NET missing mac=".into()))?;
1046
1047 Ok(NetSpec { iface, mac, mtu })
1048}
1049
1050fn parse_net_ipv4(val: &str) -> AgentdResult<NetIpv4Spec> {
1052 let mut address = None;
1053 let mut prefix_len = None;
1054 let mut gateway = None;
1055 let mut dns = None;
1056
1057 for part in val.split(',') {
1058 if let Some(v) = part.strip_prefix("addr=") {
1059 let (addr, prefix) = parse_cidr_v4(v)?;
1060 address = Some(addr);
1061 prefix_len = Some(prefix);
1062 } else if let Some(v) = part.strip_prefix("gw=") {
1063 gateway = Some(
1064 v.parse::<Ipv4Addr>()
1065 .map_err(|_| AgentdError::Config(format!("invalid IPv4 gateway: {v}")))?,
1066 );
1067 } else if let Some(v) = part.strip_prefix("dns=") {
1068 dns = Some(
1069 v.parse::<Ipv4Addr>()
1070 .map_err(|_| AgentdError::Config(format!("invalid IPv4 DNS: {v}")))?,
1071 );
1072 } else {
1073 return Err(AgentdError::Config(format!(
1074 "unknown MSB_NET_IPV4 option: {part}"
1075 )));
1076 }
1077 }
1078
1079 let address =
1080 address.ok_or_else(|| AgentdError::Config("MSB_NET_IPV4 missing addr=".into()))?;
1081 let prefix_len =
1082 prefix_len.ok_or_else(|| AgentdError::Config("MSB_NET_IPV4 missing addr=".into()))?;
1083 let gateway = gateway.ok_or_else(|| AgentdError::Config("MSB_NET_IPV4 missing gw=".into()))?;
1084
1085 Ok(NetIpv4Spec {
1086 address,
1087 prefix_len,
1088 gateway,
1089 dns,
1090 })
1091}
1092
1093fn parse_net_ipv6(val: &str) -> AgentdResult<NetIpv6Spec> {
1095 let mut address = None;
1096 let mut prefix_len = None;
1097 let mut gateway = None;
1098 let mut dns = None;
1099
1100 for part in val.split(',') {
1101 if let Some(v) = part.strip_prefix("addr=") {
1102 let (addr, prefix) = parse_cidr_v6(v)?;
1103 address = Some(addr);
1104 prefix_len = Some(prefix);
1105 } else if let Some(v) = part.strip_prefix("gw=") {
1106 gateway = Some(
1107 v.parse::<Ipv6Addr>()
1108 .map_err(|_| AgentdError::Config(format!("invalid IPv6 gateway: {v}")))?,
1109 );
1110 } else if let Some(v) = part.strip_prefix("dns=") {
1111 dns = Some(
1112 v.parse::<Ipv6Addr>()
1113 .map_err(|_| AgentdError::Config(format!("invalid IPv6 DNS: {v}")))?,
1114 );
1115 } else {
1116 return Err(AgentdError::Config(format!(
1117 "unknown MSB_NET_IPV6 option: {part}"
1118 )));
1119 }
1120 }
1121
1122 let address =
1123 address.ok_or_else(|| AgentdError::Config("MSB_NET_IPV6 missing addr=".into()))?;
1124 let prefix_len =
1125 prefix_len.ok_or_else(|| AgentdError::Config("MSB_NET_IPV6 missing addr=".into()))?;
1126 let gateway = gateway.ok_or_else(|| AgentdError::Config("MSB_NET_IPV6 missing gw=".into()))?;
1127
1128 Ok(NetIpv6Spec {
1129 address,
1130 prefix_len,
1131 gateway,
1132 dns,
1133 })
1134}
1135
1136fn parse_mac(s: &str) -> AgentdResult<[u8; 6]> {
1138 let mut mac = [0u8; 6];
1139 let mut len = 0usize;
1140 for (i, part) in s.split(':').enumerate() {
1141 if i >= 6 {
1142 return Err(AgentdError::Config(format!("invalid MAC address: {s}")));
1143 }
1144 mac[i] = u8::from_str_radix(part, 16)
1145 .map_err(|_| AgentdError::Config(format!("invalid MAC octet: {part}")))?;
1146 len = i + 1;
1147 }
1148 if len != 6 {
1149 return Err(AgentdError::Config(format!("invalid MAC address: {s}")));
1150 }
1151 Ok(mac)
1152}
1153
1154fn parse_cidr_v4(s: &str) -> AgentdResult<(Ipv4Addr, u8)> {
1156 let (addr_str, prefix_str) = s
1157 .split_once('/')
1158 .ok_or_else(|| AgentdError::Config(format!("invalid IPv4 CIDR (missing /): {s}")))?;
1159 let addr = addr_str
1160 .parse::<Ipv4Addr>()
1161 .map_err(|_| AgentdError::Config(format!("invalid IPv4 address: {addr_str}")))?;
1162 let prefix = prefix_str
1163 .parse::<u8>()
1164 .map_err(|_| AgentdError::Config(format!("invalid IPv4 prefix length: {prefix_str}")))?;
1165 if prefix > 32 {
1166 return Err(AgentdError::Config(format!(
1167 "IPv4 prefix length out of range (0-32): {prefix}"
1168 )));
1169 }
1170 Ok((addr, prefix))
1171}
1172
1173fn parse_cidr_v6(s: &str) -> AgentdResult<(Ipv6Addr, u8)> {
1175 let (addr_str, prefix_str) = s
1176 .rsplit_once('/')
1177 .ok_or_else(|| AgentdError::Config(format!("invalid IPv6 CIDR (missing /): {s}")))?;
1178 let addr = addr_str
1179 .parse::<Ipv6Addr>()
1180 .map_err(|_| AgentdError::Config(format!("invalid IPv6 address: {addr_str}")))?;
1181 let prefix = prefix_str
1182 .parse::<u8>()
1183 .map_err(|_| AgentdError::Config(format!("invalid IPv6 prefix length: {prefix_str}")))?;
1184 if prefix > 128 {
1185 return Err(AgentdError::Config(format!(
1186 "IPv6 prefix length out of range (0-128): {prefix}"
1187 )));
1188 }
1189 Ok((addr, prefix))
1190}
1191
1192fn parse_handoff_init() -> AgentdResult<Option<HandoffInit>> {
1205 let Some(cmd_str) = read_env_raw(ENV_HANDOFF_INIT) else {
1206 return Ok(None);
1207 };
1208 if cmd_str.trim().is_empty() {
1209 return Ok(None);
1210 }
1211
1212 let cmd = PathBuf::from(&cmd_str);
1213 if cmd_str != HANDOFF_INIT_AUTO && !cmd.is_absolute() {
1217 return Err(AgentdError::Config(format!(
1218 "{ENV_HANDOFF_INIT} must be an absolute path or `auto`, got: {cmd_str}"
1219 )));
1220 }
1221
1222 let argv = match read_env_raw(ENV_HANDOFF_INIT_ARGS) {
1223 Some(val) if !val.is_empty() => {
1224 decode_handoff_json::<Vec<String>>(ENV_HANDOFF_INIT_ARGS, &val)?
1225 .into_iter()
1226 .enumerate()
1227 .map(|(index, arg)| parse_handoff_arg(index, arg))
1228 .collect::<AgentdResult<Vec<_>>>()?
1229 }
1230 _ => Vec::new(),
1231 };
1232
1233 let cwd = match read_env_raw(ENV_HANDOFF_INIT_CWD) {
1234 Some(val) if !val.is_empty() => {
1235 let cwd = PathBuf::from(&val);
1236 if !cwd.is_absolute() {
1237 return Err(AgentdError::Config(format!(
1238 "{ENV_HANDOFF_INIT_CWD} must be an absolute path, got: {val}"
1239 )));
1240 }
1241 Some(cwd)
1242 }
1243 _ => None,
1244 };
1245
1246 let env = match read_env_raw(ENV_HANDOFF_INIT_ENV) {
1247 Some(val) if !val.is_empty() => {
1248 let entries = decode_handoff_json::<Vec<(String, String)>>(ENV_HANDOFF_INIT_ENV, &val)?;
1249 entries
1250 .into_iter()
1251 .map(|(key, value)| parse_handoff_env_pair(key, value))
1252 .collect::<AgentdResult<Vec<_>>>()?
1253 }
1254 _ => Vec::new(),
1255 };
1256
1257 Ok(Some(HandoffInit {
1258 cmd,
1259 argv,
1260 cwd,
1261 env,
1262 }))
1263}
1264
1265fn decode_handoff_json<T: DeserializeOwned>(env_name: &str, value: &str) -> AgentdResult<T> {
1266 let json = URL_SAFE_NO_PAD.decode(value).map_err(|e| {
1267 AgentdError::Config(format!("{env_name} must be base64url-no-padding JSON: {e}"))
1268 })?;
1269 serde_json::from_slice(&json)
1270 .map_err(|e| AgentdError::Config(format!("{env_name} contains invalid JSON: {e}")))
1271}
1272
1273fn parse_handoff_arg(index: usize, arg: String) -> AgentdResult<OsString> {
1274 if arg.contains('\0') {
1275 return Err(AgentdError::Config(format!(
1276 "{ENV_HANDOFF_INIT_ARGS} entry #{index} must not contain NUL"
1277 )));
1278 }
1279 Ok(OsString::from(arg))
1280}
1281
1282fn parse_handoff_env_pair(key: String, value: String) -> AgentdResult<(OsString, OsString)> {
1283 if key.is_empty() {
1284 return Err(AgentdError::Config(format!(
1285 "{ENV_HANDOFF_INIT_ENV} entry has empty key"
1286 )));
1287 }
1288 if key.contains('=') {
1289 return Err(AgentdError::Config(format!(
1290 "{ENV_HANDOFF_INIT_ENV} key {key:?} must not contain '='"
1291 )));
1292 }
1293 if key.contains('\0') {
1294 return Err(AgentdError::Config(format!(
1295 "{ENV_HANDOFF_INIT_ENV} key {key:?} must not contain NUL"
1296 )));
1297 }
1298 if value.contains('\0') {
1299 return Err(AgentdError::Config(format!(
1300 "{ENV_HANDOFF_INIT_ENV} value for {key:?} must not contain NUL"
1301 )));
1302 }
1303 Ok((OsString::from(key), OsString::from(value)))
1304}
1305
1306fn validate_guest_bootstrap(bootstrap: &GuestBootstrap) -> AgentdResult<()> {
1311 if let Some(root) = &bootstrap.block_root {
1312 match root {
1313 BootstrapBlockRoot::DiskImage { device, fstype } => {
1314 validate_absolute_guest_path("bootstrap block-root device", device)?;
1315 if let Some(fstype) = fstype {
1316 validate_nonempty_bootstrap_string("bootstrap block-root fstype", fstype)?;
1317 }
1318 }
1319 BootstrapBlockRoot::OciErofs { lower, upper } => {
1320 validate_absolute_guest_path("bootstrap EROFS lower device", lower)?;
1321 match upper {
1322 BootstrapBlockRootUpper::Device { device, fstype } => {
1323 validate_absolute_guest_path("bootstrap upper device", device)?;
1324 validate_nonempty_bootstrap_string("bootstrap upper fstype", fstype)?;
1325 }
1326 BootstrapBlockRootUpper::Tmpfs { .. } => {}
1327 }
1328 }
1329 }
1330 }
1331
1332 for mount in &bootstrap.dir_mounts {
1333 validate_nonempty_bootstrap_string("bootstrap directory mount tag", &mount.tag)?;
1334 validate_absolute_guest_path("bootstrap directory mount path", &mount.guest_path)?;
1335 }
1336 for mount in &bootstrap.file_mounts {
1337 validate_nonempty_bootstrap_string("bootstrap file mount tag", &mount.tag)?;
1338 validate_nonempty_bootstrap_string("bootstrap file mount filename", &mount.filename)?;
1339 validate_absolute_guest_path("bootstrap file mount path", &mount.guest_path)?;
1340 }
1341 for mount in &bootstrap.disk_mounts {
1342 validate_nonempty_bootstrap_string("bootstrap disk mount id", &mount.id)?;
1343 validate_absolute_guest_path("bootstrap disk mount path", &mount.guest_path)?;
1344 if let Some(fstype) = &mount.fstype {
1345 validate_nonempty_bootstrap_string("bootstrap disk mount fstype", fstype)?;
1346 }
1347 }
1348 for mount in &bootstrap.tmpfs_mounts {
1349 validate_absolute_guest_path("bootstrap tmpfs path", &mount.path)?;
1350 }
1351
1352 if let Some(hostname) = &bootstrap.hostname {
1353 validate_nonempty_bootstrap_string("bootstrap hostname", hostname)?;
1354 }
1355 if let Some(host_alias) = &bootstrap.host_alias {
1356 validate_nonempty_bootstrap_string("bootstrap host alias", host_alias)?;
1357 }
1358 if let Some(network) = &bootstrap.network {
1359 validate_nonempty_bootstrap_string("bootstrap network interface", &network.interface)?;
1360 if let Some(ipv4) = network.ipv4
1361 && ipv4.prefix_len > 32
1362 {
1363 return Err(AgentdError::Config(format!(
1364 "bootstrap IPv4 prefix length out of range: {}",
1365 ipv4.prefix_len
1366 )));
1367 }
1368 if let Some(ipv6) = network.ipv6
1369 && ipv6.prefix_len > 128
1370 {
1371 return Err(AgentdError::Config(format!(
1372 "bootstrap IPv6 prefix length out of range: {}",
1373 ipv6.prefix_len
1374 )));
1375 }
1376 }
1377
1378 let mut seen_rlimits = Vec::new();
1379 for rlimit in &bootstrap.rlimits {
1380 if rlimit::parse_rlimit_resource(&rlimit.resource).is_none() {
1381 return Err(AgentdError::Config(format!(
1382 "bootstrap rlimits contains unknown resource: {}",
1383 rlimit.resource
1384 )));
1385 }
1386 if rlimit.soft > rlimit.hard {
1387 return Err(AgentdError::Config(format!(
1388 "bootstrap rlimit {} has soft limit above hard limit",
1389 rlimit.resource
1390 )));
1391 }
1392 if seen_rlimits.iter().any(|name| name == &rlimit.resource) {
1393 return Err(AgentdError::Config(format!(
1394 "bootstrap rlimits contains duplicate resource: {}",
1395 rlimit.resource
1396 )));
1397 }
1398 seen_rlimits.push(rlimit.resource.clone());
1399 }
1400
1401 if let Some(user) = &bootstrap.user {
1402 validate_nonempty_bootstrap_string("bootstrap user", user)?;
1403 }
1404 if let Some(cwd) = &bootstrap.default_cwd {
1405 validate_nonempty_bootstrap_string("bootstrap default cwd", cwd)?;
1406 }
1407 for variable in &bootstrap.default_env {
1408 validate_bootstrap_env("bootstrap default env", variable)?;
1409 }
1410 if let Some(handoff) = &bootstrap.handoff_init {
1411 validate_bootstrap_handoff(handoff)?;
1412 }
1413
1414 Ok(())
1415}
1416
1417fn validate_bootstrap_handoff(handoff: &BootstrapHandoffInit) -> AgentdResult<()> {
1418 if handoff.cmd.contains('\0') {
1419 return Err(AgentdError::Config(
1420 "bootstrap handoff command must not contain NUL".into(),
1421 ));
1422 }
1423 if handoff.cmd != HANDOFF_INIT_AUTO && !handoff.cmd.starts_with('/') {
1424 return Err(AgentdError::Config(format!(
1425 "bootstrap handoff command must be absolute or `auto`: {}",
1426 handoff.cmd
1427 )));
1428 }
1429 for (index, arg) in handoff.args.iter().enumerate() {
1430 if arg.contains('\0') {
1431 return Err(AgentdError::Config(format!(
1432 "bootstrap handoff argument #{index} must not contain NUL"
1433 )));
1434 }
1435 }
1436 if let Some(cwd) = &handoff.cwd {
1437 validate_absolute_guest_path("bootstrap handoff cwd", cwd)?;
1438 }
1439 for variable in &handoff.env {
1440 validate_bootstrap_env("bootstrap handoff env", variable)?;
1441 }
1442 Ok(())
1443}
1444
1445fn validate_bootstrap_env(label: &str, variable: &BootstrapEnvVar) -> AgentdResult<()> {
1446 if variable.key.is_empty() {
1447 return Err(AgentdError::Config(format!(
1448 "{label} contains an empty key"
1449 )));
1450 }
1451 if variable.key.contains('=') || variable.key.contains('\0') {
1452 return Err(AgentdError::Config(format!(
1453 "{label} key {:?} must not contain '=' or NUL",
1454 variable.key
1455 )));
1456 }
1457 if variable.value.contains('\0') {
1458 return Err(AgentdError::Config(format!(
1459 "{label} value for {:?} must not contain NUL",
1460 variable.key
1461 )));
1462 }
1463 Ok(())
1464}
1465
1466fn validate_absolute_guest_path(label: &str, value: &str) -> AgentdResult<()> {
1467 validate_nonempty_bootstrap_string(label, value)?;
1468 if !value.starts_with('/') {
1469 return Err(AgentdError::Config(format!(
1470 "{label} must be an absolute Linux path: {value}"
1471 )));
1472 }
1473 Ok(())
1474}
1475
1476fn validate_nonempty_bootstrap_string(label: &str, value: &str) -> AgentdResult<()> {
1477 if value.is_empty() || value.contains('\0') {
1478 return Err(AgentdError::Config(format!(
1479 "{label} must be non-empty and must not contain NUL"
1480 )));
1481 }
1482 Ok(())
1483}
1484
1485fn convert_bootstrap_handoff(handoff: BootstrapHandoffInit) -> AgentdResult<HandoffInit> {
1486 Ok(HandoffInit {
1487 cmd: PathBuf::from(handoff.cmd),
1488 argv: handoff.args.into_iter().map(OsString::from).collect(),
1489 cwd: handoff.cwd.map(PathBuf::from),
1490 env: handoff
1491 .env
1492 .into_iter()
1493 .map(|variable| (OsString::from(variable.key), OsString::from(variable.value)))
1494 .collect(),
1495 })
1496}
1497
1498fn read_env(key: &str) -> Option<String> {
1500 env::var(key)
1501 .ok()
1502 .map(|v| v.trim().to_string())
1503 .filter(|v| !v.is_empty())
1504}
1505
1506fn read_env_raw(key: &str) -> Option<String> {
1511 env::var(key).ok().filter(|v| !v.is_empty())
1512}
1513
1514#[cfg(test)]
1519mod tests {
1520 use super::*;
1521
1522 #[test]
1527 fn test_scripts_path_uses_stable_default_and_avoids_duplicates() {
1528 assert_eq!(
1529 scripts_path(None),
1530 "/.msb/scripts:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
1531 );
1532 assert_eq!(
1533 scripts_path(Some("/custom/bin:/bin")),
1534 "/.msb/scripts:/custom/bin:/bin"
1535 );
1536 assert_eq!(
1537 scripts_path(Some("/bin:/.msb/scripts:/usr/bin")),
1538 "/bin:/.msb/scripts:/usr/bin"
1539 );
1540 }
1541
1542 #[test]
1543 fn test_bootstrap_preserves_structured_environment_and_handoff_values() {
1544 let bootstrap = GuestBootstrap {
1545 hostname: Some("quoted-host".to_string()),
1546 network: Some(microsandbox_protocol::bootstrap::BootstrapNetwork {
1547 interface: "eth0".to_string(),
1548 mac: [0x02, 0x5a, 0x7b, 0x13, 0x01, 0x02],
1549 mtu: 1500,
1550 ipv4: None,
1551 ipv6: None,
1552 }),
1553 rlimits: vec![ExecRlimit {
1554 resource: "nofile".to_string(),
1555 soft: 4096,
1556 hard: 65_535,
1557 }],
1558 user: Some("1000:1000".to_string()),
1559 default_cwd: Some("/workspace".to_string()),
1560 default_env: vec![BootstrapEnvVar {
1561 key: "APP_CONFIG".to_string(),
1562 value: "{\"message\":\"hello\",\"unicode\":\"lambda λ\"}\nnext\tline=a=b"
1563 .to_string(),
1564 }],
1565 security_profile: BootstrapSecurityProfile::Restricted,
1566 handoff_init: Some(BootstrapHandoffInit {
1567 cmd: "/sbin/init".to_string(),
1568 args: vec!["--label=\"hello world\"".to_string()],
1569 cwd: Some("/workspace".to_string()),
1570 env: vec![BootstrapEnvVar {
1571 key: "INIT_CONFIG".to_string(),
1572 value: "{\"enabled\":true}".to_string(),
1573 }],
1574 }),
1575 ..GuestBootstrap::default()
1576 };
1577
1578 let (boot, config) = BootParams::from_bootstrap(bootstrap).unwrap();
1579
1580 assert_eq!(boot.hostname.as_deref(), Some("quoted-host"));
1581 assert_eq!(boot.rlimits[0].hard, 65_535);
1582 assert!(matches!(boot.security_profile, SecurityProfile::Restricted));
1583 assert_eq!(config.user.as_deref(), Some("1000:1000"));
1584 assert_eq!(config.default_cwd.as_deref(), Some("/workspace"));
1585 assert_eq!(
1586 config.default_env[0].value,
1587 "{\"message\":\"hello\",\"unicode\":\"lambda λ\"}\nnext\tline=a=b"
1588 );
1589
1590 let handoff = boot.handoff_init.expect("handoff bootstrap");
1591 assert_eq!(
1592 handoff.argv,
1593 vec![OsString::from("--label=\"hello world\"")]
1594 );
1595 assert_eq!(
1596 handoff.env,
1597 vec![(
1598 OsString::from("INIT_CONFIG"),
1599 OsString::from("{\"enabled\":true}")
1600 )]
1601 );
1602 }
1603
1604 #[test]
1605 fn test_bootstrap_accepts_relative_default_cwd() {
1606 let bootstrap = GuestBootstrap {
1607 default_cwd: Some("workspace".to_string()),
1608 ..GuestBootstrap::default()
1609 };
1610
1611 let (_, config) = BootParams::from_bootstrap(bootstrap).unwrap();
1612
1613 assert_eq!(config.default_cwd.as_deref(), Some("workspace"));
1614 }
1615
1616 #[test]
1617 fn test_bootstrap_rejects_duplicate_rlimits() {
1618 let rlimit = ExecRlimit {
1619 resource: "nofile".to_string(),
1620 soft: 1024,
1621 hard: 1024,
1622 };
1623 let bootstrap = GuestBootstrap {
1624 rlimits: vec![rlimit.clone(), rlimit],
1625 ..GuestBootstrap::default()
1626 };
1627
1628 let error = BootParams::from_bootstrap(bootstrap).unwrap_err();
1629 assert!(error.to_string().contains("duplicate resource"));
1630 }
1631
1632 #[test]
1633 fn test_bootstrap_rejects_invalid_environment_key() {
1634 let bootstrap = GuestBootstrap {
1635 default_env: vec![BootstrapEnvVar {
1636 key: "BAD=KEY".to_string(),
1637 value: "value".to_string(),
1638 }],
1639 ..GuestBootstrap::default()
1640 };
1641
1642 let error = BootParams::from_bootstrap(bootstrap).unwrap_err();
1643 assert!(error.to_string().contains("must not contain '=' or NUL"));
1644 }
1645
1646 #[test]
1649 fn test_parse_block_root_disk_image() {
1650 let spec = parse_block_root("kind=disk-image,device=/dev/vda,fstype=ext4").unwrap();
1651 let BlockRootSpec::DiskImage { device, fstype } = spec else {
1652 panic!("expected DiskImage");
1653 };
1654 assert_eq!(device, "/dev/vda");
1655 assert_eq!(fstype.as_deref(), Some("ext4"));
1656 }
1657
1658 #[test]
1659 fn test_parse_block_root_disk_image_no_fstype() {
1660 let spec = parse_block_root("kind=disk-image,device=/dev/vda").unwrap();
1661 let BlockRootSpec::DiskImage { device, fstype } = spec else {
1662 panic!("expected DiskImage");
1663 };
1664 assert_eq!(device, "/dev/vda");
1665 assert_eq!(fstype, None);
1666 }
1667
1668 #[test]
1669 fn test_parse_block_root_oci_erofs() {
1670 let spec =
1671 parse_block_root("kind=oci-erofs,lower=/dev/vda,upper=/dev/vdb,upper_fstype=ext4")
1672 .unwrap();
1673 let BlockRootSpec::OciErofs { lower, upper } = spec else {
1674 panic!("expected OciErofs");
1675 };
1676 assert_eq!(lower, "/dev/vda");
1677 let BlockRootUpper::Device { device, fstype } = upper else {
1678 panic!("expected Device upper");
1679 };
1680 assert_eq!(device, "/dev/vdb");
1681 assert_eq!(fstype, "ext4");
1682 }
1683
1684 #[test]
1685 fn test_parse_block_root_oci_erofs_tmpfs_upper() {
1686 let spec =
1687 parse_block_root("kind=oci-erofs,lower=/dev/vda,upper=tmpfs,upper_size_mib=2048")
1688 .unwrap();
1689 let BlockRootSpec::OciErofs { lower, upper } = spec else {
1690 panic!("expected OciErofs");
1691 };
1692 assert_eq!(lower, "/dev/vda");
1693 let BlockRootUpper::Tmpfs { size_mib } = upper else {
1694 panic!("expected Tmpfs upper");
1695 };
1696 assert_eq!(size_mib, Some(2048));
1697 }
1698
1699 #[test]
1700 fn test_parse_block_root_oci_erofs_tmpfs_upper_no_size() {
1701 let spec = parse_block_root("kind=oci-erofs,lower=/dev/vda,upper=tmpfs").unwrap();
1702 let BlockRootSpec::OciErofs {
1703 upper: BlockRootUpper::Tmpfs { size_mib: None },
1704 ..
1705 } = spec
1706 else {
1707 panic!("expected Tmpfs upper without size");
1708 };
1709 }
1710
1711 #[test]
1712 fn test_parse_block_root_oci_erofs_tmpfs_upper_rejects_fstype() {
1713 let err = parse_block_root("kind=oci-erofs,lower=/dev/vda,upper=tmpfs,upper_fstype=ext4")
1714 .unwrap_err();
1715 assert!(err.to_string().contains("not valid with upper=tmpfs"));
1716 }
1717
1718 #[test]
1719 fn test_parse_block_root_oci_erofs_tmpfs_upper_invalid_size_errors() {
1720 let err = parse_block_root("kind=oci-erofs,lower=/dev/vda,upper=tmpfs,upper_size_mib=big")
1721 .unwrap_err();
1722 assert!(err.to_string().contains("invalid upper_size_mib"));
1723 }
1724
1725 #[test]
1726 fn test_parse_block_root_unknown_kind_errors() {
1727 let err = parse_block_root("kind=bogus,device=/dev/vda").unwrap_err();
1728 assert!(err.to_string().contains("unknown kind"));
1729 }
1730
1731 #[test]
1732 fn test_parse_block_root_missing_kind_errors() {
1733 let err = parse_block_root("/dev/vda").unwrap_err();
1734 assert!(err.to_string().contains("missing 'kind' key"));
1735 }
1736
1737 #[test]
1738 fn test_parse_block_root_disk_image_missing_device_errors() {
1739 let err = parse_block_root("kind=disk-image").unwrap_err();
1740 assert!(err.to_string().contains("missing 'device'"));
1741 }
1742
1743 #[test]
1744 fn test_parse_block_root_oci_erofs_missing_upper_errors() {
1745 let err = parse_block_root("kind=oci-erofs,lower=/dev/vda,upper_fstype=ext4").unwrap_err();
1746 assert!(err.to_string().contains("missing 'upper'"));
1747 }
1748
1749 #[test]
1750 fn test_parse_block_root_duplicate_key_errors() {
1751 let err = parse_block_root("kind=disk-image,device=/dev/vda,device=/dev/vdb").unwrap_err();
1752 assert!(err.to_string().contains("duplicate key 'device'"));
1753 }
1754
1755 #[test]
1758 fn test_parse_file_mount_entry_basic() {
1759 let spec = parse_file_mount_entry("fm_config:app.conf:/etc/app.conf").unwrap();
1760 assert_eq!(spec.tag, "fm_config");
1761 assert_eq!(spec.filename, "app.conf");
1762 assert_eq!(spec.guest_path, "/etc/app.conf");
1763 assert!(!spec.readonly);
1764 assert!(!spec.noexec);
1765 }
1766
1767 #[test]
1768 fn test_parse_file_mount_entry_readonly() {
1769 let spec = parse_file_mount_entry("fm_config:app.conf:/etc/app.conf:ro,noexec").unwrap();
1770 assert!(spec.readonly);
1771 assert!(spec.noexec);
1772 }
1773
1774 #[test]
1775 fn test_parse_file_mount_entry_too_few_parts() {
1776 assert!(parse_file_mount_entry("fm_config:/etc/app.conf").is_err());
1777 }
1778
1779 #[test]
1780 fn test_parse_file_mount_entry_empty_filename() {
1781 assert!(parse_file_mount_entry("fm_config::/etc/app.conf").is_err());
1782 }
1783
1784 #[test]
1785 fn test_parse_file_mount_entry_relative_path() {
1786 assert!(parse_file_mount_entry("fm_config:app.conf:relative/path").is_err());
1787 }
1788
1789 #[test]
1790 fn test_parse_file_mount_entry_too_many_parts() {
1791 assert!(parse_file_mount_entry("fm_config:app.conf:/etc/app.conf:ro:extra").is_err());
1792 }
1793
1794 #[test]
1795 fn test_parse_file_mount_entry_unknown_flag() {
1796 assert!(parse_file_mount_entry("fm_config:app.conf:/etc/app.conf:exec").is_err());
1797 }
1798
1799 #[test]
1800 fn test_parse_file_mount_entry_empty_tag() {
1801 assert!(parse_file_mount_entry(":app.conf:/etc/app.conf").is_err());
1802 }
1803
1804 #[test]
1807 fn test_parse_path_only() {
1808 let spec = parse_tmpfs_entry("/tmp").unwrap();
1809 assert_eq!(spec.path, "/tmp");
1810 assert_eq!(spec.size_mib, None);
1811 assert_eq!(spec.mode, None);
1812 assert!(!spec.noexec);
1813 }
1814
1815 #[test]
1816 fn test_parse_with_size() {
1817 let spec = parse_tmpfs_entry("/tmp:size=256").unwrap();
1818 assert_eq!(spec.path, "/tmp");
1819 assert_eq!(spec.size_mib, Some(256));
1820 }
1821
1822 #[test]
1823 fn test_parse_with_noexec() {
1824 let spec = parse_tmpfs_entry("/tmp:noexec").unwrap();
1825 assert_eq!(spec.path, "/tmp");
1826 assert!(spec.noexec);
1827 }
1828
1829 #[test]
1832 fn test_parse_disk_mount_entry_basic() {
1833 let spec = parse_disk_mount_entry("data_abc:/data:fstype=ext4").unwrap();
1834 assert_eq!(spec.id, "data_abc");
1835 assert_eq!(spec.guest_path, "/data");
1836 assert_eq!(spec.fstype.as_deref(), Some("ext4"));
1837 assert!(!spec.readonly);
1838 assert!(!spec.noexec);
1839 }
1840
1841 #[test]
1842 fn test_parse_disk_mount_entry_readonly() {
1843 let spec = parse_disk_mount_entry("seed_7f:/seed:ro,noexec,fstype=ext4").unwrap();
1844 assert!(spec.readonly);
1845 assert!(spec.noexec);
1846 assert_eq!(spec.fstype.as_deref(), Some("ext4"));
1847 }
1848
1849 #[test]
1850 fn test_parse_disk_mount_entry_no_fstype_means_autodetect() {
1851 let spec = parse_disk_mount_entry("probe_1:/data:ro").unwrap();
1852 assert!(spec.fstype.is_none());
1853 assert!(spec.readonly);
1854 }
1855
1856 #[test]
1857 fn test_parse_disk_mount_entry_autodetect_no_ro() {
1858 let spec = parse_disk_mount_entry("probe_1:/data").unwrap();
1859 assert!(spec.fstype.is_none());
1860 assert!(!spec.readonly);
1861 }
1862
1863 #[test]
1864 fn test_parse_disk_mount_entry_rejects_unknown_flag() {
1865 let err = parse_disk_mount_entry("id:/data:exec").unwrap_err();
1866 assert!(err.to_string().contains("unsupported mount option"));
1867 }
1868
1869 #[test]
1870 fn test_parse_disk_mount_entry_rejects_relative_path() {
1871 assert!(parse_disk_mount_entry("id:relative").is_err());
1872 }
1873
1874 #[test]
1875 fn test_parse_disk_mount_entry_rejects_empty_id() {
1876 assert!(parse_disk_mount_entry(":/data:fstype=ext4").is_err());
1877 }
1878
1879 #[test]
1880 fn test_parse_disk_mount_entry_rejects_too_many_parts() {
1881 assert!(parse_disk_mount_entry("id:/data:fstype=ext4:extra").is_err());
1882 }
1883
1884 #[test]
1885 fn test_parse_disk_mounts_multiple_entries() {
1886 let specs =
1887 parse_disk_mounts("data_1:/data:fstype=ext4;seed_2:/seed:ro;probe_3:/p").unwrap();
1888 assert_eq!(specs.len(), 3);
1889 assert_eq!(specs[0].guest_path, "/data");
1890 assert!(specs[1].readonly);
1891 assert!(specs[2].fstype.is_none());
1892 }
1893
1894 #[test]
1895 fn test_parse_with_ro() {
1896 let spec = parse_tmpfs_entry("/seed:size=64,ro").unwrap();
1897 assert_eq!(spec.path, "/seed");
1898 assert_eq!(spec.size_mib, Some(64));
1899 assert!(spec.readonly);
1900 assert!(!spec.noexec);
1901 }
1902
1903 #[test]
1904 fn test_parse_ro_defaults_to_false_when_absent() {
1905 let spec = parse_tmpfs_entry("/tmp:size=256").unwrap();
1906 assert!(!spec.readonly);
1907 }
1908
1909 #[test]
1910 fn test_parse_with_octal_mode() {
1911 let spec = parse_tmpfs_entry("/tmp:mode=1777").unwrap();
1912 assert_eq!(spec.mode, Some(0o1777));
1913
1914 let spec = parse_tmpfs_entry("/data:mode=755").unwrap();
1915 assert_eq!(spec.mode, Some(0o755));
1916 }
1917
1918 #[test]
1919 fn test_parse_multi_options() {
1920 let spec = parse_tmpfs_entry("/tmp:size=256,mode=1777,noexec").unwrap();
1921 assert_eq!(spec.path, "/tmp");
1922 assert_eq!(spec.size_mib, Some(256));
1923 assert_eq!(spec.mode, Some(0o1777));
1924 assert!(spec.noexec);
1925 }
1926
1927 #[test]
1928 fn test_parse_unknown_option_errors() {
1929 let err = parse_tmpfs_entry("/tmp:bogus=42").unwrap_err();
1930 assert!(err.to_string().contains("unknown mount option"));
1931 }
1932
1933 #[test]
1934 fn test_parse_invalid_size_errors() {
1935 let err = parse_tmpfs_entry("/tmp:size=abc").unwrap_err();
1936 assert!(err.to_string().contains("invalid tmpfs size"));
1937 }
1938
1939 #[test]
1940 fn test_parse_invalid_mode_errors() {
1941 let err = parse_tmpfs_entry("/tmp:mode=zzz").unwrap_err();
1942 assert!(err.to_string().contains("invalid octal tmpfs mode"));
1943 }
1944
1945 #[test]
1946 fn test_parse_empty_path_errors() {
1947 let err = parse_tmpfs_entry(":size=256").unwrap_err();
1948 assert!(err.to_string().contains("empty path"));
1949 }
1950
1951 #[test]
1954 fn test_parse_net_full() {
1955 let spec = parse_net("iface=eth0,mac=02:5a:7b:13:01:02,mtu=1500").unwrap();
1956 assert_eq!(spec.iface, "eth0");
1957 assert_eq!(spec.mac, [0x02, 0x5a, 0x7b, 0x13, 0x01, 0x02]);
1958 assert_eq!(spec.mtu, 1500);
1959 }
1960
1961 #[test]
1962 fn test_parse_net_default_mtu() {
1963 let spec = parse_net("iface=eth0,mac=02:00:00:00:00:01").unwrap();
1964 assert_eq!(spec.mtu, 1500);
1965 }
1966
1967 #[test]
1968 fn test_parse_net_missing_iface() {
1969 assert!(parse_net("mac=02:00:00:00:00:01").is_err());
1970 }
1971
1972 #[test]
1973 fn test_parse_net_missing_mac() {
1974 assert!(parse_net("iface=eth0").is_err());
1975 }
1976
1977 #[test]
1978 fn test_parse_net_unknown_option() {
1979 assert!(parse_net("iface=eth0,mac=02:00:00:00:00:01,bogus=42").is_err());
1980 }
1981
1982 #[test]
1983 fn test_parse_net_ipv4() {
1984 let spec = parse_net_ipv4("addr=100.96.1.2/30,gw=100.96.1.1,dns=100.96.1.1").unwrap();
1985 assert_eq!(spec.address, Ipv4Addr::new(100, 96, 1, 2));
1986 assert_eq!(spec.prefix_len, 30);
1987 assert_eq!(spec.gateway, Ipv4Addr::new(100, 96, 1, 1));
1988 assert_eq!(spec.dns, Some(Ipv4Addr::new(100, 96, 1, 1)));
1989 }
1990
1991 #[test]
1992 fn test_parse_net_ipv4_no_dns() {
1993 let spec = parse_net_ipv4("addr=10.0.0.2/24,gw=10.0.0.1").unwrap();
1994 assert_eq!(spec.dns, None);
1995 }
1996
1997 #[test]
1998 fn test_parse_net_ipv4_missing_addr() {
1999 assert!(parse_net_ipv4("gw=10.0.0.1").is_err());
2000 }
2001
2002 #[test]
2003 fn test_parse_net_ipv6() {
2004 let spec = parse_net_ipv6(
2005 "addr=fd42:6d73:62:2a::2/64,gw=fd42:6d73:62:2a::1,dns=fd42:6d73:62:2a::1",
2006 )
2007 .unwrap();
2008 assert_eq!(
2009 spec.address,
2010 "fd42:6d73:62:2a::2".parse::<Ipv6Addr>().unwrap()
2011 );
2012 assert_eq!(spec.prefix_len, 64);
2013 assert_eq!(
2014 spec.gateway,
2015 "fd42:6d73:62:2a::1".parse::<Ipv6Addr>().unwrap()
2016 );
2017 assert!(spec.dns.is_some());
2018 }
2019
2020 #[test]
2021 fn test_parse_mac_valid() {
2022 let mac = parse_mac("02:5a:7b:13:01:02").unwrap();
2023 assert_eq!(mac, [0x02, 0x5a, 0x7b, 0x13, 0x01, 0x02]);
2024 }
2025
2026 #[test]
2027 fn test_parse_mac_invalid() {
2028 assert!(parse_mac("02:5a:7b").is_err());
2029 assert!(parse_mac("zz:00:00:00:00:00").is_err());
2030 }
2031
2032 #[test]
2033 fn test_parse_cidr_v4() {
2034 let (addr, prefix) = parse_cidr_v4("100.96.1.2/30").unwrap();
2035 assert_eq!(addr, Ipv4Addr::new(100, 96, 1, 2));
2036 assert_eq!(prefix, 30);
2037 }
2038
2039 #[test]
2040 fn test_parse_cidr_v6() {
2041 let (addr, prefix) = parse_cidr_v6("fd42:6d73:62:2a::2/64").unwrap();
2042 assert_eq!(addr, "fd42:6d73:62:2a::2".parse::<Ipv6Addr>().unwrap());
2043 assert_eq!(prefix, 64);
2044 }
2045
2046 #[test]
2049 fn test_parse_rlimits_happy_path() {
2050 let rlimits = parse_rlimits("nofile=65535;nproc=4096:8192").unwrap();
2051 assert_eq!(rlimits.len(), 2);
2052 assert_eq!(rlimits[0].resource, "nofile");
2053 assert_eq!(rlimits[0].soft, 65535);
2054 assert_eq!(rlimits[0].hard, 65535);
2055 assert_eq!(rlimits[1].resource, "nproc");
2056 assert_eq!(rlimits[1].soft, 4096);
2057 assert_eq!(rlimits[1].hard, 8192);
2058 }
2059
2060 #[test]
2061 fn test_parse_rlimits_ignores_empty_entries() {
2062 let rlimits = parse_rlimits("nofile=1024;").unwrap();
2063 assert_eq!(rlimits.len(), 1);
2064 assert_eq!(rlimits[0].resource, "nofile");
2065 }
2066
2067 #[test]
2068 fn test_parse_rlimits_rejects_unknown_resource() {
2069 let err = parse_rlimits("bogus=1024").unwrap_err();
2070 assert!(
2071 matches!(err, AgentdError::Config(msg) if msg.contains("unknown resource: bogus")),
2072 "unexpected error shape"
2073 );
2074 }
2075
2076 #[test]
2077 fn test_parse_rlimits_rejects_duplicate_resource() {
2078 let err = parse_rlimits("nofile=1024;nofile=65535").unwrap_err();
2079 assert!(
2080 matches!(err, AgentdError::Config(msg) if msg.contains("duplicate resource: nofile")),
2081 "unexpected error shape"
2082 );
2083 }
2084
2085 #[test]
2086 fn test_parse_rlimits_rejects_malformed_entry() {
2087 assert!(parse_rlimits("nofile").is_err());
2088 assert!(parse_rlimits("nofile=abc").is_err());
2089 assert!(parse_rlimits("nofile=65535:1024").is_err()); }
2091
2092 static HANDOFF_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2097
2098 fn with_handoff_env<R>(
2099 cmd: Option<&str>,
2100 args: Option<&str>,
2101 cwd: Option<&str>,
2102 env_var: Option<&str>,
2103 f: impl FnOnce() -> R,
2104 ) -> R {
2105 let _guard = HANDOFF_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2106 unsafe {
2107 match cmd {
2108 Some(v) => env::set_var(ENV_HANDOFF_INIT, v),
2109 None => env::remove_var(ENV_HANDOFF_INIT),
2110 }
2111 match args {
2112 Some(v) => env::set_var(ENV_HANDOFF_INIT_ARGS, v),
2113 None => env::remove_var(ENV_HANDOFF_INIT_ARGS),
2114 }
2115 match cwd {
2116 Some(v) => env::set_var(ENV_HANDOFF_INIT_CWD, v),
2117 None => env::remove_var(ENV_HANDOFF_INIT_CWD),
2118 }
2119 match env_var {
2120 Some(v) => env::set_var(ENV_HANDOFF_INIT_ENV, v),
2121 None => env::remove_var(ENV_HANDOFF_INIT_ENV),
2122 }
2123 }
2124 let out = f();
2125 unsafe {
2126 env::remove_var(ENV_HANDOFF_INIT);
2127 env::remove_var(ENV_HANDOFF_INIT_ARGS);
2128 env::remove_var(ENV_HANDOFF_INIT_CWD);
2129 env::remove_var(ENV_HANDOFF_INIT_ENV);
2130 }
2131 out
2132 }
2133
2134 fn encode_handoff_json<T: serde::Serialize>(value: &T) -> String {
2135 use base64::Engine as _;
2136
2137 let json = serde_json::to_vec(value).unwrap();
2138 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json)
2139 }
2140
2141 #[test]
2142 fn test_parse_handoff_init_unset_returns_none() {
2143 let res = with_handoff_env(None, None, None, None, parse_handoff_init).unwrap();
2144 assert!(res.is_none());
2145 }
2146
2147 #[test]
2148 fn test_parse_handoff_init_empty_returns_none() {
2149 let res = with_handoff_env(Some(""), None, None, None, parse_handoff_init).unwrap();
2150 assert!(res.is_none());
2151 }
2152
2153 #[test]
2154 fn test_parse_handoff_init_cmd_only() {
2155 let res = with_handoff_env(
2156 Some("/lib/systemd/systemd"),
2157 None,
2158 None,
2159 None,
2160 parse_handoff_init,
2161 )
2162 .unwrap()
2163 .unwrap();
2164 assert_eq!(res.cmd, PathBuf::from("/lib/systemd/systemd"));
2165 assert!(res.argv.is_empty());
2166 assert!(res.env.is_empty());
2167 }
2168
2169 #[test]
2170 fn test_parse_handoff_init_with_argv() {
2171 let argv = encode_handoff_json(&vec!["--unit=multi-user.target", "--log-level=warning"]);
2172 let res = with_handoff_env(
2173 Some("/lib/systemd/systemd"),
2174 Some(&argv),
2175 None,
2176 None,
2177 parse_handoff_init,
2178 )
2179 .unwrap()
2180 .unwrap();
2181 assert_eq!(
2182 res.argv,
2183 vec![
2184 OsString::from("--unit=multi-user.target"),
2185 OsString::from("--log-level=warning"),
2186 ]
2187 );
2188 }
2189
2190 #[test]
2191 fn test_parse_handoff_init_with_env() {
2192 let envs = encode_handoff_json(&vec![("container", "microsandbox"), ("LANG", "C.UTF-8")]);
2193 let res = with_handoff_env(
2194 Some("/sbin/init"),
2195 None,
2196 None,
2197 Some(&envs),
2198 parse_handoff_init,
2199 )
2200 .unwrap()
2201 .unwrap();
2202 assert_eq!(
2203 res.env,
2204 vec![
2205 (OsString::from("container"), OsString::from("microsandbox")),
2206 (OsString::from("LANG"), OsString::from("C.UTF-8")),
2207 ]
2208 );
2209 }
2210
2211 #[test]
2212 fn test_parse_handoff_init_with_cwd() {
2213 let res = with_handoff_env(
2214 Some("/sbin/init"),
2215 None,
2216 Some("/opt/hermes"),
2217 None,
2218 parse_handoff_init,
2219 )
2220 .unwrap()
2221 .unwrap();
2222 assert_eq!(res.cwd, Some(PathBuf::from("/opt/hermes")));
2223 }
2224
2225 #[test]
2226 fn test_parse_handoff_init_argv_with_spaces_preserved() {
2227 let argv = encode_handoff_json(&vec![
2228 "--label=hello world",
2229 "--config=/etc/foo;bar",
2230 "old\x1fseparator",
2231 ]);
2232 let res = with_handoff_env(
2233 Some("/sbin/init"),
2234 Some(&argv),
2235 None,
2236 None,
2237 parse_handoff_init,
2238 )
2239 .unwrap()
2240 .unwrap();
2241 assert_eq!(
2242 res.argv,
2243 vec![
2244 OsString::from("--label=hello world"),
2245 OsString::from("--config=/etc/foo;bar"),
2246 OsString::from("old\x1fseparator"),
2247 ]
2248 );
2249 }
2250
2251 #[test]
2252 fn test_parse_handoff_init_rejects_relative_path() {
2253 let err =
2254 with_handoff_env(Some("sbin/init"), None, None, None, parse_handoff_init).unwrap_err();
2255 assert!(err.to_string().contains("absolute path"));
2256 }
2257
2258 #[test]
2259 fn test_parse_handoff_init_env_rejects_invalid_base64() {
2260 let err = with_handoff_env(
2261 Some("/sbin/init"),
2262 None,
2263 None,
2264 Some("not base64!"),
2265 parse_handoff_init,
2266 )
2267 .unwrap_err();
2268 assert!(err.to_string().contains("base64url-no-padding JSON"));
2269 }
2270
2271 #[test]
2272 fn test_parse_handoff_init_cwd_rejects_relative_path() {
2273 let err = with_handoff_env(
2274 Some("/sbin/init"),
2275 None,
2276 Some("opt/hermes"),
2277 None,
2278 parse_handoff_init,
2279 )
2280 .unwrap_err();
2281 assert!(err.to_string().contains("absolute path"));
2282 }
2283
2284 #[test]
2285 fn test_parse_handoff_init_env_entry_empty_key_rejected() {
2286 let envs = encode_handoff_json(&vec![("", "value")]);
2287 let err = with_handoff_env(
2288 Some("/sbin/init"),
2289 None,
2290 None,
2291 Some(&envs),
2292 parse_handoff_init,
2293 )
2294 .unwrap_err();
2295 assert!(err.to_string().contains("empty key"));
2296 }
2297
2298 #[test]
2299 fn test_parse_handoff_init_arg_rejects_nul() {
2300 let argv = encode_handoff_json(&vec!["ok", "bad\0arg"]);
2301 let err = with_handoff_env(
2302 Some("/sbin/init"),
2303 Some(&argv),
2304 None,
2305 None,
2306 parse_handoff_init,
2307 )
2308 .unwrap_err();
2309 assert!(err.to_string().contains("entry #1"));
2310 assert!(err.to_string().contains("NUL"));
2311 }
2312
2313 #[test]
2314 fn test_parse_handoff_init_env_key_rejects_equals() {
2315 let envs = encode_handoff_json(&vec![("BAD=KEY", "value")]);
2316 let err = with_handoff_env(
2317 Some("/sbin/init"),
2318 None,
2319 None,
2320 Some(&envs),
2321 parse_handoff_init,
2322 )
2323 .unwrap_err();
2324 assert!(err.to_string().contains("must not contain '='"));
2325 }
2326
2327 #[test]
2328 fn test_parse_handoff_init_env_key_rejects_nul() {
2329 let envs = encode_handoff_json(&vec![("BAD\0KEY", "value")]);
2330 let err = with_handoff_env(
2331 Some("/sbin/init"),
2332 None,
2333 None,
2334 Some(&envs),
2335 parse_handoff_init,
2336 )
2337 .unwrap_err();
2338 assert!(err.to_string().contains("key"));
2339 assert!(err.to_string().contains("NUL"));
2340 }
2341
2342 #[test]
2343 fn test_parse_handoff_init_env_value_rejects_nul() {
2344 let envs = encode_handoff_json(&vec![("KEY", "bad\0value")]);
2345 let err = with_handoff_env(
2346 Some("/sbin/init"),
2347 None,
2348 None,
2349 Some(&envs),
2350 parse_handoff_init,
2351 )
2352 .unwrap_err();
2353 assert!(err.to_string().contains("value for"));
2354 assert!(err.to_string().contains("NUL"));
2355 }
2356
2357 #[test]
2358 fn test_parse_handoff_init_env_value_with_equals_is_value() {
2359 let envs = encode_handoff_json(&vec![("PATH", "/a:/b=/c")]);
2360 let res = with_handoff_env(
2361 Some("/sbin/init"),
2362 None,
2363 None,
2364 Some(&envs),
2365 parse_handoff_init,
2366 )
2367 .unwrap()
2368 .unwrap();
2369 assert_eq!(
2370 res.env,
2371 vec![(OsString::from("PATH"), OsString::from("/a:/b=/c"))]
2372 );
2373 }
2374}