Skip to main content

smith_config/
executor.rs

1//! Executor service configuration
2
3use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::env;
7use std::fs;
8use std::path::PathBuf;
9
10/// Executor service configuration
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct ExecutorConfig {
13    /// Executor node name
14    pub node_name: String,
15
16    /// Work directory root
17    pub work_root: PathBuf,
18
19    /// State directory for persistence
20    pub state_dir: PathBuf,
21
22    /// Audit directory for audit logs
23    pub audit_dir: PathBuf,
24
25    /// User UID to run intents as
26    pub user_uid: u32,
27
28    /// User GID to run intents as
29    pub user_gid: u32,
30
31    /// Enable Landlock LSM sandboxing
32    pub landlock_enabled: bool,
33
34    /// Egress proxy socket path
35    pub egress_proxy_socket: PathBuf,
36
37    /// Metrics port for Prometheus
38    pub metrics_port: Option<u16>,
39
40    /// Intent stream configurations by capability
41    pub intent_streams: HashMap<String, IntentStreamConfig>,
42
43    /// Results configuration
44    pub results: ResultsConfig,
45
46    /// Resource limits
47    pub limits: LimitsConfig,
48
49    /// Security configuration
50    pub security: SecurityConfig,
51
52    /// Capability bundle configuration
53    pub capabilities: CapabilityConfig,
54
55    /// OPA policy update configuration
56    pub policy: PolicyConfig,
57
58    /// NATS configuration specific to executor
59    pub nats_config: ExecutorNatsConfig,
60
61    /// Supply chain attestation configuration
62    pub attestation: AttestationConfig,
63
64    /// Micro-VM pool configuration for persistent shell environments
65    #[serde(default)]
66    pub vm_pool: VmPoolConfig,
67}
68
69/// Intent stream configuration for a capability
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct IntentStreamConfig {
72    /// NATS subject pattern
73    pub subject: String,
74
75    /// Maximum age for messages
76    pub max_age: String,
77
78    /// Maximum bytes in stream
79    pub max_bytes: String,
80
81    /// Number of worker instances
82    pub workers: u32,
83}
84
85/// Results stream configuration
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct ResultsConfig {
88    /// Subject prefix for results
89    pub subject_prefix: String,
90
91    /// Maximum age for result messages
92    pub max_age: String,
93}
94
95/// Resource limits configuration
96#[derive(Debug, Clone, Serialize, Deserialize, Default)]
97pub struct LimitsConfig {
98    /// Default resource limits
99    pub defaults: DefaultLimits,
100
101    /// Per-capability limit overrides
102    pub overrides: HashMap<String, DefaultLimits>,
103}
104
105/// Default resource limits for intent execution
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct DefaultLimits {
108    /// CPU time per 100ms window
109    pub cpu_ms_per_100ms: u32,
110
111    /// Memory limit in bytes
112    pub mem_bytes: u64,
113
114    /// I/O bytes limit
115    pub io_bytes: u64,
116
117    /// Maximum number of processes/threads
118    pub pids_max: u32,
119
120    /// Temporary filesystem size in MB
121    pub tmpfs_mb: u32,
122
123    /// Maximum intent payload size
124    pub intent_max_bytes: u64,
125}
126
127/// Security configuration
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct SecurityConfig {
130    /// Public keys directory for signature verification
131    pub pubkeys_dir: PathBuf,
132
133    /// JWT token issuers (URLs)
134    pub jwt_issuers: Vec<String>,
135
136    /// Enable strict sandbox mode
137    pub strict_sandbox: bool,
138
139    /// Enable network isolation
140    pub network_isolation: bool,
141
142    /// Allowed outbound network destinations
143    pub allowed_destinations: Vec<String>,
144}
145
146/// Capability enforcement configuration
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct CapabilityConfig {
149    /// Path to capability derivations file
150    pub derivations_path: PathBuf,
151
152    /// Enable capability enforcement
153    pub enforcement_enabled: bool,
154}
155
156/// OPA policy update configuration
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct PolicyConfig {
159    /// Policy update check interval in seconds
160    pub update_interval_seconds: u64,
161
162    /// NATS subject for streaming policy updates
163    #[serde(default = "PolicyConfig::default_updates_subject")]
164    pub updates_subject: String,
165
166    /// Optional queue group for load-balanced policy updates
167    #[serde(default)]
168    pub updates_queue: Option<String>,
169}
170
171/// NATS configuration specific to executor service
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct ExecutorNatsConfig {
174    /// NATS server URLs
175    pub servers: Vec<String>,
176
177    /// JetStream domain
178    pub jetstream_domain: String,
179
180    /// TLS certificate file path
181    pub tls_cert: Option<PathBuf>,
182
183    /// TLS key file path
184    pub tls_key: Option<PathBuf>,
185
186    /// TLS CA file path
187    pub tls_ca: Option<PathBuf>,
188}
189
190/// Supply chain attestation configuration
191#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct AttestationConfig {
193    /// Enable capability bundle signing and verification
194    pub enable_capability_signing: bool,
195
196    /// Enable container image verification
197    pub enable_image_verification: bool,
198
199    /// Enable SLSA provenance generation and verification
200    pub enable_slsa_provenance: bool,
201
202    /// Fail execution if attestation verification fails
203    pub fail_on_signature_error: bool,
204
205    /// Cosign public key for verification (optional for keyless)
206    pub cosign_public_key: Option<String>,
207
208    /// SLSA provenance output directory
209    pub provenance_output_dir: PathBuf,
210
211    /// Attestation verification cache TTL in seconds
212    pub verification_cache_ttl: u64,
213
214    /// Periodic attestation verification interval in seconds
215    pub periodic_verification_interval: u64,
216}
217
218/// Persistent micro-VM pool configuration
219#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct VmPoolConfig {
221    /// Enable the pool; when disabled the executor falls back to per-intent sandboxes
222    #[serde(default)]
223    pub enabled: bool,
224
225    /// Root directory where per-session volumes are stored
226    pub volume_root: PathBuf,
227
228    /// Optional Nix profile or flake reference used to hydrate VM environments
229    pub nix_profile: Option<String>,
230
231    /// Base shell binary used to execute commands inside a VM
232    pub shell: PathBuf,
233
234    /// Additional shell arguments (e.g., ["-lc"] or nix develop flags)
235    #[serde(default)]
236    pub shell_args: Vec<String>,
237
238    /// Static environment variables injected into every VM command
239    #[serde(default)]
240    pub env: HashMap<String, String>,
241
242    /// Maximum number of concurrently active VMs
243    pub max_vms: usize,
244
245    /// Idle duration (seconds) before a VM is automatically shut down
246    pub idle_shutdown_seconds: u64,
247
248    /// Additional grace period after shutdown before pruning persistent volumes
249    pub prune_after_seconds: u64,
250
251    /// Optional delay before triggering a backup of the user volume once stopped
252    pub backup_after_seconds: Option<u64>,
253
254    /// Destination directory for backups (if enabled)
255    pub backup_destination: Option<PathBuf>,
256
257    /// Optional bootstrap command invoked once when the VM volume is created
258    #[serde(default)]
259    pub bootstrap_command: Option<Vec<String>>,
260}
261
262impl Default for VmPoolConfig {
263    fn default() -> Self {
264        Self {
265            enabled: false,
266            volume_root: PathBuf::from("/var/lib/smith/executor/vm-pool"),
267            nix_profile: None,
268            shell: PathBuf::from("/bin/bash"),
269            shell_args: vec!["-lc".to_string()],
270            env: HashMap::new(),
271            max_vms: 32,
272            idle_shutdown_seconds: 900,
273            prune_after_seconds: 3_600,
274            backup_after_seconds: None,
275            backup_destination: None,
276            bootstrap_command: None,
277        }
278    }
279}
280
281impl VmPoolConfig {
282    pub fn validate(&self) -> Result<()> {
283        if !self.enabled {
284            return Ok(());
285        }
286
287        if self.max_vms == 0 {
288            return Err(anyhow::anyhow!(
289                "vm_pool.max_vms must be greater than zero when the pool is enabled"
290            ));
291        }
292
293        if self.idle_shutdown_seconds == 0 {
294            return Err(anyhow::anyhow!(
295                "vm_pool.idle_shutdown_seconds must be greater than zero"
296            ));
297        }
298
299        if self.prune_after_seconds == 0 {
300            return Err(anyhow::anyhow!(
301                "vm_pool.prune_after_seconds must be greater than zero"
302            ));
303        }
304
305        if let Some(backup_after) = self.backup_after_seconds {
306            if backup_after == 0 {
307                return Err(anyhow::anyhow!(
308                    "vm_pool.backup_after_seconds must be greater than zero"
309                ));
310            }
311            if self.backup_destination.is_none() {
312                return Err(anyhow::anyhow!(
313                    "vm_pool.backup_destination must be set when backup_after_seconds is provided"
314                ));
315            }
316        }
317
318        if !self.volume_root.exists() {
319            std::fs::create_dir_all(&self.volume_root).with_context(|| {
320                format!(
321                    "Failed to create vm_pool.volume_root directory: {}",
322                    self.volume_root.display()
323                )
324            })?;
325        }
326
327        if let Some(dest) = &self.backup_destination {
328            if !dest.exists() {
329                std::fs::create_dir_all(dest).with_context(|| {
330                    format!(
331                        "Failed to create vm_pool.backup_destination directory: {}",
332                        dest.display()
333                    )
334                })?;
335            }
336        }
337
338        Ok(())
339    }
340}
341
342impl Default for ExecutorConfig {
343    fn default() -> Self {
344        let mut intent_streams = HashMap::new();
345
346        intent_streams.insert(
347            "fs.read.v1".to_string(),
348            IntentStreamConfig {
349                subject: "smith.intents.fs.read.v1".to_string(),
350                max_age: "10m".to_string(),
351                max_bytes: "1GB".to_string(),
352                workers: 4,
353            },
354        );
355
356        Self {
357            node_name: "exec-01".to_string(),
358            work_root: PathBuf::from("/var/lib/smith/executor/work"),
359            state_dir: PathBuf::from("/var/lib/smith/executor/state"),
360            audit_dir: PathBuf::from("/var/lib/smith/executor/audit"),
361            user_uid: 65534, // nobody
362            user_gid: 65534, // nobody
363            landlock_enabled: true,
364            egress_proxy_socket: PathBuf::from("/run/smith/egress-proxy.sock"),
365            metrics_port: Some(9090),
366            intent_streams,
367            results: ResultsConfig::default(),
368            limits: LimitsConfig::default(),
369            security: SecurityConfig::default(),
370            capabilities: CapabilityConfig::default(),
371            policy: PolicyConfig::default(),
372            nats_config: ExecutorNatsConfig::default(),
373            attestation: AttestationConfig::default(),
374            vm_pool: VmPoolConfig::default(),
375        }
376    }
377}
378
379impl Default for ResultsConfig {
380    fn default() -> Self {
381        Self {
382            subject_prefix: "smith.results.".to_string(),
383            max_age: "5m".to_string(),
384        }
385    }
386}
387
388impl Default for DefaultLimits {
389    fn default() -> Self {
390        Self {
391            cpu_ms_per_100ms: 50,
392            mem_bytes: 256 * 1024 * 1024, // 256MB
393            io_bytes: 10 * 1024 * 1024,   // 10MB
394            pids_max: 32,
395            tmpfs_mb: 64,
396            intent_max_bytes: 64 * 1024, // 64KB
397        }
398    }
399}
400
401impl Default for SecurityConfig {
402    fn default() -> Self {
403        Self {
404            pubkeys_dir: PathBuf::from("/etc/smith/executor/pubkeys"),
405            jwt_issuers: vec!["https://auth.smith.example.com/".to_string()],
406            strict_sandbox: false,
407            network_isolation: true,
408            allowed_destinations: vec![],
409        }
410    }
411}
412
413impl Default for CapabilityConfig {
414    fn default() -> Self {
415        Self {
416            derivations_path: PathBuf::from("build/capability/sandbox_profiles/derivations.json"),
417            enforcement_enabled: true,
418        }
419    }
420}
421
422impl Default for PolicyConfig {
423    fn default() -> Self {
424        Self {
425            update_interval_seconds: 300, // 5 minutes
426            updates_subject: "smith.policies.updates".to_string(),
427            updates_queue: None,
428        }
429    }
430}
431
432impl Default for ExecutorNatsConfig {
433    fn default() -> Self {
434        Self {
435            servers: vec!["nats://127.0.0.1:4222".to_string()],
436            jetstream_domain: "JS".to_string(),
437            tls_cert: Some(PathBuf::from("/etc/smith/executor/nats.crt")),
438            tls_key: Some(PathBuf::from("/etc/smith/executor/nats.key")),
439            tls_ca: Some(PathBuf::from("/etc/smith/executor/ca.crt")),
440        }
441    }
442}
443
444impl ExecutorConfig {
445    pub fn validate(&self) -> Result<()> {
446        // Validate node name
447        if self.node_name.is_empty() {
448            return Err(anyhow::anyhow!("Node name cannot be empty"));
449        }
450
451        if self.node_name.len() > 63 {
452            return Err(anyhow::anyhow!("Node name too long (max 63 chars)"));
453        }
454
455        // Validate directories exist or can be created
456        for (name, path) in [
457            ("work_root", &self.work_root),
458            ("state_dir", &self.state_dir),
459            ("audit_dir", &self.audit_dir),
460        ] {
461            if let Some(parent) = path.parent() {
462                if !parent.exists() {
463                    fs::create_dir_all(parent).with_context(|| {
464                        format!(
465                            "Failed to create {} parent directory: {}",
466                            name,
467                            parent.display()
468                        )
469                    })?;
470                }
471            }
472        }
473
474        // Validate UIDs/GIDs
475        if self.user_uid == 0 {
476            tracing::warn!("⚠️  Running as root (UID 0) is not recommended for security");
477        }
478
479        if self.user_gid == 0 {
480            tracing::warn!("⚠️  Running as root group (GID 0) is not recommended for security");
481        }
482
483        // Validate metrics port
484        if let Some(port) = self.metrics_port {
485            if port < 1024 {
486                return Err(anyhow::anyhow!(
487                    "Invalid metrics port: {}. Must be between 1024 and 65535",
488                    port
489                ));
490            }
491        }
492
493        // Validate intent stream configurations
494        if self.intent_streams.is_empty() {
495            return Err(anyhow::anyhow!("No intent streams configured"));
496        }
497
498        for (capability, stream_config) in &self.intent_streams {
499            stream_config.validate().map_err(|e| {
500                anyhow::anyhow!("Intent stream '{}' validation failed: {}", capability, e)
501            })?;
502        }
503
504        // Validate sub-configurations
505        self.results
506            .validate()
507            .context("Results configuration validation failed")?;
508
509        self.limits
510            .validate()
511            .context("Limits configuration validation failed")?;
512
513        self.security
514            .validate()
515            .context("Security configuration validation failed")?;
516
517        self.capabilities
518            .validate()
519            .context("Capability configuration validation failed")?;
520
521        self.policy
522            .validate()
523            .context("Policy configuration validation failed")?;
524
525        self.nats_config
526            .validate()
527            .context("NATS configuration validation failed")?;
528
529        self.vm_pool
530            .validate()
531            .context("VM pool configuration validation failed")?;
532
533        Ok(())
534    }
535
536    pub fn development() -> Self {
537        Self {
538            work_root: PathBuf::from("/tmp/smith/executor/work"),
539            state_dir: PathBuf::from("/tmp/smith/executor/state"),
540            audit_dir: PathBuf::from("/tmp/smith/executor/audit"),
541            landlock_enabled: false, // May not be available in all dev environments
542            security: SecurityConfig {
543                strict_sandbox: false,
544                network_isolation: false,
545                ..Default::default()
546            },
547            limits: LimitsConfig {
548                defaults: DefaultLimits {
549                    cpu_ms_per_100ms: 80,         // More generous for development
550                    mem_bytes: 512 * 1024 * 1024, // 512MB
551                    io_bytes: 50 * 1024 * 1024,   // 50MB
552                    ..Default::default()
553                },
554                overrides: HashMap::new(),
555            },
556            nats_config: ExecutorNatsConfig::default(),
557            ..Default::default()
558        }
559    }
560
561    pub fn production() -> Self {
562        Self {
563            landlock_enabled: true,
564            security: SecurityConfig {
565                strict_sandbox: true,
566                network_isolation: true,
567                allowed_destinations: vec!["127.0.0.1".to_string(), "::1".to_string()],
568                ..Default::default()
569            },
570            limits: LimitsConfig {
571                defaults: DefaultLimits {
572                    cpu_ms_per_100ms: 30,         // Strict limits for production
573                    mem_bytes: 128 * 1024 * 1024, // 128MB
574                    io_bytes: 5 * 1024 * 1024,    // 5MB
575                    pids_max: 16,
576                    tmpfs_mb: 32,
577                    intent_max_bytes: 32 * 1024, // 32KB
578                },
579                overrides: HashMap::new(),
580            },
581            capabilities: CapabilityConfig {
582                enforcement_enabled: true,
583                ..Default::default()
584            },
585            policy: PolicyConfig {
586                update_interval_seconds: 60, // More frequent in production
587                ..Default::default()
588            },
589            nats_config: ExecutorNatsConfig::default(),
590            ..Default::default()
591        }
592    }
593
594    pub fn testing() -> Self {
595        Self {
596            work_root: PathBuf::from("/tmp/smith-test/work"),
597            state_dir: PathBuf::from("/tmp/smith-test/state"),
598            audit_dir: PathBuf::from("/tmp/smith-test/audit"),
599            landlock_enabled: false,        // Disable for test simplicity
600            metrics_port: None,             // Disable metrics in tests
601            intent_streams: HashMap::new(), // Tests define their own
602            security: SecurityConfig {
603                strict_sandbox: false,
604                network_isolation: false,
605                jwt_issuers: vec![], // No JWT validation in tests
606                ..Default::default()
607            },
608            limits: LimitsConfig {
609                defaults: DefaultLimits {
610                    cpu_ms_per_100ms: 100,         // Generous for test timing
611                    mem_bytes: 1024 * 1024 * 1024, // 1GB
612                    io_bytes: 100 * 1024 * 1024,   // 100MB
613                    pids_max: 64,
614                    tmpfs_mb: 128,
615                    intent_max_bytes: 1024 * 1024, // 1MB
616                },
617                overrides: HashMap::new(),
618            },
619            capabilities: CapabilityConfig {
620                enforcement_enabled: false, // Disable capability enforcement in tests
621                ..Default::default()
622            },
623            nats_config: ExecutorNatsConfig::default(),
624            ..Default::default()
625        }
626    }
627}
628
629impl IntentStreamConfig {
630    pub fn validate(&self) -> Result<()> {
631        if self.subject.is_empty() {
632            return Err(anyhow::anyhow!("Subject cannot be empty"));
633        }
634
635        if self.workers == 0 {
636            return Err(anyhow::anyhow!("Worker count must be > 0"));
637        }
638
639        if self.workers > 64 {
640            return Err(anyhow::anyhow!("Worker count too high (max 64)"));
641        }
642
643        // Validate duration format
644        self.validate_duration(&self.max_age)
645            .context("Invalid max_age format")?;
646
647        // Validate byte size format
648        self.validate_byte_size(&self.max_bytes)
649            .context("Invalid max_bytes format")?;
650
651        Ok(())
652    }
653
654    fn validate_duration(&self, duration_str: &str) -> Result<()> {
655        if duration_str.is_empty() {
656            return Err(anyhow::anyhow!("Duration cannot be empty"));
657        }
658
659        let valid_suffixes = ["s", "m", "h", "d"];
660        let has_valid_suffix = valid_suffixes
661            .iter()
662            .any(|&suffix| duration_str.ends_with(suffix));
663
664        if !has_valid_suffix {
665            return Err(anyhow::anyhow!(
666                "Duration must end with valid time unit (s, m, h, d): {}",
667                duration_str
668            ));
669        }
670
671        let numeric_part = &duration_str[..duration_str.len() - 1];
672        numeric_part
673            .parse::<u64>()
674            .with_context(|| format!("Invalid numeric part in duration: {}", duration_str))?;
675
676        Ok(())
677    }
678
679    fn validate_byte_size(&self, size_str: &str) -> Result<()> {
680        if size_str.is_empty() {
681            return Err(anyhow::anyhow!("Byte size cannot be empty"));
682        }
683
684        let valid_suffixes = ["TB", "GB", "MB", "KB", "B"]; // Longest first
685        let suffix = valid_suffixes
686            .iter()
687            .find(|&&suffix| size_str.ends_with(suffix))
688            .ok_or_else(|| {
689                anyhow::anyhow!(
690                    "Byte size must end with valid unit (B, KB, MB, GB, TB): {}",
691                    size_str
692                )
693            })?;
694
695        if let Some(numeric_part) = size_str.strip_suffix(suffix) {
696            numeric_part
697                .parse::<u64>()
698                .with_context(|| format!("Invalid numeric part in byte size: {}", size_str))?;
699        } else {
700            return Err(anyhow::anyhow!("Failed to parse byte size: {}", size_str));
701        }
702
703        Ok(())
704    }
705}
706
707impl ResultsConfig {
708    pub fn validate(&self) -> Result<()> {
709        if self.subject_prefix.is_empty() {
710            return Err(anyhow::anyhow!("Results subject prefix cannot be empty"));
711        }
712
713        // Simple duration validation
714        if !self.max_age.ends_with(['s', 'm', 'h', 'd']) {
715            return Err(anyhow::anyhow!(
716                "Results max_age must end with valid time unit (s, m, h, d): {}",
717                self.max_age
718            ));
719        }
720
721        Ok(())
722    }
723}
724
725impl LimitsConfig {
726    pub fn validate(&self) -> Result<()> {
727        self.defaults
728            .validate()
729            .context("Default limits validation failed")?;
730
731        for (capability, limits) in &self.overrides {
732            limits.validate().map_err(|e| {
733                anyhow::anyhow!(
734                    "Limits override for '{}' validation failed: {}",
735                    capability,
736                    e
737                )
738            })?;
739        }
740
741        Ok(())
742    }
743}
744
745impl DefaultLimits {
746    pub fn validate(&self) -> Result<()> {
747        if self.cpu_ms_per_100ms > 100 {
748            return Err(anyhow::anyhow!("CPU limit cannot exceed 100ms per 100ms"));
749        }
750
751        if self.mem_bytes == 0 {
752            return Err(anyhow::anyhow!("Memory limit cannot be zero"));
753        }
754
755        if self.mem_bytes > 8 * 1024 * 1024 * 1024 {
756            tracing::warn!("Memory limit > 8GB may be excessive");
757        }
758
759        if self.pids_max == 0 || self.pids_max > 1024 {
760            return Err(anyhow::anyhow!("PID limit must be between 1 and 1024"));
761        }
762
763        if self.tmpfs_mb > 1024 {
764            tracing::warn!("tmpfs size > 1GB may consume excessive memory");
765        }
766
767        if self.intent_max_bytes > 10 * 1024 * 1024 {
768            tracing::warn!("Intent max bytes > 10MB may cause memory issues");
769        }
770
771        Ok(())
772    }
773}
774
775impl SecurityConfig {
776    pub fn validate(&self) -> Result<()> {
777        // Validate JWT issuers are valid URLs
778        for issuer in &self.jwt_issuers {
779            url::Url::parse(issuer)
780                .with_context(|| format!("Invalid JWT issuer URL: {}", issuer))?;
781        }
782
783        // Validate allowed destinations
784        for dest in &self.allowed_destinations {
785            if dest.parse::<std::net::IpAddr>().is_err() && !dest.contains(':') {
786                // Simple hostname validation
787                if dest.is_empty() || dest.len() > 255 {
788                    return Err(anyhow::anyhow!("Invalid destination: {}", dest));
789                }
790            }
791        }
792
793        Ok(())
794    }
795}
796
797impl CapabilityConfig {
798    pub fn validate(&self) -> Result<()> {
799        if self.derivations_path.as_os_str().is_empty() {
800            return Err(anyhow::anyhow!(
801                "Capability derivations path cannot be empty"
802            ));
803        }
804
805        Ok(())
806    }
807}
808
809impl PolicyConfig {
810    fn default_updates_subject() -> String {
811        "smith.policies.updates".to_string()
812    }
813
814    pub fn validate(&self) -> Result<()> {
815        if self.update_interval_seconds == 0 {
816            return Err(anyhow::anyhow!("Policy update interval must be > 0"));
817        }
818
819        if self.update_interval_seconds < 60 {
820            tracing::warn!("Policy update interval < 60s may cause excessive load");
821        }
822
823        if self.updates_subject.trim().is_empty() {
824            return Err(anyhow::anyhow!("Policy updates subject cannot be empty"));
825        }
826
827        if let Some(queue) = &self.updates_queue {
828            if queue.trim().is_empty() {
829                return Err(anyhow::anyhow!(
830                    "Policy updates queue group cannot be blank"
831                ));
832            }
833        }
834
835        Ok(())
836    }
837}
838
839impl ExecutorNatsConfig {
840    pub fn validate(&self) -> Result<()> {
841        // Validate NATS servers format
842        for server in &self.servers {
843            if !server.starts_with("nats://") && !server.starts_with("tls://") {
844                return Err(anyhow::anyhow!("Invalid NATS server URL: {}", server));
845            }
846        }
847
848        // Validate TLS configuration consistency
849        if let (Some(cert), Some(key), Some(ca)) = (&self.tls_cert, &self.tls_key, &self.tls_ca) {
850            // All TLS files specified - validate they exist
851            if !cert.exists() {
852                return Err(anyhow::anyhow!(
853                    "TLS cert file not found: {}",
854                    cert.display()
855                ));
856            }
857            if !key.exists() {
858                return Err(anyhow::anyhow!("TLS key file not found: {}", key.display()));
859            }
860            if !ca.exists() {
861                return Err(anyhow::anyhow!("TLS CA file not found: {}", ca.display()));
862            }
863        }
864
865        Ok(())
866    }
867}
868
869/// Policy derivations loaded from derivations.json
870#[derive(Debug, Clone, Serialize, Deserialize)]
871pub struct PolicyDerivations {
872    pub seccomp_allow: HashMap<String, Vec<String>>,
873    pub landlock_paths: HashMap<String, LandlockProfile>,
874    pub cgroups: HashMap<String, CgroupLimits>,
875}
876
877/// Landlock access profile for a capability
878#[derive(Debug, Clone, Serialize, Deserialize)]
879pub struct LandlockProfile {
880    /// Paths with read access
881    pub read: Vec<String>,
882    /// Paths with write access  
883    pub write: Vec<String>,
884}
885
886/// Cgroup resource limits for a capability
887#[derive(Debug, Clone, Serialize, Deserialize)]
888pub struct CgroupLimits {
889    /// CPU percentage limit
890    pub cpu_pct: u32,
891    /// Memory limit in MB
892    pub mem_mb: u64,
893}
894
895impl ExecutorConfig {
896    /// Convert byte size string to actual bytes
897    pub fn parse_byte_size(size_str: &str) -> Result<u64> {
898        let multipliers = [
899            ("TB", 1024_u64.pow(4)),
900            ("GB", 1024_u64.pow(3)),
901            ("MB", 1024_u64.pow(2)),
902            ("KB", 1024),
903            ("B", 1),
904        ];
905
906        for (suffix, multiplier) in &multipliers {
907            if let Some(numeric_part) = size_str.strip_suffix(suffix) {
908                let number: u64 = numeric_part
909                    .parse()
910                    .with_context(|| format!("Invalid numeric part in byte size: {}", size_str))?;
911                return Ok(number * multiplier);
912            }
913        }
914
915        Err(anyhow::anyhow!("Invalid byte size format: {}", size_str))
916    }
917
918    /// Convert duration string to seconds
919    pub fn parse_duration_seconds(duration_str: &str) -> Result<u64> {
920        let multipliers = [
921            ("d", 86400), // days
922            ("h", 3600),  // hours
923            ("m", 60),    // minutes
924            ("s", 1),     // seconds
925        ];
926
927        for (suffix, multiplier) in &multipliers {
928            if let Some(numeric_part) = duration_str.strip_suffix(suffix) {
929                let number: u64 = numeric_part.parse().with_context(|| {
930                    format!("Invalid numeric part in duration: {}", duration_str)
931                })?;
932                return Ok(number * multiplier);
933            }
934        }
935
936        Err(anyhow::anyhow!("Invalid duration format: {}", duration_str))
937    }
938
939    /// Load configuration from TOML file
940    pub fn load(path: &std::path::Path) -> Result<Self> {
941        let content = std::fs::read_to_string(path)
942            .with_context(|| format!("Failed to read config file: {}", path.display()))?;
943
944        let raw_value: toml::Value = toml::from_str(&content)
945            .with_context(|| format!("Failed to parse TOML config: {}", path.display()))?;
946
947        let mut config: ExecutorConfig = if let Some(executor_table) = raw_value.get("executor") {
948            executor_table
949                .clone()
950                .try_into()
951                .map_err(anyhow::Error::from)
952                .with_context(|| {
953                    format!(
954                        "Failed to parse TOML config `[executor]` section: {}",
955                        path.display()
956                    )
957                })?
958        } else {
959            toml::from_str(&content)
960                .map_err(anyhow::Error::from)
961                .with_context(|| {
962                    format!(
963                        "Failed to parse TOML config: {} (expected top-level executor fields \
964                         or an `[executor]` table)",
965                        path.display()
966                    )
967                })?
968        };
969
970        config.apply_env_overrides()?;
971        config.validate()?;
972        Ok(config)
973    }
974
975    fn apply_env_overrides(&mut self) -> Result<()> {
976        if let Ok(raw_servers) = env::var("SMITH_EXECUTOR_NATS_SERVERS") {
977            let servers = Self::parse_env_server_list(&raw_servers);
978            if !servers.is_empty() {
979                self.nats_config.servers = servers;
980            }
981        } else if let Ok(single) = env::var("SMITH_EXECUTOR_NATS_URL") {
982            let trimmed = single.trim();
983            if !trimmed.is_empty() {
984                self.nats_config.servers = vec![trimmed.to_string()];
985            }
986        } else if let Ok(single) = env::var("SMITH_NATS_URL") {
987            let trimmed = single.trim();
988            if !trimmed.is_empty() {
989                self.nats_config.servers = vec![trimmed.to_string()];
990            }
991        }
992
993        if let Ok(domain) = env::var("SMITH_EXECUTOR_JETSTREAM_DOMAIN")
994            .or_else(|_| env::var("SMITH_NATS_JETSTREAM_DOMAIN"))
995            .or_else(|_| env::var("SMITH_JETSTREAM_DOMAIN"))
996        {
997            let trimmed = domain.trim();
998            if !trimmed.is_empty() {
999                self.nats_config.jetstream_domain = trimmed.to_string();
1000            }
1001        }
1002
1003        Ok(())
1004    }
1005
1006    fn parse_env_server_list(raw: &str) -> Vec<String> {
1007        raw.split(|c| c == ',' || c == ';')
1008            .map(|part| part.trim())
1009            .filter(|part| !part.is_empty())
1010            .map(|part| part.to_string())
1011            .collect()
1012    }
1013}
1014
1015impl PolicyDerivations {
1016    /// Load policy derivations from JSON file
1017    pub fn load(path: &std::path::Path) -> Result<Self> {
1018        let content = std::fs::read_to_string(path)
1019            .with_context(|| format!("Failed to read derivations file: {}", path.display()))?;
1020
1021        let derivations: PolicyDerivations = serde_json::from_str(&content)
1022            .with_context(|| format!("Failed to parse derivations JSON: {}", path.display()))?;
1023
1024        Ok(derivations)
1025    }
1026
1027    /// Get seccomp syscall allowlist for a capability
1028    pub fn get_seccomp_allowlist(&self, capability: &str) -> Option<&Vec<String>> {
1029        self.seccomp_allow.get(capability)
1030    }
1031
1032    /// Get landlock paths configuration for a capability
1033    pub fn get_landlock_profile(&self, capability: &str) -> Option<&LandlockProfile> {
1034        self.landlock_paths.get(capability)
1035    }
1036
1037    /// Get cgroup limits for a capability
1038    pub fn get_cgroup_limits(&self, capability: &str) -> Option<&CgroupLimits> {
1039        self.cgroups.get(capability)
1040    }
1041}
1042
1043impl Default for AttestationConfig {
1044    fn default() -> Self {
1045        Self {
1046            enable_capability_signing: true,
1047            enable_image_verification: true,
1048            enable_slsa_provenance: true,
1049            fail_on_signature_error: std::env::var("SMITH_FAIL_ON_SIGNATURE_ERROR")
1050                .unwrap_or_else(|_| "true".to_string())
1051                .parse()
1052                .unwrap_or(true),
1053            cosign_public_key: std::env::var("SMITH_COSIGN_PUBLIC_KEY").ok(),
1054            provenance_output_dir: PathBuf::from(
1055                std::env::var("SMITH_PROVENANCE_OUTPUT_DIR")
1056                    .unwrap_or_else(|_| "build/attestation".to_string()),
1057            ),
1058            verification_cache_ttl: 3600,        // 1 hour
1059            periodic_verification_interval: 300, // 5 minutes
1060        }
1061    }
1062}
1063
1064#[cfg(test)]
1065mod tests {
1066    use super::*;
1067    use std::collections::HashMap;
1068    use std::path::PathBuf;
1069    use tempfile::tempdir;
1070
1071    #[test]
1072    fn test_executor_config_creation() {
1073        let config = ExecutorConfig {
1074            node_name: "test-executor".to_string(),
1075            work_root: PathBuf::from("/tmp/work"),
1076            state_dir: PathBuf::from("/tmp/state"),
1077            audit_dir: PathBuf::from("/tmp/audit"),
1078            user_uid: 1000,
1079            user_gid: 1000,
1080            landlock_enabled: true,
1081            egress_proxy_socket: PathBuf::from("/tmp/proxy.sock"),
1082            metrics_port: Some(9090),
1083            intent_streams: HashMap::new(),
1084            results: ResultsConfig::default(),
1085            limits: LimitsConfig::default(),
1086            security: SecurityConfig::default(),
1087            capabilities: CapabilityConfig::default(),
1088            policy: PolicyConfig::default(),
1089            nats_config: ExecutorNatsConfig::default(),
1090            attestation: AttestationConfig::default(),
1091            vm_pool: VmPoolConfig::default(),
1092        };
1093
1094        assert_eq!(config.node_name, "test-executor");
1095        assert_eq!(config.work_root, PathBuf::from("/tmp/work"));
1096        assert_eq!(config.user_uid, 1000);
1097        assert!(config.landlock_enabled);
1098        assert_eq!(config.metrics_port, Some(9090));
1099    }
1100
1101    #[test]
1102    fn test_intent_stream_config() {
1103        let stream_config = IntentStreamConfig {
1104            subject: "smith.intents.test".to_string(),
1105            max_age: "1h".to_string(),
1106            max_bytes: "10MB".to_string(),
1107            workers: 4,
1108        };
1109
1110        assert_eq!(stream_config.subject, "smith.intents.test");
1111        assert_eq!(stream_config.max_age, "1h");
1112        assert_eq!(stream_config.max_bytes, "10MB");
1113        assert_eq!(stream_config.workers, 4);
1114    }
1115
1116    #[test]
1117    fn test_intent_stream_config_validation() {
1118        let mut config = IntentStreamConfig {
1119            subject: "smith.intents.test".to_string(),
1120            max_age: "1h".to_string(),
1121            max_bytes: "1GB".to_string(), // Use GB as in the default config
1122            workers: 4,
1123        };
1124
1125        assert!(config.validate().is_ok());
1126
1127        // Test empty subject
1128        config.subject = "".to_string();
1129        assert!(config.validate().is_err());
1130        config.subject = "smith.intents.test".to_string(); // Fix it
1131
1132        // Test zero workers
1133        config.workers = 0;
1134        assert!(config.validate().is_err());
1135
1136        // Test too many workers
1137        config.workers = 100;
1138        assert!(config.validate().is_err());
1139
1140        // Test valid workers
1141        config.workers = 32;
1142        assert!(config.validate().is_ok());
1143    }
1144
1145    #[test]
1146    fn test_results_config_default() {
1147        let results_config = ResultsConfig::default();
1148
1149        assert_eq!(results_config.subject_prefix, "smith.results."); // Includes trailing dot
1150        assert_eq!(results_config.max_age, "5m"); // Correct default max_age
1151    }
1152
1153    #[test]
1154    fn test_limits_config_default() {
1155        let limits_config = LimitsConfig::default();
1156
1157        // Just verify the structure exists and has defaults
1158        assert_eq!(limits_config.overrides.len(), 0); // Empty by default
1159                                                      // The actual defaults are in the impl
1160    }
1161
1162    #[test]
1163    fn test_default_limits_validation() {
1164        let mut limits = DefaultLimits::default();
1165        assert!(limits.validate().is_ok());
1166
1167        // Test CPU limit validation
1168        limits.cpu_ms_per_100ms = 150; // > 100
1169        assert!(limits.validate().is_err());
1170        limits.cpu_ms_per_100ms = 50; // Valid
1171
1172        // Test memory limit validation
1173        limits.mem_bytes = 0; // Invalid
1174        assert!(limits.validate().is_err());
1175        limits.mem_bytes = 64 * 1024 * 1024; // Valid
1176
1177        // Test PID limit validation
1178        limits.pids_max = 0; // Invalid
1179        assert!(limits.validate().is_err());
1180        limits.pids_max = 2000; // > 1024, invalid
1181        assert!(limits.validate().is_err());
1182        limits.pids_max = 64; // Valid
1183
1184        assert!(limits.validate().is_ok());
1185    }
1186
1187    #[test]
1188    fn test_security_config_validation() {
1189        let mut security_config = SecurityConfig::default();
1190        assert!(security_config.validate().is_ok());
1191
1192        // Test invalid JWT issuer
1193        security_config.jwt_issuers = vec!["invalid-url".to_string()];
1194        assert!(security_config.validate().is_err());
1195
1196        // Test valid JWT issuer
1197        security_config.jwt_issuers = vec!["https://auth.example.com".to_string()];
1198        assert!(security_config.validate().is_ok());
1199
1200        // Test allowed destinations validation
1201        security_config.allowed_destinations =
1202            vec!["192.168.1.1".to_string(), "example.com".to_string()];
1203        assert!(security_config.validate().is_ok());
1204
1205        // Test invalid destination (empty string)
1206        security_config.allowed_destinations = vec!["".to_string()];
1207        assert!(security_config.validate().is_err());
1208
1209        // Test invalid destination (too long)
1210        security_config.allowed_destinations = vec!["a".repeat(256)];
1211        assert!(security_config.validate().is_err());
1212    }
1213
1214    #[test]
1215    fn test_policy_config_validation() {
1216        let mut policy_config = PolicyConfig::default();
1217        assert!(policy_config.validate().is_ok());
1218
1219        // Test zero update interval (invalid)
1220        policy_config.update_interval_seconds = 0;
1221        assert!(policy_config.validate().is_err());
1222
1223        // Test valid update interval
1224        policy_config.update_interval_seconds = 300;
1225        assert!(policy_config.validate().is_ok());
1226    }
1227
1228    #[test]
1229    fn test_executor_nats_config_validation() {
1230        let mut nats_config = ExecutorNatsConfig {
1231            servers: vec!["nats://127.0.0.1:4222".to_string()],
1232            jetstream_domain: "JS".to_string(),
1233            tls_cert: None, // No TLS files for testing
1234            tls_key: None,
1235            tls_ca: None,
1236        };
1237        assert!(nats_config.validate().is_ok());
1238
1239        // Test invalid server URL
1240        nats_config.servers = vec!["invalid-url".to_string()];
1241        assert!(nats_config.validate().is_err());
1242
1243        // Test valid server URLs
1244        nats_config.servers = vec![
1245            "nats://localhost:4222".to_string(),
1246            "tls://nats.example.com:4222".to_string(),
1247        ];
1248        assert!(nats_config.validate().is_ok());
1249    }
1250
1251    #[test]
1252    fn test_executor_nats_config_tls_validation() {
1253        let temp_dir = tempdir().unwrap();
1254        let cert_path = temp_dir.path().join("cert.pem");
1255        let key_path = temp_dir.path().join("key.pem");
1256        let ca_path = temp_dir.path().join("ca.pem");
1257
1258        // Create dummy files
1259        std::fs::write(&cert_path, "cert").unwrap();
1260        std::fs::write(&key_path, "key").unwrap();
1261        std::fs::write(&ca_path, "ca").unwrap();
1262
1263        let valid_config = ExecutorNatsConfig {
1264            tls_cert: Some(cert_path.clone()),
1265            tls_key: Some(key_path.clone()),
1266            tls_ca: Some(ca_path.clone()),
1267            ..ExecutorNatsConfig::default()
1268        };
1269        assert!(valid_config.validate().is_ok());
1270
1271        let missing_cert = ExecutorNatsConfig {
1272            tls_cert: Some(temp_dir.path().join("missing.pem")),
1273            tls_key: Some(key_path.clone()),
1274            tls_ca: Some(ca_path.clone()),
1275            ..ExecutorNatsConfig::default()
1276        };
1277        assert!(missing_cert.validate().is_err());
1278
1279        let missing_key = ExecutorNatsConfig {
1280            tls_cert: Some(cert_path),
1281            tls_key: Some(temp_dir.path().join("missing.pem")),
1282            tls_ca: Some(ca_path),
1283            ..ExecutorNatsConfig::default()
1284        };
1285        assert!(missing_key.validate().is_err());
1286    }
1287
1288    #[test]
1289    #[ignore] // requires infra/config/smith-executor.toml from deployment repo
1290    fn test_repo_executor_config_loads() {
1291        let path = PathBuf::from("../../infra/config/smith-executor.toml");
1292        let result = ExecutorConfig::load(&path);
1293        assert!(result.is_ok(), "error: {:?}", result.unwrap_err());
1294    }
1295
1296    #[test]
1297    fn test_executor_env_overrides_nats_servers() {
1298        let temp_dir = tempdir().unwrap();
1299        let config_path = temp_dir.path().join("executor.toml");
1300
1301        let mut config = ExecutorConfig::development();
1302        config.work_root = temp_dir.path().join("work");
1303        config.state_dir = temp_dir.path().join("state");
1304        config.audit_dir = temp_dir.path().join("audit");
1305        config.egress_proxy_socket = temp_dir.path().join("proxy.sock");
1306        config.security.pubkeys_dir = temp_dir.path().join("pubkeys");
1307        config.capabilities.derivations_path = temp_dir.path().join("capability.json");
1308        config.attestation.provenance_output_dir = temp_dir.path().join("attestation_outputs");
1309        config.nats_config.tls_cert = None;
1310        config.nats_config.tls_key = None;
1311        config.nats_config.tls_ca = None;
1312
1313        let toml = toml::to_string(&config).unwrap();
1314        std::fs::write(&config_path, toml).unwrap();
1315
1316        let prev_servers = env::var("SMITH_EXECUTOR_NATS_SERVERS").ok();
1317        let prev_exec_url = env::var("SMITH_EXECUTOR_NATS_URL").ok();
1318        let prev_nats_url = env::var("SMITH_NATS_URL").ok();
1319        let prev_domain = env::var("SMITH_NATS_JETSTREAM_DOMAIN").ok();
1320        let prev_exec_domain = env::var("SMITH_EXECUTOR_JETSTREAM_DOMAIN").ok();
1321
1322        env::remove_var("SMITH_EXECUTOR_NATS_SERVERS");
1323        env::remove_var("SMITH_EXECUTOR_NATS_URL");
1324        env::remove_var("SMITH_NATS_URL");
1325        env::remove_var("SMITH_NATS_JETSTREAM_DOMAIN");
1326        env::remove_var("SMITH_EXECUTOR_JETSTREAM_DOMAIN");
1327
1328        env::set_var(
1329            "SMITH_EXECUTOR_NATS_SERVERS",
1330            "nats://localhost:7222, nats://backup:7223",
1331        );
1332        env::set_var("SMITH_NATS_JETSTREAM_DOMAIN", "devtools");
1333
1334        let loaded = ExecutorConfig::load(&config_path).unwrap();
1335        assert_eq!(
1336            loaded.nats_config.servers,
1337            vec![
1338                "nats://localhost:7222".to_string(),
1339                "nats://backup:7223".to_string()
1340            ]
1341        );
1342        assert_eq!(loaded.nats_config.jetstream_domain, "devtools");
1343
1344        restore_env_var("SMITH_EXECUTOR_NATS_SERVERS", prev_servers);
1345        restore_env_var("SMITH_EXECUTOR_NATS_URL", prev_exec_url);
1346        restore_env_var("SMITH_NATS_URL", prev_nats_url);
1347        restore_env_var("SMITH_NATS_JETSTREAM_DOMAIN", prev_domain);
1348        restore_env_var("SMITH_EXECUTOR_JETSTREAM_DOMAIN", prev_exec_domain);
1349    }
1350
1351    #[test]
1352    fn test_attestation_config_default() {
1353        let attestation_config = AttestationConfig::default();
1354
1355        assert!(attestation_config.enable_capability_signing);
1356        assert!(attestation_config.enable_image_verification);
1357        assert!(attestation_config.enable_slsa_provenance);
1358        assert_eq!(attestation_config.verification_cache_ttl, 3600);
1359        assert_eq!(attestation_config.periodic_verification_interval, 300);
1360    }
1361
1362    #[test]
1363    fn test_cgroup_limits() {
1364        let cgroup_limits = CgroupLimits {
1365            cpu_pct: 50,
1366            mem_mb: 128,
1367        };
1368
1369        assert_eq!(cgroup_limits.cpu_pct, 50);
1370        assert_eq!(cgroup_limits.mem_mb, 128);
1371    }
1372
1373    #[test]
1374    fn test_executor_config_presets() {
1375        // Test development preset
1376        let dev_config = ExecutorConfig::development();
1377        assert_eq!(dev_config.node_name, "exec-01"); // Uses default node_name
1378        assert!(!dev_config.landlock_enabled);
1379        assert!(!dev_config.security.strict_sandbox);
1380
1381        // Test production preset
1382        let prod_config = ExecutorConfig::production();
1383        assert!(prod_config.landlock_enabled);
1384        assert!(prod_config.security.strict_sandbox);
1385        assert!(prod_config.security.network_isolation);
1386        assert!(prod_config.capabilities.enforcement_enabled);
1387
1388        // Test testing preset
1389        let test_config = ExecutorConfig::testing();
1390        assert!(!test_config.landlock_enabled);
1391        assert!(!test_config.security.strict_sandbox);
1392        assert!(!test_config.capabilities.enforcement_enabled);
1393        assert_eq!(test_config.metrics_port, None);
1394    }
1395
1396    #[test]
1397    fn test_parse_byte_size() {
1398        assert_eq!(ExecutorConfig::parse_byte_size("1024B").unwrap(), 1024);
1399        assert_eq!(ExecutorConfig::parse_byte_size("10KB").unwrap(), 10 * 1024);
1400        assert_eq!(
1401            ExecutorConfig::parse_byte_size("5MB").unwrap(),
1402            5 * 1024 * 1024
1403        );
1404        assert_eq!(
1405            ExecutorConfig::parse_byte_size("2GB").unwrap(),
1406            2 * 1024 * 1024 * 1024
1407        );
1408
1409        // Test invalid formats
1410        assert!(ExecutorConfig::parse_byte_size("invalid").is_err());
1411        assert!(ExecutorConfig::parse_byte_size("10XB").is_err());
1412        assert!(ExecutorConfig::parse_byte_size("").is_err());
1413    }
1414
1415    #[test]
1416    fn test_serialization_roundtrip() {
1417        let original = ExecutorConfig {
1418            node_name: "test-node".to_string(),
1419            work_root: PathBuf::from("/work"),
1420            state_dir: PathBuf::from("/state"),
1421            audit_dir: PathBuf::from("/audit"),
1422            user_uid: 1001,
1423            user_gid: 1001,
1424            landlock_enabled: false,
1425            egress_proxy_socket: PathBuf::from("/proxy.sock"),
1426            metrics_port: Some(8080),
1427            intent_streams: HashMap::new(),
1428            results: ResultsConfig::default(),
1429            limits: LimitsConfig::default(),
1430            security: SecurityConfig::default(),
1431            capabilities: CapabilityConfig::default(),
1432            policy: PolicyConfig::default(),
1433            nats_config: ExecutorNatsConfig::default(),
1434            attestation: AttestationConfig::default(),
1435            vm_pool: VmPoolConfig::default(),
1436        };
1437
1438        // Test JSON serialization roundtrip
1439        let json = serde_json::to_string(&original).unwrap();
1440        let deserialized: ExecutorConfig = serde_json::from_str(&json).unwrap();
1441
1442        assert_eq!(original.node_name, deserialized.node_name);
1443        assert_eq!(original.work_root, deserialized.work_root);
1444        assert_eq!(original.user_uid, deserialized.user_uid);
1445        assert_eq!(original.landlock_enabled, deserialized.landlock_enabled);
1446        assert_eq!(original.metrics_port, deserialized.metrics_port);
1447    }
1448
1449    #[test]
1450    fn test_debug_formatting() {
1451        let config = ExecutorConfig {
1452            node_name: "debug-test".to_string(),
1453            work_root: PathBuf::from("/work"),
1454            state_dir: PathBuf::from("/state"),
1455            audit_dir: PathBuf::from("/audit"),
1456            user_uid: 1000,
1457            user_gid: 1000,
1458            landlock_enabled: true,
1459            egress_proxy_socket: PathBuf::from("/proxy.sock"),
1460            metrics_port: Some(9090),
1461            intent_streams: HashMap::new(),
1462            results: ResultsConfig::default(),
1463            limits: LimitsConfig::default(),
1464            security: SecurityConfig::default(),
1465            capabilities: CapabilityConfig::default(),
1466            policy: PolicyConfig::default(),
1467            nats_config: ExecutorNatsConfig::default(),
1468            attestation: AttestationConfig::default(),
1469            vm_pool: VmPoolConfig::default(),
1470        };
1471
1472        let debug_output = format!("{:?}", config);
1473        assert!(debug_output.contains("debug-test"));
1474        assert!(debug_output.contains("/work"));
1475        assert!(debug_output.contains("1000"));
1476    }
1477
1478    fn restore_env_var(name: &str, value: Option<String>) {
1479        if let Some(value) = value {
1480            env::set_var(name, value);
1481        } else {
1482            env::remove_var(name);
1483        }
1484    }
1485}