Skip to main content

a3s_box_core/
config.rs

1use crate::network::NetworkMode;
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4
5/// TEE (Trusted Execution Environment) configuration.
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
7#[serde(tag = "kind", rename_all = "snake_case")]
8pub enum TeeConfig {
9    /// No TEE (standard VM)
10    #[default]
11    None,
12
13    /// AMD SEV-SNP (Secure Encrypted Virtualization - Secure Nested Paging)
14    SevSnp {
15        /// Workload identifier for attestation
16        workload_id: String,
17        /// CPU generation: "milan" or "genoa"
18        #[serde(default)]
19        generation: SevSnpGeneration,
20        /// Enable simulation mode (no hardware required, for development)
21        #[serde(default)]
22        simulate: bool,
23    },
24
25    /// Intel TDX (Trust Domain Extensions) — stub, not yet implemented at runtime.
26    Tdx {
27        /// Workload identifier for attestation
28        workload_id: String,
29        /// Enable simulation mode (no hardware required, for development)
30        #[serde(default)]
31        simulate: bool,
32    },
33}
34
35/// AMD SEV-SNP CPU generation.
36#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
37#[serde(rename_all = "lowercase")]
38pub enum SevSnpGeneration {
39    /// AMD EPYC Milan (3rd gen)
40    #[default]
41    Milan,
42    /// AMD EPYC Genoa (4th gen)
43    Genoa,
44}
45
46impl SevSnpGeneration {
47    /// Get the generation as a string for TEE config.
48    pub fn as_str(&self) -> &'static str {
49        match self {
50            SevSnpGeneration::Milan => "milan",
51            SevSnpGeneration::Genoa => "genoa",
52        }
53    }
54}
55
56/// Cache configuration for cold start optimization.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct CacheConfig {
59    /// Enable rootfs and layer caching (default: true)
60    #[serde(default = "default_true")]
61    pub enabled: bool,
62
63    /// Cache directory (default: ~/.a3s/cache)
64    pub cache_dir: Option<PathBuf>,
65
66    /// Maximum number of cached rootfs entries (default: 10)
67    #[serde(default = "default_max_rootfs_entries")]
68    pub max_rootfs_entries: usize,
69
70    /// Maximum total cache size in bytes (default: 10 GB)
71    #[serde(default = "default_max_cache_bytes")]
72    pub max_cache_bytes: u64,
73}
74
75fn default_true() -> bool {
76    true
77}
78
79fn default_max_rootfs_entries() -> usize {
80    10
81}
82
83fn default_max_cache_bytes() -> u64 {
84    10 * 1024 * 1024 * 1024 // 10 GB
85}
86
87impl Default for CacheConfig {
88    fn default() -> Self {
89        Self {
90            enabled: true,
91            cache_dir: None,
92            max_rootfs_entries: 10,
93            max_cache_bytes: 10 * 1024 * 1024 * 1024,
94        }
95    }
96}
97
98/// Warm pool configuration for pre-booted VMs.
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct PoolConfig {
101    /// Enable warm pool (default: false)
102    #[serde(default)]
103    pub enabled: bool,
104
105    /// Minimum number of pre-warmed idle VMs to maintain
106    #[serde(default = "default_min_idle")]
107    pub min_idle: usize,
108
109    /// Maximum number of VMs in the pool (idle + in-use)
110    #[serde(default = "default_max_pool_size")]
111    pub max_size: usize,
112
113    /// Time-to-live for idle VMs in seconds (0 = unlimited)
114    #[serde(default = "default_idle_ttl")]
115    pub idle_ttl_secs: u64,
116
117    /// Autoscaling policy for dynamic min_idle adjustment
118    #[serde(default)]
119    pub scaling: ScalingPolicy,
120}
121
122/// Autoscaling policy for dynamic warm pool sizing.
123///
124/// When enabled, the pool monitors acquire hit/miss rates over a sliding
125/// window and adjusts `min_idle` up or down to match demand pressure.
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct ScalingPolicy {
128    /// Enable autoscaling (default: false)
129    #[serde(default)]
130    pub enabled: bool,
131
132    /// Miss rate threshold to trigger scale-up (default: 0.3 = 30%)
133    #[serde(default = "default_scale_up_threshold")]
134    pub scale_up_threshold: f64,
135
136    /// Miss rate threshold to trigger scale-down (default: 0.05 = 5%)
137    #[serde(default = "default_scale_down_threshold")]
138    pub scale_down_threshold: f64,
139
140    /// Upper bound for dynamic min_idle (default: 0 = use max_size)
141    #[serde(default)]
142    pub max_min_idle: usize,
143
144    /// Seconds between scaling decisions (default: 60)
145    #[serde(default = "default_cooldown_secs")]
146    pub cooldown_secs: u64,
147
148    /// Observation window for miss rate calculation in seconds (default: 120)
149    #[serde(default = "default_window_secs")]
150    pub window_secs: u64,
151}
152
153fn default_scale_up_threshold() -> f64 {
154    0.3
155}
156
157fn default_scale_down_threshold() -> f64 {
158    0.05
159}
160
161fn default_cooldown_secs() -> u64 {
162    60
163}
164
165fn default_window_secs() -> u64 {
166    120
167}
168
169impl Default for ScalingPolicy {
170    fn default() -> Self {
171        Self {
172            enabled: false,
173            scale_up_threshold: 0.3,
174            scale_down_threshold: 0.05,
175            max_min_idle: 0,
176            cooldown_secs: 60,
177            window_secs: 120,
178        }
179    }
180}
181
182fn default_min_idle() -> usize {
183    1
184}
185
186fn default_max_pool_size() -> usize {
187    5
188}
189
190fn default_idle_ttl() -> u64 {
191    300 // 5 minutes
192}
193
194impl Default for PoolConfig {
195    fn default() -> Self {
196        Self {
197            enabled: false,
198            min_idle: 1,
199            max_size: 5,
200            idle_ttl_secs: 300,
201            scaling: ScalingPolicy::default(),
202        }
203    }
204}
205
206/// Resource limits for a box instance.
207///
208/// Tier 1 limits (rlimits, cpuset) work on all platforms.
209/// Tier 2 limits (cgroup-based) are Linux-only and best-effort.
210#[derive(Debug, Clone, Serialize, Deserialize, Default)]
211pub struct ResourceLimits {
212    /// PID limit inside the guest (--pids-limit).
213    /// Maps to RLIMIT_NPROC in guest rlimits.
214    #[serde(default)]
215    pub pids_limit: Option<u64>,
216
217    /// CPU pinning: comma-separated CPU IDs (--cpuset-cpus "0,1,3").
218    /// Applied via sched_setaffinity() on the shim process (Linux only).
219    #[serde(default)]
220    pub cpuset_cpus: Option<String>,
221
222    /// Custom rlimits (--ulimit), format: "RESOURCE=SOFT:HARD".
223    #[serde(default)]
224    pub ulimits: Vec<String>,
225
226    /// CPU shares (--cpu-shares), relative weight 2-262144.
227    /// Applied via cgroup v2 cpu.weight (Linux only).
228    #[serde(default)]
229    pub cpu_shares: Option<u64>,
230
231    /// CPU quota in microseconds per --cpu-period (--cpu-quota).
232    /// Applied via cgroup v2 cpu.max (Linux only).
233    #[serde(default)]
234    pub cpu_quota: Option<i64>,
235
236    /// CPU period in microseconds (--cpu-period, default 100000).
237    /// Applied via cgroup v2 cpu.max (Linux only).
238    #[serde(default)]
239    pub cpu_period: Option<u64>,
240
241    /// Memory reservation/soft limit in bytes (--memory-reservation).
242    /// Applied via cgroup v2 memory.low (Linux only).
243    #[serde(default)]
244    pub memory_reservation: Option<u64>,
245
246    /// Memory+swap limit in bytes (--memory-swap, -1 = unlimited).
247    /// Applied via cgroup v2 memory.swap.max (Linux only).
248    #[serde(default)]
249    pub memory_swap: Option<i64>,
250}
251
252/// Box configuration
253#[derive(Debug, Clone, Serialize, Deserialize)]
254pub struct BoxConfig {
255    /// OCI image reference (e.g., "nginx:alpine", "ghcr.io/org/app:latest")
256    #[serde(default)]
257    pub image: String,
258
259    /// Workspace directory (mounted to /workspace inside the VM)
260    pub workspace: PathBuf,
261
262    /// Resource limits
263    pub resources: ResourceConfig,
264
265    /// Log level
266    pub log_level: LogLevel,
267
268    /// Enable gRPC debug logging
269    pub debug_grpc: bool,
270
271    /// TEE (Trusted Execution Environment) configuration
272    #[serde(default)]
273    pub tee: TeeConfig,
274
275    /// Command override (replaces OCI CMD when set)
276    #[serde(default)]
277    pub cmd: Vec<String>,
278
279    /// Entrypoint override (replaces OCI ENTRYPOINT when set)
280    #[serde(default)]
281    pub entrypoint_override: Option<Vec<String>>,
282
283    /// Extra volume mounts (host_path:guest_path or host_path:guest_path:ro)
284    #[serde(default)]
285    pub volumes: Vec<String>,
286
287    /// Extra environment variables for the entrypoint
288    #[serde(default)]
289    pub extra_env: Vec<(String, String)>,
290
291    /// Cache configuration for cold start optimization
292    #[serde(default)]
293    pub cache: CacheConfig,
294
295    /// Warm pool configuration for pre-booted VMs
296    #[serde(default)]
297    pub pool: PoolConfig,
298
299    /// Port mappings: "host_port:guest_port" (e.g., "8080:80")
300    /// Maps host ports to guest ports via TSI (Transparent Socket Impersonation).
301    #[serde(default)]
302    pub port_map: Vec<String>,
303
304    /// Custom DNS servers (e.g., "1.1.1.1").
305    /// If empty, reads from host /etc/resolv.conf, falling back to 8.8.8.8.
306    #[serde(default)]
307    pub dns: Vec<String>,
308
309    /// Network mode: TSI (default), bridge (passt-based), or none.
310    #[serde(default)]
311    pub network: NetworkMode,
312
313    /// tmpfs mounts (ephemeral in-guest filesystems).
314    /// Format: "/path" or "/path:size=100m"
315    #[serde(default)]
316    pub tmpfs: Vec<String>,
317
318    /// Resource limits (PID limits, CPU pinning, ulimits, cgroup controls).
319    #[serde(default)]
320    pub resource_limits: ResourceLimits,
321
322    /// Linux capabilities to add (e.g., "NET_ADMIN", "SYS_PTRACE")
323    #[serde(default)]
324    pub cap_add: Vec<String>,
325
326    /// Linux capabilities to drop (e.g., "ALL", "NET_RAW")
327    #[serde(default)]
328    pub cap_drop: Vec<String>,
329
330    /// Security options (e.g., "seccomp=unconfined", "no-new-privileges")
331    #[serde(default)]
332    pub security_opt: Vec<String>,
333
334    /// Run in privileged mode (disables all security restrictions)
335    #[serde(default)]
336    pub privileged: bool,
337
338    /// Mount the container rootfs as read-only.
339    ///
340    /// Volume mounts (-v host:guest) remain writable by default.
341    /// Requires guest init to be present in the rootfs image.
342    #[serde(default)]
343    pub read_only: bool,
344
345    /// Optional sidecar process to run alongside the main container inside the VM.
346    ///
347    /// The sidecar is launched before the main container entrypoint and runs
348    /// as a co-process inside the same MicroVM. Intended for security proxies
349    /// such as SafeClaw that intercept and classify agent traffic.
350    #[serde(default)]
351    pub sidecar: Option<SidecarConfig>,
352
353    /// Preserve the box filesystem across stop/start cycles.
354    ///
355    /// When true, the overlay upper layer (or copy rootfs) is kept on disk
356    /// after the box stops and reused on the next start. Changes made inside
357    /// the box persist between restarts, similar to a traditional VM.
358    ///
359    /// When false (default), the writable layer is wiped on every stop,
360    /// giving a clean slate on each start.
361    #[serde(default)]
362    pub persistent: bool,
363}
364
365impl Default for BoxConfig {
366    fn default() -> Self {
367        Self {
368            image: String::new(),
369            // Empty path signals the runtime to create a per-box workspace
370            // under ~/.a3s/boxes/<box_id>/workspace/ at boot time.
371            workspace: PathBuf::new(),
372            resources: ResourceConfig::default(),
373            log_level: LogLevel::Info,
374            debug_grpc: false,
375            tee: TeeConfig::default(),
376            cmd: vec![],
377            entrypoint_override: None,
378            volumes: vec![],
379            extra_env: vec![],
380            cache: CacheConfig::default(),
381            pool: PoolConfig::default(),
382            port_map: vec![],
383            dns: vec![],
384            network: NetworkMode::default(),
385            tmpfs: vec![],
386            resource_limits: ResourceLimits::default(),
387            cap_add: vec![],
388            cap_drop: vec![],
389            security_opt: vec![],
390            privileged: false,
391            read_only: false,
392            sidecar: None,
393            persistent: false,
394        }
395    }
396}
397
398/// Sidecar process configuration.
399///
400/// A sidecar runs as a co-process inside the same MicroVM alongside the main
401/// container. It is launched before the main entrypoint and communicates with
402/// the host via a dedicated vsock port.
403///
404/// Primary use case: SafeClaw security proxy that intercepts and classifies
405/// agent traffic before it reaches the LLM.
406///
407/// # Data flow
408///
409/// ```text
410/// Agent → SafeClaw (vsock 4092) → classified/sanitized → LLM
411/// ```
412#[derive(Debug, Clone, Serialize, Deserialize)]
413pub struct SidecarConfig {
414    /// OCI image reference for the sidecar (e.g., "ghcr.io/a3s-lab/safeclaw:latest")
415    pub image: String,
416
417    /// Vsock port the sidecar listens on for host-side control (default: 4092)
418    #[serde(default = "default_sidecar_vsock_port")]
419    pub vsock_port: u32,
420
421    /// Extra environment variables for the sidecar process
422    #[serde(default)]
423    pub env: Vec<(String, String)>,
424}
425
426fn default_sidecar_vsock_port() -> u32 {
427    4092
428}
429
430impl Default for SidecarConfig {
431    fn default() -> Self {
432        Self {
433            image: String::new(),
434            vsock_port: default_sidecar_vsock_port(),
435            env: vec![],
436        }
437    }
438}
439
440/// Resource configuration
441#[derive(Debug, Clone, Serialize, Deserialize)]
442pub struct ResourceConfig {
443    /// Number of virtual CPUs
444    pub vcpus: u32,
445
446    /// Memory in MB
447    pub memory_mb: u32,
448
449    /// Disk space in MB
450    pub disk_mb: u32,
451
452    /// Box lifetime timeout in seconds (0 = unlimited)
453    pub timeout: u64,
454}
455
456impl Default for ResourceConfig {
457    fn default() -> Self {
458        Self {
459            vcpus: 2,
460            memory_mb: 1024,
461            disk_mb: 4096,
462            timeout: 3600, // 1 hour
463        }
464    }
465}
466
467/// Log level
468#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
469pub enum LogLevel {
470    Debug,
471    Info,
472    Warn,
473    Error,
474}
475
476impl From<LogLevel> for tracing::Level {
477    fn from(level: LogLevel) -> Self {
478        match level {
479            LogLevel::Debug => tracing::Level::DEBUG,
480            LogLevel::Info => tracing::Level::INFO,
481            LogLevel::Warn => tracing::Level::WARN,
482            LogLevel::Error => tracing::Level::ERROR,
483        }
484    }
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490
491    #[test]
492    fn test_box_config_default() {
493        let config = BoxConfig::default();
494
495        assert!(config.image.is_empty());
496        // Empty workspace signals the runtime to use a per-box directory at boot time.
497        assert!(config.workspace.as_os_str().is_empty());
498        assert_eq!(config.resources.vcpus, 2);
499        assert!(!config.debug_grpc);
500        assert!(!config.read_only);
501    }
502
503    #[test]
504    fn test_box_config_read_only_default_false() {
505        let config = BoxConfig::default();
506        assert!(!config.read_only);
507    }
508
509    #[test]
510    fn test_box_config_read_only_serde() {
511        // read_only defaults to false when absent from JSON
512        let json = r#"{"image":"test","workspace":"","resources":{"vcpus":2,"memory_mb":512,"disk_mb":4096,"timeout":3600},"log_level":"Info","debug_grpc":false}"#;
513        let config: BoxConfig = serde_json::from_str(json).unwrap();
514        assert!(!config.read_only);
515
516        // read_only=true roundtrips correctly
517        let config = BoxConfig {
518            read_only: true,
519            ..Default::default()
520        };
521        let json = serde_json::to_string(&config).unwrap();
522        let deserialized: BoxConfig = serde_json::from_str(&json).unwrap();
523        assert!(deserialized.read_only);
524    }
525
526    #[test]
527    fn test_resource_config_default() {
528        let config = ResourceConfig::default();
529
530        assert_eq!(config.vcpus, 2);
531        assert_eq!(config.memory_mb, 1024);
532        assert_eq!(config.disk_mb, 4096);
533        assert_eq!(config.timeout, 3600);
534    }
535
536    #[test]
537    fn test_resource_config_custom() {
538        let config = ResourceConfig {
539            vcpus: 4,
540            memory_mb: 2048,
541            disk_mb: 8192,
542            timeout: 7200,
543        };
544
545        assert_eq!(config.vcpus, 4);
546        assert_eq!(config.memory_mb, 2048);
547        assert_eq!(config.disk_mb, 8192);
548        assert_eq!(config.timeout, 7200);
549    }
550
551    #[test]
552    fn test_log_level_conversion() {
553        assert_eq!(tracing::Level::from(LogLevel::Debug), tracing::Level::DEBUG);
554        assert_eq!(tracing::Level::from(LogLevel::Info), tracing::Level::INFO);
555        assert_eq!(tracing::Level::from(LogLevel::Warn), tracing::Level::WARN);
556        assert_eq!(tracing::Level::from(LogLevel::Error), tracing::Level::ERROR);
557    }
558
559    #[test]
560    fn test_box_config_serialization() {
561        let config = BoxConfig::default();
562        let json = serde_json::to_string(&config).unwrap();
563
564        assert!(json.contains("workspace"));
565        assert!(json.contains("resources"));
566    }
567
568    #[test]
569    fn test_box_config_deserialization() {
570        let json = r#"{
571            "image": "nginx:alpine",
572            "workspace": "/tmp/workspace",
573            "resources": {
574                "vcpus": 4,
575                "memory_mb": 2048,
576                "disk_mb": 8192,
577                "timeout": 1800
578            },
579            "log_level": "Debug",
580            "debug_grpc": true
581        }"#;
582
583        let config: BoxConfig = serde_json::from_str(json).unwrap();
584        assert_eq!(config.image, "nginx:alpine");
585        assert_eq!(config.workspace.to_str().unwrap(), "/tmp/workspace");
586        assert_eq!(config.resources.vcpus, 4);
587        assert!(config.debug_grpc);
588    }
589
590    #[test]
591    fn test_resource_config_serialization() {
592        let config = ResourceConfig {
593            vcpus: 8,
594            memory_mb: 4096,
595            disk_mb: 16384,
596            timeout: 0,
597        };
598
599        let json = serde_json::to_string(&config).unwrap();
600        let parsed: ResourceConfig = serde_json::from_str(&json).unwrap();
601
602        assert_eq!(parsed.vcpus, 8);
603        assert_eq!(parsed.memory_mb, 4096);
604        assert_eq!(parsed.timeout, 0); // Unlimited
605    }
606
607    #[test]
608    fn test_log_level_serialization() {
609        let levels = vec![
610            LogLevel::Debug,
611            LogLevel::Info,
612            LogLevel::Warn,
613            LogLevel::Error,
614        ];
615
616        for level in levels {
617            let json = serde_json::to_string(&level).unwrap();
618            let parsed: LogLevel = serde_json::from_str(&json).unwrap();
619            assert_eq!(tracing::Level::from(parsed), tracing::Level::from(level));
620        }
621    }
622
623    #[test]
624    fn test_config_clone() {
625        let config = BoxConfig::default();
626        let cloned = config.clone();
627
628        assert_eq!(config.workspace, cloned.workspace);
629        assert_eq!(config.resources.vcpus, cloned.resources.vcpus);
630    }
631
632    #[test]
633    fn test_config_debug() {
634        let config = BoxConfig::default();
635        let debug_str = format!("{:?}", config);
636
637        assert!(debug_str.contains("BoxConfig"));
638        assert!(debug_str.contains("workspace"));
639    }
640
641    #[test]
642    fn test_tee_config_default() {
643        let tee = TeeConfig::default();
644        assert_eq!(tee, TeeConfig::None);
645    }
646
647    #[test]
648    fn test_tee_config_sev_snp() {
649        let tee = TeeConfig::SevSnp {
650            workload_id: "test-agent".to_string(),
651            generation: SevSnpGeneration::Milan,
652            simulate: false,
653        };
654
655        match tee {
656            TeeConfig::SevSnp {
657                workload_id,
658                generation,
659                simulate,
660            } => {
661                assert_eq!(workload_id, "test-agent");
662                assert_eq!(generation, SevSnpGeneration::Milan);
663                assert!(!simulate);
664            }
665            _ => panic!("Expected SevSnp variant"),
666        }
667    }
668
669    #[test]
670    fn test_sev_snp_generation_as_str() {
671        assert_eq!(SevSnpGeneration::Milan.as_str(), "milan");
672        assert_eq!(SevSnpGeneration::Genoa.as_str(), "genoa");
673    }
674
675    #[test]
676    fn test_sev_snp_generation_default() {
677        let gen = SevSnpGeneration::default();
678        assert_eq!(gen, SevSnpGeneration::Milan);
679    }
680
681    #[test]
682    fn test_tee_config_serialization() {
683        let tee = TeeConfig::SevSnp {
684            workload_id: "my-workload".to_string(),
685            generation: SevSnpGeneration::Genoa,
686            simulate: false,
687        };
688
689        let json = serde_json::to_string(&tee).unwrap();
690        let parsed: TeeConfig = serde_json::from_str(&json).unwrap();
691
692        assert_eq!(parsed, tee);
693    }
694
695    #[test]
696    fn test_tee_config_none_serialization() {
697        let tee = TeeConfig::None;
698        let json = serde_json::to_string(&tee).unwrap();
699        let parsed: TeeConfig = serde_json::from_str(&json).unwrap();
700
701        assert_eq!(parsed, TeeConfig::None);
702    }
703
704    #[test]
705    fn test_tee_config_tdx() {
706        let tee = TeeConfig::Tdx {
707            workload_id: "tdx-workload".to_string(),
708            simulate: false,
709        };
710        let json = serde_json::to_string(&tee).unwrap();
711        let parsed: TeeConfig = serde_json::from_str(&json).unwrap();
712        match parsed {
713            TeeConfig::Tdx {
714                workload_id,
715                simulate,
716            } => {
717                assert_eq!(workload_id, "tdx-workload");
718                assert!(!simulate);
719            }
720            _ => panic!("Expected Tdx variant"),
721        }
722    }
723
724    #[test]
725    fn test_tee_config_tdx_simulate() {
726        let tee = TeeConfig::Tdx {
727            workload_id: "test".to_string(),
728            simulate: true,
729        };
730        let json = serde_json::to_string(&tee).unwrap();
731        let parsed: TeeConfig = serde_json::from_str(&json).unwrap();
732        match parsed {
733            TeeConfig::Tdx { simulate, .. } => assert!(simulate),
734            _ => panic!("Expected Tdx variant"),
735        }
736    }
737
738    #[test]
739    fn test_box_config_with_tee() {
740        let config = BoxConfig {
741            tee: TeeConfig::SevSnp {
742                workload_id: "secure-agent".to_string(),
743                generation: SevSnpGeneration::Milan,
744                simulate: false,
745            },
746            ..Default::default()
747        };
748
749        let json = serde_json::to_string(&config).unwrap();
750        let parsed: BoxConfig = serde_json::from_str(&json).unwrap();
751
752        match parsed.tee {
753            TeeConfig::SevSnp {
754                workload_id,
755                generation,
756                simulate,
757            } => {
758                assert_eq!(workload_id, "secure-agent");
759                assert_eq!(generation, SevSnpGeneration::Milan);
760                assert!(!simulate);
761            }
762            _ => panic!("Expected SevSnp TEE config"),
763        }
764    }
765
766    #[test]
767    fn test_box_config_default_has_no_tee() {
768        let config = BoxConfig::default();
769        assert_eq!(config.tee, TeeConfig::None);
770    }
771
772    // --- CacheConfig tests ---
773
774    #[test]
775    fn test_cache_config_default() {
776        let config = CacheConfig::default();
777        assert!(config.enabled);
778        assert!(config.cache_dir.is_none());
779        assert_eq!(config.max_rootfs_entries, 10);
780        assert_eq!(config.max_cache_bytes, 10 * 1024 * 1024 * 1024);
781    }
782
783    #[test]
784    fn test_cache_config_serialization() {
785        let config = CacheConfig {
786            enabled: false,
787            cache_dir: Some(PathBuf::from("/tmp/cache")),
788            max_rootfs_entries: 5,
789            max_cache_bytes: 1024 * 1024 * 1024,
790        };
791
792        let json = serde_json::to_string(&config).unwrap();
793        let parsed: CacheConfig = serde_json::from_str(&json).unwrap();
794
795        assert!(!parsed.enabled);
796        assert_eq!(parsed.cache_dir, Some(PathBuf::from("/tmp/cache")));
797        assert_eq!(parsed.max_rootfs_entries, 5);
798        assert_eq!(parsed.max_cache_bytes, 1024 * 1024 * 1024);
799    }
800
801    #[test]
802    fn test_cache_config_deserialization_defaults() {
803        let json = "{}";
804        let config: CacheConfig = serde_json::from_str(json).unwrap();
805
806        assert!(config.enabled);
807        assert!(config.cache_dir.is_none());
808        assert_eq!(config.max_rootfs_entries, 10);
809        assert_eq!(config.max_cache_bytes, 10 * 1024 * 1024 * 1024);
810    }
811
812    // --- PoolConfig tests ---
813
814    #[test]
815    fn test_pool_config_default() {
816        let config = PoolConfig::default();
817        assert!(!config.enabled);
818        assert_eq!(config.min_idle, 1);
819        assert_eq!(config.max_size, 5);
820        assert_eq!(config.idle_ttl_secs, 300);
821    }
822
823    #[test]
824    fn test_pool_config_serialization() {
825        let config = PoolConfig {
826            enabled: true,
827            min_idle: 3,
828            max_size: 10,
829            idle_ttl_secs: 600,
830            ..Default::default()
831        };
832
833        let json = serde_json::to_string(&config).unwrap();
834        let parsed: PoolConfig = serde_json::from_str(&json).unwrap();
835
836        assert!(parsed.enabled);
837        assert_eq!(parsed.min_idle, 3);
838        assert_eq!(parsed.max_size, 10);
839        assert_eq!(parsed.idle_ttl_secs, 600);
840    }
841
842    #[test]
843    fn test_pool_config_deserialization_defaults() {
844        let json = "{}";
845        let config: PoolConfig = serde_json::from_str(json).unwrap();
846
847        assert!(!config.enabled);
848        assert_eq!(config.min_idle, 1);
849        assert_eq!(config.max_size, 5);
850        assert_eq!(config.idle_ttl_secs, 300);
851    }
852
853    // --- BoxConfig with new fields ---
854
855    #[test]
856    fn test_box_config_default_has_cache_and_pool() {
857        let config = BoxConfig::default();
858        assert!(config.cache.enabled);
859        assert!(!config.pool.enabled);
860    }
861
862    #[test]
863    fn test_box_config_with_cache_serialization() {
864        let config = BoxConfig {
865            cache: CacheConfig {
866                enabled: false,
867                cache_dir: Some(PathBuf::from("/custom/cache")),
868                max_rootfs_entries: 20,
869                max_cache_bytes: 5 * 1024 * 1024 * 1024,
870            },
871            ..Default::default()
872        };
873
874        let json = serde_json::to_string(&config).unwrap();
875        let parsed: BoxConfig = serde_json::from_str(&json).unwrap();
876
877        assert!(!parsed.cache.enabled);
878        assert_eq!(parsed.cache.cache_dir, Some(PathBuf::from("/custom/cache")));
879        assert_eq!(parsed.cache.max_rootfs_entries, 20);
880    }
881
882    #[test]
883    fn test_box_config_with_pool_serialization() {
884        let config = BoxConfig {
885            pool: PoolConfig {
886                enabled: true,
887                min_idle: 2,
888                max_size: 8,
889                idle_ttl_secs: 120,
890                ..Default::default()
891            },
892            ..Default::default()
893        };
894
895        let json = serde_json::to_string(&config).unwrap();
896        let parsed: BoxConfig = serde_json::from_str(&json).unwrap();
897
898        assert!(parsed.pool.enabled);
899        assert_eq!(parsed.pool.min_idle, 2);
900        assert_eq!(parsed.pool.max_size, 8);
901        assert_eq!(parsed.pool.idle_ttl_secs, 120);
902    }
903
904    #[test]
905    fn test_box_config_backward_compatible_deserialization() {
906        // JSON without cache/pool fields should still deserialize with defaults
907        let json = r#"{
908            "workspace": "/tmp/workspace",
909            "resources": {
910                "vcpus": 2,
911                "memory_mb": 1024,
912                "disk_mb": 4096,
913                "timeout": 3600
914            },
915            "log_level": "Info",
916            "debug_grpc": false
917        }"#;
918
919        let config: BoxConfig = serde_json::from_str(json).unwrap();
920        assert!(config.cache.enabled);
921        assert!(!config.pool.enabled);
922    }
923
924    // --- ResourceLimits tests ---
925
926    #[test]
927    fn test_resource_limits_default() {
928        let limits = ResourceLimits::default();
929        assert!(limits.pids_limit.is_none());
930        assert!(limits.cpuset_cpus.is_none());
931        assert!(limits.ulimits.is_empty());
932        assert!(limits.cpu_shares.is_none());
933        assert!(limits.cpu_quota.is_none());
934        assert!(limits.cpu_period.is_none());
935        assert!(limits.memory_reservation.is_none());
936        assert!(limits.memory_swap.is_none());
937    }
938
939    #[test]
940    fn test_resource_limits_serialization() {
941        let limits = ResourceLimits {
942            pids_limit: Some(100),
943            cpuset_cpus: Some("0,1".to_string()),
944            ulimits: vec!["nofile=1024:4096".to_string()],
945            cpu_shares: Some(512),
946            cpu_quota: Some(50000),
947            cpu_period: Some(100000),
948            memory_reservation: Some(256 * 1024 * 1024),
949            memory_swap: Some(1024 * 1024 * 1024),
950        };
951
952        let json = serde_json::to_string(&limits).unwrap();
953        let parsed: ResourceLimits = serde_json::from_str(&json).unwrap();
954
955        assert_eq!(parsed.pids_limit, Some(100));
956        assert_eq!(parsed.cpuset_cpus, Some("0,1".to_string()));
957        assert_eq!(parsed.ulimits, vec!["nofile=1024:4096"]);
958        assert_eq!(parsed.cpu_shares, Some(512));
959        assert_eq!(parsed.cpu_quota, Some(50000));
960        assert_eq!(parsed.cpu_period, Some(100000));
961        assert_eq!(parsed.memory_reservation, Some(256 * 1024 * 1024));
962        assert_eq!(parsed.memory_swap, Some(1024 * 1024 * 1024));
963    }
964
965    #[test]
966    fn test_resource_limits_deserialization_defaults() {
967        let json = "{}";
968        let limits: ResourceLimits = serde_json::from_str(json).unwrap();
969        assert!(limits.pids_limit.is_none());
970        assert!(limits.ulimits.is_empty());
971    }
972
973    #[test]
974    fn test_resource_limits_memory_swap_unlimited() {
975        let limits = ResourceLimits {
976            memory_swap: Some(-1),
977            ..Default::default()
978        };
979
980        let json = serde_json::to_string(&limits).unwrap();
981        let parsed: ResourceLimits = serde_json::from_str(&json).unwrap();
982        assert_eq!(parsed.memory_swap, Some(-1));
983    }
984
985    #[test]
986    fn test_box_config_with_resource_limits() {
987        let config = BoxConfig {
988            resource_limits: ResourceLimits {
989                pids_limit: Some(256),
990                cpu_shares: Some(1024),
991                ..Default::default()
992            },
993            ..Default::default()
994        };
995
996        let json = serde_json::to_string(&config).unwrap();
997        let parsed: BoxConfig = serde_json::from_str(&json).unwrap();
998
999        assert_eq!(parsed.resource_limits.pids_limit, Some(256));
1000        assert_eq!(parsed.resource_limits.cpu_shares, Some(1024));
1001    }
1002
1003    #[test]
1004    fn test_box_config_backward_compat_no_resource_limits() {
1005        // Old configs without resource_limits should deserialize with defaults
1006        let json = r#"{
1007            "workspace": "/tmp/workspace",
1008            "resources": {
1009                "vcpus": 2,
1010                "memory_mb": 1024,
1011                "disk_mb": 4096,
1012                "timeout": 3600
1013            },
1014            "log_level": "Info",
1015            "debug_grpc": false
1016        }"#;
1017
1018        let config: BoxConfig = serde_json::from_str(json).unwrap();
1019        assert!(config.resource_limits.pids_limit.is_none());
1020        assert!(config.resource_limits.ulimits.is_empty());
1021    }
1022
1023    // ── SidecarConfig tests ───────────────────────────────────────────
1024
1025    #[test]
1026    fn test_sidecar_config_default() {
1027        let s = SidecarConfig::default();
1028        assert!(s.image.is_empty());
1029        assert_eq!(s.vsock_port, 4092);
1030        assert!(s.env.is_empty());
1031    }
1032
1033    #[test]
1034    fn test_sidecar_config_roundtrip() {
1035        let s = SidecarConfig {
1036            image: "ghcr.io/a3s-lab/safeclaw:latest".to_string(),
1037            vsock_port: 4092,
1038            env: vec![
1039                ("LOG_LEVEL".to_string(), "debug".to_string()),
1040                ("MODE".to_string(), "proxy".to_string()),
1041            ],
1042        };
1043        let json = serde_json::to_string(&s).unwrap();
1044        let parsed: SidecarConfig = serde_json::from_str(&json).unwrap();
1045        assert_eq!(parsed.image, "ghcr.io/a3s-lab/safeclaw:latest");
1046        assert_eq!(parsed.vsock_port, 4092);
1047        assert_eq!(parsed.env.len(), 2);
1048        assert_eq!(
1049            parsed.env[0],
1050            ("LOG_LEVEL".to_string(), "debug".to_string())
1051        );
1052    }
1053
1054    #[test]
1055    fn test_sidecar_config_default_vsock_port_from_json() {
1056        let json = r#"{"image":"safeclaw:latest"}"#;
1057        let s: SidecarConfig = serde_json::from_str(json).unwrap();
1058        assert_eq!(s.vsock_port, 4092);
1059        assert!(s.env.is_empty());
1060    }
1061
1062    #[test]
1063    fn test_box_config_default_has_no_sidecar() {
1064        let config = BoxConfig::default();
1065        assert!(config.sidecar.is_none());
1066    }
1067
1068    #[test]
1069    fn test_box_config_with_sidecar_roundtrip() {
1070        let mut config = BoxConfig::default();
1071        config.sidecar = Some(SidecarConfig {
1072            image: "safeclaw:latest".to_string(),
1073            vsock_port: 4092,
1074            env: vec![],
1075        });
1076        let json = serde_json::to_string(&config).unwrap();
1077        let parsed: BoxConfig = serde_json::from_str(&json).unwrap();
1078        let sidecar = parsed.sidecar.unwrap();
1079        assert_eq!(sidecar.image, "safeclaw:latest");
1080        assert_eq!(sidecar.vsock_port, 4092);
1081    }
1082
1083    #[test]
1084    fn test_box_config_without_sidecar_deserializes_as_none() {
1085        // Old configs without sidecar field should deserialize with sidecar=None
1086        let config = BoxConfig::default();
1087        let json = serde_json::to_string(&config).unwrap();
1088        let parsed: BoxConfig = serde_json::from_str(&json).unwrap();
1089        assert!(parsed.sidecar.is_none());
1090    }
1091}