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}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct ApiConfig {
57 pub port: u16,
59 pub host: String,
61 #[serde(skip_serializing)]
63 pub auth_token: Option<String>,
64 pub timeout_seconds: u64,
66 pub max_body_size: usize,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct DatabaseConfig {
73 #[serde(skip_serializing)]
75 pub url: Option<String>,
76 #[serde(skip_serializing)]
78 pub redis_url: Option<String>,
79 pub qdrant_url: String,
81 pub qdrant_collection: String,
83 pub vector_dimension: usize,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct LoggingConfig {
90 pub level: String,
92 pub format: LogFormat,
94 pub structured: bool,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
100pub enum LogFormat {
101 Json,
102 Pretty,
103 Compact,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct SecurityConfig {
109 pub key_provider: KeyProvider,
111 pub enable_compression: bool,
113 pub enable_backups: bool,
114 pub enable_safety_checks: bool,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
119pub enum KeyProvider {
120 Environment { var_name: String },
121 File { path: PathBuf },
122 Keychain { service: String, account: String },
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct NativeExecutionConfig {
129 pub enabled: bool,
131 pub default_executable: String,
133 pub working_directory: PathBuf,
135 pub enforce_resource_limits: bool,
137 pub max_memory_mb: Option<u64>,
139 pub max_cpu_seconds: Option<u64>,
141 pub max_execution_time_seconds: u64,
143 pub allowed_executables: Vec<String>,
145}
146
147impl Default for NativeExecutionConfig {
148 fn default() -> Self {
149 Self {
150 enabled: false, default_executable: "bash".to_string(),
152 working_directory: PathBuf::from("/tmp/symbiont-native"),
153 enforce_resource_limits: true,
154 max_memory_mb: Some(2048),
155 max_cpu_seconds: Some(300),
156 max_execution_time_seconds: 300,
157 allowed_executables: vec![
158 "bash".to_string(),
159 "sh".to_string(),
160 "python3".to_string(),
161 "python".to_string(),
162 "node".to_string(),
163 ],
164 }
165 }
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct StorageConfig {
171 pub context_path: PathBuf,
173 pub git_clone_path: PathBuf,
175 pub backup_path: PathBuf,
177 pub max_context_size_mb: u64,
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct Slm {
184 pub enabled: bool,
186 pub model_allow_lists: ModelAllowListConfig,
188 pub sandbox_profiles: HashMap<String, SandboxProfile>,
190 pub default_sandbox_profile: String,
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize, Default)]
196pub struct ModelAllowListConfig {
197 pub global_models: Vec<Model>,
199 pub agent_model_maps: HashMap<String, Vec<String>>,
201 pub allow_runtime_overrides: bool,
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct Model {
208 pub id: String,
210 pub name: String,
212 pub provider: ModelProvider,
214 pub capabilities: Vec<ModelCapability>,
216 pub resource_requirements: ModelResourceRequirements,
218}
219
220#[derive(Debug, Clone, Serialize, Deserialize)]
222pub enum ModelProvider {
223 HuggingFace { model_path: String },
224 LocalFile { file_path: PathBuf },
225 OpenAI { model_name: String },
226 Anthropic { model_name: String },
227 Custom { endpoint_url: String },
228}
229
230#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
232pub enum ModelCapability {
233 TextGeneration,
234 CodeGeneration,
235 Reasoning,
236 ToolUse,
237 FunctionCalling,
238 Embeddings,
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct ModelResourceRequirements {
244 pub min_memory_mb: u64,
246 pub preferred_cpu_cores: f32,
248 pub gpu_requirements: Option<GpuRequirements>,
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize)]
254pub struct GpuRequirements {
255 pub min_vram_mb: u64,
257 pub compute_capability: String,
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize)]
263pub struct SandboxProfile {
264 pub resources: ResourceConstraints,
266 pub filesystem: FilesystemControls,
268 pub process_limits: ProcessLimits,
270 pub network: NetworkPolicy,
272 pub security: SecuritySettings,
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize)]
278pub struct ResourceConstraints {
279 pub max_memory_mb: u64,
281 pub max_cpu_cores: f32,
283 pub max_disk_mb: u64,
285 pub gpu_access: GpuAccess,
287 pub max_io_bandwidth_mbps: Option<u64>,
289}
290
291#[derive(Debug, Clone, Serialize, Deserialize)]
293pub struct FilesystemControls {
294 pub read_paths: Vec<String>,
296 pub write_paths: Vec<String>,
298 pub denied_paths: Vec<String>,
300 pub allow_temp_files: bool,
302 pub max_file_size_mb: u64,
304}
305
306#[derive(Debug, Clone, Serialize, Deserialize)]
308pub struct ProcessLimits {
309 pub max_child_processes: u32,
311 pub max_execution_time_seconds: u64,
313 pub allowed_syscalls: Vec<String>,
315 pub process_priority: i8,
317}
318
319#[derive(Debug, Clone, Serialize, Deserialize)]
321pub struct NetworkPolicy {
322 pub access_mode: NetworkAccessMode,
324 pub allowed_destinations: Vec<NetworkDestination>,
326 pub max_bandwidth_mbps: Option<u64>,
328}
329
330#[derive(Debug, Clone, Serialize, Deserialize)]
332pub enum NetworkAccessMode {
333 None,
335 Restricted,
337 Full,
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize)]
343pub struct NetworkDestination {
344 pub host: String,
346 pub port: Option<u16>,
348 pub protocol: Option<NetworkProtocol>,
350}
351
352#[derive(Debug, Clone, Serialize, Deserialize)]
354pub enum NetworkProtocol {
355 TCP,
356 UDP,
357 HTTP,
358 HTTPS,
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize)]
363pub enum GpuAccess {
364 None,
366 Shared { max_memory_mb: u64 },
368 Exclusive,
370}
371
372#[derive(Debug, Clone, Serialize, Deserialize)]
374pub struct SecuritySettings {
375 pub strict_syscall_filtering: bool,
377 pub disable_debugging: bool,
379 pub enable_audit_logging: bool,
381 pub require_encryption: bool,
383}
384
385impl Default for ApiConfig {
386 fn default() -> Self {
387 Self {
388 port: 8080,
389 host: "127.0.0.1".to_string(),
390 auth_token: None,
391 timeout_seconds: 60,
392 max_body_size: 16 * 1024 * 1024, }
394 }
395}
396
397impl Default for DatabaseConfig {
398 fn default() -> Self {
399 Self {
400 url: None,
401 redis_url: None,
402 qdrant_url: "http://localhost:6333".to_string(),
403 qdrant_collection: "agent_knowledge".to_string(),
404 vector_dimension: 1536,
405 }
406 }
407}
408
409impl Default for LoggingConfig {
410 fn default() -> Self {
411 Self {
412 level: "info".to_string(),
413 format: LogFormat::Pretty,
414 structured: false,
415 }
416 }
417}
418
419impl Default for SecurityConfig {
420 fn default() -> Self {
421 Self {
422 key_provider: KeyProvider::Environment {
423 var_name: "SYMBIONT_SECRET_KEY".to_string(),
424 },
425 enable_compression: true,
426 enable_backups: true,
427 enable_safety_checks: true,
428 }
429 }
430}
431
432impl Default for StorageConfig {
433 fn default() -> Self {
434 Self {
435 context_path: PathBuf::from("./agent_storage"),
436 git_clone_path: PathBuf::from("./temp_repos"),
437 backup_path: PathBuf::from("./backups"),
438 max_context_size_mb: 100,
439 }
440 }
441}
442
443impl Default for Slm {
444 fn default() -> Self {
445 let mut profiles = HashMap::new();
446 profiles.insert("secure".to_string(), SandboxProfile::secure_default());
447 profiles.insert("standard".to_string(), SandboxProfile::standard_default());
448
449 Self {
450 enabled: false,
451 model_allow_lists: ModelAllowListConfig::default(),
452 sandbox_profiles: profiles,
453 default_sandbox_profile: "secure".to_string(),
454 }
455 }
456}
457
458impl SandboxProfile {
459 pub fn secure_default() -> Self {
461 Self {
462 resources: ResourceConstraints {
463 max_memory_mb: 512,
464 max_cpu_cores: 1.0,
465 max_disk_mb: 100,
466 gpu_access: GpuAccess::None,
467 max_io_bandwidth_mbps: Some(10),
468 },
469 filesystem: FilesystemControls {
470 read_paths: vec!["/tmp/sandbox/*".to_string()],
471 write_paths: vec!["/tmp/sandbox/output/*".to_string()],
472 denied_paths: vec!["/etc/*".to_string(), "/proc/*".to_string()],
473 allow_temp_files: true,
474 max_file_size_mb: 10,
475 },
476 process_limits: ProcessLimits {
477 max_child_processes: 0,
478 max_execution_time_seconds: 300,
479 allowed_syscalls: vec!["read".to_string(), "write".to_string(), "open".to_string()],
480 process_priority: 19,
481 },
482 network: NetworkPolicy {
483 access_mode: NetworkAccessMode::None,
484 allowed_destinations: vec![],
485 max_bandwidth_mbps: None,
486 },
487 security: SecuritySettings {
488 strict_syscall_filtering: true,
489 disable_debugging: true,
490 enable_audit_logging: true,
491 require_encryption: true,
492 },
493 }
494 }
495
496 pub fn standard_default() -> Self {
498 Self {
499 resources: ResourceConstraints {
500 max_memory_mb: 1024,
501 max_cpu_cores: 2.0,
502 max_disk_mb: 500,
503 gpu_access: GpuAccess::Shared {
504 max_memory_mb: 1024,
505 },
506 max_io_bandwidth_mbps: Some(50),
507 },
508 filesystem: FilesystemControls {
509 read_paths: vec!["/tmp/*".to_string(), "/home/sandbox/*".to_string()],
510 write_paths: vec!["/tmp/*".to_string(), "/home/sandbox/*".to_string()],
511 denied_paths: vec!["/etc/passwd".to_string(), "/etc/shadow".to_string()],
512 allow_temp_files: true,
513 max_file_size_mb: 100,
514 },
515 process_limits: ProcessLimits {
516 max_child_processes: 5,
517 max_execution_time_seconds: 600,
518 allowed_syscalls: vec![], process_priority: 0,
520 },
521 network: NetworkPolicy {
522 access_mode: NetworkAccessMode::Restricted,
523 allowed_destinations: vec![NetworkDestination {
524 host: "api.openai.com".to_string(),
525 port: Some(443),
526 protocol: Some(NetworkProtocol::HTTPS),
527 }],
528 max_bandwidth_mbps: Some(100),
529 },
530 security: SecuritySettings {
531 strict_syscall_filtering: false,
532 disable_debugging: false,
533 enable_audit_logging: true,
534 require_encryption: false,
535 },
536 }
537 }
538
539 pub fn validate(&self) -> Result<(), Box<dyn std::error::Error>> {
541 if self.resources.max_memory_mb == 0 {
543 return Err("max_memory_mb must be > 0".into());
544 }
545 if self.resources.max_cpu_cores <= 0.0 {
546 return Err("max_cpu_cores must be > 0".into());
547 }
548
549 for path in &self.filesystem.read_paths {
551 if path.is_empty() {
552 return Err("read_paths cannot contain empty strings".into());
553 }
554 }
555
556 if self.process_limits.max_execution_time_seconds == 0 {
558 return Err("max_execution_time_seconds must be > 0".into());
559 }
560
561 Ok(())
562 }
563}
564
565impl Slm {
566 pub fn validate(&self) -> Result<(), ConfigError> {
568 if !self
570 .sandbox_profiles
571 .contains_key(&self.default_sandbox_profile)
572 {
573 return Err(ConfigError::InvalidValue {
574 key: "slm.default_sandbox_profile".to_string(),
575 reason: format!(
576 "Profile '{}' not found in sandbox_profiles",
577 self.default_sandbox_profile
578 ),
579 });
580 }
581
582 let mut model_ids = std::collections::HashSet::new();
584 for model in &self.model_allow_lists.global_models {
585 if !model_ids.insert(&model.id) {
586 return Err(ConfigError::InvalidValue {
587 key: "slm.model_allow_lists.global_models".to_string(),
588 reason: format!("Duplicate model ID: {}", model.id),
589 });
590 }
591 }
592
593 for (agent_id, model_ids) in &self.model_allow_lists.agent_model_maps {
595 for model_id in model_ids {
596 if !self
597 .model_allow_lists
598 .global_models
599 .iter()
600 .any(|m| &m.id == model_id)
601 {
602 return Err(ConfigError::InvalidValue {
603 key: format!("slm.model_allow_lists.agent_model_maps.{}", agent_id),
604 reason: format!("Model ID '{}' not found in global_models", model_id),
605 });
606 }
607 }
608 }
609
610 for (profile_name, profile) in &self.sandbox_profiles {
612 profile.validate().map_err(|e| ConfigError::InvalidValue {
613 key: format!("slm.sandbox_profiles.{}", profile_name),
614 reason: e.to_string(),
615 })?;
616 }
617
618 Ok(())
619 }
620
621 pub fn get_allowed_models(&self, agent_id: &str) -> Vec<&Model> {
623 if let Some(model_ids) = self.model_allow_lists.agent_model_maps.get(agent_id) {
625 self.model_allow_lists
626 .global_models
627 .iter()
628 .filter(|model| model_ids.contains(&model.id))
629 .collect()
630 } else {
631 self.model_allow_lists.global_models.iter().collect()
633 }
634 }
635}
636
637impl Config {
638 pub fn from_env() -> Result<Self, ConfigError> {
640 let mut config = Self::default();
641
642 if let Ok(port) = env::var("API_PORT") {
644 config.api.port = port.parse().map_err(|_| ConfigError::InvalidValue {
645 key: "API_PORT".to_string(),
646 reason: "Invalid port number".to_string(),
647 })?;
648 }
649
650 if let Ok(host) = env::var("API_HOST") {
651 config.api.host = host;
652 }
653
654 if let Ok(token) = env::var("API_AUTH_TOKEN") {
656 match Self::validate_auth_token(&token) {
657 Ok(validated_token) => {
658 config.api.auth_token = Some(validated_token);
659 }
660 Err(e) => {
661 tracing::error!("Invalid API_AUTH_TOKEN: {}", e);
662 eprintln!("⚠️ ERROR: Invalid API_AUTH_TOKEN: {}", e);
663 }
665 }
666 }
667
668 if let Ok(db_url) = env::var("DATABASE_URL") {
670 config.database.url = Some(db_url);
671 }
672
673 if let Ok(redis_url) = env::var("REDIS_URL") {
674 config.database.redis_url = Some(redis_url);
675 }
676
677 if let Ok(qdrant_url) = env::var("QDRANT_URL") {
678 config.database.qdrant_url = qdrant_url;
679 }
680
681 if let Ok(log_level) = env::var("LOG_LEVEL") {
683 config.logging.level = log_level;
684 }
685
686 if let Ok(key_var) = env::var("SYMBIONT_SECRET_KEY_VAR") {
688 config.security.key_provider = KeyProvider::Environment { var_name: key_var };
689 }
690
691 if let Ok(context_path) = env::var("CONTEXT_STORAGE_PATH") {
693 config.storage.context_path = PathBuf::from(context_path);
694 }
695
696 if let Ok(git_path) = env::var("GIT_CLONE_BASE_PATH") {
697 config.storage.git_clone_path = PathBuf::from(git_path);
698 }
699
700 if let Ok(backup_path) = env::var("BACKUP_DIRECTORY") {
701 config.storage.backup_path = PathBuf::from(backup_path);
702 }
703
704 Ok(config)
705 }
706
707 pub fn from_file<P: AsRef<std::path::Path>>(path: P) -> Result<Self, ConfigError> {
709 let content = std::fs::read_to_string(path).map_err(|e| ConfigError::IoError {
710 message: e.to_string(),
711 })?;
712
713 let config: Self = toml::from_str(&content).map_err(|e| ConfigError::ParseError {
714 message: e.to_string(),
715 })?;
716
717 Ok(config)
718 }
719
720 pub fn validate(&self) -> Result<(), ConfigError> {
722 if self.api.port == 0 {
724 return Err(ConfigError::InvalidValue {
725 key: "api.port".to_string(),
726 reason: "Port cannot be 0".to_string(),
727 });
728 }
729
730 let valid_levels = ["error", "warn", "info", "debug", "trace"];
732 if !valid_levels.contains(&self.logging.level.as_str()) {
733 return Err(ConfigError::InvalidValue {
734 key: "logging.level".to_string(),
735 reason: format!("Must be one of: {}", valid_levels.join(", ")),
736 });
737 }
738
739 if self.database.vector_dimension == 0 {
741 return Err(ConfigError::InvalidValue {
742 key: "database.vector_dimension".to_string(),
743 reason: "Vector dimension must be > 0".to_string(),
744 });
745 }
746
747 if let Some(slm) = &self.slm {
749 if slm.enabled {
750 slm.validate()?;
751 }
752 }
753
754 Ok(())
755 }
756
757 pub fn get_api_auth_token(&self) -> Result<String, ConfigError> {
759 match &self.api.auth_token {
760 Some(token) => Ok(token.clone()),
761 None => Err(ConfigError::MissingRequired {
762 key: "API_AUTH_TOKEN".to_string(),
763 }),
764 }
765 }
766
767 pub fn get_database_url(&self) -> Result<String, ConfigError> {
769 match &self.database.url {
770 Some(url) => Ok(url.clone()),
771 None => Err(ConfigError::MissingRequired {
772 key: "DATABASE_URL".to_string(),
773 }),
774 }
775 }
776
777 pub fn get_secret_key(&self) -> Result<String, ConfigError> {
779 match &self.security.key_provider {
780 KeyProvider::Environment { var_name } => {
781 env::var(var_name).map_err(|_| ConfigError::MissingRequired {
782 key: var_name.clone(),
783 })
784 }
785 KeyProvider::File { path } => std::fs::read_to_string(path)
786 .map(|s| s.trim().to_string())
787 .map_err(|e| ConfigError::IoError {
788 message: e.to_string(),
789 }),
790 KeyProvider::Keychain { service, account } => {
791 #[cfg(feature = "keychain")]
792 {
793 use keyring::Entry;
794 let entry =
795 Entry::new(service, account).map_err(|e| ConfigError::EnvError {
796 message: e.to_string(),
797 })?;
798 entry.get_password().map_err(|e| ConfigError::EnvError {
799 message: e.to_string(),
800 })
801 }
802 #[cfg(not(feature = "keychain"))]
803 {
804 Err(ConfigError::EnvError {
805 message: "Keychain support not enabled".to_string(),
806 })
807 }
808 }
809 }
810 }
811
812 fn validate_auth_token(token: &str) -> Result<String, ConfigError> {
822 let trimmed = token.trim();
824
825 if trimmed.is_empty() {
827 return Err(ConfigError::InvalidValue {
828 key: "auth_token".to_string(),
829 reason: "Token cannot be empty".to_string(),
830 });
831 }
832
833 let weak_tokens = [
836 "dev",
837 "test",
838 "password",
839 "secret",
840 "token",
841 "api_key",
842 "12345678",
843 "admin",
844 "root",
845 "default",
846 "changeme",
847 "letmein",
848 "qwerty",
849 "abc123",
850 "password123",
851 ];
852
853 if weak_tokens.contains(&trimmed.to_lowercase().as_str()) {
854 return Err(ConfigError::InvalidValue {
855 key: "auth_token".to_string(),
856 reason: format!(
857 "Token '{}' is a known weak/default token. Use a strong random token instead.",
858 trimmed
859 ),
860 });
861 }
862
863 if trimmed.len() < 8 {
865 return Err(ConfigError::InvalidValue {
866 key: "auth_token".to_string(),
867 reason: "Token must be at least 8 characters long".to_string(),
868 });
869 }
870
871 if trimmed
873 .chars()
874 .all(|c| c == trimmed.chars().next().unwrap())
875 {
876 tracing::warn!("⚠️ Auth token appears weak (all same character)");
877 }
878
879 if trimmed.contains(' ') && !trimmed.starts_with("Bearer ") {
881 return Err(ConfigError::InvalidValue {
882 key: "auth_token".to_string(),
883 reason: "Token should not contain spaces (unless it's a Bearer token)".to_string(),
884 });
885 }
886
887 Ok(trimmed.to_string())
888 }
889}
890
891#[cfg(test)]
892mod tests {
893 use super::*;
894 use serial_test::serial;
895 use std::collections::HashMap;
896 use std::env;
897 use std::path::PathBuf;
898
899 #[test]
900 fn test_default_config() {
901 let config = Config::default();
902 assert_eq!(config.api.port, 8080);
903 assert_eq!(config.api.host, "127.0.0.1");
904 assert!(config.validate().is_ok());
905 }
906
907 #[test]
908 #[serial]
909 fn test_config_from_env() {
910 env::set_var("API_PORT", "9090");
911 env::set_var("API_HOST", "0.0.0.0");
912 env::set_var("LOG_LEVEL", "debug");
913
914 let config = Config::from_env().unwrap();
915 assert_eq!(config.api.port, 9090);
916 assert_eq!(config.api.host, "0.0.0.0");
917 assert_eq!(config.logging.level, "debug");
918
919 env::remove_var("API_PORT");
921 env::remove_var("API_HOST");
922 env::remove_var("LOG_LEVEL");
923 }
924
925 #[test]
926 fn test_invalid_port() {
927 let mut config = Config::default();
928 config.api.port = 0;
929 assert!(config.validate().is_err());
930 }
931
932 #[test]
933 fn test_invalid_log_level() {
934 let mut config = Config::default();
935 config.logging.level = "invalid".to_string();
936 assert!(config.validate().is_err());
937 }
938
939 #[test]
941 fn test_slm_default_config() {
942 let slm = Slm::default();
943 assert!(!slm.enabled);
944 assert_eq!(slm.default_sandbox_profile, "secure");
945 assert!(slm.sandbox_profiles.contains_key("secure"));
946 assert!(slm.sandbox_profiles.contains_key("standard"));
947 assert!(slm.validate().is_ok());
948 }
949
950 #[test]
951 fn test_slm_validation_invalid_default_profile() {
952 let slm = Slm {
953 default_sandbox_profile: "nonexistent".to_string(),
954 ..Default::default()
955 };
956
957 let result = slm.validate();
958 assert!(result.is_err());
959 if let Err(ConfigError::InvalidValue { key, reason }) = result {
960 assert_eq!(key, "slm.default_sandbox_profile");
961 assert!(reason.contains("nonexistent"));
962 }
963 }
964
965 #[test]
966 fn test_slm_validation_duplicate_model_ids() {
967 let model1 = Model {
968 id: "duplicate".to_string(),
969 name: "Model 1".to_string(),
970 provider: ModelProvider::LocalFile {
971 file_path: PathBuf::from("/tmp/model1.gguf"),
972 },
973 capabilities: vec![ModelCapability::TextGeneration],
974 resource_requirements: ModelResourceRequirements {
975 min_memory_mb: 512,
976 preferred_cpu_cores: 1.0,
977 gpu_requirements: None,
978 },
979 };
980
981 let model2 = Model {
982 id: "duplicate".to_string(), name: "Model 2".to_string(),
984 provider: ModelProvider::LocalFile {
985 file_path: PathBuf::from("/tmp/model2.gguf"),
986 },
987 capabilities: vec![ModelCapability::CodeGeneration],
988 resource_requirements: ModelResourceRequirements {
989 min_memory_mb: 1024,
990 preferred_cpu_cores: 2.0,
991 gpu_requirements: None,
992 },
993 };
994
995 let mut slm = Slm::default();
996 slm.model_allow_lists.global_models = vec![model1, model2];
997
998 let result = slm.validate();
999 assert!(result.is_err());
1000 if let Err(ConfigError::InvalidValue { key, reason }) = result {
1001 assert_eq!(key, "slm.model_allow_lists.global_models");
1002 assert!(reason.contains("Duplicate model ID: duplicate"));
1003 }
1004 }
1005
1006 #[test]
1007 fn test_slm_validation_invalid_agent_model_mapping() {
1008 let model = Model {
1009 id: "test_model".to_string(),
1010 name: "Test Model".to_string(),
1011 provider: ModelProvider::LocalFile {
1012 file_path: PathBuf::from("/tmp/test.gguf"),
1013 },
1014 capabilities: vec![ModelCapability::TextGeneration],
1015 resource_requirements: ModelResourceRequirements {
1016 min_memory_mb: 512,
1017 preferred_cpu_cores: 1.0,
1018 gpu_requirements: None,
1019 },
1020 };
1021
1022 let mut slm = Slm::default();
1023 slm.model_allow_lists.global_models = vec![model];
1024
1025 let mut agent_model_maps = HashMap::new();
1026 agent_model_maps.insert(
1027 "test_agent".to_string(),
1028 vec!["nonexistent_model".to_string()],
1029 );
1030 slm.model_allow_lists.agent_model_maps = agent_model_maps;
1031
1032 let result = slm.validate();
1033 assert!(result.is_err());
1034 if let Err(ConfigError::InvalidValue { key, reason }) = result {
1035 assert_eq!(key, "slm.model_allow_lists.agent_model_maps.test_agent");
1036 assert!(reason.contains("Model ID 'nonexistent_model' not found"));
1037 }
1038 }
1039
1040 #[test]
1041 fn test_slm_get_allowed_models_with_agent_mapping() {
1042 let model1 = Model {
1043 id: "model1".to_string(),
1044 name: "Model 1".to_string(),
1045 provider: ModelProvider::LocalFile {
1046 file_path: PathBuf::from("/tmp/model1.gguf"),
1047 },
1048 capabilities: vec![ModelCapability::TextGeneration],
1049 resource_requirements: ModelResourceRequirements {
1050 min_memory_mb: 512,
1051 preferred_cpu_cores: 1.0,
1052 gpu_requirements: None,
1053 },
1054 };
1055
1056 let model2 = Model {
1057 id: "model2".to_string(),
1058 name: "Model 2".to_string(),
1059 provider: ModelProvider::LocalFile {
1060 file_path: PathBuf::from("/tmp/model2.gguf"),
1061 },
1062 capabilities: vec![ModelCapability::CodeGeneration],
1063 resource_requirements: ModelResourceRequirements {
1064 min_memory_mb: 1024,
1065 preferred_cpu_cores: 2.0,
1066 gpu_requirements: None,
1067 },
1068 };
1069
1070 let mut slm = Slm::default();
1071 slm.model_allow_lists.global_models = vec![model1, model2];
1072
1073 let mut agent_model_maps = HashMap::new();
1074 agent_model_maps.insert("agent1".to_string(), vec!["model1".to_string()]);
1075 slm.model_allow_lists.agent_model_maps = agent_model_maps;
1076
1077 let allowed_models = slm.get_allowed_models("agent1");
1079 assert_eq!(allowed_models.len(), 1);
1080 assert_eq!(allowed_models[0].id, "model1");
1081
1082 let allowed_models = slm.get_allowed_models("agent2");
1084 assert_eq!(allowed_models.len(), 2);
1085 }
1086
1087 #[test]
1089 fn test_sandbox_profile_secure_default() {
1090 let profile = SandboxProfile::secure_default();
1091 assert_eq!(profile.resources.max_memory_mb, 512);
1092 assert_eq!(profile.resources.max_cpu_cores, 1.0);
1093 assert!(matches!(profile.resources.gpu_access, GpuAccess::None));
1094 assert!(matches!(
1095 profile.network.access_mode,
1096 NetworkAccessMode::None
1097 ));
1098 assert!(profile.security.strict_syscall_filtering);
1099 assert!(profile.validate().is_ok());
1100 }
1101
1102 #[test]
1103 fn test_sandbox_profile_standard_default() {
1104 let profile = SandboxProfile::standard_default();
1105 assert_eq!(profile.resources.max_memory_mb, 1024);
1106 assert_eq!(profile.resources.max_cpu_cores, 2.0);
1107 assert!(matches!(
1108 profile.resources.gpu_access,
1109 GpuAccess::Shared { .. }
1110 ));
1111 assert!(matches!(
1112 profile.network.access_mode,
1113 NetworkAccessMode::Restricted
1114 ));
1115 assert!(!profile.security.strict_syscall_filtering);
1116 assert!(profile.validate().is_ok());
1117 }
1118
1119 #[test]
1120 fn test_sandbox_profile_validation_zero_memory() {
1121 let mut profile = SandboxProfile::secure_default();
1122 profile.resources.max_memory_mb = 0;
1123
1124 let result = profile.validate();
1125 assert!(result.is_err());
1126 assert!(result
1127 .unwrap_err()
1128 .to_string()
1129 .contains("max_memory_mb must be > 0"));
1130 }
1131
1132 #[test]
1133 fn test_sandbox_profile_validation_zero_cpu() {
1134 let mut profile = SandboxProfile::secure_default();
1135 profile.resources.max_cpu_cores = 0.0;
1136
1137 let result = profile.validate();
1138 assert!(result.is_err());
1139 assert!(result
1140 .unwrap_err()
1141 .to_string()
1142 .contains("max_cpu_cores must be > 0"));
1143 }
1144
1145 #[test]
1146 fn test_sandbox_profile_validation_empty_read_path() {
1147 let mut profile = SandboxProfile::secure_default();
1148 profile.filesystem.read_paths.push("".to_string());
1149
1150 let result = profile.validate();
1151 assert!(result.is_err());
1152 assert!(result
1153 .unwrap_err()
1154 .to_string()
1155 .contains("read_paths cannot contain empty strings"));
1156 }
1157
1158 #[test]
1159 fn test_sandbox_profile_validation_zero_execution_time() {
1160 let mut profile = SandboxProfile::secure_default();
1161 profile.process_limits.max_execution_time_seconds = 0;
1162
1163 let result = profile.validate();
1164 assert!(result.is_err());
1165 assert!(result
1166 .unwrap_err()
1167 .to_string()
1168 .contains("max_execution_time_seconds must be > 0"));
1169 }
1170
1171 #[test]
1173 fn test_model_provider_variants() {
1174 let huggingface_model = Model {
1175 id: "hf_model".to_string(),
1176 name: "HuggingFace Model".to_string(),
1177 provider: ModelProvider::HuggingFace {
1178 model_path: "microsoft/DialoGPT-medium".to_string(),
1179 },
1180 capabilities: vec![ModelCapability::TextGeneration],
1181 resource_requirements: ModelResourceRequirements {
1182 min_memory_mb: 512,
1183 preferred_cpu_cores: 1.0,
1184 gpu_requirements: None,
1185 },
1186 };
1187
1188 let openai_model = Model {
1189 id: "openai_model".to_string(),
1190 name: "OpenAI Model".to_string(),
1191 provider: ModelProvider::OpenAI {
1192 model_name: "gpt-3.5-turbo".to_string(),
1193 },
1194 capabilities: vec![ModelCapability::TextGeneration, ModelCapability::Reasoning],
1195 resource_requirements: ModelResourceRequirements {
1196 min_memory_mb: 0, preferred_cpu_cores: 0.0,
1198 gpu_requirements: None,
1199 },
1200 };
1201
1202 assert_eq!(huggingface_model.id, "hf_model");
1203 assert_eq!(openai_model.id, "openai_model");
1204 }
1205
1206 #[test]
1207 fn test_model_capabilities() {
1208 let all_capabilities = vec![
1209 ModelCapability::TextGeneration,
1210 ModelCapability::CodeGeneration,
1211 ModelCapability::Reasoning,
1212 ModelCapability::ToolUse,
1213 ModelCapability::FunctionCalling,
1214 ModelCapability::Embeddings,
1215 ];
1216
1217 let model = Model {
1218 id: "full_model".to_string(),
1219 name: "Full Capability Model".to_string(),
1220 provider: ModelProvider::LocalFile {
1221 file_path: PathBuf::from("/tmp/full.gguf"),
1222 },
1223 capabilities: all_capabilities.clone(),
1224 resource_requirements: ModelResourceRequirements {
1225 min_memory_mb: 2048,
1226 preferred_cpu_cores: 4.0,
1227 gpu_requirements: Some(GpuRequirements {
1228 min_vram_mb: 8192,
1229 compute_capability: "7.5".to_string(),
1230 }),
1231 },
1232 };
1233
1234 assert_eq!(model.capabilities.len(), 6);
1235 for capability in &all_capabilities {
1236 assert!(model.capabilities.contains(capability));
1237 }
1238 }
1239
1240 #[test]
1242 fn test_config_validation_vector_dimension() {
1243 let mut config = Config::default();
1244 config.database.vector_dimension = 0;
1245
1246 let result = config.validate();
1247 assert!(result.is_err());
1248 if let Err(ConfigError::InvalidValue { key, reason }) = result {
1249 assert_eq!(key, "database.vector_dimension");
1250 assert!(reason.contains("Vector dimension must be > 0"));
1251 }
1252 }
1253
1254 #[test]
1255 fn test_config_validation_with_slm() {
1256 let mut config = Config::default();
1257 let slm = Slm {
1258 enabled: true,
1259 default_sandbox_profile: "invalid".to_string(), ..Default::default()
1261 };
1262 config.slm = Some(slm);
1263
1264 let result = config.validate();
1265 assert!(result.is_err());
1266 }
1267
1268 #[test]
1269 fn test_config_secret_key_retrieval() {
1270 env::set_var("TEST_SECRET_KEY", "test_secret_123");
1272
1273 let mut config = Config::default();
1274 config.security.key_provider = KeyProvider::Environment {
1275 var_name: "TEST_SECRET_KEY".to_string(),
1276 };
1277
1278 let key = config.get_secret_key();
1279 assert!(key.is_ok());
1280 assert_eq!(key.unwrap(), "test_secret_123");
1281
1282 env::remove_var("TEST_SECRET_KEY");
1283 }
1284
1285 #[test]
1286 fn test_config_secret_key_missing() {
1287 let mut config = Config::default();
1288 config.security.key_provider = KeyProvider::Environment {
1289 var_name: "NONEXISTENT_KEY".to_string(),
1290 };
1291
1292 let result = config.get_secret_key();
1293 assert!(result.is_err());
1294 if let Err(ConfigError::MissingRequired { key }) = result {
1295 assert_eq!(key, "NONEXISTENT_KEY");
1296 }
1297 }
1298
1299 #[test]
1300 fn test_network_policy_configurations() {
1301 let destination = NetworkDestination {
1303 host: "api.openai.com".to_string(),
1304 port: Some(443),
1305 protocol: Some(NetworkProtocol::HTTPS),
1306 };
1307
1308 let network_policy = NetworkPolicy {
1309 access_mode: NetworkAccessMode::Restricted,
1310 allowed_destinations: vec![destination],
1311 max_bandwidth_mbps: Some(100),
1312 };
1313
1314 let profile = SandboxProfile {
1315 resources: ResourceConstraints {
1316 max_memory_mb: 1024,
1317 max_cpu_cores: 2.0,
1318 max_disk_mb: 500,
1319 gpu_access: GpuAccess::None,
1320 max_io_bandwidth_mbps: Some(50),
1321 },
1322 filesystem: FilesystemControls {
1323 read_paths: vec!["/tmp/*".to_string()],
1324 write_paths: vec!["/tmp/output/*".to_string()],
1325 denied_paths: vec!["/etc/*".to_string()],
1326 allow_temp_files: true,
1327 max_file_size_mb: 10,
1328 },
1329 process_limits: ProcessLimits {
1330 max_child_processes: 2,
1331 max_execution_time_seconds: 300,
1332 allowed_syscalls: vec!["read".to_string(), "write".to_string()],
1333 process_priority: 0,
1334 },
1335 network: network_policy,
1336 security: SecuritySettings {
1337 strict_syscall_filtering: true,
1338 disable_debugging: true,
1339 enable_audit_logging: true,
1340 require_encryption: false,
1341 },
1342 };
1343
1344 assert!(profile.validate().is_ok());
1345 assert!(matches!(
1346 profile.network.access_mode,
1347 NetworkAccessMode::Restricted
1348 ));
1349 assert_eq!(profile.network.allowed_destinations.len(), 1);
1350 assert_eq!(
1351 profile.network.allowed_destinations[0].host,
1352 "api.openai.com"
1353 );
1354 }
1355
1356 #[test]
1357 fn test_gpu_requirements_configurations() {
1358 let gpu_requirements = GpuRequirements {
1359 min_vram_mb: 4096,
1360 compute_capability: "8.0".to_string(),
1361 };
1362
1363 let model = Model {
1364 id: "gpu_model".to_string(),
1365 name: "GPU Model".to_string(),
1366 provider: ModelProvider::LocalFile {
1367 file_path: PathBuf::from("/tmp/gpu.gguf"),
1368 },
1369 capabilities: vec![ModelCapability::TextGeneration],
1370 resource_requirements: ModelResourceRequirements {
1371 min_memory_mb: 1024,
1372 preferred_cpu_cores: 2.0,
1373 gpu_requirements: Some(gpu_requirements),
1374 },
1375 };
1376
1377 assert!(model.resource_requirements.gpu_requirements.is_some());
1378 let gpu_req = model.resource_requirements.gpu_requirements.unwrap();
1379 assert_eq!(gpu_req.min_vram_mb, 4096);
1380 assert_eq!(gpu_req.compute_capability, "8.0");
1381 }
1382
1383 #[test]
1384 #[serial]
1385 fn test_config_from_env_invalid_port() {
1386 env::set_var("API_PORT", "invalid");
1387
1388 let result = Config::from_env();
1389 assert!(result.is_err());
1390 if let Err(ConfigError::InvalidValue { key, reason }) = result {
1391 assert_eq!(key, "API_PORT");
1392 assert!(reason.contains("Invalid port number"));
1393 }
1394
1395 env::remove_var("API_PORT");
1396 }
1397
1398 #[test]
1399 fn test_api_auth_token_missing() {
1400 let config = Config::default();
1401
1402 let result = config.get_api_auth_token();
1403 assert!(result.is_err());
1404 if let Err(ConfigError::MissingRequired { key }) = result {
1405 assert_eq!(key, "API_AUTH_TOKEN");
1406 }
1407 }
1408
1409 #[test]
1410 fn test_database_url_missing() {
1411 let config = Config::default();
1412
1413 let result = config.get_database_url();
1414 assert!(result.is_err());
1415 if let Err(ConfigError::MissingRequired { key }) = result {
1416 assert_eq!(key, "DATABASE_URL");
1417 }
1418 }
1419
1420 #[test]
1425 fn test_validate_auth_token_valid_strong_token() {
1426 let tokens = vec![
1427 "MySecureToken123",
1428 "a1b2c3d4e5f6g7h8",
1429 "production_token_2024",
1430 "Bearer_abc123def456",
1431 ];
1432
1433 for token in tokens {
1434 let result = Config::validate_auth_token(token);
1435 assert!(result.is_ok(), "Token '{}' should be valid", token);
1436 assert_eq!(result.unwrap(), token.trim());
1437 }
1438 }
1439
1440 #[test]
1441 fn test_validate_auth_token_empty() {
1442 assert!(Config::validate_auth_token("").is_err());
1443 assert!(Config::validate_auth_token(" ").is_err());
1444 assert!(Config::validate_auth_token("\t\n").is_err());
1445 }
1446
1447 #[test]
1448 fn test_validate_auth_token_too_short() {
1449 let short_tokens = vec!["abc", "12345", "short", "1234567"];
1450
1451 for token in short_tokens {
1452 let result = Config::validate_auth_token(token);
1453 assert!(
1454 result.is_err(),
1455 "Token '{}' should be rejected (too short)",
1456 token
1457 );
1458
1459 if let Err(ConfigError::InvalidValue { reason, .. }) = result {
1460 assert!(reason.contains("at least 8 characters"));
1461 }
1462 }
1463 }
1464
1465 #[test]
1466 fn test_validate_auth_token_weak_defaults() {
1467 let weak_tokens = vec![
1468 "dev", "test", "password", "secret", "token", "admin", "root", "default", "changeme",
1469 "12345678",
1470 ];
1471
1472 for token in weak_tokens {
1473 let result = Config::validate_auth_token(token);
1474 assert!(result.is_err(), "Weak token '{}' should be rejected", token);
1475
1476 if let Err(ConfigError::InvalidValue { reason, .. }) = result {
1477 assert!(
1478 reason.contains("weak/default token"),
1479 "Expected 'weak/default token' message for '{}', got: {}",
1480 token,
1481 reason
1482 );
1483 }
1484 }
1485 }
1486
1487 #[test]
1488 fn test_validate_auth_token_case_insensitive_weak_check() {
1489 let tokens = vec!["DEV", "Test", "PASSWORD", "Admin", "ROOT"];
1490
1491 for token in tokens {
1492 let result = Config::validate_auth_token(token);
1493 assert!(
1494 result.is_err(),
1495 "Token '{}' should be rejected (case-insensitive)",
1496 token
1497 );
1498 }
1499 }
1500
1501 #[test]
1502 fn test_validate_auth_token_with_spaces() {
1503 let result = Config::validate_auth_token("my token here");
1505 assert!(result.is_err());
1506
1507 if let Err(ConfigError::InvalidValue { reason, .. }) = result {
1508 assert!(reason.contains("should not contain spaces"));
1509 }
1510 }
1511
1512 #[test]
1513 fn test_validate_auth_token_trims_whitespace() {
1514 let result = Config::validate_auth_token(" validtoken123 ");
1515 assert!(result.is_ok());
1516 assert_eq!(result.unwrap(), "validtoken123");
1517 }
1518
1519 #[test]
1520 fn test_validate_auth_token_minimum_length_boundary() {
1521 assert!(Config::validate_auth_token("12345678").is_err()); assert!(Config::validate_auth_token("abcdefgh").is_ok());
1524
1525 assert!(Config::validate_auth_token("abcdefg").is_err());
1527 }
1528
1529 #[test]
1530 #[serial]
1531 fn test_validate_auth_token_integration_with_from_env() {
1532 env::set_var("API_AUTH_TOKEN", "dev");
1536 let config = Config::from_env().unwrap();
1537 assert!(config.api.auth_token.is_none());
1539 env::remove_var("API_AUTH_TOKEN");
1540
1541 env::set_var("API_AUTH_TOKEN", "strong_secure_token_12345");
1543 let config = Config::from_env().unwrap();
1544 assert!(config.api.auth_token.is_some());
1545 assert_eq!(config.api.auth_token.unwrap(), "strong_secure_token_12345");
1546 env::remove_var("API_AUTH_TOKEN");
1547 }
1548
1549 #[test]
1550 fn test_validate_auth_token_special_characters_allowed() {
1551 let tokens = vec![
1552 "token-with-dashes",
1553 "token_with_underscores",
1554 "token.with.dots",
1555 "token@with#special$chars",
1556 ];
1557
1558 for token in tokens {
1559 let result = Config::validate_auth_token(token);
1560 assert!(
1561 result.is_ok(),
1562 "Token '{}' with special chars should be valid",
1563 token
1564 );
1565 }
1566 }
1567}