1use crate::runtime::constants::envs as const_envs;
4use crate::runtime::layout::dirs as const_dirs;
5use boxlite_shared::errors::BoxliteResult;
6use dirs::home_dir;
7use serde::{Deserialize, Serialize};
8use std::path::PathBuf;
9
10#[derive(Clone, Debug, Serialize, Deserialize)]
19pub struct SecurityOptions {
20 #[serde(default = "default_jailer_enabled")]
28 pub jailer_enabled: bool,
29
30 #[serde(default = "default_seccomp_enabled")]
35 pub seccomp_enabled: bool,
36
37 #[serde(default)]
43 pub uid: Option<u32>,
44
45 #[serde(default)]
51 pub gid: Option<u32>,
52
53 #[serde(default)]
58 pub new_pid_ns: bool,
59
60 #[serde(default)]
66 pub new_net_ns: bool,
67
68 #[serde(default = "default_chroot_base")]
72 pub chroot_base: PathBuf,
73
74 #[serde(default = "default_chroot_enabled")]
79 pub chroot_enabled: bool,
80
81 #[serde(default = "default_close_fds")]
86 pub close_fds: bool,
87
88 #[serde(default = "default_sanitize_env")]
93 pub sanitize_env: bool,
94
95 #[serde(default = "default_env_allowlist")]
99 pub env_allowlist: Vec<String>,
100
101 #[serde(default)]
103 pub resource_limits: ResourceLimits,
104
105 #[serde(default)]
109 pub sandbox_profile: Option<PathBuf>,
110
111 #[serde(default = "default_network_enabled")]
116 pub network_enabled: bool,
117}
118
119#[derive(Clone, Debug, Default, Serialize, Deserialize)]
121pub struct ResourceLimits {
122 #[serde(default)]
124 pub max_open_files: Option<u64>,
125
126 #[serde(default)]
128 pub max_file_size: Option<u64>,
129
130 #[serde(default)]
132 pub max_processes: Option<u64>,
133
134 #[serde(default)]
136 pub max_memory: Option<u64>,
137
138 #[serde(default)]
140 pub max_cpu_time: Option<u64>,
141}
142
143fn default_jailer_enabled() -> bool {
146 false
147}
148
149fn default_seccomp_enabled() -> bool {
150 false
151}
152
153fn default_chroot_base() -> PathBuf {
154 PathBuf::from("/srv/boxlite")
155}
156
157fn default_chroot_enabled() -> bool {
158 cfg!(target_os = "linux")
159}
160
161fn default_close_fds() -> bool {
162 true
163}
164
165fn default_sanitize_env() -> bool {
166 true
167}
168
169fn default_env_allowlist() -> Vec<String> {
170 vec![
171 "RUST_LOG".to_string(),
172 "PATH".to_string(),
173 "HOME".to_string(),
174 "USER".to_string(),
175 "LANG".to_string(),
176 "TERM".to_string(),
177 ]
178}
179
180fn default_network_enabled() -> bool {
181 true
182}
183
184impl Default for SecurityOptions {
185 fn default() -> Self {
186 Self {
187 jailer_enabled: default_jailer_enabled(),
188 seccomp_enabled: default_seccomp_enabled(),
189 uid: None,
190 gid: None,
191 new_pid_ns: false,
192 new_net_ns: false,
193 chroot_base: default_chroot_base(),
194 chroot_enabled: default_chroot_enabled(),
195 close_fds: default_close_fds(),
196 sanitize_env: default_sanitize_env(),
197 env_allowlist: default_env_allowlist(),
198 resource_limits: ResourceLimits::default(),
199 sandbox_profile: None,
200 network_enabled: default_network_enabled(),
201 }
202 }
203}
204
205impl SecurityOptions {
206 pub fn development() -> Self {
210 Self {
211 jailer_enabled: false,
212 seccomp_enabled: false,
213 chroot_enabled: false,
214 close_fds: false,
215 sanitize_env: false,
216 ..Default::default()
217 }
218 }
219
220 pub fn standard() -> Self {
225 Self {
226 jailer_enabled: cfg!(any(target_os = "linux", target_os = "macos")),
227 seccomp_enabled: cfg!(target_os = "linux"),
228 ..Default::default()
229 }
230 }
231
232 pub fn maximum() -> Self {
236 Self {
237 jailer_enabled: true,
238 seccomp_enabled: cfg!(target_os = "linux"),
239 uid: Some(65534), gid: Some(65534), new_pid_ns: cfg!(target_os = "linux"),
242 new_net_ns: false, chroot_enabled: cfg!(target_os = "linux"),
244 close_fds: true,
245 sanitize_env: true,
246 env_allowlist: vec!["RUST_LOG".to_string()],
247 resource_limits: ResourceLimits {
248 max_open_files: Some(1024),
249 max_file_size: Some(1024 * 1024 * 1024), max_processes: Some(100),
251 max_memory: None, max_cpu_time: None, },
254 ..Default::default()
255 }
256 }
257
258 pub fn is_full_isolation_available() -> bool {
260 cfg!(target_os = "linux")
261 }
262
263 pub fn builder() -> SecurityOptionsBuilder {
278 SecurityOptionsBuilder::new()
279 }
280}
281
282#[derive(Debug, Clone)]
302pub struct SecurityOptionsBuilder {
303 inner: SecurityOptions,
304}
305
306impl Default for SecurityOptionsBuilder {
307 fn default() -> Self {
308 Self::new()
309 }
310}
311
312impl SecurityOptionsBuilder {
313 pub fn new() -> Self {
315 Self {
316 inner: SecurityOptions::default(),
317 }
318 }
319
320 pub fn development() -> Self {
324 Self {
325 inner: SecurityOptions::development(),
326 }
327 }
328
329 pub fn standard() -> Self {
333 Self {
334 inner: SecurityOptions::standard(),
335 }
336 }
337
338 pub fn maximum() -> Self {
342 Self {
343 inner: SecurityOptions::maximum(),
344 }
345 }
346
347 pub fn jailer_enabled(&mut self, enabled: bool) -> &mut Self {
353 self.inner.jailer_enabled = enabled;
354 self
355 }
356
357 pub fn seccomp_enabled(&mut self, enabled: bool) -> &mut Self {
359 self.inner.seccomp_enabled = enabled;
360 self
361 }
362
363 pub fn uid(&mut self, uid: u32) -> &mut Self {
365 self.inner.uid = Some(uid);
366 self
367 }
368
369 pub fn gid(&mut self, gid: u32) -> &mut Self {
371 self.inner.gid = Some(gid);
372 self
373 }
374
375 pub fn new_pid_ns(&mut self, enabled: bool) -> &mut Self {
377 self.inner.new_pid_ns = enabled;
378 self
379 }
380
381 pub fn new_net_ns(&mut self, enabled: bool) -> &mut Self {
383 self.inner.new_net_ns = enabled;
384 self
385 }
386
387 pub fn chroot_base(&mut self, path: impl Into<PathBuf>) -> &mut Self {
393 self.inner.chroot_base = path.into();
394 self
395 }
396
397 pub fn chroot_enabled(&mut self, enabled: bool) -> &mut Self {
399 self.inner.chroot_enabled = enabled;
400 self
401 }
402
403 pub fn close_fds(&mut self, enabled: bool) -> &mut Self {
405 self.inner.close_fds = enabled;
406 self
407 }
408
409 pub fn sanitize_env(&mut self, enabled: bool) -> &mut Self {
415 self.inner.sanitize_env = enabled;
416 self
417 }
418
419 pub fn env_allowlist(&mut self, vars: Vec<String>) -> &mut Self {
421 self.inner.env_allowlist = vars;
422 self
423 }
424
425 pub fn allow_env(&mut self, var: impl Into<String>) -> &mut Self {
427 self.inner.env_allowlist.push(var.into());
428 self
429 }
430
431 pub fn resource_limits(&mut self, limits: ResourceLimits) -> &mut Self {
437 self.inner.resource_limits = limits;
438 self
439 }
440
441 pub fn max_open_files(&mut self, limit: u64) -> &mut Self {
443 self.inner.resource_limits.max_open_files = Some(limit);
444 self
445 }
446
447 pub fn max_file_size_bytes(&mut self, bytes: u64) -> &mut Self {
449 self.inner.resource_limits.max_file_size = Some(bytes);
450 self
451 }
452
453 pub fn max_processes(&mut self, limit: u64) -> &mut Self {
455 self.inner.resource_limits.max_processes = Some(limit);
456 self
457 }
458
459 pub fn max_memory_bytes(&mut self, bytes: u64) -> &mut Self {
461 self.inner.resource_limits.max_memory = Some(bytes);
462 self
463 }
464
465 pub fn max_cpu_time_seconds(&mut self, seconds: u64) -> &mut Self {
467 self.inner.resource_limits.max_cpu_time = Some(seconds);
468 self
469 }
470
471 pub fn sandbox_profile(&mut self, path: impl Into<PathBuf>) -> &mut Self {
477 self.inner.sandbox_profile = Some(path.into());
478 self
479 }
480
481 pub fn network_enabled(&mut self, enabled: bool) -> &mut Self {
483 self.inner.network_enabled = enabled;
484 self
485 }
486
487 pub fn build(&self) -> SecurityOptions {
493 self.inner.clone()
494 }
495}
496
497#[derive(Clone, Debug, Serialize, Deserialize)]
504pub struct BoxliteOptions {
505 #[serde(default = "default_home_dir")]
506 pub home_dir: PathBuf,
507 #[serde(default)]
529 pub image_registries: Vec<String>,
530}
531
532fn default_home_dir() -> PathBuf {
533 std::env::var(const_envs::BOXLITE_HOME)
534 .map(PathBuf::from)
535 .unwrap_or_else(|_| {
536 let mut path = home_dir().unwrap_or_else(|| PathBuf::from("."));
537 path.push(const_dirs::BOXLITE_DIR);
538 path
539 })
540}
541
542impl Default for BoxliteOptions {
543 fn default() -> Self {
544 Self {
545 home_dir: default_home_dir(),
546 image_registries: Vec::new(),
547 }
548 }
549}
550
551#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
553pub struct BoxOptions {
554 pub cpus: Option<u8>,
555 pub memory_mib: Option<u32>,
556 pub disk_size_gb: Option<u64>,
562 pub working_dir: Option<String>,
563 pub env: Vec<(String, String)>,
564 pub rootfs: RootfsSpec,
565 pub volumes: Vec<VolumeSpec>,
566 pub network: NetworkSpec,
567 pub ports: Vec<PortSpec>,
568 #[serde(default)]
576 pub isolate_mounts: bool,
577
578 #[serde(default = "default_auto_remove")]
587 pub auto_remove: bool,
588
589 #[serde(default = "default_detach")]
599 pub detach: bool,
600
601 #[serde(default)]
607 pub security: SecurityOptions,
608
609 #[serde(default)]
618 pub entrypoint: Option<Vec<String>>,
619
620 #[serde(default)]
629 pub cmd: Option<Vec<String>>,
630
631 #[serde(default)]
634 pub user: Option<String>,
635}
636
637fn default_auto_remove() -> bool {
638 true
639}
640
641fn default_detach() -> bool {
642 false
643}
644
645impl Default for BoxOptions {
646 fn default() -> Self {
647 Self {
648 cpus: None,
649 memory_mib: None,
650 disk_size_gb: None,
651 working_dir: None,
652 env: Vec::new(),
653 rootfs: RootfsSpec::default(),
654 volumes: Vec::new(),
655 network: NetworkSpec::default(),
656 ports: Vec::new(),
657 isolate_mounts: false,
658 auto_remove: default_auto_remove(),
659 detach: default_detach(),
660 security: SecurityOptions::default(),
661 entrypoint: None,
662 cmd: None,
663 user: None,
664 }
665 }
666}
667
668impl BoxOptions {
669 pub fn sanitize(&self) -> BoxliteResult<()> {
675 if self.auto_remove && self.detach {
681 return Err(boxlite_shared::errors::BoxliteError::Config(
682 "auto_remove=true is incompatible with detach=true. \
683 Detached boxes should use auto_remove=false for manual lifecycle control."
684 .to_string(),
685 ));
686 }
687
688 #[cfg(not(target_os = "linux"))]
689 if self.isolate_mounts {
690 return Err(boxlite_shared::errors::BoxliteError::Unsupported(
691 "isolate_mounts is only supported on Linux".to_string(),
692 ));
693 }
694 Ok(())
695 }
696}
697
698#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
700pub enum RootfsSpec {
701 Image(String),
703 RootfsPath(String),
705}
706
707impl Default for RootfsSpec {
708 fn default() -> Self {
709 Self::Image("alpine:latest".into())
710 }
711}
712
713#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
715pub struct VolumeSpec {
716 pub host_path: String,
717 pub guest_path: String,
718 pub read_only: bool,
719}
720
721#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
723pub enum NetworkSpec {
724 #[default]
725 Isolated,
726 }
729
730#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
731pub enum PortProtocol {
732 #[default]
733 Tcp,
734 Udp,
735 }
737
738fn default_protocol() -> PortProtocol {
739 PortProtocol::Tcp
740}
741
742#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
744pub struct PortSpec {
745 pub host_port: Option<u16>, pub guest_port: u16,
747 #[serde(default = "default_protocol")]
748 pub protocol: PortProtocol,
749 pub host_ip: Option<String>, }
751
752#[cfg(test)]
753mod tests {
754 use super::*;
755
756 #[test]
757 fn test_box_options_defaults() {
758 let opts = BoxOptions::default();
759 assert!(opts.auto_remove, "auto_remove should default to true");
760 assert!(!opts.detach, "detach should default to false");
761 }
762
763 #[test]
764 fn test_box_options_serde_defaults() {
765 let json = r#"{
768 "rootfs": {"Image": "alpine:latest"},
769 "env": [],
770 "volumes": [],
771 "network": "Isolated",
772 "ports": []
773 }"#;
774 let opts: BoxOptions = serde_json::from_str(json).unwrap();
775 assert!(
776 opts.auto_remove,
777 "auto_remove should default to true via serde"
778 );
779 assert!(!opts.detach, "detach should default to false via serde");
780 }
781
782 #[test]
783 fn test_box_options_serde_explicit_values() {
784 let json = r#"{
785 "rootfs": {"Image": "alpine"},
786 "env": [],
787 "volumes": [],
788 "network": "Isolated",
789 "ports": [],
790 "auto_remove": false,
791 "detach": true
792 }"#;
793 let opts: BoxOptions = serde_json::from_str(json).unwrap();
794 assert!(
795 !opts.auto_remove,
796 "explicit auto_remove=false should be respected"
797 );
798 assert!(opts.detach, "explicit detach=true should be respected");
799 }
800
801 #[test]
802 fn test_box_options_roundtrip() {
803 let opts = BoxOptions {
804 auto_remove: false,
805 detach: true,
806 ..Default::default()
807 };
808
809 let json = serde_json::to_string(&opts).unwrap();
810 let opts2: BoxOptions = serde_json::from_str(&json).unwrap();
811
812 assert_eq!(opts.auto_remove, opts2.auto_remove);
813 assert_eq!(opts.detach, opts2.detach);
814 }
815
816 #[test]
817 fn test_sanitize_auto_remove_detach_incompatible() {
818 let opts = BoxOptions {
820 auto_remove: true,
821 detach: true,
822 ..Default::default()
823 };
824 let result = opts.sanitize();
825 assert!(
826 result.is_err(),
827 "auto_remove=true + detach=true should fail"
828 );
829 let err_msg = result.unwrap_err().to_string();
830 assert!(
831 err_msg.contains("incompatible"),
832 "Error should mention incompatibility"
833 );
834 }
835
836 #[test]
837 fn test_sanitize_valid_combinations() {
838 let opts1 = BoxOptions {
840 auto_remove: true,
841 detach: false,
842 ..Default::default()
843 };
844 assert!(opts1.sanitize().is_ok());
845
846 let opts2 = BoxOptions {
848 auto_remove: false,
849 detach: true,
850 ..Default::default()
851 };
852 assert!(opts2.sanitize().is_ok());
853
854 let opts3 = BoxOptions {
856 auto_remove: false,
857 detach: false,
858 ..Default::default()
859 };
860 assert!(opts3.sanitize().is_ok());
861 }
862
863 #[test]
868 fn test_security_builder_new() {
869 let opts = SecurityOptionsBuilder::new().build();
870 assert!(!opts.jailer_enabled);
872 assert!(!opts.seccomp_enabled);
873 }
874
875 #[test]
876 fn test_security_builder_presets() {
877 let dev = SecurityOptionsBuilder::development().build();
878 assert!(!dev.jailer_enabled);
879 assert!(!dev.close_fds);
880
881 let std = SecurityOptionsBuilder::standard().build();
882 assert!(std.jailer_enabled || !cfg!(any(target_os = "linux", target_os = "macos")));
883
884 let max = SecurityOptionsBuilder::maximum().build();
885 assert!(max.jailer_enabled);
886 assert!(max.close_fds);
887 assert!(max.sanitize_env);
888 }
889
890 #[test]
891 fn test_security_builder_chaining() {
892 let opts = SecurityOptionsBuilder::standard()
893 .jailer_enabled(true)
894 .seccomp_enabled(false)
895 .max_open_files(2048)
896 .max_processes(50)
897 .build();
898
899 assert!(opts.jailer_enabled);
900 assert!(!opts.seccomp_enabled);
901 assert_eq!(opts.resource_limits.max_open_files, Some(2048));
902 assert_eq!(opts.resource_limits.max_processes, Some(50));
903 }
904
905 #[test]
906 fn test_security_builder_resource_limits() {
907 let opts = SecurityOptionsBuilder::new()
908 .max_open_files(1024)
909 .max_file_size_bytes(1024 * 1024)
910 .max_processes(100)
911 .max_memory_bytes(512 * 1024 * 1024)
912 .max_cpu_time_seconds(300)
913 .build();
914
915 assert_eq!(opts.resource_limits.max_open_files, Some(1024));
916 assert_eq!(opts.resource_limits.max_file_size, Some(1024 * 1024));
917 assert_eq!(opts.resource_limits.max_processes, Some(100));
918 assert_eq!(opts.resource_limits.max_memory, Some(512 * 1024 * 1024));
919 assert_eq!(opts.resource_limits.max_cpu_time, Some(300));
920 }
921
922 #[test]
923 fn test_security_builder_env_allowlist() {
924 let opts = SecurityOptionsBuilder::new()
925 .env_allowlist(vec!["FOO".to_string()])
926 .allow_env("BAR")
927 .allow_env("BAZ")
928 .build();
929
930 assert_eq!(opts.env_allowlist.len(), 3);
931 assert!(opts.env_allowlist.contains(&"FOO".to_string()));
932 assert!(opts.env_allowlist.contains(&"BAR".to_string()));
933 assert!(opts.env_allowlist.contains(&"BAZ".to_string()));
934 }
935
936 #[test]
937 fn test_security_builder_via_security_options() {
938 let opts = SecurityOptions::builder().jailer_enabled(true).build();
940
941 assert!(opts.jailer_enabled);
942 }
943
944 #[test]
949 fn test_box_options_cmd_default_is_none() {
950 let opts = BoxOptions::default();
951 assert!(opts.cmd.is_none());
952 }
953
954 #[test]
955 fn test_box_options_user_default_is_none() {
956 let opts = BoxOptions::default();
957 assert!(opts.user.is_none());
958 }
959
960 #[test]
961 fn test_box_options_cmd_serde_roundtrip() {
962 let opts = BoxOptions {
963 cmd: Some(vec!["--flag".to_string(), "value".to_string()]),
964 user: Some("1000:1000".to_string()),
965 ..Default::default()
966 };
967
968 let json = serde_json::to_string(&opts).unwrap();
969 let opts2: BoxOptions = serde_json::from_str(&json).unwrap();
970
971 assert_eq!(
972 opts2.cmd,
973 Some(vec!["--flag".to_string(), "value".to_string()])
974 );
975 assert_eq!(opts2.user, Some("1000:1000".to_string()));
976 }
977
978 #[test]
979 fn test_box_options_cmd_serde_missing_defaults_to_none() {
980 let json = r#"{
981 "rootfs": {"Image": "alpine:latest"},
982 "env": [],
983 "volumes": [],
984 "network": "Isolated",
985 "ports": []
986 }"#;
987 let opts: BoxOptions = serde_json::from_str(json).unwrap();
988 assert!(
989 opts.cmd.is_none(),
990 "cmd should default to None when missing from JSON"
991 );
992 assert!(
993 opts.user.is_none(),
994 "user should default to None when missing from JSON"
995 );
996 }
997
998 #[test]
999 fn test_box_options_cmd_explicit_in_json() {
1000 let json = r#"{
1001 "rootfs": {"Image": "docker:dind"},
1002 "env": [],
1003 "volumes": [],
1004 "network": "Isolated",
1005 "ports": [],
1006 "cmd": ["--iptables=false"],
1007 "user": "1000:1000"
1008 }"#;
1009 let opts: BoxOptions = serde_json::from_str(json).unwrap();
1010 assert_eq!(opts.cmd, Some(vec!["--iptables=false".to_string()]));
1011 assert_eq!(opts.user, Some("1000:1000".to_string()));
1012 }
1013
1014 #[test]
1015 fn test_box_options_entrypoint_default_is_none() {
1016 let opts = BoxOptions::default();
1017 assert!(opts.entrypoint.is_none());
1018 }
1019
1020 #[test]
1021 fn test_box_options_entrypoint_serde_roundtrip() {
1022 let opts = BoxOptions {
1023 entrypoint: Some(vec!["dockerd".to_string()]),
1024 cmd: Some(vec!["--iptables=false".to_string()]),
1025 ..Default::default()
1026 };
1027
1028 let json = serde_json::to_string(&opts).unwrap();
1029 let opts2: BoxOptions = serde_json::from_str(&json).unwrap();
1030
1031 assert_eq!(opts2.entrypoint, Some(vec!["dockerd".to_string()]));
1032 assert_eq!(opts2.cmd, Some(vec!["--iptables=false".to_string()]));
1033 }
1034
1035 #[test]
1036 fn test_box_options_entrypoint_missing_defaults_to_none() {
1037 let json = r#"{
1038 "rootfs": {"Image": "alpine:latest"},
1039 "env": [],
1040 "volumes": [],
1041 "network": "Isolated",
1042 "ports": []
1043 }"#;
1044 let opts: BoxOptions = serde_json::from_str(json).unwrap();
1045 assert!(
1046 opts.entrypoint.is_none(),
1047 "entrypoint should default to None when missing from JSON"
1048 );
1049 }
1050
1051 #[test]
1052 fn test_box_options_entrypoint_explicit_in_json() {
1053 let json = r#"{
1054 "rootfs": {"Image": "docker:dind"},
1055 "env": [],
1056 "volumes": [],
1057 "network": "Isolated",
1058 "ports": [],
1059 "entrypoint": ["dockerd"],
1060 "cmd": ["--iptables=false"]
1061 }"#;
1062 let opts: BoxOptions = serde_json::from_str(json).unwrap();
1063 assert_eq!(opts.entrypoint, Some(vec!["dockerd".to_string()]));
1064 assert_eq!(opts.cmd, Some(vec!["--iptables=false".to_string()]));
1065 }
1066
1067 #[test]
1068 fn test_security_builder_non_consuming() {
1069 let mut builder = SecurityOptionsBuilder::standard();
1071 builder.max_open_files(1024);
1072
1073 let opts1 = builder.build();
1074 let opts2 = builder.max_processes(50).build();
1075
1076 assert_eq!(opts1.resource_limits.max_open_files, Some(1024));
1078 assert_eq!(opts2.resource_limits.max_open_files, Some(1024));
1079
1080 assert!(opts1.resource_limits.max_processes.is_none());
1082 assert_eq!(opts2.resource_limits.max_processes, Some(50));
1083 }
1084}