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