1use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::env;
7use std::fs;
8use std::path::PathBuf;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct ExecutorConfig {
13 pub node_name: String,
15
16 pub work_root: PathBuf,
18
19 pub state_dir: PathBuf,
21
22 pub audit_dir: PathBuf,
24
25 pub user_uid: u32,
27
28 pub user_gid: u32,
30
31 pub landlock_enabled: bool,
33
34 pub egress_proxy_socket: PathBuf,
36
37 pub metrics_port: Option<u16>,
39
40 pub intent_streams: HashMap<String, IntentStreamConfig>,
42
43 pub results: ResultsConfig,
45
46 pub limits: LimitsConfig,
48
49 pub security: SecurityConfig,
51
52 pub capabilities: CapabilityConfig,
54
55 pub policy: PolicyConfig,
57
58 pub nats_config: ExecutorNatsConfig,
60
61 pub attestation: AttestationConfig,
63
64 #[serde(default)]
66 pub vm_pool: VmPoolConfig,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct IntentStreamConfig {
72 pub subject: String,
74
75 pub max_age: String,
77
78 pub max_bytes: String,
80
81 pub workers: u32,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct ResultsConfig {
88 pub subject_prefix: String,
90
91 pub max_age: String,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize, Default)]
97pub struct LimitsConfig {
98 pub defaults: DefaultLimits,
100
101 pub overrides: HashMap<String, DefaultLimits>,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct DefaultLimits {
108 pub cpu_ms_per_100ms: u32,
110
111 pub mem_bytes: u64,
113
114 pub io_bytes: u64,
116
117 pub pids_max: u32,
119
120 pub tmpfs_mb: u32,
122
123 pub intent_max_bytes: u64,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct SecurityConfig {
130 pub pubkeys_dir: PathBuf,
132
133 pub jwt_issuers: Vec<String>,
135
136 pub strict_sandbox: bool,
138
139 pub network_isolation: bool,
141
142 pub allowed_destinations: Vec<String>,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct CapabilityConfig {
149 pub derivations_path: PathBuf,
151
152 pub enforcement_enabled: bool,
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct PolicyConfig {
159 pub update_interval_seconds: u64,
161
162 #[serde(default = "PolicyConfig::default_updates_subject")]
164 pub updates_subject: String,
165
166 #[serde(default)]
168 pub updates_queue: Option<String>,
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct ExecutorNatsConfig {
174 pub servers: Vec<String>,
176
177 pub jetstream_domain: String,
179
180 pub tls_cert: Option<PathBuf>,
182
183 pub tls_key: Option<PathBuf>,
185
186 pub tls_ca: Option<PathBuf>,
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct AttestationConfig {
193 pub enable_capability_signing: bool,
195
196 pub enable_image_verification: bool,
198
199 pub enable_slsa_provenance: bool,
201
202 pub fail_on_signature_error: bool,
204
205 pub cosign_public_key: Option<String>,
207
208 pub provenance_output_dir: PathBuf,
210
211 pub verification_cache_ttl: u64,
213
214 pub periodic_verification_interval: u64,
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct VmPoolConfig {
221 #[serde(default)]
223 pub enabled: bool,
224
225 pub volume_root: PathBuf,
227
228 pub nix_profile: Option<String>,
230
231 pub shell: PathBuf,
233
234 #[serde(default)]
236 pub shell_args: Vec<String>,
237
238 #[serde(default)]
240 pub env: HashMap<String, String>,
241
242 pub max_vms: usize,
244
245 pub idle_shutdown_seconds: u64,
247
248 pub prune_after_seconds: u64,
250
251 pub backup_after_seconds: Option<u64>,
253
254 pub backup_destination: Option<PathBuf>,
256
257 #[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, user_gid: 65534, 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, io_bytes: 10 * 1024 * 1024, pids_max: 32,
395 tmpfs_mb: 64,
396 intent_max_bytes: 64 * 1024, }
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, 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 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 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 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 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 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 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, 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, mem_bytes: 512 * 1024 * 1024, io_bytes: 50 * 1024 * 1024, ..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, mem_bytes: 128 * 1024 * 1024, io_bytes: 5 * 1024 * 1024, pids_max: 16,
576 tmpfs_mb: 32,
577 intent_max_bytes: 32 * 1024, },
579 overrides: HashMap::new(),
580 },
581 capabilities: CapabilityConfig {
582 enforcement_enabled: true,
583 ..Default::default()
584 },
585 policy: PolicyConfig {
586 update_interval_seconds: 60, ..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, metrics_port: None, intent_streams: HashMap::new(), security: SecurityConfig {
603 strict_sandbox: false,
604 network_isolation: false,
605 jwt_issuers: vec![], ..Default::default()
607 },
608 limits: LimitsConfig {
609 defaults: DefaultLimits {
610 cpu_ms_per_100ms: 100, mem_bytes: 1024 * 1024 * 1024, io_bytes: 100 * 1024 * 1024, pids_max: 64,
614 tmpfs_mb: 128,
615 intent_max_bytes: 1024 * 1024, },
617 overrides: HashMap::new(),
618 },
619 capabilities: CapabilityConfig {
620 enforcement_enabled: false, ..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 self.validate_duration(&self.max_age)
645 .context("Invalid max_age format")?;
646
647 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"]; 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 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 for issuer in &self.jwt_issuers {
779 url::Url::parse(issuer)
780 .with_context(|| format!("Invalid JWT issuer URL: {}", issuer))?;
781 }
782
783 for dest in &self.allowed_destinations {
785 if dest.parse::<std::net::IpAddr>().is_err() && !dest.contains(':') {
786 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 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 if let (Some(cert), Some(key), Some(ca)) = (&self.tls_cert, &self.tls_key, &self.tls_ca) {
850 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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
879pub struct LandlockProfile {
880 pub read: Vec<String>,
882 pub write: Vec<String>,
884}
885
886#[derive(Debug, Clone, Serialize, Deserialize)]
888pub struct CgroupLimits {
889 pub cpu_pct: u32,
891 pub mem_mb: u64,
893}
894
895impl ExecutorConfig {
896 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 pub fn parse_duration_seconds(duration_str: &str) -> Result<u64> {
920 let multipliers = [
921 ("d", 86400), ("h", 3600), ("m", 60), ("s", 1), ];
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 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 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 pub fn get_seccomp_allowlist(&self, capability: &str) -> Option<&Vec<String>> {
1029 self.seccomp_allow.get(capability)
1030 }
1031
1032 pub fn get_landlock_profile(&self, capability: &str) -> Option<&LandlockProfile> {
1034 self.landlock_paths.get(capability)
1035 }
1036
1037 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, periodic_verification_interval: 300, }
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(), workers: 4,
1123 };
1124
1125 assert!(config.validate().is_ok());
1126
1127 config.subject = "".to_string();
1129 assert!(config.validate().is_err());
1130 config.subject = "smith.intents.test".to_string(); config.workers = 0;
1134 assert!(config.validate().is_err());
1135
1136 config.workers = 100;
1138 assert!(config.validate().is_err());
1139
1140 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."); assert_eq!(results_config.max_age, "5m"); }
1152
1153 #[test]
1154 fn test_limits_config_default() {
1155 let limits_config = LimitsConfig::default();
1156
1157 assert_eq!(limits_config.overrides.len(), 0); }
1161
1162 #[test]
1163 fn test_default_limits_validation() {
1164 let mut limits = DefaultLimits::default();
1165 assert!(limits.validate().is_ok());
1166
1167 limits.cpu_ms_per_100ms = 150; assert!(limits.validate().is_err());
1170 limits.cpu_ms_per_100ms = 50; limits.mem_bytes = 0; assert!(limits.validate().is_err());
1175 limits.mem_bytes = 64 * 1024 * 1024; limits.pids_max = 0; assert!(limits.validate().is_err());
1180 limits.pids_max = 2000; assert!(limits.validate().is_err());
1182 limits.pids_max = 64; 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 security_config.jwt_issuers = vec!["invalid-url".to_string()];
1194 assert!(security_config.validate().is_err());
1195
1196 security_config.jwt_issuers = vec!["https://auth.example.com".to_string()];
1198 assert!(security_config.validate().is_ok());
1199
1200 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 security_config.allowed_destinations = vec!["".to_string()];
1207 assert!(security_config.validate().is_err());
1208
1209 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 policy_config.update_interval_seconds = 0;
1221 assert!(policy_config.validate().is_err());
1222
1223 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, tls_key: None,
1235 tls_ca: None,
1236 };
1237 assert!(nats_config.validate().is_ok());
1238
1239 nats_config.servers = vec!["invalid-url".to_string()];
1241 assert!(nats_config.validate().is_err());
1242
1243 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 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] 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 let dev_config = ExecutorConfig::development();
1377 assert_eq!(dev_config.node_name, "exec-01"); assert!(!dev_config.landlock_enabled);
1379 assert!(!dev_config.security.strict_sandbox);
1380
1381 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 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 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 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}