1use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::env;
9use std::path::PathBuf;
10use thiserror::Error;
11
12#[derive(Debug, Error)]
14pub enum ConfigError {
15 #[error("Missing required configuration: {key}")]
16 MissingRequired { key: String },
17
18 #[error("Invalid configuration value for {key}: {reason}")]
19 InvalidValue { key: String, reason: String },
20
21 #[error("Environment variable error: {message}")]
22 EnvError { message: String },
23
24 #[error("IO error reading config file: {message}")]
25 IoError { message: String },
26
27 #[error("Configuration parsing error: {message}")]
28 ParseError { message: String },
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize, Default)]
33pub struct Config {
34 pub api: ApiConfig,
36 pub database: DatabaseConfig,
38 pub logging: LoggingConfig,
40 pub security: SecurityConfig,
42 pub storage: StorageConfig,
44 pub slm: Option<Slm>,
46 pub routing: Option<crate::routing::RoutingConfig>,
48 pub native_execution: Option<NativeExecutionConfig>,
50 pub agentpin: Option<crate::integrations::agentpin::AgentPinConfig>,
52 #[cfg(feature = "cli-executor")]
54 pub cli_executor: Option<CliExecutorConfigToml>,
55}
56
57#[derive(Clone, Serialize, Deserialize)]
63pub struct ApiConfig {
64 pub port: u16,
66 pub host: String,
68 #[serde(skip_serializing)]
70 pub auth_token: Option<String>,
71 pub timeout_seconds: u64,
73 pub max_body_size: usize,
75}
76
77impl std::fmt::Debug for ApiConfig {
78 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79 f.debug_struct("ApiConfig")
80 .field("port", &self.port)
81 .field("host", &self.host)
82 .field(
83 "auth_token",
84 &self.auth_token.as_ref().map(|_| "[REDACTED]"),
85 )
86 .field("timeout_seconds", &self.timeout_seconds)
87 .field("max_body_size", &self.max_body_size)
88 .finish()
89 }
90}
91
92impl Drop for ApiConfig {
93 fn drop(&mut self) {
94 use zeroize::Zeroize;
95 if let Some(ref mut t) = self.auth_token {
96 t.zeroize();
97 }
98 }
99}
100
101#[derive(Clone, Serialize, Deserialize)]
106pub struct DatabaseConfig {
107 #[serde(skip_serializing)]
109 pub url: Option<String>,
110 #[serde(skip_serializing)]
112 pub redis_url: Option<String>,
113 pub qdrant_url: String,
115 pub qdrant_collection: String,
117 pub vector_dimension: usize,
119}
120
121impl std::fmt::Debug for DatabaseConfig {
122 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123 f.debug_struct("DatabaseConfig")
124 .field("url", &self.url.as_ref().map(|_| "[REDACTED]"))
125 .field("redis_url", &self.redis_url.as_ref().map(|_| "[REDACTED]"))
126 .field("qdrant_url", &self.qdrant_url)
127 .field("qdrant_collection", &self.qdrant_collection)
128 .field("vector_dimension", &self.vector_dimension)
129 .finish()
130 }
131}
132
133impl Drop for DatabaseConfig {
134 fn drop(&mut self) {
135 use zeroize::Zeroize;
136 if let Some(ref mut u) = self.url {
137 u.zeroize();
138 }
139 if let Some(ref mut u) = self.redis_url {
140 u.zeroize();
141 }
142 }
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct LoggingConfig {
148 pub level: String,
150 pub format: LogFormat,
152 pub structured: bool,
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize)]
158pub enum LogFormat {
159 Json,
160 Pretty,
161 Compact,
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct SecurityConfig {
167 pub key_provider: KeyProvider,
169 pub enable_compression: bool,
171 pub enable_backups: bool,
172 pub enable_safety_checks: bool,
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize)]
177pub enum KeyProvider {
178 Environment { var_name: String },
179 File { path: PathBuf },
180 Keychain { service: String, account: String },
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct NativeExecutionConfig {
187 pub enabled: bool,
189 pub default_executable: String,
191 pub working_directory: PathBuf,
193 pub enforce_resource_limits: bool,
195 pub max_memory_mb: Option<u64>,
197 pub max_cpu_seconds: Option<u64>,
199 pub max_execution_time_seconds: u64,
201 pub allowed_executables: Vec<String>,
203}
204
205impl Default for NativeExecutionConfig {
206 fn default() -> Self {
207 Self {
208 enabled: false, default_executable: "bash".to_string(),
210 working_directory: PathBuf::from("/tmp/symbiont-native"),
211 enforce_resource_limits: true,
212 max_memory_mb: Some(2048),
213 max_cpu_seconds: Some(300),
214 max_execution_time_seconds: 300,
215 allowed_executables: vec![], }
217 }
218}
219
220#[cfg(feature = "cli-executor")]
223#[derive(Debug, Clone, Serialize, Deserialize, Default)]
224pub struct CliExecutorConfigToml {
225 pub max_runtime_seconds: Option<u64>,
227 pub idle_timeout_seconds: Option<u64>,
229 pub max_output_bytes: Option<usize>,
231 pub adapters: Option<HashMap<String, AdapterConfigToml>>,
233}
234
235#[cfg(feature = "cli-executor")]
237#[derive(Debug, Clone, Serialize, Deserialize, Default)]
238pub struct AdapterConfigToml {
239 pub executable: Option<String>,
241 pub model: Option<String>,
243 pub max_turns: Option<u32>,
245 pub allowed_tools: Option<Vec<String>>,
247 pub disallowed_tools: Option<Vec<String>>,
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct StorageConfig {
254 pub context_path: PathBuf,
256 pub git_clone_path: PathBuf,
258 pub backup_path: PathBuf,
260 pub max_context_size_mb: u64,
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct Slm {
267 pub enabled: bool,
269 pub model_allow_lists: ModelAllowListConfig,
271 pub sandbox_profiles: HashMap<String, SandboxProfile>,
273 pub default_sandbox_profile: String,
275}
276
277#[derive(Debug, Clone, Serialize, Deserialize, Default)]
279pub struct ModelAllowListConfig {
280 pub global_models: Vec<Model>,
282 pub agent_model_maps: HashMap<String, Vec<String>>,
284 pub allow_runtime_overrides: bool,
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct Model {
291 pub id: String,
293 pub name: String,
295 pub provider: ModelProvider,
297 pub capabilities: Vec<ModelCapability>,
299 pub resource_requirements: ModelResourceRequirements,
301}
302
303#[derive(Debug, Clone, Serialize, Deserialize)]
305pub enum ModelProvider {
306 HuggingFace { model_path: String },
307 LocalFile { file_path: PathBuf },
308 OpenAI { model_name: String },
309 Anthropic { model_name: String },
310 Custom { endpoint_url: String },
311}
312
313#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
315pub enum ModelCapability {
316 TextGeneration,
317 CodeGeneration,
318 Reasoning,
319 ToolUse,
320 FunctionCalling,
321 Embeddings,
322}
323
324#[derive(Debug, Clone, Serialize, Deserialize)]
326pub struct ModelResourceRequirements {
327 pub min_memory_mb: u64,
329 pub preferred_cpu_cores: f32,
331 pub gpu_requirements: Option<GpuRequirements>,
333}
334
335#[derive(Debug, Clone, Serialize, Deserialize)]
337pub struct GpuRequirements {
338 pub min_vram_mb: u64,
340 pub compute_capability: String,
342}
343
344#[derive(Debug, Clone, Serialize, Deserialize)]
346pub struct SandboxProfile {
347 pub resources: ResourceConstraints,
349 pub filesystem: FilesystemControls,
351 pub process_limits: ProcessLimits,
353 pub network: NetworkPolicy,
355 pub security: SecuritySettings,
357}
358
359#[derive(Debug, Clone, Serialize, Deserialize)]
361pub struct ResourceConstraints {
362 pub max_memory_mb: u64,
364 pub max_cpu_cores: f32,
366 pub max_disk_mb: u64,
368 pub gpu_access: GpuAccess,
370 pub max_io_bandwidth_mbps: Option<u64>,
372}
373
374#[derive(Debug, Clone, Serialize, Deserialize)]
376pub struct FilesystemControls {
377 pub read_paths: Vec<String>,
379 pub write_paths: Vec<String>,
381 pub denied_paths: Vec<String>,
383 pub allow_temp_files: bool,
385 pub max_file_size_mb: u64,
387}
388
389#[derive(Debug, Clone, Serialize, Deserialize)]
391pub struct ProcessLimits {
392 pub max_child_processes: u32,
394 pub max_execution_time_seconds: u64,
396 pub allowed_syscalls: Vec<String>,
398 pub process_priority: i8,
400}
401
402#[derive(Debug, Clone, Serialize, Deserialize)]
404pub struct NetworkPolicy {
405 pub access_mode: NetworkAccessMode,
407 pub allowed_destinations: Vec<NetworkDestination>,
409 pub max_bandwidth_mbps: Option<u64>,
411}
412
413#[derive(Debug, Clone, Serialize, Deserialize)]
415pub enum NetworkAccessMode {
416 None,
418 Restricted,
420 Full,
422}
423
424#[derive(Debug, Clone, Serialize, Deserialize)]
426pub struct NetworkDestination {
427 pub host: String,
429 pub port: Option<u16>,
431 pub protocol: Option<NetworkProtocol>,
433}
434
435#[derive(Debug, Clone, Serialize, Deserialize)]
437pub enum NetworkProtocol {
438 TCP,
439 UDP,
440 HTTP,
441 HTTPS,
442}
443
444#[derive(Debug, Clone, Serialize, Deserialize)]
446pub enum GpuAccess {
447 None,
449 Shared { max_memory_mb: u64 },
451 Exclusive,
453}
454
455#[derive(Debug, Clone, Serialize, Deserialize)]
457pub struct SecuritySettings {
458 pub strict_syscall_filtering: bool,
460 pub disable_debugging: bool,
462 pub enable_audit_logging: bool,
464 pub require_encryption: bool,
466}
467
468impl Default for ApiConfig {
469 fn default() -> Self {
470 Self {
471 port: 8080,
472 host: "127.0.0.1".to_string(),
473 auth_token: None,
474 timeout_seconds: 60,
475 max_body_size: 16 * 1024 * 1024, }
477 }
478}
479
480impl Default for DatabaseConfig {
481 fn default() -> Self {
482 Self {
483 url: None,
484 redis_url: None,
485 qdrant_url: "http://localhost:6333".to_string(),
486 qdrant_collection: "agent_knowledge".to_string(),
487 vector_dimension: 1536,
488 }
489 }
490}
491
492impl Default for LoggingConfig {
493 fn default() -> Self {
494 Self {
495 level: "info".to_string(),
496 format: LogFormat::Pretty,
497 structured: false,
498 }
499 }
500}
501
502impl Default for SecurityConfig {
503 fn default() -> Self {
504 Self {
505 key_provider: KeyProvider::Environment {
506 var_name: "SYMBIONT_SECRET_KEY".to_string(),
507 },
508 enable_compression: true,
509 enable_backups: true,
510 enable_safety_checks: true,
511 }
512 }
513}
514
515impl Default for StorageConfig {
516 fn default() -> Self {
517 Self {
518 context_path: PathBuf::from("./agent_storage"),
519 git_clone_path: PathBuf::from("./temp_repos"),
520 backup_path: PathBuf::from("./backups"),
521 max_context_size_mb: 100,
522 }
523 }
524}
525
526impl Default for Slm {
527 fn default() -> Self {
528 let mut profiles = HashMap::new();
529 profiles.insert("secure".to_string(), SandboxProfile::secure_default());
530 profiles.insert("standard".to_string(), SandboxProfile::standard_default());
531
532 Self {
533 enabled: false,
534 model_allow_lists: ModelAllowListConfig::default(),
535 sandbox_profiles: profiles,
536 default_sandbox_profile: "secure".to_string(),
537 }
538 }
539}
540
541impl SandboxProfile {
542 pub fn secure_default() -> Self {
544 Self {
545 resources: ResourceConstraints {
546 max_memory_mb: 512,
547 max_cpu_cores: 1.0,
548 max_disk_mb: 100,
549 gpu_access: GpuAccess::None,
550 max_io_bandwidth_mbps: Some(10),
551 },
552 filesystem: FilesystemControls {
553 read_paths: vec!["/tmp/sandbox/*".to_string()],
554 write_paths: vec!["/tmp/sandbox/output/*".to_string()],
555 denied_paths: vec!["/etc/*".to_string(), "/proc/*".to_string()],
556 allow_temp_files: true,
557 max_file_size_mb: 10,
558 },
559 process_limits: ProcessLimits {
560 max_child_processes: 0,
561 max_execution_time_seconds: 300,
562 allowed_syscalls: vec!["read".to_string(), "write".to_string(), "open".to_string()],
563 process_priority: 19,
564 },
565 network: NetworkPolicy {
566 access_mode: NetworkAccessMode::None,
567 allowed_destinations: vec![],
568 max_bandwidth_mbps: None,
569 },
570 security: SecuritySettings {
571 strict_syscall_filtering: true,
572 disable_debugging: true,
573 enable_audit_logging: true,
574 require_encryption: true,
575 },
576 }
577 }
578
579 pub fn standard_default() -> Self {
581 Self {
582 resources: ResourceConstraints {
583 max_memory_mb: 1024,
584 max_cpu_cores: 2.0,
585 max_disk_mb: 500,
586 gpu_access: GpuAccess::Shared {
587 max_memory_mb: 1024,
588 },
589 max_io_bandwidth_mbps: Some(50),
590 },
591 filesystem: FilesystemControls {
592 read_paths: vec!["/tmp/*".to_string(), "/home/sandbox/*".to_string()],
593 write_paths: vec!["/tmp/*".to_string(), "/home/sandbox/*".to_string()],
594 denied_paths: vec!["/etc/passwd".to_string(), "/etc/shadow".to_string()],
595 allow_temp_files: true,
596 max_file_size_mb: 100,
597 },
598 process_limits: ProcessLimits {
599 max_child_processes: 5,
600 max_execution_time_seconds: 600,
601 allowed_syscalls: vec![], process_priority: 0,
603 },
604 network: NetworkPolicy {
605 access_mode: NetworkAccessMode::Restricted,
606 allowed_destinations: vec![NetworkDestination {
607 host: "api.openai.com".to_string(),
608 port: Some(443),
609 protocol: Some(NetworkProtocol::HTTPS),
610 }],
611 max_bandwidth_mbps: Some(100),
612 },
613 security: SecuritySettings {
614 strict_syscall_filtering: false,
615 disable_debugging: false,
616 enable_audit_logging: true,
617 require_encryption: false,
618 },
619 }
620 }
621
622 pub fn validate(&self) -> Result<(), Box<dyn std::error::Error>> {
624 if self.resources.max_memory_mb == 0 {
626 return Err("max_memory_mb must be > 0".into());
627 }
628 if self.resources.max_cpu_cores <= 0.0 {
629 return Err("max_cpu_cores must be > 0".into());
630 }
631
632 for path in &self.filesystem.read_paths {
634 if path.is_empty() {
635 return Err("read_paths cannot contain empty strings".into());
636 }
637 }
638
639 if self.process_limits.max_execution_time_seconds == 0 {
641 return Err("max_execution_time_seconds must be > 0".into());
642 }
643
644 Ok(())
645 }
646}
647
648impl Slm {
649 pub fn validate(&self) -> Result<(), ConfigError> {
651 if !self
653 .sandbox_profiles
654 .contains_key(&self.default_sandbox_profile)
655 {
656 return Err(ConfigError::InvalidValue {
657 key: "slm.default_sandbox_profile".to_string(),
658 reason: format!(
659 "Profile '{}' not found in sandbox_profiles",
660 self.default_sandbox_profile
661 ),
662 });
663 }
664
665 let mut model_ids = std::collections::HashSet::new();
667 for model in &self.model_allow_lists.global_models {
668 if !model_ids.insert(&model.id) {
669 return Err(ConfigError::InvalidValue {
670 key: "slm.model_allow_lists.global_models".to_string(),
671 reason: format!("Duplicate model ID: {}", model.id),
672 });
673 }
674 }
675
676 for (agent_id, model_ids) in &self.model_allow_lists.agent_model_maps {
678 for model_id in model_ids {
679 if !self
680 .model_allow_lists
681 .global_models
682 .iter()
683 .any(|m| &m.id == model_id)
684 {
685 return Err(ConfigError::InvalidValue {
686 key: format!("slm.model_allow_lists.agent_model_maps.{}", agent_id),
687 reason: format!("Model ID '{}' not found in global_models", model_id),
688 });
689 }
690 }
691 }
692
693 for (profile_name, profile) in &self.sandbox_profiles {
695 profile.validate().map_err(|e| ConfigError::InvalidValue {
696 key: format!("slm.sandbox_profiles.{}", profile_name),
697 reason: e.to_string(),
698 })?;
699 }
700
701 Ok(())
702 }
703
704 pub fn get_allowed_models(&self, agent_id: &str) -> Vec<&Model> {
706 if let Some(model_ids) = self.model_allow_lists.agent_model_maps.get(agent_id) {
708 self.model_allow_lists
709 .global_models
710 .iter()
711 .filter(|model| model_ids.contains(&model.id))
712 .collect()
713 } else {
714 self.model_allow_lists.global_models.iter().collect()
716 }
717 }
718}
719
720impl Config {
721 pub fn from_env() -> Result<Self, ConfigError> {
723 let mut config = Self::default();
724
725 if let Ok(port) = env::var("API_PORT") {
727 config.api.port = port.parse().map_err(|_| ConfigError::InvalidValue {
728 key: "API_PORT".to_string(),
729 reason: "Invalid port number".to_string(),
730 })?;
731 }
732
733 if let Ok(host) = env::var("API_HOST") {
734 config.api.host = host;
735 }
736
737 if let Ok(token) = env::var("API_AUTH_TOKEN") {
739 match Self::validate_auth_token(&token) {
740 Ok(validated_token) => {
741 config.api.auth_token = Some(validated_token);
742 }
743 Err(e) => {
744 tracing::error!("Invalid API_AUTH_TOKEN: {}", e);
745 eprintln!("⚠️ ERROR: Invalid API_AUTH_TOKEN: {}", e);
746 }
748 }
749 }
750
751 if let Ok(db_url) = env::var("DATABASE_URL") {
753 config.database.url = Some(db_url);
754 }
755
756 if let Ok(redis_url) = env::var("REDIS_URL") {
757 config.database.redis_url = Some(redis_url);
758 }
759
760 if let Ok(qdrant_url) = env::var("QDRANT_URL") {
761 config.database.qdrant_url = qdrant_url;
762 }
763
764 if let Ok(log_level) = env::var("LOG_LEVEL") {
766 config.logging.level = log_level;
767 }
768
769 if let Ok(key_var) = env::var("SYMBIONT_SECRET_KEY_VAR") {
771 config.security.key_provider = KeyProvider::Environment { var_name: key_var };
772 }
773
774 if let Ok(context_path) = env::var("CONTEXT_STORAGE_PATH") {
776 config.storage.context_path = PathBuf::from(context_path);
777 }
778
779 if let Ok(git_path) = env::var("GIT_CLONE_BASE_PATH") {
780 config.storage.git_clone_path = PathBuf::from(git_path);
781 }
782
783 if let Ok(backup_path) = env::var("BACKUP_DIRECTORY") {
784 config.storage.backup_path = PathBuf::from(backup_path);
785 }
786
787 Ok(config)
788 }
789
790 pub fn from_file<P: AsRef<std::path::Path>>(path: P) -> Result<Self, ConfigError> {
792 let content = std::fs::read_to_string(path).map_err(|e| ConfigError::IoError {
793 message: e.to_string(),
794 })?;
795
796 let config: Self = toml::from_str(&content).map_err(|e| ConfigError::ParseError {
797 message: e.to_string(),
798 })?;
799
800 Ok(config)
801 }
802
803 pub fn validate(&self) -> Result<(), ConfigError> {
805 if self.api.port == 0 {
807 return Err(ConfigError::InvalidValue {
808 key: "api.port".to_string(),
809 reason: "Port cannot be 0".to_string(),
810 });
811 }
812
813 let valid_levels = ["error", "warn", "info", "debug", "trace"];
815 if !valid_levels.contains(&self.logging.level.as_str()) {
816 return Err(ConfigError::InvalidValue {
817 key: "logging.level".to_string(),
818 reason: format!("Must be one of: {}", valid_levels.join(", ")),
819 });
820 }
821
822 if self.database.vector_dimension == 0 {
824 return Err(ConfigError::InvalidValue {
825 key: "database.vector_dimension".to_string(),
826 reason: "Vector dimension must be > 0".to_string(),
827 });
828 }
829
830 if let Some(slm) = &self.slm {
832 if slm.enabled {
833 slm.validate()?;
834 }
835 }
836
837 Ok(())
838 }
839
840 pub fn get_api_auth_token(&self) -> Result<String, ConfigError> {
842 match &self.api.auth_token {
843 Some(token) => Ok(token.clone()),
844 None => Err(ConfigError::MissingRequired {
845 key: "API_AUTH_TOKEN".to_string(),
846 }),
847 }
848 }
849
850 pub fn get_database_url(&self) -> Result<String, ConfigError> {
852 match &self.database.url {
853 Some(url) => Ok(url.clone()),
854 None => Err(ConfigError::MissingRequired {
855 key: "DATABASE_URL".to_string(),
856 }),
857 }
858 }
859
860 pub fn get_secret_key(&self) -> Result<String, ConfigError> {
862 match &self.security.key_provider {
863 KeyProvider::Environment { var_name } => {
864 env::var(var_name).map_err(|_| ConfigError::MissingRequired {
865 key: var_name.clone(),
866 })
867 }
868 KeyProvider::File { path } => std::fs::read_to_string(path)
869 .map(|s| s.trim().to_string())
870 .map_err(|e| ConfigError::IoError {
871 message: e.to_string(),
872 }),
873 KeyProvider::Keychain { service, account } => {
874 #[cfg(feature = "keychain")]
875 {
876 use keyring::Entry;
877 let entry =
878 Entry::new(service, account).map_err(|e| ConfigError::EnvError {
879 message: e.to_string(),
880 })?;
881 entry.get_password().map_err(|e| ConfigError::EnvError {
882 message: e.to_string(),
883 })
884 }
885 #[cfg(not(feature = "keychain"))]
886 {
887 Err(ConfigError::EnvError {
888 message: "Keychain support not enabled".to_string(),
889 })
890 }
891 }
892 }
893 }
894
895 fn validate_auth_token(token: &str) -> Result<String, ConfigError> {
905 let trimmed = token.trim();
907
908 if trimmed.is_empty() {
910 return Err(ConfigError::InvalidValue {
911 key: "auth_token".to_string(),
912 reason: "Token cannot be empty".to_string(),
913 });
914 }
915
916 let weak_tokens = [
919 "dev",
920 "test",
921 "password",
922 "secret",
923 "token",
924 "api_key",
925 "12345678",
926 "admin",
927 "root",
928 "default",
929 "changeme",
930 "letmein",
931 "qwerty",
932 "abc123",
933 "password123",
934 ];
935
936 if weak_tokens.contains(&trimmed.to_lowercase().as_str()) {
937 return Err(ConfigError::InvalidValue {
938 key: "auth_token".to_string(),
939 reason: format!(
940 "Token '{}' is a known weak/default token. Use a strong random token instead.",
941 trimmed
942 ),
943 });
944 }
945
946 if trimmed.len() < 8 {
948 return Err(ConfigError::InvalidValue {
949 key: "auth_token".to_string(),
950 reason: "Token must be at least 8 characters long".to_string(),
951 });
952 }
953
954 if trimmed
956 .chars()
957 .all(|c| c == trimmed.chars().next().unwrap())
958 {
959 tracing::warn!("⚠️ Auth token appears weak (all same character)");
960 }
961
962 if trimmed.contains(' ') && !trimmed.starts_with("Bearer ") {
964 return Err(ConfigError::InvalidValue {
965 key: "auth_token".to_string(),
966 reason: "Token should not contain spaces (unless it's a Bearer token)".to_string(),
967 });
968 }
969
970 Ok(trimmed.to_string())
971 }
972}
973
974#[cfg(test)]
975mod tests {
976 use super::*;
977 use serial_test::serial;
978 use std::collections::HashMap;
979 use std::env;
980 use std::path::PathBuf;
981
982 #[test]
983 fn test_default_config() {
984 let config = Config::default();
985 assert_eq!(config.api.port, 8080);
986 assert_eq!(config.api.host, "127.0.0.1");
987 assert!(config.validate().is_ok());
988 }
989
990 #[test]
991 #[serial]
992 fn test_config_from_env() {
993 env::set_var("API_PORT", "9090");
994 env::set_var("API_HOST", "0.0.0.0");
995 env::set_var("LOG_LEVEL", "debug");
996
997 let config = Config::from_env().unwrap();
998 assert_eq!(config.api.port, 9090);
999 assert_eq!(config.api.host, "0.0.0.0");
1000 assert_eq!(config.logging.level, "debug");
1001
1002 env::remove_var("API_PORT");
1004 env::remove_var("API_HOST");
1005 env::remove_var("LOG_LEVEL");
1006 }
1007
1008 #[test]
1009 fn test_invalid_port() {
1010 let mut config = Config::default();
1011 config.api.port = 0;
1012 assert!(config.validate().is_err());
1013 }
1014
1015 #[test]
1016 fn test_invalid_log_level() {
1017 let mut config = Config::default();
1018 config.logging.level = "invalid".to_string();
1019 assert!(config.validate().is_err());
1020 }
1021
1022 #[test]
1024 fn test_slm_default_config() {
1025 let slm = Slm::default();
1026 assert!(!slm.enabled);
1027 assert_eq!(slm.default_sandbox_profile, "secure");
1028 assert!(slm.sandbox_profiles.contains_key("secure"));
1029 assert!(slm.sandbox_profiles.contains_key("standard"));
1030 assert!(slm.validate().is_ok());
1031 }
1032
1033 #[test]
1034 fn test_slm_validation_invalid_default_profile() {
1035 let slm = Slm {
1036 default_sandbox_profile: "nonexistent".to_string(),
1037 ..Default::default()
1038 };
1039
1040 let result = slm.validate();
1041 assert!(result.is_err());
1042 if let Err(ConfigError::InvalidValue { key, reason }) = result {
1043 assert_eq!(key, "slm.default_sandbox_profile");
1044 assert!(reason.contains("nonexistent"));
1045 }
1046 }
1047
1048 #[test]
1049 fn test_slm_validation_duplicate_model_ids() {
1050 let model1 = Model {
1051 id: "duplicate".to_string(),
1052 name: "Model 1".to_string(),
1053 provider: ModelProvider::LocalFile {
1054 file_path: PathBuf::from("/tmp/model1.gguf"),
1055 },
1056 capabilities: vec![ModelCapability::TextGeneration],
1057 resource_requirements: ModelResourceRequirements {
1058 min_memory_mb: 512,
1059 preferred_cpu_cores: 1.0,
1060 gpu_requirements: None,
1061 },
1062 };
1063
1064 let model2 = Model {
1065 id: "duplicate".to_string(), name: "Model 2".to_string(),
1067 provider: ModelProvider::LocalFile {
1068 file_path: PathBuf::from("/tmp/model2.gguf"),
1069 },
1070 capabilities: vec![ModelCapability::CodeGeneration],
1071 resource_requirements: ModelResourceRequirements {
1072 min_memory_mb: 1024,
1073 preferred_cpu_cores: 2.0,
1074 gpu_requirements: None,
1075 },
1076 };
1077
1078 let mut slm = Slm::default();
1079 slm.model_allow_lists.global_models = vec![model1, model2];
1080
1081 let result = slm.validate();
1082 assert!(result.is_err());
1083 if let Err(ConfigError::InvalidValue { key, reason }) = result {
1084 assert_eq!(key, "slm.model_allow_lists.global_models");
1085 assert!(reason.contains("Duplicate model ID: duplicate"));
1086 }
1087 }
1088
1089 #[test]
1090 fn test_slm_validation_invalid_agent_model_mapping() {
1091 let model = Model {
1092 id: "test_model".to_string(),
1093 name: "Test Model".to_string(),
1094 provider: ModelProvider::LocalFile {
1095 file_path: PathBuf::from("/tmp/test.gguf"),
1096 },
1097 capabilities: vec![ModelCapability::TextGeneration],
1098 resource_requirements: ModelResourceRequirements {
1099 min_memory_mb: 512,
1100 preferred_cpu_cores: 1.0,
1101 gpu_requirements: None,
1102 },
1103 };
1104
1105 let mut slm = Slm::default();
1106 slm.model_allow_lists.global_models = vec![model];
1107
1108 let mut agent_model_maps = HashMap::new();
1109 agent_model_maps.insert(
1110 "test_agent".to_string(),
1111 vec!["nonexistent_model".to_string()],
1112 );
1113 slm.model_allow_lists.agent_model_maps = agent_model_maps;
1114
1115 let result = slm.validate();
1116 assert!(result.is_err());
1117 if let Err(ConfigError::InvalidValue { key, reason }) = result {
1118 assert_eq!(key, "slm.model_allow_lists.agent_model_maps.test_agent");
1119 assert!(reason.contains("Model ID 'nonexistent_model' not found"));
1120 }
1121 }
1122
1123 #[test]
1124 fn test_slm_get_allowed_models_with_agent_mapping() {
1125 let model1 = Model {
1126 id: "model1".to_string(),
1127 name: "Model 1".to_string(),
1128 provider: ModelProvider::LocalFile {
1129 file_path: PathBuf::from("/tmp/model1.gguf"),
1130 },
1131 capabilities: vec![ModelCapability::TextGeneration],
1132 resource_requirements: ModelResourceRequirements {
1133 min_memory_mb: 512,
1134 preferred_cpu_cores: 1.0,
1135 gpu_requirements: None,
1136 },
1137 };
1138
1139 let model2 = Model {
1140 id: "model2".to_string(),
1141 name: "Model 2".to_string(),
1142 provider: ModelProvider::LocalFile {
1143 file_path: PathBuf::from("/tmp/model2.gguf"),
1144 },
1145 capabilities: vec![ModelCapability::CodeGeneration],
1146 resource_requirements: ModelResourceRequirements {
1147 min_memory_mb: 1024,
1148 preferred_cpu_cores: 2.0,
1149 gpu_requirements: None,
1150 },
1151 };
1152
1153 let mut slm = Slm::default();
1154 slm.model_allow_lists.global_models = vec![model1, model2];
1155
1156 let mut agent_model_maps = HashMap::new();
1157 agent_model_maps.insert("agent1".to_string(), vec!["model1".to_string()]);
1158 slm.model_allow_lists.agent_model_maps = agent_model_maps;
1159
1160 let allowed_models = slm.get_allowed_models("agent1");
1162 assert_eq!(allowed_models.len(), 1);
1163 assert_eq!(allowed_models[0].id, "model1");
1164
1165 let allowed_models = slm.get_allowed_models("agent2");
1167 assert_eq!(allowed_models.len(), 2);
1168 }
1169
1170 #[test]
1172 fn test_sandbox_profile_secure_default() {
1173 let profile = SandboxProfile::secure_default();
1174 assert_eq!(profile.resources.max_memory_mb, 512);
1175 assert_eq!(profile.resources.max_cpu_cores, 1.0);
1176 assert!(matches!(profile.resources.gpu_access, GpuAccess::None));
1177 assert!(matches!(
1178 profile.network.access_mode,
1179 NetworkAccessMode::None
1180 ));
1181 assert!(profile.security.strict_syscall_filtering);
1182 assert!(profile.validate().is_ok());
1183 }
1184
1185 #[test]
1186 fn test_sandbox_profile_standard_default() {
1187 let profile = SandboxProfile::standard_default();
1188 assert_eq!(profile.resources.max_memory_mb, 1024);
1189 assert_eq!(profile.resources.max_cpu_cores, 2.0);
1190 assert!(matches!(
1191 profile.resources.gpu_access,
1192 GpuAccess::Shared { .. }
1193 ));
1194 assert!(matches!(
1195 profile.network.access_mode,
1196 NetworkAccessMode::Restricted
1197 ));
1198 assert!(!profile.security.strict_syscall_filtering);
1199 assert!(profile.validate().is_ok());
1200 }
1201
1202 #[test]
1203 fn test_sandbox_profile_validation_zero_memory() {
1204 let mut profile = SandboxProfile::secure_default();
1205 profile.resources.max_memory_mb = 0;
1206
1207 let result = profile.validate();
1208 assert!(result.is_err());
1209 assert!(result
1210 .unwrap_err()
1211 .to_string()
1212 .contains("max_memory_mb must be > 0"));
1213 }
1214
1215 #[test]
1216 fn test_sandbox_profile_validation_zero_cpu() {
1217 let mut profile = SandboxProfile::secure_default();
1218 profile.resources.max_cpu_cores = 0.0;
1219
1220 let result = profile.validate();
1221 assert!(result.is_err());
1222 assert!(result
1223 .unwrap_err()
1224 .to_string()
1225 .contains("max_cpu_cores must be > 0"));
1226 }
1227
1228 #[test]
1229 fn test_sandbox_profile_validation_empty_read_path() {
1230 let mut profile = SandboxProfile::secure_default();
1231 profile.filesystem.read_paths.push("".to_string());
1232
1233 let result = profile.validate();
1234 assert!(result.is_err());
1235 assert!(result
1236 .unwrap_err()
1237 .to_string()
1238 .contains("read_paths cannot contain empty strings"));
1239 }
1240
1241 #[test]
1242 fn test_sandbox_profile_validation_zero_execution_time() {
1243 let mut profile = SandboxProfile::secure_default();
1244 profile.process_limits.max_execution_time_seconds = 0;
1245
1246 let result = profile.validate();
1247 assert!(result.is_err());
1248 assert!(result
1249 .unwrap_err()
1250 .to_string()
1251 .contains("max_execution_time_seconds must be > 0"));
1252 }
1253
1254 #[test]
1256 fn test_model_provider_variants() {
1257 let huggingface_model = Model {
1258 id: "hf_model".to_string(),
1259 name: "HuggingFace Model".to_string(),
1260 provider: ModelProvider::HuggingFace {
1261 model_path: "microsoft/DialoGPT-medium".to_string(),
1262 },
1263 capabilities: vec![ModelCapability::TextGeneration],
1264 resource_requirements: ModelResourceRequirements {
1265 min_memory_mb: 512,
1266 preferred_cpu_cores: 1.0,
1267 gpu_requirements: None,
1268 },
1269 };
1270
1271 let openai_model = Model {
1272 id: "openai_model".to_string(),
1273 name: "OpenAI Model".to_string(),
1274 provider: ModelProvider::OpenAI {
1275 model_name: "gpt-3.5-turbo".to_string(),
1276 },
1277 capabilities: vec![ModelCapability::TextGeneration, ModelCapability::Reasoning],
1278 resource_requirements: ModelResourceRequirements {
1279 min_memory_mb: 0, preferred_cpu_cores: 0.0,
1281 gpu_requirements: None,
1282 },
1283 };
1284
1285 assert_eq!(huggingface_model.id, "hf_model");
1286 assert_eq!(openai_model.id, "openai_model");
1287 }
1288
1289 #[test]
1290 fn test_model_capabilities() {
1291 let all_capabilities = vec![
1292 ModelCapability::TextGeneration,
1293 ModelCapability::CodeGeneration,
1294 ModelCapability::Reasoning,
1295 ModelCapability::ToolUse,
1296 ModelCapability::FunctionCalling,
1297 ModelCapability::Embeddings,
1298 ];
1299
1300 let model = Model {
1301 id: "full_model".to_string(),
1302 name: "Full Capability Model".to_string(),
1303 provider: ModelProvider::LocalFile {
1304 file_path: PathBuf::from("/tmp/full.gguf"),
1305 },
1306 capabilities: all_capabilities.clone(),
1307 resource_requirements: ModelResourceRequirements {
1308 min_memory_mb: 2048,
1309 preferred_cpu_cores: 4.0,
1310 gpu_requirements: Some(GpuRequirements {
1311 min_vram_mb: 8192,
1312 compute_capability: "7.5".to_string(),
1313 }),
1314 },
1315 };
1316
1317 assert_eq!(model.capabilities.len(), 6);
1318 for capability in &all_capabilities {
1319 assert!(model.capabilities.contains(capability));
1320 }
1321 }
1322
1323 #[test]
1325 fn test_config_validation_vector_dimension() {
1326 let mut config = Config::default();
1327 config.database.vector_dimension = 0;
1328
1329 let result = config.validate();
1330 assert!(result.is_err());
1331 if let Err(ConfigError::InvalidValue { key, reason }) = result {
1332 assert_eq!(key, "database.vector_dimension");
1333 assert!(reason.contains("Vector dimension must be > 0"));
1334 }
1335 }
1336
1337 #[test]
1338 fn test_config_validation_with_slm() {
1339 let mut config = Config::default();
1340 let slm = Slm {
1341 enabled: true,
1342 default_sandbox_profile: "invalid".to_string(), ..Default::default()
1344 };
1345 config.slm = Some(slm);
1346
1347 let result = config.validate();
1348 assert!(result.is_err());
1349 }
1350
1351 #[test]
1352 fn test_config_secret_key_retrieval() {
1353 env::set_var("TEST_SECRET_KEY", "test_secret_123");
1355
1356 let mut config = Config::default();
1357 config.security.key_provider = KeyProvider::Environment {
1358 var_name: "TEST_SECRET_KEY".to_string(),
1359 };
1360
1361 let key = config.get_secret_key();
1362 assert!(key.is_ok());
1363 assert_eq!(key.unwrap(), "test_secret_123");
1364
1365 env::remove_var("TEST_SECRET_KEY");
1366 }
1367
1368 #[test]
1369 fn test_config_secret_key_missing() {
1370 let mut config = Config::default();
1371 config.security.key_provider = KeyProvider::Environment {
1372 var_name: "NONEXISTENT_KEY".to_string(),
1373 };
1374
1375 let result = config.get_secret_key();
1376 assert!(result.is_err());
1377 if let Err(ConfigError::MissingRequired { key }) = result {
1378 assert_eq!(key, "NONEXISTENT_KEY");
1379 }
1380 }
1381
1382 #[test]
1383 fn test_network_policy_configurations() {
1384 let destination = NetworkDestination {
1386 host: "api.openai.com".to_string(),
1387 port: Some(443),
1388 protocol: Some(NetworkProtocol::HTTPS),
1389 };
1390
1391 let network_policy = NetworkPolicy {
1392 access_mode: NetworkAccessMode::Restricted,
1393 allowed_destinations: vec![destination],
1394 max_bandwidth_mbps: Some(100),
1395 };
1396
1397 let profile = SandboxProfile {
1398 resources: ResourceConstraints {
1399 max_memory_mb: 1024,
1400 max_cpu_cores: 2.0,
1401 max_disk_mb: 500,
1402 gpu_access: GpuAccess::None,
1403 max_io_bandwidth_mbps: Some(50),
1404 },
1405 filesystem: FilesystemControls {
1406 read_paths: vec!["/tmp/*".to_string()],
1407 write_paths: vec!["/tmp/output/*".to_string()],
1408 denied_paths: vec!["/etc/*".to_string()],
1409 allow_temp_files: true,
1410 max_file_size_mb: 10,
1411 },
1412 process_limits: ProcessLimits {
1413 max_child_processes: 2,
1414 max_execution_time_seconds: 300,
1415 allowed_syscalls: vec!["read".to_string(), "write".to_string()],
1416 process_priority: 0,
1417 },
1418 network: network_policy,
1419 security: SecuritySettings {
1420 strict_syscall_filtering: true,
1421 disable_debugging: true,
1422 enable_audit_logging: true,
1423 require_encryption: false,
1424 },
1425 };
1426
1427 assert!(profile.validate().is_ok());
1428 assert!(matches!(
1429 profile.network.access_mode,
1430 NetworkAccessMode::Restricted
1431 ));
1432 assert_eq!(profile.network.allowed_destinations.len(), 1);
1433 assert_eq!(
1434 profile.network.allowed_destinations[0].host,
1435 "api.openai.com"
1436 );
1437 }
1438
1439 #[test]
1440 fn test_gpu_requirements_configurations() {
1441 let gpu_requirements = GpuRequirements {
1442 min_vram_mb: 4096,
1443 compute_capability: "8.0".to_string(),
1444 };
1445
1446 let model = Model {
1447 id: "gpu_model".to_string(),
1448 name: "GPU Model".to_string(),
1449 provider: ModelProvider::LocalFile {
1450 file_path: PathBuf::from("/tmp/gpu.gguf"),
1451 },
1452 capabilities: vec![ModelCapability::TextGeneration],
1453 resource_requirements: ModelResourceRequirements {
1454 min_memory_mb: 1024,
1455 preferred_cpu_cores: 2.0,
1456 gpu_requirements: Some(gpu_requirements),
1457 },
1458 };
1459
1460 assert!(model.resource_requirements.gpu_requirements.is_some());
1461 let gpu_req = model.resource_requirements.gpu_requirements.unwrap();
1462 assert_eq!(gpu_req.min_vram_mb, 4096);
1463 assert_eq!(gpu_req.compute_capability, "8.0");
1464 }
1465
1466 #[test]
1467 #[serial]
1468 fn test_config_from_env_invalid_port() {
1469 env::set_var("API_PORT", "invalid");
1470
1471 let result = Config::from_env();
1472 assert!(result.is_err());
1473 if let Err(ConfigError::InvalidValue { key, reason }) = result {
1474 assert_eq!(key, "API_PORT");
1475 assert!(reason.contains("Invalid port number"));
1476 }
1477
1478 env::remove_var("API_PORT");
1479 }
1480
1481 #[test]
1482 fn test_api_auth_token_missing() {
1483 let config = Config::default();
1484
1485 let result = config.get_api_auth_token();
1486 assert!(result.is_err());
1487 if let Err(ConfigError::MissingRequired { key }) = result {
1488 assert_eq!(key, "API_AUTH_TOKEN");
1489 }
1490 }
1491
1492 #[test]
1493 fn test_database_url_missing() {
1494 let config = Config::default();
1495
1496 let result = config.get_database_url();
1497 assert!(result.is_err());
1498 if let Err(ConfigError::MissingRequired { key }) = result {
1499 assert_eq!(key, "DATABASE_URL");
1500 }
1501 }
1502
1503 #[test]
1508 fn test_validate_auth_token_valid_strong_token() {
1509 let tokens = vec![
1510 "MySecureToken123",
1511 "a1b2c3d4e5f6g7h8",
1512 "production_token_2024",
1513 "Bearer_abc123def456",
1514 ];
1515
1516 for token in tokens {
1517 let result = Config::validate_auth_token(token);
1518 assert!(result.is_ok(), "Token '{}' should be valid", token);
1519 assert_eq!(result.unwrap(), token.trim());
1520 }
1521 }
1522
1523 #[test]
1524 fn test_validate_auth_token_empty() {
1525 assert!(Config::validate_auth_token("").is_err());
1526 assert!(Config::validate_auth_token(" ").is_err());
1527 assert!(Config::validate_auth_token("\t\n").is_err());
1528 }
1529
1530 #[test]
1531 fn test_validate_auth_token_too_short() {
1532 let short_tokens = vec!["abc", "12345", "short", "1234567"];
1533
1534 for token in short_tokens {
1535 let result = Config::validate_auth_token(token);
1536 assert!(
1537 result.is_err(),
1538 "Token '{}' should be rejected (too short)",
1539 token
1540 );
1541
1542 if let Err(ConfigError::InvalidValue { reason, .. }) = result {
1543 assert!(reason.contains("at least 8 characters"));
1544 }
1545 }
1546 }
1547
1548 #[test]
1549 fn test_validate_auth_token_weak_defaults() {
1550 let weak_tokens = vec![
1551 "dev", "test", "password", "secret", "token", "admin", "root", "default", "changeme",
1552 "12345678",
1553 ];
1554
1555 for token in weak_tokens {
1556 let result = Config::validate_auth_token(token);
1557 assert!(result.is_err(), "Weak token '{}' should be rejected", token);
1558
1559 if let Err(ConfigError::InvalidValue { reason, .. }) = result {
1560 assert!(
1561 reason.contains("weak/default token"),
1562 "Expected 'weak/default token' message for '{}', got: {}",
1563 token,
1564 reason
1565 );
1566 }
1567 }
1568 }
1569
1570 #[test]
1571 fn test_validate_auth_token_case_insensitive_weak_check() {
1572 let tokens = vec!["DEV", "Test", "PASSWORD", "Admin", "ROOT"];
1573
1574 for token in tokens {
1575 let result = Config::validate_auth_token(token);
1576 assert!(
1577 result.is_err(),
1578 "Token '{}' should be rejected (case-insensitive)",
1579 token
1580 );
1581 }
1582 }
1583
1584 #[test]
1585 fn test_validate_auth_token_with_spaces() {
1586 let result = Config::validate_auth_token("my token here");
1588 assert!(result.is_err());
1589
1590 if let Err(ConfigError::InvalidValue { reason, .. }) = result {
1591 assert!(reason.contains("should not contain spaces"));
1592 }
1593 }
1594
1595 #[test]
1596 fn test_validate_auth_token_trims_whitespace() {
1597 let result = Config::validate_auth_token(" validtoken123 ");
1598 assert!(result.is_ok());
1599 assert_eq!(result.unwrap(), "validtoken123");
1600 }
1601
1602 #[test]
1603 fn test_validate_auth_token_minimum_length_boundary() {
1604 assert!(Config::validate_auth_token("12345678").is_err()); assert!(Config::validate_auth_token("abcdefgh").is_ok());
1607
1608 assert!(Config::validate_auth_token("abcdefg").is_err());
1610 }
1611
1612 #[test]
1613 #[serial]
1614 fn test_validate_auth_token_integration_with_from_env() {
1615 env::set_var("API_AUTH_TOKEN", "dev");
1619 let config = Config::from_env().unwrap();
1620 assert!(config.api.auth_token.is_none());
1622 env::remove_var("API_AUTH_TOKEN");
1623
1624 env::set_var("API_AUTH_TOKEN", "strong_secure_token_12345");
1626 let config = Config::from_env().unwrap();
1627 assert!(config.api.auth_token.is_some());
1628 assert_eq!(
1631 config.api.auth_token.clone().unwrap(),
1632 "strong_secure_token_12345"
1633 );
1634 env::remove_var("API_AUTH_TOKEN");
1635 }
1636
1637 #[test]
1638 fn test_validate_auth_token_special_characters_allowed() {
1639 let tokens = vec![
1640 "token-with-dashes",
1641 "token_with_underscores",
1642 "token.with.dots",
1643 "token@with#special$chars",
1644 ];
1645
1646 for token in tokens {
1647 let result = Config::validate_auth_token(token);
1648 assert!(
1649 result.is_ok(),
1650 "Token '{}' with special chars should be valid",
1651 token
1652 );
1653 }
1654 }
1655}