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 intent_streams.insert(
357 "http.fetch.v1".to_string(),
358 IntentStreamConfig {
359 subject: "smith.intents.http.fetch.v1".to_string(),
360 max_age: "10m".to_string(),
361 max_bytes: "1GB".to_string(),
362 workers: 4,
363 },
364 );
365
366 Self {
367 node_name: "exec-01".to_string(),
368 work_root: PathBuf::from("/var/lib/smith/executor/work"),
369 state_dir: PathBuf::from("/var/lib/smith/executor/state"),
370 audit_dir: PathBuf::from("/var/lib/smith/executor/audit"),
371 user_uid: 65534, user_gid: 65534, landlock_enabled: true,
374 egress_proxy_socket: PathBuf::from("/run/smith/egress-proxy.sock"),
375 metrics_port: Some(9090),
376 intent_streams,
377 results: ResultsConfig::default(),
378 limits: LimitsConfig::default(),
379 security: SecurityConfig::default(),
380 capabilities: CapabilityConfig::default(),
381 policy: PolicyConfig::default(),
382 nats_config: ExecutorNatsConfig::default(),
383 attestation: AttestationConfig::default(),
384 vm_pool: VmPoolConfig::default(),
385 }
386 }
387}
388
389impl Default for ResultsConfig {
390 fn default() -> Self {
391 Self {
392 subject_prefix: "smith.results.".to_string(),
393 max_age: "5m".to_string(),
394 }
395 }
396}
397
398impl Default for DefaultLimits {
399 fn default() -> Self {
400 Self {
401 cpu_ms_per_100ms: 50,
402 mem_bytes: 256 * 1024 * 1024, io_bytes: 10 * 1024 * 1024, pids_max: 32,
405 tmpfs_mb: 64,
406 intent_max_bytes: 64 * 1024, }
408 }
409}
410
411impl Default for SecurityConfig {
412 fn default() -> Self {
413 Self {
414 pubkeys_dir: PathBuf::from("/etc/smith/executor/pubkeys"),
415 jwt_issuers: vec!["https://auth.smith.example.com/".to_string()],
416 strict_sandbox: false,
417 network_isolation: true,
418 allowed_destinations: vec![],
419 }
420 }
421}
422
423impl Default for CapabilityConfig {
424 fn default() -> Self {
425 Self {
426 derivations_path: PathBuf::from("build/capability/sandbox_profiles/derivations.json"),
427 enforcement_enabled: true,
428 }
429 }
430}
431
432impl Default for PolicyConfig {
433 fn default() -> Self {
434 Self {
435 update_interval_seconds: 300, updates_subject: "smith.policies.updates".to_string(),
437 updates_queue: None,
438 }
439 }
440}
441
442impl Default for ExecutorNatsConfig {
443 fn default() -> Self {
444 Self {
445 servers: vec!["nats://127.0.0.1:4222".to_string()],
446 jetstream_domain: "JS".to_string(),
447 tls_cert: Some(PathBuf::from("/etc/smith/executor/nats.crt")),
448 tls_key: Some(PathBuf::from("/etc/smith/executor/nats.key")),
449 tls_ca: Some(PathBuf::from("/etc/smith/executor/ca.crt")),
450 }
451 }
452}
453
454impl ExecutorConfig {
455 pub fn validate(&self) -> Result<()> {
456 if self.node_name.is_empty() {
458 return Err(anyhow::anyhow!("Node name cannot be empty"));
459 }
460
461 if self.node_name.len() > 63 {
462 return Err(anyhow::anyhow!("Node name too long (max 63 chars)"));
463 }
464
465 for (name, path) in [
467 ("work_root", &self.work_root),
468 ("state_dir", &self.state_dir),
469 ("audit_dir", &self.audit_dir),
470 ] {
471 if let Some(parent) = path.parent() {
472 if !parent.exists() {
473 fs::create_dir_all(parent).with_context(|| {
474 format!(
475 "Failed to create {} parent directory: {}",
476 name,
477 parent.display()
478 )
479 })?;
480 }
481 }
482 }
483
484 if self.user_uid == 0 {
486 tracing::warn!("⚠️ Running as root (UID 0) is not recommended for security");
487 }
488
489 if self.user_gid == 0 {
490 tracing::warn!("⚠️ Running as root group (GID 0) is not recommended for security");
491 }
492
493 if let Some(port) = self.metrics_port {
495 if port < 1024 {
496 return Err(anyhow::anyhow!(
497 "Invalid metrics port: {}. Must be between 1024 and 65535",
498 port
499 ));
500 }
501 }
502
503 if self.intent_streams.is_empty() {
505 return Err(anyhow::anyhow!("No intent streams configured"));
506 }
507
508 for (capability, stream_config) in &self.intent_streams {
509 stream_config.validate().map_err(|e| {
510 anyhow::anyhow!("Intent stream '{}' validation failed: {}", capability, e)
511 })?;
512 }
513
514 self.results
516 .validate()
517 .context("Results configuration validation failed")?;
518
519 self.limits
520 .validate()
521 .context("Limits configuration validation failed")?;
522
523 self.security
524 .validate()
525 .context("Security configuration validation failed")?;
526
527 self.capabilities
528 .validate()
529 .context("Capability configuration validation failed")?;
530
531 self.policy
532 .validate()
533 .context("Policy configuration validation failed")?;
534
535 self.nats_config
536 .validate()
537 .context("NATS configuration validation failed")?;
538
539 self.vm_pool
540 .validate()
541 .context("VM pool configuration validation failed")?;
542
543 Ok(())
544 }
545
546 pub fn development() -> Self {
547 Self {
548 work_root: PathBuf::from("/tmp/smith/executor/work"),
549 state_dir: PathBuf::from("/tmp/smith/executor/state"),
550 audit_dir: PathBuf::from("/tmp/smith/executor/audit"),
551 landlock_enabled: false, security: SecurityConfig {
553 strict_sandbox: false,
554 network_isolation: false,
555 ..Default::default()
556 },
557 limits: LimitsConfig {
558 defaults: DefaultLimits {
559 cpu_ms_per_100ms: 80, mem_bytes: 512 * 1024 * 1024, io_bytes: 50 * 1024 * 1024, ..Default::default()
563 },
564 overrides: HashMap::new(),
565 },
566 nats_config: ExecutorNatsConfig::default(),
567 ..Default::default()
568 }
569 }
570
571 pub fn production() -> Self {
572 Self {
573 landlock_enabled: true,
574 security: SecurityConfig {
575 strict_sandbox: true,
576 network_isolation: true,
577 allowed_destinations: vec!["127.0.0.1".to_string(), "::1".to_string()],
578 ..Default::default()
579 },
580 limits: LimitsConfig {
581 defaults: DefaultLimits {
582 cpu_ms_per_100ms: 30, mem_bytes: 128 * 1024 * 1024, io_bytes: 5 * 1024 * 1024, pids_max: 16,
586 tmpfs_mb: 32,
587 intent_max_bytes: 32 * 1024, },
589 overrides: {
590 let mut overrides = HashMap::new();
591
592 overrides.insert(
594 "http.fetch.v1".to_string(),
595 DefaultLimits {
596 io_bytes: 20 * 1024 * 1024, intent_max_bytes: 128 * 1024, ..DefaultLimits::default()
599 },
600 );
601
602 overrides
603 },
604 },
605 capabilities: CapabilityConfig {
606 enforcement_enabled: true,
607 ..Default::default()
608 },
609 policy: PolicyConfig {
610 update_interval_seconds: 60, ..Default::default()
612 },
613 nats_config: ExecutorNatsConfig::default(),
614 ..Default::default()
615 }
616 }
617
618 pub fn testing() -> Self {
619 Self {
620 work_root: PathBuf::from("/tmp/smith-test/work"),
621 state_dir: PathBuf::from("/tmp/smith-test/state"),
622 audit_dir: PathBuf::from("/tmp/smith-test/audit"),
623 landlock_enabled: false, metrics_port: None, intent_streams: HashMap::new(), security: SecurityConfig {
627 strict_sandbox: false,
628 network_isolation: false,
629 jwt_issuers: vec![], ..Default::default()
631 },
632 limits: LimitsConfig {
633 defaults: DefaultLimits {
634 cpu_ms_per_100ms: 100, mem_bytes: 1024 * 1024 * 1024, io_bytes: 100 * 1024 * 1024, pids_max: 64,
638 tmpfs_mb: 128,
639 intent_max_bytes: 1024 * 1024, },
641 overrides: HashMap::new(),
642 },
643 capabilities: CapabilityConfig {
644 enforcement_enabled: false, ..Default::default()
646 },
647 nats_config: ExecutorNatsConfig::default(),
648 ..Default::default()
649 }
650 }
651}
652
653impl IntentStreamConfig {
654 pub fn validate(&self) -> Result<()> {
655 if self.subject.is_empty() {
656 return Err(anyhow::anyhow!("Subject cannot be empty"));
657 }
658
659 if self.workers == 0 {
660 return Err(anyhow::anyhow!("Worker count must be > 0"));
661 }
662
663 if self.workers > 64 {
664 return Err(anyhow::anyhow!("Worker count too high (max 64)"));
665 }
666
667 self.validate_duration(&self.max_age)
669 .context("Invalid max_age format")?;
670
671 self.validate_byte_size(&self.max_bytes)
673 .context("Invalid max_bytes format")?;
674
675 Ok(())
676 }
677
678 fn validate_duration(&self, duration_str: &str) -> Result<()> {
679 if duration_str.is_empty() {
680 return Err(anyhow::anyhow!("Duration cannot be empty"));
681 }
682
683 let valid_suffixes = ["s", "m", "h", "d"];
684 let has_valid_suffix = valid_suffixes
685 .iter()
686 .any(|&suffix| duration_str.ends_with(suffix));
687
688 if !has_valid_suffix {
689 return Err(anyhow::anyhow!(
690 "Duration must end with valid time unit (s, m, h, d): {}",
691 duration_str
692 ));
693 }
694
695 let numeric_part = &duration_str[..duration_str.len() - 1];
696 numeric_part
697 .parse::<u64>()
698 .with_context(|| format!("Invalid numeric part in duration: {}", duration_str))?;
699
700 Ok(())
701 }
702
703 fn validate_byte_size(&self, size_str: &str) -> Result<()> {
704 if size_str.is_empty() {
705 return Err(anyhow::anyhow!("Byte size cannot be empty"));
706 }
707
708 let valid_suffixes = ["TB", "GB", "MB", "KB", "B"]; let suffix = valid_suffixes
710 .iter()
711 .find(|&&suffix| size_str.ends_with(suffix))
712 .ok_or_else(|| {
713 anyhow::anyhow!(
714 "Byte size must end with valid unit (B, KB, MB, GB, TB): {}",
715 size_str
716 )
717 })?;
718
719 if let Some(numeric_part) = size_str.strip_suffix(suffix) {
720 numeric_part
721 .parse::<u64>()
722 .with_context(|| format!("Invalid numeric part in byte size: {}", size_str))?;
723 } else {
724 return Err(anyhow::anyhow!("Failed to parse byte size: {}", size_str));
725 }
726
727 Ok(())
728 }
729}
730
731impl ResultsConfig {
732 pub fn validate(&self) -> Result<()> {
733 if self.subject_prefix.is_empty() {
734 return Err(anyhow::anyhow!("Results subject prefix cannot be empty"));
735 }
736
737 if !self.max_age.ends_with(['s', 'm', 'h', 'd']) {
739 return Err(anyhow::anyhow!(
740 "Results max_age must end with valid time unit (s, m, h, d): {}",
741 self.max_age
742 ));
743 }
744
745 Ok(())
746 }
747}
748
749impl LimitsConfig {
750 pub fn validate(&self) -> Result<()> {
751 self.defaults
752 .validate()
753 .context("Default limits validation failed")?;
754
755 for (capability, limits) in &self.overrides {
756 limits.validate().map_err(|e| {
757 anyhow::anyhow!(
758 "Limits override for '{}' validation failed: {}",
759 capability,
760 e
761 )
762 })?;
763 }
764
765 Ok(())
766 }
767}
768
769impl DefaultLimits {
770 pub fn validate(&self) -> Result<()> {
771 if self.cpu_ms_per_100ms > 100 {
772 return Err(anyhow::anyhow!("CPU limit cannot exceed 100ms per 100ms"));
773 }
774
775 if self.mem_bytes == 0 {
776 return Err(anyhow::anyhow!("Memory limit cannot be zero"));
777 }
778
779 if self.mem_bytes > 8 * 1024 * 1024 * 1024 {
780 tracing::warn!("Memory limit > 8GB may be excessive");
781 }
782
783 if self.pids_max == 0 || self.pids_max > 1024 {
784 return Err(anyhow::anyhow!("PID limit must be between 1 and 1024"));
785 }
786
787 if self.tmpfs_mb > 1024 {
788 tracing::warn!("tmpfs size > 1GB may consume excessive memory");
789 }
790
791 if self.intent_max_bytes > 10 * 1024 * 1024 {
792 tracing::warn!("Intent max bytes > 10MB may cause memory issues");
793 }
794
795 Ok(())
796 }
797}
798
799impl SecurityConfig {
800 pub fn validate(&self) -> Result<()> {
801 for issuer in &self.jwt_issuers {
803 url::Url::parse(issuer)
804 .with_context(|| format!("Invalid JWT issuer URL: {}", issuer))?;
805 }
806
807 for dest in &self.allowed_destinations {
809 if dest.parse::<std::net::IpAddr>().is_err() && !dest.contains(':') {
810 if dest.is_empty() || dest.len() > 255 {
812 return Err(anyhow::anyhow!("Invalid destination: {}", dest));
813 }
814 }
815 }
816
817 Ok(())
818 }
819}
820
821impl CapabilityConfig {
822 pub fn validate(&self) -> Result<()> {
823 if self.derivations_path.as_os_str().is_empty() {
824 return Err(anyhow::anyhow!(
825 "Capability derivations path cannot be empty"
826 ));
827 }
828
829 Ok(())
830 }
831}
832
833impl PolicyConfig {
834 fn default_updates_subject() -> String {
835 "smith.policies.updates".to_string()
836 }
837
838 pub fn validate(&self) -> Result<()> {
839 if self.update_interval_seconds == 0 {
840 return Err(anyhow::anyhow!("Policy update interval must be > 0"));
841 }
842
843 if self.update_interval_seconds < 60 {
844 tracing::warn!("Policy update interval < 60s may cause excessive load");
845 }
846
847 if self.updates_subject.trim().is_empty() {
848 return Err(anyhow::anyhow!("Policy updates subject cannot be empty"));
849 }
850
851 if let Some(queue) = &self.updates_queue {
852 if queue.trim().is_empty() {
853 return Err(anyhow::anyhow!(
854 "Policy updates queue group cannot be blank"
855 ));
856 }
857 }
858
859 Ok(())
860 }
861}
862
863impl ExecutorNatsConfig {
864 pub fn validate(&self) -> Result<()> {
865 for server in &self.servers {
867 if !server.starts_with("nats://") && !server.starts_with("tls://") {
868 return Err(anyhow::anyhow!("Invalid NATS server URL: {}", server));
869 }
870 }
871
872 if let (Some(cert), Some(key), Some(ca)) = (&self.tls_cert, &self.tls_key, &self.tls_ca) {
874 if !cert.exists() {
876 return Err(anyhow::anyhow!(
877 "TLS cert file not found: {}",
878 cert.display()
879 ));
880 }
881 if !key.exists() {
882 return Err(anyhow::anyhow!("TLS key file not found: {}", key.display()));
883 }
884 if !ca.exists() {
885 return Err(anyhow::anyhow!("TLS CA file not found: {}", ca.display()));
886 }
887 }
888
889 Ok(())
890 }
891}
892
893#[derive(Debug, Clone, Serialize, Deserialize)]
895pub struct PolicyDerivations {
896 pub seccomp_allow: HashMap<String, Vec<String>>,
897 pub landlock_paths: HashMap<String, LandlockProfile>,
898 pub cgroups: HashMap<String, CgroupLimits>,
899}
900
901#[derive(Debug, Clone, Serialize, Deserialize)]
903pub struct LandlockProfile {
904 pub read: Vec<String>,
906 pub write: Vec<String>,
908}
909
910#[derive(Debug, Clone, Serialize, Deserialize)]
912pub struct CgroupLimits {
913 pub cpu_pct: u32,
915 pub mem_mb: u64,
917}
918
919impl ExecutorConfig {
920 pub fn parse_byte_size(size_str: &str) -> Result<u64> {
922 let multipliers = [
923 ("TB", 1024_u64.pow(4)),
924 ("GB", 1024_u64.pow(3)),
925 ("MB", 1024_u64.pow(2)),
926 ("KB", 1024),
927 ("B", 1),
928 ];
929
930 for (suffix, multiplier) in &multipliers {
931 if let Some(numeric_part) = size_str.strip_suffix(suffix) {
932 let number: u64 = numeric_part
933 .parse()
934 .with_context(|| format!("Invalid numeric part in byte size: {}", size_str))?;
935 return Ok(number * multiplier);
936 }
937 }
938
939 Err(anyhow::anyhow!("Invalid byte size format: {}", size_str))
940 }
941
942 pub fn parse_duration_seconds(duration_str: &str) -> Result<u64> {
944 let multipliers = [
945 ("d", 86400), ("h", 3600), ("m", 60), ("s", 1), ];
950
951 for (suffix, multiplier) in &multipliers {
952 if let Some(numeric_part) = duration_str.strip_suffix(suffix) {
953 let number: u64 = numeric_part.parse().with_context(|| {
954 format!("Invalid numeric part in duration: {}", duration_str)
955 })?;
956 return Ok(number * multiplier);
957 }
958 }
959
960 Err(anyhow::anyhow!("Invalid duration format: {}", duration_str))
961 }
962
963 pub fn load(path: &std::path::Path) -> Result<Self> {
965 let content = std::fs::read_to_string(path)
966 .with_context(|| format!("Failed to read config file: {}", path.display()))?;
967
968 let raw_value: toml::Value = toml::from_str(&content)
969 .with_context(|| format!("Failed to parse TOML config: {}", path.display()))?;
970
971 let mut config: ExecutorConfig = if let Some(executor_table) = raw_value.get("executor") {
972 executor_table
973 .clone()
974 .try_into()
975 .map_err(anyhow::Error::from)
976 .with_context(|| {
977 format!(
978 "Failed to parse TOML config `[executor]` section: {}",
979 path.display()
980 )
981 })?
982 } else {
983 toml::from_str(&content)
984 .map_err(anyhow::Error::from)
985 .with_context(|| {
986 format!(
987 "Failed to parse TOML config: {} (expected top-level executor fields \
988 or an `[executor]` table)",
989 path.display()
990 )
991 })?
992 };
993
994 config.apply_env_overrides()?;
995 config.validate()?;
996 Ok(config)
997 }
998
999 fn apply_env_overrides(&mut self) -> Result<()> {
1000 if let Ok(raw_servers) = env::var("SMITH_EXECUTOR_NATS_SERVERS") {
1001 let servers = Self::parse_env_server_list(&raw_servers);
1002 if !servers.is_empty() {
1003 self.nats_config.servers = servers;
1004 }
1005 } else if let Ok(single) = env::var("SMITH_EXECUTOR_NATS_URL") {
1006 let trimmed = single.trim();
1007 if !trimmed.is_empty() {
1008 self.nats_config.servers = vec![trimmed.to_string()];
1009 }
1010 } else if let Ok(single) = env::var("SMITH_NATS_URL") {
1011 let trimmed = single.trim();
1012 if !trimmed.is_empty() {
1013 self.nats_config.servers = vec![trimmed.to_string()];
1014 }
1015 }
1016
1017 if let Ok(domain) = env::var("SMITH_EXECUTOR_JETSTREAM_DOMAIN")
1018 .or_else(|_| env::var("SMITH_NATS_JETSTREAM_DOMAIN"))
1019 .or_else(|_| env::var("SMITH_JETSTREAM_DOMAIN"))
1020 {
1021 let trimmed = domain.trim();
1022 if !trimmed.is_empty() {
1023 self.nats_config.jetstream_domain = trimmed.to_string();
1024 }
1025 }
1026
1027 Ok(())
1028 }
1029
1030 fn parse_env_server_list(raw: &str) -> Vec<String> {
1031 raw.split(|c| c == ',' || c == ';')
1032 .map(|part| part.trim())
1033 .filter(|part| !part.is_empty())
1034 .map(|part| part.to_string())
1035 .collect()
1036 }
1037}
1038
1039impl PolicyDerivations {
1040 pub fn load(path: &std::path::Path) -> Result<Self> {
1042 let content = std::fs::read_to_string(path)
1043 .with_context(|| format!("Failed to read derivations file: {}", path.display()))?;
1044
1045 let derivations: PolicyDerivations = serde_json::from_str(&content)
1046 .with_context(|| format!("Failed to parse derivations JSON: {}", path.display()))?;
1047
1048 Ok(derivations)
1049 }
1050
1051 pub fn get_seccomp_allowlist(&self, capability: &str) -> Option<&Vec<String>> {
1053 self.seccomp_allow.get(capability)
1054 }
1055
1056 pub fn get_landlock_profile(&self, capability: &str) -> Option<&LandlockProfile> {
1058 self.landlock_paths.get(capability)
1059 }
1060
1061 pub fn get_cgroup_limits(&self, capability: &str) -> Option<&CgroupLimits> {
1063 self.cgroups.get(capability)
1064 }
1065}
1066
1067impl Default for AttestationConfig {
1068 fn default() -> Self {
1069 Self {
1070 enable_capability_signing: true,
1071 enable_image_verification: true,
1072 enable_slsa_provenance: true,
1073 fail_on_signature_error: std::env::var("SMITH_FAIL_ON_SIGNATURE_ERROR")
1074 .unwrap_or_else(|_| "true".to_string())
1075 .parse()
1076 .unwrap_or(true),
1077 cosign_public_key: std::env::var("SMITH_COSIGN_PUBLIC_KEY").ok(),
1078 provenance_output_dir: PathBuf::from(
1079 std::env::var("SMITH_PROVENANCE_OUTPUT_DIR")
1080 .unwrap_or_else(|_| "build/attestation".to_string()),
1081 ),
1082 verification_cache_ttl: 3600, periodic_verification_interval: 300, }
1085 }
1086}
1087
1088#[cfg(test)]
1089mod tests {
1090 use super::*;
1091 use std::collections::HashMap;
1092 use std::path::PathBuf;
1093 use tempfile::tempdir;
1094
1095 #[test]
1096 fn test_executor_config_creation() {
1097 let config = ExecutorConfig {
1098 node_name: "test-executor".to_string(),
1099 work_root: PathBuf::from("/tmp/work"),
1100 state_dir: PathBuf::from("/tmp/state"),
1101 audit_dir: PathBuf::from("/tmp/audit"),
1102 user_uid: 1000,
1103 user_gid: 1000,
1104 landlock_enabled: true,
1105 egress_proxy_socket: PathBuf::from("/tmp/proxy.sock"),
1106 metrics_port: Some(9090),
1107 intent_streams: HashMap::new(),
1108 results: ResultsConfig::default(),
1109 limits: LimitsConfig::default(),
1110 security: SecurityConfig::default(),
1111 capabilities: CapabilityConfig::default(),
1112 policy: PolicyConfig::default(),
1113 nats_config: ExecutorNatsConfig::default(),
1114 attestation: AttestationConfig::default(),
1115 vm_pool: VmPoolConfig::default(),
1116 };
1117
1118 assert_eq!(config.node_name, "test-executor");
1119 assert_eq!(config.work_root, PathBuf::from("/tmp/work"));
1120 assert_eq!(config.user_uid, 1000);
1121 assert!(config.landlock_enabled);
1122 assert_eq!(config.metrics_port, Some(9090));
1123 }
1124
1125 #[test]
1126 fn test_intent_stream_config() {
1127 let stream_config = IntentStreamConfig {
1128 subject: "smith.intents.test".to_string(),
1129 max_age: "1h".to_string(),
1130 max_bytes: "10MB".to_string(),
1131 workers: 4,
1132 };
1133
1134 assert_eq!(stream_config.subject, "smith.intents.test");
1135 assert_eq!(stream_config.max_age, "1h");
1136 assert_eq!(stream_config.max_bytes, "10MB");
1137 assert_eq!(stream_config.workers, 4);
1138 }
1139
1140 #[test]
1141 fn test_intent_stream_config_validation() {
1142 let mut config = IntentStreamConfig {
1143 subject: "smith.intents.test".to_string(),
1144 max_age: "1h".to_string(),
1145 max_bytes: "1GB".to_string(), workers: 4,
1147 };
1148
1149 assert!(config.validate().is_ok());
1150
1151 config.subject = "".to_string();
1153 assert!(config.validate().is_err());
1154 config.subject = "smith.intents.test".to_string(); config.workers = 0;
1158 assert!(config.validate().is_err());
1159
1160 config.workers = 100;
1162 assert!(config.validate().is_err());
1163
1164 config.workers = 32;
1166 assert!(config.validate().is_ok());
1167 }
1168
1169 #[test]
1170 fn test_results_config_default() {
1171 let results_config = ResultsConfig::default();
1172
1173 assert_eq!(results_config.subject_prefix, "smith.results."); assert_eq!(results_config.max_age, "5m"); }
1176
1177 #[test]
1178 fn test_limits_config_default() {
1179 let limits_config = LimitsConfig::default();
1180
1181 assert_eq!(limits_config.overrides.len(), 0); }
1185
1186 #[test]
1187 fn test_default_limits_validation() {
1188 let mut limits = DefaultLimits::default();
1189 assert!(limits.validate().is_ok());
1190
1191 limits.cpu_ms_per_100ms = 150; assert!(limits.validate().is_err());
1194 limits.cpu_ms_per_100ms = 50; limits.mem_bytes = 0; assert!(limits.validate().is_err());
1199 limits.mem_bytes = 64 * 1024 * 1024; limits.pids_max = 0; assert!(limits.validate().is_err());
1204 limits.pids_max = 2000; assert!(limits.validate().is_err());
1206 limits.pids_max = 64; assert!(limits.validate().is_ok());
1209 }
1210
1211 #[test]
1212 fn test_security_config_validation() {
1213 let mut security_config = SecurityConfig::default();
1214 assert!(security_config.validate().is_ok());
1215
1216 security_config.jwt_issuers = vec!["invalid-url".to_string()];
1218 assert!(security_config.validate().is_err());
1219
1220 security_config.jwt_issuers = vec!["https://auth.example.com".to_string()];
1222 assert!(security_config.validate().is_ok());
1223
1224 security_config.allowed_destinations =
1226 vec!["192.168.1.1".to_string(), "example.com".to_string()];
1227 assert!(security_config.validate().is_ok());
1228
1229 security_config.allowed_destinations = vec!["".to_string()];
1231 assert!(security_config.validate().is_err());
1232
1233 security_config.allowed_destinations = vec!["a".repeat(256)];
1235 assert!(security_config.validate().is_err());
1236 }
1237
1238 #[test]
1239 fn test_policy_config_validation() {
1240 let mut policy_config = PolicyConfig::default();
1241 assert!(policy_config.validate().is_ok());
1242
1243 policy_config.update_interval_seconds = 0;
1245 assert!(policy_config.validate().is_err());
1246
1247 policy_config.update_interval_seconds = 300;
1249 assert!(policy_config.validate().is_ok());
1250 }
1251
1252 #[test]
1253 fn test_executor_nats_config_validation() {
1254 let mut nats_config = ExecutorNatsConfig {
1255 servers: vec!["nats://127.0.0.1:4222".to_string()],
1256 jetstream_domain: "JS".to_string(),
1257 tls_cert: None, tls_key: None,
1259 tls_ca: None,
1260 };
1261 assert!(nats_config.validate().is_ok());
1262
1263 nats_config.servers = vec!["invalid-url".to_string()];
1265 assert!(nats_config.validate().is_err());
1266
1267 nats_config.servers = vec![
1269 "nats://localhost:4222".to_string(),
1270 "tls://nats.example.com:4222".to_string(),
1271 ];
1272 assert!(nats_config.validate().is_ok());
1273 }
1274
1275 #[test]
1276 fn test_executor_nats_config_tls_validation() {
1277 let temp_dir = tempdir().unwrap();
1278 let cert_path = temp_dir.path().join("cert.pem");
1279 let key_path = temp_dir.path().join("key.pem");
1280 let ca_path = temp_dir.path().join("ca.pem");
1281
1282 std::fs::write(&cert_path, "cert").unwrap();
1284 std::fs::write(&key_path, "key").unwrap();
1285 std::fs::write(&ca_path, "ca").unwrap();
1286
1287 let valid_config = ExecutorNatsConfig {
1288 tls_cert: Some(cert_path.clone()),
1289 tls_key: Some(key_path.clone()),
1290 tls_ca: Some(ca_path.clone()),
1291 ..ExecutorNatsConfig::default()
1292 };
1293 assert!(valid_config.validate().is_ok());
1294
1295 let missing_cert = ExecutorNatsConfig {
1296 tls_cert: Some(temp_dir.path().join("missing.pem")),
1297 tls_key: Some(key_path.clone()),
1298 tls_ca: Some(ca_path.clone()),
1299 ..ExecutorNatsConfig::default()
1300 };
1301 assert!(missing_cert.validate().is_err());
1302
1303 let missing_key = ExecutorNatsConfig {
1304 tls_cert: Some(cert_path),
1305 tls_key: Some(temp_dir.path().join("missing.pem")),
1306 tls_ca: Some(ca_path),
1307 ..ExecutorNatsConfig::default()
1308 };
1309 assert!(missing_key.validate().is_err());
1310 }
1311
1312 #[test]
1313 #[ignore] fn test_repo_executor_config_loads() {
1315 let path = PathBuf::from("../../infra/config/smith-executor.toml");
1316 let result = ExecutorConfig::load(&path);
1317 assert!(result.is_ok(), "error: {:?}", result.unwrap_err());
1318 }
1319
1320 #[test]
1321 fn test_executor_env_overrides_nats_servers() {
1322 let temp_dir = tempdir().unwrap();
1323 let config_path = temp_dir.path().join("executor.toml");
1324
1325 let mut config = ExecutorConfig::development();
1326 config.work_root = temp_dir.path().join("work");
1327 config.state_dir = temp_dir.path().join("state");
1328 config.audit_dir = temp_dir.path().join("audit");
1329 config.egress_proxy_socket = temp_dir.path().join("proxy.sock");
1330 config.security.pubkeys_dir = temp_dir.path().join("pubkeys");
1331 config.capabilities.derivations_path = temp_dir.path().join("capability.json");
1332 config.attestation.provenance_output_dir = temp_dir.path().join("attestation_outputs");
1333 config.nats_config.tls_cert = None;
1334 config.nats_config.tls_key = None;
1335 config.nats_config.tls_ca = None;
1336
1337 let toml = toml::to_string(&config).unwrap();
1338 std::fs::write(&config_path, toml).unwrap();
1339
1340 let prev_servers = env::var("SMITH_EXECUTOR_NATS_SERVERS").ok();
1341 let prev_exec_url = env::var("SMITH_EXECUTOR_NATS_URL").ok();
1342 let prev_nats_url = env::var("SMITH_NATS_URL").ok();
1343 let prev_domain = env::var("SMITH_NATS_JETSTREAM_DOMAIN").ok();
1344 let prev_exec_domain = env::var("SMITH_EXECUTOR_JETSTREAM_DOMAIN").ok();
1345
1346 env::remove_var("SMITH_EXECUTOR_NATS_SERVERS");
1347 env::remove_var("SMITH_EXECUTOR_NATS_URL");
1348 env::remove_var("SMITH_NATS_URL");
1349 env::remove_var("SMITH_NATS_JETSTREAM_DOMAIN");
1350 env::remove_var("SMITH_EXECUTOR_JETSTREAM_DOMAIN");
1351
1352 env::set_var(
1353 "SMITH_EXECUTOR_NATS_SERVERS",
1354 "nats://localhost:7222, nats://backup:7223",
1355 );
1356 env::set_var("SMITH_NATS_JETSTREAM_DOMAIN", "devtools");
1357
1358 let loaded = ExecutorConfig::load(&config_path).unwrap();
1359 assert_eq!(
1360 loaded.nats_config.servers,
1361 vec![
1362 "nats://localhost:7222".to_string(),
1363 "nats://backup:7223".to_string()
1364 ]
1365 );
1366 assert_eq!(loaded.nats_config.jetstream_domain, "devtools");
1367
1368 restore_env_var("SMITH_EXECUTOR_NATS_SERVERS", prev_servers);
1369 restore_env_var("SMITH_EXECUTOR_NATS_URL", prev_exec_url);
1370 restore_env_var("SMITH_NATS_URL", prev_nats_url);
1371 restore_env_var("SMITH_NATS_JETSTREAM_DOMAIN", prev_domain);
1372 restore_env_var("SMITH_EXECUTOR_JETSTREAM_DOMAIN", prev_exec_domain);
1373 }
1374
1375 #[test]
1376 fn test_attestation_config_default() {
1377 let attestation_config = AttestationConfig::default();
1378
1379 assert!(attestation_config.enable_capability_signing);
1380 assert!(attestation_config.enable_image_verification);
1381 assert!(attestation_config.enable_slsa_provenance);
1382 assert_eq!(attestation_config.verification_cache_ttl, 3600);
1383 assert_eq!(attestation_config.periodic_verification_interval, 300);
1384 }
1385
1386 #[test]
1387 fn test_cgroup_limits() {
1388 let cgroup_limits = CgroupLimits {
1389 cpu_pct: 50,
1390 mem_mb: 128,
1391 };
1392
1393 assert_eq!(cgroup_limits.cpu_pct, 50);
1394 assert_eq!(cgroup_limits.mem_mb, 128);
1395 }
1396
1397 #[test]
1398 fn test_executor_config_presets() {
1399 let dev_config = ExecutorConfig::development();
1401 assert_eq!(dev_config.node_name, "exec-01"); assert!(!dev_config.landlock_enabled);
1403 assert!(!dev_config.security.strict_sandbox);
1404
1405 let prod_config = ExecutorConfig::production();
1407 assert!(prod_config.landlock_enabled);
1408 assert!(prod_config.security.strict_sandbox);
1409 assert!(prod_config.security.network_isolation);
1410 assert!(prod_config.capabilities.enforcement_enabled);
1411
1412 let test_config = ExecutorConfig::testing();
1414 assert!(!test_config.landlock_enabled);
1415 assert!(!test_config.security.strict_sandbox);
1416 assert!(!test_config.capabilities.enforcement_enabled);
1417 assert_eq!(test_config.metrics_port, None);
1418 }
1419
1420 #[test]
1421 fn test_parse_byte_size() {
1422 assert_eq!(ExecutorConfig::parse_byte_size("1024B").unwrap(), 1024);
1423 assert_eq!(ExecutorConfig::parse_byte_size("10KB").unwrap(), 10 * 1024);
1424 assert_eq!(
1425 ExecutorConfig::parse_byte_size("5MB").unwrap(),
1426 5 * 1024 * 1024
1427 );
1428 assert_eq!(
1429 ExecutorConfig::parse_byte_size("2GB").unwrap(),
1430 2 * 1024 * 1024 * 1024
1431 );
1432
1433 assert!(ExecutorConfig::parse_byte_size("invalid").is_err());
1435 assert!(ExecutorConfig::parse_byte_size("10XB").is_err());
1436 assert!(ExecutorConfig::parse_byte_size("").is_err());
1437 }
1438
1439 #[test]
1440 fn test_serialization_roundtrip() {
1441 let original = ExecutorConfig {
1442 node_name: "test-node".to_string(),
1443 work_root: PathBuf::from("/work"),
1444 state_dir: PathBuf::from("/state"),
1445 audit_dir: PathBuf::from("/audit"),
1446 user_uid: 1001,
1447 user_gid: 1001,
1448 landlock_enabled: false,
1449 egress_proxy_socket: PathBuf::from("/proxy.sock"),
1450 metrics_port: Some(8080),
1451 intent_streams: HashMap::new(),
1452 results: ResultsConfig::default(),
1453 limits: LimitsConfig::default(),
1454 security: SecurityConfig::default(),
1455 capabilities: CapabilityConfig::default(),
1456 policy: PolicyConfig::default(),
1457 nats_config: ExecutorNatsConfig::default(),
1458 attestation: AttestationConfig::default(),
1459 vm_pool: VmPoolConfig::default(),
1460 };
1461
1462 let json = serde_json::to_string(&original).unwrap();
1464 let deserialized: ExecutorConfig = serde_json::from_str(&json).unwrap();
1465
1466 assert_eq!(original.node_name, deserialized.node_name);
1467 assert_eq!(original.work_root, deserialized.work_root);
1468 assert_eq!(original.user_uid, deserialized.user_uid);
1469 assert_eq!(original.landlock_enabled, deserialized.landlock_enabled);
1470 assert_eq!(original.metrics_port, deserialized.metrics_port);
1471 }
1472
1473 #[test]
1474 fn test_debug_formatting() {
1475 let config = ExecutorConfig {
1476 node_name: "debug-test".to_string(),
1477 work_root: PathBuf::from("/work"),
1478 state_dir: PathBuf::from("/state"),
1479 audit_dir: PathBuf::from("/audit"),
1480 user_uid: 1000,
1481 user_gid: 1000,
1482 landlock_enabled: true,
1483 egress_proxy_socket: PathBuf::from("/proxy.sock"),
1484 metrics_port: Some(9090),
1485 intent_streams: HashMap::new(),
1486 results: ResultsConfig::default(),
1487 limits: LimitsConfig::default(),
1488 security: SecurityConfig::default(),
1489 capabilities: CapabilityConfig::default(),
1490 policy: PolicyConfig::default(),
1491 nats_config: ExecutorNatsConfig::default(),
1492 attestation: AttestationConfig::default(),
1493 vm_pool: VmPoolConfig::default(),
1494 };
1495
1496 let debug_output = format!("{:?}", config);
1497 assert!(debug_output.contains("debug-test"));
1498 assert!(debug_output.contains("/work"));
1499 assert!(debug_output.contains("1000"));
1500 }
1501
1502 fn restore_env_var(name: &str, value: Option<String>) {
1503 if let Some(value) = value {
1504 env::set_var(name, value);
1505 } else {
1506 env::remove_var(name);
1507 }
1508 }
1509}