Skip to main content

boxlite/runtime/
options.rs

1//! Configuration for Boxlite.
2
3use 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// ============================================================================
11// Security Options
12// ============================================================================
13
14/// Security isolation options for a box.
15///
16/// These options control how the boxlite-shim process is isolated from the host.
17/// Different presets are available for different security requirements.
18#[derive(Clone, Debug, Serialize, Deserialize)]
19pub struct SecurityOptions {
20    /// Enable jailer isolation.
21    ///
22    /// When true, applies platform-specific security isolation:
23    /// - Linux: seccomp, namespaces, chroot, privilege drop
24    /// - macOS: sandbox-exec profile
25    ///
26    /// Default: false (use `SecurityOptions::standard()` or `maximum()` to enable)
27    #[serde(default = "default_jailer_enabled")]
28    pub jailer_enabled: bool,
29
30    /// Enable seccomp syscall filtering (Linux only).
31    ///
32    /// When true, applies a whitelist of allowed syscalls.
33    /// Default: false (use `SecurityOptions::standard()` or `maximum()` to enable)
34    #[serde(default = "default_seccomp_enabled")]
35    pub seccomp_enabled: bool,
36
37    /// UID to drop to after setup (Linux only).
38    ///
39    /// - None: Auto-allocate an unprivileged UID
40    /// - Some(0): Don't drop privileges (not recommended)
41    /// - Some(uid): Drop to specific UID
42    #[serde(default)]
43    pub uid: Option<u32>,
44
45    /// GID to drop to after setup (Linux only).
46    ///
47    /// - None: Auto-allocate an unprivileged GID
48    /// - Some(0): Don't drop privileges (not recommended)
49    /// - Some(gid): Drop to specific GID
50    #[serde(default)]
51    pub gid: Option<u32>,
52
53    /// Create new PID namespace (Linux only).
54    ///
55    /// When true, the shim becomes PID 1 in a new namespace.
56    /// Default: false
57    #[serde(default)]
58    pub new_pid_ns: bool,
59
60    /// Create new network namespace (Linux only).
61    ///
62    /// When true, creates isolated network namespace.
63    /// Note: gvproxy handles networking, so this may not be needed.
64    /// Default: false
65    #[serde(default)]
66    pub new_net_ns: bool,
67
68    /// Base directory for chroot jails (Linux only).
69    ///
70    /// Default: /srv/boxlite
71    #[serde(default = "default_chroot_base")]
72    pub chroot_base: PathBuf,
73
74    /// Enable chroot isolation (Linux only).
75    ///
76    /// When true, uses pivot_root to isolate filesystem.
77    /// Default: true on Linux
78    #[serde(default = "default_chroot_enabled")]
79    pub chroot_enabled: bool,
80
81    /// Close inherited file descriptors.
82    ///
83    /// When true, closes all FDs except stdin/stdout/stderr before VM start.
84    /// Default: true
85    #[serde(default = "default_close_fds")]
86    pub close_fds: bool,
87
88    /// Sanitize environment variables.
89    ///
90    /// When true, clears all environment variables except those in allowlist.
91    /// Default: true
92    #[serde(default = "default_sanitize_env")]
93    pub sanitize_env: bool,
94
95    /// Environment variables to preserve when sanitizing.
96    ///
97    /// Default: ["RUST_LOG", "PATH", "HOME", "USER", "LANG"]
98    #[serde(default = "default_env_allowlist")]
99    pub env_allowlist: Vec<String>,
100
101    /// Resource limits to apply.
102    #[serde(default)]
103    pub resource_limits: ResourceLimits,
104
105    /// Custom sandbox profile path (macOS only).
106    ///
107    /// If None, uses the built-in modular sandbox profile.
108    #[serde(default)]
109    pub sandbox_profile: Option<PathBuf>,
110
111    /// Enable network access in sandbox (macOS only).
112    ///
113    /// When true, adds network policy to the sandbox.
114    /// Default: true (needed for gvproxy VM networking)
115    #[serde(default = "default_network_enabled")]
116    pub network_enabled: bool,
117}
118
119/// Resource limits for the jailed process.
120#[derive(Clone, Debug, Default, Serialize, Deserialize)]
121pub struct ResourceLimits {
122    /// Maximum number of open file descriptors (RLIMIT_NOFILE).
123    #[serde(default)]
124    pub max_open_files: Option<u64>,
125
126    /// Maximum file size in bytes (RLIMIT_FSIZE).
127    #[serde(default)]
128    pub max_file_size: Option<u64>,
129
130    /// Maximum number of processes (RLIMIT_NPROC).
131    #[serde(default)]
132    pub max_processes: Option<u64>,
133
134    /// Maximum virtual memory in bytes (RLIMIT_AS).
135    #[serde(default)]
136    pub max_memory: Option<u64>,
137
138    /// Maximum CPU time in seconds (RLIMIT_CPU).
139    #[serde(default)]
140    pub max_cpu_time: Option<u64>,
141}
142
143// Default value functions for SecurityOptions
144
145fn 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    /// Development mode: minimal isolation for debugging.
207    ///
208    /// Use this when debugging issues where isolation interferes.
209    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    /// Standard mode: recommended for most use cases.
221    ///
222    /// Provides good security without being overly restrictive.
223    /// Enables jailer on Linux/macOS, seccomp on Linux.
224    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    /// Maximum mode: all isolation features enabled.
233    ///
234    /// Use this for untrusted workloads (AI sandbox, multi-tenant).
235    pub fn maximum() -> Self {
236        Self {
237            jailer_enabled: true,
238            seccomp_enabled: cfg!(target_os = "linux"),
239            uid: Some(65534), // nobody
240            gid: Some(65534), // nogroup
241            new_pid_ns: cfg!(target_os = "linux"),
242            new_net_ns: false, // gvproxy needs network
243            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), // 1GB
250                max_processes: Some(100),
251                max_memory: None,   // Let VM config handle this
252                max_cpu_time: None, // Let VM config handle this
253            },
254            ..Default::default()
255        }
256    }
257
258    /// Check if current platform supports full jailer features.
259    pub fn is_full_isolation_available() -> bool {
260        cfg!(target_os = "linux")
261    }
262
263    /// Create a builder for customizing security options.
264    ///
265    /// Starts with default (development) settings.
266    ///
267    /// # Example
268    ///
269    /// ```
270    /// use boxlite::runtime::options::SecurityOptions;
271    ///
272    /// let security = SecurityOptions::builder()
273    ///     .jailer_enabled(true)
274    ///     .max_open_files(1024)
275    ///     .build();
276    /// ```
277    pub fn builder() -> SecurityOptionsBuilder {
278        SecurityOptionsBuilder::new()
279    }
280}
281
282// ============================================================================
283// Security Options Builder (C-BUILDER: Non-consuming builder pattern)
284// ============================================================================
285
286/// Builder for customizing [`SecurityOptions`].
287///
288/// Provides a fluent API for configuring security isolation options.
289/// Uses non-consuming methods per Rust API guidelines (C-BUILDER).
290///
291/// # Example
292///
293/// ```
294/// use boxlite::runtime::options::SecurityOptionsBuilder;
295///
296/// let security = SecurityOptionsBuilder::standard()
297///     .max_open_files(2048)
298///     .max_file_size_bytes(1024 * 1024 * 512) // 512 MiB
299///     .build();
300/// ```
301#[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    /// Create a builder starting from default options.
314    pub fn new() -> Self {
315        Self {
316            inner: SecurityOptions::default(),
317        }
318    }
319
320    /// Create a builder starting from development settings.
321    ///
322    /// Minimal isolation for debugging.
323    pub fn development() -> Self {
324        Self {
325            inner: SecurityOptions::development(),
326        }
327    }
328
329    /// Create a builder starting from standard settings.
330    ///
331    /// Recommended for most use cases.
332    pub fn standard() -> Self {
333        Self {
334            inner: SecurityOptions::standard(),
335        }
336    }
337
338    /// Create a builder starting from maximum security settings.
339    ///
340    /// All isolation features enabled.
341    pub fn maximum() -> Self {
342        Self {
343            inner: SecurityOptions::maximum(),
344        }
345    }
346
347    // ─────────────────────────────────────────────────────────────────────
348    // Core isolation settings
349    // ─────────────────────────────────────────────────────────────────────
350
351    /// Enable or disable jailer isolation.
352    pub fn jailer_enabled(&mut self, enabled: bool) -> &mut Self {
353        self.inner.jailer_enabled = enabled;
354        self
355    }
356
357    /// Enable or disable seccomp syscall filtering (Linux only).
358    pub fn seccomp_enabled(&mut self, enabled: bool) -> &mut Self {
359        self.inner.seccomp_enabled = enabled;
360        self
361    }
362
363    /// Set UID to drop to after setup (Linux only).
364    pub fn uid(&mut self, uid: u32) -> &mut Self {
365        self.inner.uid = Some(uid);
366        self
367    }
368
369    /// Set GID to drop to after setup (Linux only).
370    pub fn gid(&mut self, gid: u32) -> &mut Self {
371        self.inner.gid = Some(gid);
372        self
373    }
374
375    /// Enable or disable new PID namespace (Linux only).
376    pub fn new_pid_ns(&mut self, enabled: bool) -> &mut Self {
377        self.inner.new_pid_ns = enabled;
378        self
379    }
380
381    /// Enable or disable new network namespace (Linux only).
382    pub fn new_net_ns(&mut self, enabled: bool) -> &mut Self {
383        self.inner.new_net_ns = enabled;
384        self
385    }
386
387    // ─────────────────────────────────────────────────────────────────────
388    // Filesystem isolation
389    // ─────────────────────────────────────────────────────────────────────
390
391    /// Set base directory for chroot jails (Linux only).
392    pub fn chroot_base(&mut self, path: impl Into<PathBuf>) -> &mut Self {
393        self.inner.chroot_base = path.into();
394        self
395    }
396
397    /// Enable or disable chroot isolation (Linux only).
398    pub fn chroot_enabled(&mut self, enabled: bool) -> &mut Self {
399        self.inner.chroot_enabled = enabled;
400        self
401    }
402
403    /// Enable or disable closing inherited file descriptors.
404    pub fn close_fds(&mut self, enabled: bool) -> &mut Self {
405        self.inner.close_fds = enabled;
406        self
407    }
408
409    // ─────────────────────────────────────────────────────────────────────
410    // Environment settings
411    // ─────────────────────────────────────────────────────────────────────
412
413    /// Enable or disable environment variable sanitization.
414    pub fn sanitize_env(&mut self, enabled: bool) -> &mut Self {
415        self.inner.sanitize_env = enabled;
416        self
417    }
418
419    /// Set environment variables to preserve when sanitizing.
420    pub fn env_allowlist(&mut self, vars: Vec<String>) -> &mut Self {
421        self.inner.env_allowlist = vars;
422        self
423    }
424
425    /// Add an environment variable to the allowlist.
426    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    // ─────────────────────────────────────────────────────────────────────
432    // Resource limits (type-safe setters)
433    // ─────────────────────────────────────────────────────────────────────
434
435    /// Set all resource limits at once.
436    pub fn resource_limits(&mut self, limits: ResourceLimits) -> &mut Self {
437        self.inner.resource_limits = limits;
438        self
439    }
440
441    /// Set maximum number of open file descriptors.
442    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    /// Set maximum file size in bytes.
448    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    /// Set maximum number of processes.
454    pub fn max_processes(&mut self, limit: u64) -> &mut Self {
455        self.inner.resource_limits.max_processes = Some(limit);
456        self
457    }
458
459    /// Set maximum virtual memory in bytes.
460    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    /// Set maximum CPU time in seconds.
466    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    // ─────────────────────────────────────────────────────────────────────
472    // macOS-specific settings
473    // ─────────────────────────────────────────────────────────────────────
474
475    /// Set custom sandbox profile path (macOS only).
476    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    /// Enable or disable network access in sandbox (macOS only).
482    pub fn network_enabled(&mut self, enabled: bool) -> &mut Self {
483        self.inner.network_enabled = enabled;
484        self
485    }
486
487    // ─────────────────────────────────────────────────────────────────────
488    // Build
489    // ─────────────────────────────────────────────────────────────────────
490
491    /// Build the configured [`SecurityOptions`].
492    pub fn build(&self) -> SecurityOptions {
493        self.inner.clone()
494    }
495}
496
497// ============================================================================
498// Runtime Options
499// ============================================================================
500/// Configuration options for BoxliteRuntime.
501///
502/// Users can create it with defaults and modify fields as needed.
503#[derive(Clone, Debug, Serialize, Deserialize)]
504pub struct BoxliteOptions {
505    #[serde(default = "default_home_dir")]
506    pub home_dir: PathBuf,
507    /// Registries to search for unqualified image references.
508    ///
509    /// When pulling an image without a registry prefix (e.g., `"alpine"`),
510    /// these registries are tried in order until one succeeds.
511    ///
512    /// - Empty list (default): Uses docker.io as the implicit default
513    /// - Non-empty list: Tries each registry in order, first success wins
514    /// - Fully qualified refs (e.g., `"quay.io/foo"`) bypass this list
515    ///
516    /// # Example
517    ///
518    /// ```ignore
519    /// BoxliteOptions {
520    ///     image_registries: vec![
521    ///         "ghcr.io/myorg".to_string(),
522    ///         "docker.io".to_string(),
523    ///     ],
524    ///     ..Default::default()
525    /// }
526    /// // "alpine" → tries ghcr.io/myorg/alpine, then docker.io/alpine
527    /// ```
528    #[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/// Options used when constructing a box.
552#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
553pub struct BoxOptions {
554    pub cpus: Option<u8>,
555    pub memory_mib: Option<u32>,
556    /// Disk size in GB for the container rootfs (sparse, grows as needed).
557    ///
558    /// The actual disk will be at least as large as the base image.
559    /// If set, the COW overlay will have this virtual size, allowing
560    /// the container to write more data than the base image size.
561    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    /// Enable bind mount isolation for the shared mounts directory.
569    ///
570    /// When true, creates a read-only bind mount from `mounts/` to `shared/`,
571    /// preventing the guest from modifying host-prepared files.
572    ///
573    /// Requires CAP_SYS_ADMIN (privileged) or FUSE (rootless) on Linux.
574    /// Defaults to false.
575    #[serde(default)]
576    pub isolate_mounts: bool,
577
578    /// Automatically remove box when stopped.
579    ///
580    /// When true (default), the box is removed from the database and its
581    /// files are deleted when `stop()` is called. This is similar to
582    /// Docker's `--rm` flag.
583    ///
584    /// When false, the box is preserved after stop and can be restarted
585    /// with `runtime.get(box_id)`.
586    #[serde(default = "default_auto_remove")]
587    pub auto_remove: bool,
588
589    /// Whether the box should continue running when the parent process exits.
590    ///
591    /// When false (default), the box will automatically stop when the process
592    /// that created it exits. This ensures orphan boxes don't accumulate.
593    /// Similar to running a process in the foreground.
594    ///
595    /// When true, the box runs independently and survives parent process exit.
596    /// The box can be reattached using `runtime.get(box_id)`. Similar to
597    /// Docker's `-d` (detach) flag.
598    #[serde(default = "default_detach")]
599    pub detach: bool,
600
601    /// Security isolation options for the box.
602    ///
603    /// Controls how the boxlite-shim process is isolated from the host.
604    /// Different presets are available: `SecurityOptions::development()`,
605    /// `SecurityOptions::standard()`, `SecurityOptions::maximum()`.
606    #[serde(default)]
607    pub security: SecurityOptions,
608
609    /// Override the image's ENTRYPOINT directive.
610    ///
611    /// When set, completely replaces the image's ENTRYPOINT.
612    /// Use with `cmd` to build the full command:
613    ///   Final execution = entrypoint + cmd
614    ///
615    /// Example: For `docker:dind`, bypass the failing entrypoint script:
616    ///   `entrypoint = vec!["dockerd"]`, `cmd = vec!["--iptables=false"]`
617    #[serde(default)]
618    pub entrypoint: Option<Vec<String>>,
619
620    /// Override the image's CMD directive.
621    ///
622    /// The image ENTRYPOINT is preserved; these args replace the image's CMD.
623    /// Final execution = image_entrypoint + cmd.
624    ///
625    /// Example: For `docker:dind` (ENTRYPOINT=["dockerd-entrypoint.sh"]),
626    /// setting `cmd = vec!["--iptables=false"]` produces:
627    /// `["dockerd-entrypoint.sh", "--iptables=false"]`
628    #[serde(default)]
629    pub cmd: Option<Vec<String>>,
630
631    /// Username or UID (format: <name|uid>[:<group|gid>]).
632    /// If None, uses the image's USER directive (defaults to root).
633    #[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    /// Sanitize and validate options.
670    ///
671    /// Validates option combinations:
672    /// - `auto_remove=true` with `detach=true` is invalid (detached boxes need manual lifecycle control)
673    /// - `isolate_mounts=true` is only supported on Linux
674    pub fn sanitize(&self) -> BoxliteResult<()> {
675        // Validate auto_remove + detach combination
676        // A detached box that auto-removes doesn't make practical sense:
677        // - detach=true: box survives parent exit
678        // - auto_remove=true: box removed on stop
679        // This combination is confusing - detached boxes should have manual lifecycle control
680        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/// How to populate the box root filesystem.
699#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
700pub enum RootfsSpec {
701    /// Pull/resolve this registry image reference.
702    Image(String),
703    /// Use an already prepared rootfs at the given host path.
704    RootfsPath(String),
705}
706
707impl Default for RootfsSpec {
708    fn default() -> Self {
709        Self::Image("alpine:latest".into())
710    }
711}
712
713/// Filesystem mount specification.
714#[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/// Network isolation options.
722#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
723pub enum NetworkSpec {
724    #[default]
725    Isolated,
726    // Host,
727    // Custom(String),
728}
729
730#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
731pub enum PortProtocol {
732    #[default]
733    Tcp,
734    Udp,
735    // Sctp,
736}
737
738fn default_protocol() -> PortProtocol {
739    PortProtocol::Tcp
740}
741
742/// Port mapping specification (host -> guest).
743#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
744pub struct PortSpec {
745    pub host_port: Option<u16>, // None/0 => dynamically assigned
746    pub guest_port: u16,
747    #[serde(default = "default_protocol")]
748    pub protocol: PortProtocol,
749    pub host_ip: Option<String>, // Optional bind IP, defaults to 0.0.0.0/:: if None
750}
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        // Test that serde uses correct defaults for missing fields
766        // Must include all required fields that don't have serde defaults
767        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        // auto_remove=true + detach=true is invalid
819        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        // auto_remove=true, detach=false (default) - valid
839        let opts1 = BoxOptions {
840            auto_remove: true,
841            detach: false,
842            ..Default::default()
843        };
844        assert!(opts1.sanitize().is_ok());
845
846        // auto_remove=false, detach=true - valid
847        let opts2 = BoxOptions {
848            auto_remove: false,
849            detach: true,
850            ..Default::default()
851        };
852        assert!(opts2.sanitize().is_ok());
853
854        // auto_remove=false, detach=false - valid
855        let opts3 = BoxOptions {
856            auto_remove: false,
857            detach: false,
858            ..Default::default()
859        };
860        assert!(opts3.sanitize().is_ok());
861    }
862
863    // ========================================================================
864    // SecurityOptionsBuilder tests
865    // ========================================================================
866
867    #[test]
868    fn test_security_builder_new() {
869        let opts = SecurityOptionsBuilder::new().build();
870        // Default should match SecurityOptions::default()
871        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        // Test the convenience method on SecurityOptions
939        let opts = SecurityOptions::builder().jailer_enabled(true).build();
940
941        assert!(opts.jailer_enabled);
942    }
943
944    // ========================================================================
945    // cmd/user option tests
946    // ========================================================================
947
948    #[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        // Verify builder can be reused (non-consuming pattern)
1070        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        // Both should have max_open_files
1077        assert_eq!(opts1.resource_limits.max_open_files, Some(1024));
1078        assert_eq!(opts2.resource_limits.max_open_files, Some(1024));
1079
1080        // Only opts2 should have max_processes
1081        assert!(opts1.resource_limits.max_processes.is_none());
1082        assert_eq!(opts2.resource_limits.max_processes, Some(50));
1083    }
1084}