1use std::path::Path;
26
27use serde::{Deserialize, Serialize};
28use tracing::warn;
29
30use crate::error::{ConfigError, IronpostError};
31
32#[derive(Debug, Clone, Default, Serialize, Deserialize)]
37pub struct IronpostConfig {
38 #[serde(default)]
40 pub general: GeneralConfig,
41 #[serde(default)]
43 pub metrics: MetricsConfig,
44 #[serde(default)]
46 pub ebpf: EbpfConfig,
47 #[serde(default)]
49 pub log_pipeline: LogPipelineConfig,
50 #[serde(default)]
52 pub container: ContainerConfig,
53 #[serde(default)]
55 pub sbom: SbomConfig,
56}
57
58impl IronpostConfig {
59 pub async fn load(path: impl AsRef<Path>) -> Result<Self, IronpostError> {
87 let mut config = Self::from_file(path).await?;
88 config.apply_env_overrides();
89 config.validate()?;
90 Ok(config)
91 }
92
93 pub async fn from_file(path: impl AsRef<Path>) -> Result<Self, IronpostError> {
99 let path = path.as_ref();
100 let content = tokio::fs::read_to_string(path).await.map_err(|e| {
101 if e.kind() == std::io::ErrorKind::NotFound {
102 IronpostError::Config(ConfigError::FileNotFound {
103 path: path.display().to_string(),
104 })
105 } else {
106 IronpostError::Io(e)
107 }
108 })?;
109 let config = Self::parse(&content)?;
110 config.validate()?;
111 Ok(config)
112 }
113
114 pub fn parse(toml_str: &str) -> Result<Self, IronpostError> {
120 toml::from_str(toml_str).map_err(|e| {
121 IronpostError::Config(ConfigError::ParseFailed {
122 reason: e.to_string(),
123 })
124 })
125 }
126
127 pub fn apply_env_overrides(&mut self) {
132 override_string(&mut self.general.log_level, "IRONPOST_GENERAL_LOG_LEVEL");
134 override_string(&mut self.general.log_format, "IRONPOST_GENERAL_LOG_FORMAT");
135 override_string(&mut self.general.data_dir, "IRONPOST_GENERAL_DATA_DIR");
136 override_string(&mut self.general.pid_file, "IRONPOST_GENERAL_PID_FILE");
137
138 override_bool(&mut self.metrics.enabled, "IRONPOST_METRICS_ENABLED");
140 override_string(
141 &mut self.metrics.listen_addr,
142 "IRONPOST_METRICS_LISTEN_ADDR",
143 );
144 override_u16(&mut self.metrics.port, "IRONPOST_METRICS_PORT");
145 override_string(&mut self.metrics.endpoint, "IRONPOST_METRICS_ENDPOINT");
146
147 override_bool(&mut self.ebpf.enabled, "IRONPOST_EBPF_ENABLED");
149 override_string(&mut self.ebpf.interface, "IRONPOST_EBPF_INTERFACE");
150 override_string(&mut self.ebpf.xdp_mode, "IRONPOST_EBPF_XDP_MODE");
151 override_usize(
152 &mut self.ebpf.ring_buffer_size,
153 "IRONPOST_EBPF_RING_BUFFER_SIZE",
154 );
155 override_usize(
156 &mut self.ebpf.blocklist_max_entries,
157 "IRONPOST_EBPF_BLOCKLIST_MAX_ENTRIES",
158 );
159
160 override_bool(
162 &mut self.log_pipeline.enabled,
163 "IRONPOST_LOG_PIPELINE_ENABLED",
164 );
165 override_csv(
166 &mut self.log_pipeline.sources,
167 "IRONPOST_LOG_PIPELINE_SOURCES",
168 );
169 override_string(
170 &mut self.log_pipeline.syslog_bind,
171 "IRONPOST_LOG_PIPELINE_SYSLOG_BIND",
172 );
173 override_string(
174 &mut self.log_pipeline.syslog_tcp_bind,
175 "IRONPOST_LOG_PIPELINE_SYSLOG_TCP_BIND",
176 );
177 override_csv(
178 &mut self.log_pipeline.watch_paths,
179 "IRONPOST_LOG_PIPELINE_WATCH_PATHS",
180 );
181 override_usize(
182 &mut self.log_pipeline.batch_size,
183 "IRONPOST_LOG_PIPELINE_BATCH_SIZE",
184 );
185 override_u64(
186 &mut self.log_pipeline.flush_interval_secs,
187 "IRONPOST_LOG_PIPELINE_FLUSH_INTERVAL_SECS",
188 );
189
190 override_string(
192 &mut self.log_pipeline.storage.postgres_url,
193 "IRONPOST_STORAGE_POSTGRES_URL",
194 );
195 override_string(
196 &mut self.log_pipeline.storage.redis_url,
197 "IRONPOST_STORAGE_REDIS_URL",
198 );
199 override_u32(
200 &mut self.log_pipeline.storage.retention_days,
201 "IRONPOST_STORAGE_RETENTION_DAYS",
202 );
203
204 override_bool(&mut self.container.enabled, "IRONPOST_CONTAINER_ENABLED");
206 override_string(
207 &mut self.container.docker_socket,
208 "IRONPOST_CONTAINER_DOCKER_SOCKET",
209 );
210 override_u64(
211 &mut self.container.poll_interval_secs,
212 "IRONPOST_CONTAINER_POLL_INTERVAL_SECS",
213 );
214 override_string(
215 &mut self.container.policy_path,
216 "IRONPOST_CONTAINER_POLICY_PATH",
217 );
218 override_bool(
219 &mut self.container.auto_isolate,
220 "IRONPOST_CONTAINER_AUTO_ISOLATE",
221 );
222
223 override_bool(&mut self.sbom.enabled, "IRONPOST_SBOM_ENABLED");
225 override_csv(&mut self.sbom.scan_dirs, "IRONPOST_SBOM_SCAN_DIRS");
226 override_u32(
227 &mut self.sbom.vuln_db_update_hours,
228 "IRONPOST_SBOM_VULN_DB_UPDATE_HOURS",
229 );
230 override_string(&mut self.sbom.vuln_db_path, "IRONPOST_SBOM_VULN_DB_PATH");
231 override_string(&mut self.sbom.min_severity, "IRONPOST_SBOM_MIN_SEVERITY");
232 override_string(&mut self.sbom.output_format, "IRONPOST_SBOM_OUTPUT_FORMAT");
233 }
234
235 pub fn validate(&self) -> Result<(), IronpostError> {
241 let valid_levels = ["trace", "debug", "info", "warn", "error"];
243 if !valid_levels.contains(&self.general.log_level.as_str()) {
244 return Err(ConfigError::InvalidValue {
245 field: "general.log_level".to_owned(),
246 reason: format!("must be one of: {}", valid_levels.join(", ")),
247 }
248 .into());
249 }
250
251 let valid_formats = ["json", "pretty"];
253 if !valid_formats.contains(&self.general.log_format.as_str()) {
254 return Err(ConfigError::InvalidValue {
255 field: "general.log_format".to_owned(),
256 reason: format!("must be one of: {}", valid_formats.join(", ")),
257 }
258 .into());
259 }
260
261 if self.ebpf.enabled {
263 let valid_modes = ["native", "skb", "hw"];
264 if !valid_modes.contains(&self.ebpf.xdp_mode.as_str()) {
265 return Err(ConfigError::InvalidValue {
266 field: "ebpf.xdp_mode".to_owned(),
267 reason: format!("must be one of: {}", valid_modes.join(", ")),
268 }
269 .into());
270 }
271
272 if self.ebpf.interface.is_empty() {
273 return Err(ConfigError::InvalidValue {
274 field: "ebpf.interface".to_owned(),
275 reason: "interface must not be empty when ebpf is enabled".to_owned(),
276 }
277 .into());
278 }
279 }
280
281 if self.sbom.enabled {
283 let valid_sbom_formats = ["spdx", "cyclonedx"];
284 if !valid_sbom_formats.contains(&self.sbom.output_format.as_str()) {
285 return Err(ConfigError::InvalidValue {
286 field: "sbom.output_format".to_owned(),
287 reason: format!("must be one of: {}", valid_sbom_formats.join(", ")),
288 }
289 .into());
290 }
291 }
292
293 if self.sbom.enabled {
295 let valid_severities = ["info", "low", "medium", "high", "critical"];
296 if !valid_severities.contains(&self.sbom.min_severity.as_str()) {
297 return Err(ConfigError::InvalidValue {
298 field: "sbom.min_severity".to_owned(),
299 reason: format!("must be one of: {}", valid_severities.join(", ")),
300 }
301 .into());
302 }
303 }
304
305 if self.metrics.enabled {
307 self.metrics.validate()?;
308 }
309
310 if self.ebpf.enabled {
312 self.ebpf.validate()?;
313 }
314 if self.log_pipeline.enabled {
315 self.log_pipeline.validate()?;
316 }
317 if self.container.enabled {
318 self.container.validate()?;
319 }
320 if self.sbom.enabled {
321 self.sbom.validate()?;
322 }
323
324 Ok(())
325 }
326}
327
328#[derive(Debug, Clone, Serialize, Deserialize)]
332#[serde(default)]
333pub struct GeneralConfig {
334 pub log_level: String,
336 pub log_format: String,
338 pub data_dir: String,
340 pub pid_file: String,
342}
343
344impl Default for GeneralConfig {
345 fn default() -> Self {
346 Self {
347 log_level: "info".to_owned(),
348 log_format: "json".to_owned(),
349 data_dir: "/var/lib/ironpost".to_owned(),
350 pid_file: "/var/run/ironpost/ironpost.pid".to_owned(),
351 }
352 }
353}
354
355#[derive(Debug, Clone, Serialize, Deserialize)]
357#[serde(default)]
358pub struct MetricsConfig {
359 pub enabled: bool,
361 pub listen_addr: String,
363 pub port: u16,
365 pub endpoint: String,
367}
368
369impl Default for MetricsConfig {
370 fn default() -> Self {
371 Self {
372 enabled: true,
373 listen_addr: "127.0.0.1".to_owned(),
374 port: 9100,
375 endpoint: "/metrics".to_owned(),
376 }
377 }
378}
379
380impl MetricsConfig {
381 pub fn validate(&self) -> Result<(), IronpostError> {
383 if self.port == 0 {
384 return Err(ConfigError::InvalidValue {
385 field: "metrics.port".to_owned(),
386 reason: "must be greater than 0".to_owned(),
387 }
388 .into());
389 }
390 if self.listen_addr.is_empty() {
391 return Err(ConfigError::InvalidValue {
392 field: "metrics.listen_addr".to_owned(),
393 reason: "must not be empty".to_owned(),
394 }
395 .into());
396 }
397 if !self.endpoint.starts_with('/') {
398 return Err(ConfigError::InvalidValue {
399 field: "metrics.endpoint".to_owned(),
400 reason: "must start with '/'".to_owned(),
401 }
402 .into());
403 }
404 if self.endpoint != "/metrics" {
405 return Err(ConfigError::InvalidValue {
406 field: "metrics.endpoint".to_owned(),
407 reason: "only '/metrics' is currently supported".to_owned(),
408 }
409 .into());
410 }
411 Ok(())
412 }
413}
414
415#[derive(Debug, Clone, Serialize, Deserialize)]
417#[serde(default)]
418pub struct EbpfConfig {
419 pub enabled: bool,
421 pub interface: String,
423 pub xdp_mode: String,
425 pub ring_buffer_size: usize,
427 pub blocklist_max_entries: usize,
429}
430
431impl Default for EbpfConfig {
432 fn default() -> Self {
433 Self {
434 enabled: false,
435 interface: "eth0".to_owned(),
436 xdp_mode: "skb".to_owned(),
437 ring_buffer_size: 256 * 1024, blocklist_max_entries: 10_000,
439 }
440 }
441}
442
443impl EbpfConfig {
444 pub fn validate(&self) -> Result<(), IronpostError> {
446 if self.ring_buffer_size == 0 {
447 return Err(ConfigError::InvalidValue {
448 field: "ebpf.ring_buffer_size".to_owned(),
449 reason: "must be greater than 0".to_owned(),
450 }
451 .into());
452 }
453 if self.blocklist_max_entries == 0 {
454 return Err(ConfigError::InvalidValue {
455 field: "ebpf.blocklist_max_entries".to_owned(),
456 reason: "must be greater than 0".to_owned(),
457 }
458 .into());
459 }
460 Ok(())
461 }
462}
463
464#[derive(Debug, Clone, Serialize, Deserialize)]
466#[serde(default)]
467pub struct LogPipelineConfig {
468 pub enabled: bool,
470 pub sources: Vec<String>,
472 pub syslog_bind: String,
474 pub syslog_tcp_bind: String,
476 pub watch_paths: Vec<String>,
478 pub batch_size: usize,
480 pub flush_interval_secs: u64,
482 #[serde(default)]
484 pub storage: StorageConfig,
485}
486
487impl Default for LogPipelineConfig {
488 fn default() -> Self {
489 Self {
490 enabled: true,
491 sources: vec!["syslog".to_owned(), "file".to_owned()],
492 syslog_bind: "0.0.0.0:514".to_owned(),
493 syslog_tcp_bind: "0.0.0.0:601".to_owned(),
494 watch_paths: vec!["/var/log/syslog".to_owned()],
495 batch_size: 100,
496 flush_interval_secs: 5,
497 storage: StorageConfig::default(),
498 }
499 }
500}
501
502impl LogPipelineConfig {
503 pub fn validate(&self) -> Result<(), IronpostError> {
505 if self.batch_size == 0 {
506 return Err(ConfigError::InvalidValue {
507 field: "log_pipeline.batch_size".to_owned(),
508 reason: "must be greater than 0".to_owned(),
509 }
510 .into());
511 }
512 if self.batch_size > 10_000 {
513 return Err(ConfigError::InvalidValue {
514 field: "log_pipeline.batch_size".to_owned(),
515 reason: "must not exceed 10,000 (performance limit)".to_owned(),
516 }
517 .into());
518 }
519 if self.flush_interval_secs == 0 {
520 return Err(ConfigError::InvalidValue {
521 field: "log_pipeline.flush_interval_secs".to_owned(),
522 reason: "must be greater than 0".to_owned(),
523 }
524 .into());
525 }
526 self.storage.validate()?;
527 Ok(())
528 }
529}
530
531#[derive(Debug, Clone, Serialize, Deserialize)]
533#[serde(default)]
534pub struct StorageConfig {
535 pub postgres_url: String,
537 pub redis_url: String,
539 pub retention_days: u32,
541}
542
543impl Default for StorageConfig {
544 fn default() -> Self {
545 Self {
546 postgres_url: "postgresql://localhost:5432/ironpost".to_owned(),
547 redis_url: "redis://localhost:6379".to_owned(),
548 retention_days: 30,
549 }
550 }
551}
552
553impl StorageConfig {
554 pub fn validate(&self) -> Result<(), IronpostError> {
556 if self.retention_days == 0 {
557 return Err(ConfigError::InvalidValue {
558 field: "log_pipeline.storage.retention_days".to_owned(),
559 reason: "must be greater than 0".to_owned(),
560 }
561 .into());
562 }
563 if self.retention_days > 3650 {
564 return Err(ConfigError::InvalidValue {
565 field: "log_pipeline.storage.retention_days".to_owned(),
566 reason: "must not exceed 3,650 days (10 years)".to_owned(),
567 }
568 .into());
569 }
570 Ok(())
571 }
572}
573
574#[derive(Debug, Clone, Serialize, Deserialize)]
576#[serde(default)]
577pub struct ContainerConfig {
578 pub enabled: bool,
580 pub docker_socket: String,
582 pub poll_interval_secs: u64,
584 pub policy_path: String,
586 pub auto_isolate: bool,
588}
589
590impl Default for ContainerConfig {
591 fn default() -> Self {
592 Self {
593 enabled: false,
594 docker_socket: "/var/run/docker.sock".to_owned(),
595 poll_interval_secs: 10,
596 policy_path: "/etc/ironpost/policies".to_owned(),
597 auto_isolate: false,
598 }
599 }
600}
601
602impl ContainerConfig {
603 pub fn validate(&self) -> Result<(), IronpostError> {
605 if self.poll_interval_secs == 0 {
606 return Err(ConfigError::InvalidValue {
607 field: "container.poll_interval_secs".to_owned(),
608 reason: "must be greater than 0".to_owned(),
609 }
610 .into());
611 }
612 if self.poll_interval_secs > 3600 {
613 return Err(ConfigError::InvalidValue {
614 field: "container.poll_interval_secs".to_owned(),
615 reason: "must not exceed 3,600 seconds (1 hour)".to_owned(),
616 }
617 .into());
618 }
619 if self.docker_socket.is_empty() {
620 return Err(ConfigError::InvalidValue {
621 field: "container.docker_socket".to_owned(),
622 reason: "must not be empty".to_owned(),
623 }
624 .into());
625 }
626 Ok(())
627 }
628}
629
630#[derive(Debug, Clone, Serialize, Deserialize)]
632#[serde(default)]
633pub struct SbomConfig {
634 pub enabled: bool,
636 pub scan_dirs: Vec<String>,
638 pub vuln_db_update_hours: u32,
640 pub vuln_db_path: String,
642 pub min_severity: String,
644 pub output_format: String,
646}
647
648impl Default for SbomConfig {
649 fn default() -> Self {
650 Self {
651 enabled: false,
652 scan_dirs: vec![".".to_owned()],
653 vuln_db_update_hours: 24,
654 vuln_db_path: "/var/lib/ironpost/vuln-db".to_owned(),
655 min_severity: "medium".to_owned(),
656 output_format: "cyclonedx".to_owned(),
657 }
658 }
659}
660
661impl SbomConfig {
662 pub fn validate(&self) -> Result<(), IronpostError> {
664 if self.vuln_db_update_hours == 0 {
665 return Err(ConfigError::InvalidValue {
666 field: "sbom.vuln_db_update_hours".to_owned(),
667 reason: "must be greater than 0".to_owned(),
668 }
669 .into());
670 }
671 if self.vuln_db_update_hours > 8760 {
672 return Err(ConfigError::InvalidValue {
673 field: "sbom.vuln_db_update_hours".to_owned(),
674 reason: "must not exceed 8,760 hours (1 year)".to_owned(),
675 }
676 .into());
677 }
678 if self.scan_dirs.is_empty() {
679 return Err(ConfigError::InvalidValue {
680 field: "sbom.scan_dirs".to_owned(),
681 reason: "must have at least one directory".to_owned(),
682 }
683 .into());
684 }
685 Ok(())
686 }
687}
688
689fn override_string(target: &mut String, env_key: &str) {
692 if let Ok(val) = std::env::var(env_key) {
693 *target = val;
694 }
695}
696
697fn override_bool(target: &mut bool, env_key: &str) {
698 if let Ok(val) = std::env::var(env_key) {
699 match val.parse::<bool>() {
700 Ok(parsed) => *target = parsed,
701 Err(_) => warn!(
702 env_key,
703 value = val.as_str(),
704 "failed to parse bool from env var, ignoring"
705 ),
706 }
707 }
708}
709
710fn override_usize(target: &mut usize, env_key: &str) {
711 if let Ok(val) = std::env::var(env_key) {
712 match val.parse::<usize>() {
713 Ok(parsed) => *target = parsed,
714 Err(_) => warn!(
715 env_key,
716 value = val.as_str(),
717 "failed to parse usize from env var, ignoring"
718 ),
719 }
720 }
721}
722
723fn override_u32(target: &mut u32, env_key: &str) {
724 if let Ok(val) = std::env::var(env_key) {
725 match val.parse::<u32>() {
726 Ok(parsed) => *target = parsed,
727 Err(_) => warn!(
728 env_key,
729 value = val.as_str(),
730 "failed to parse u32 from env var, ignoring"
731 ),
732 }
733 }
734}
735
736fn override_u64(target: &mut u64, env_key: &str) {
737 if let Ok(val) = std::env::var(env_key) {
738 match val.parse::<u64>() {
739 Ok(parsed) => *target = parsed,
740 Err(_) => warn!(
741 env_key,
742 value = val.as_str(),
743 "failed to parse u64 from env var, ignoring"
744 ),
745 }
746 }
747}
748
749fn override_u16(target: &mut u16, env_key: &str) {
750 if let Ok(val) = std::env::var(env_key) {
751 match val.parse::<u16>() {
752 Ok(parsed) => *target = parsed,
753 Err(_) => warn!(
754 env_key,
755 value = val.as_str(),
756 "failed to parse u16 from env var, ignoring"
757 ),
758 }
759 }
760}
761
762fn override_csv(target: &mut Vec<String>, env_key: &str) {
763 if let Ok(val) = std::env::var(env_key) {
764 *target = val.split(',').map(|s| s.trim().to_owned()).collect();
765 }
766}
767
768#[cfg(test)]
769mod tests {
770 use super::*;
771 use serial_test::serial;
772
773 #[test]
774 fn default_config_has_sane_values() {
775 let config = IronpostConfig::default();
776 assert_eq!(config.general.log_level, "info");
777 assert_eq!(config.general.log_format, "json");
778 assert!(!config.ebpf.enabled);
779 assert_eq!(config.ebpf.interface, "eth0");
780 assert!(config.log_pipeline.enabled);
781 assert!(!config.container.enabled);
782 assert!(!config.sbom.enabled);
783 }
784
785 #[test]
786 fn default_config_passes_validation() {
787 let config = IronpostConfig::default();
788 config.validate().unwrap();
789 }
790
791 #[test]
792 fn from_str_empty_toml_uses_defaults() {
793 let config = IronpostConfig::parse("").unwrap();
794 assert_eq!(config.general.log_level, "info");
795 assert_eq!(config.ebpf.interface, "eth0");
796 }
797
798 #[test]
799 fn from_str_partial_toml_merges_with_defaults() {
800 let toml = r#"
801[general]
802log_level = "debug"
803
804[ebpf]
805enabled = true
806interface = "ens3"
807"#;
808 let config = IronpostConfig::parse(toml).unwrap();
809 assert_eq!(config.general.log_level, "debug");
810 assert_eq!(config.general.log_format, "json");
812 assert!(config.ebpf.enabled);
813 assert_eq!(config.ebpf.interface, "ens3");
814 }
815
816 #[test]
817 fn from_str_full_toml() {
818 let toml = r#"
819[general]
820log_level = "warn"
821log_format = "pretty"
822data_dir = "/opt/ironpost/data"
823pid_file = "/opt/ironpost/ironpost.pid"
824
825[ebpf]
826enabled = true
827interface = "ens3"
828xdp_mode = "native"
829ring_buffer_size = 524288
830blocklist_max_entries = 50000
831
832[log_pipeline]
833enabled = true
834sources = ["syslog", "file", "journald"]
835syslog_bind = "127.0.0.1:5140"
836watch_paths = ["/var/log/auth.log", "/var/log/kern.log"]
837batch_size = 200
838flush_interval_secs = 10
839
840[log_pipeline.storage]
841postgres_url = "postgresql://db:5432/ironpost"
842redis_url = "redis://cache:6379"
843retention_days = 90
844
845[container]
846enabled = true
847docker_socket = "/run/docker.sock"
848poll_interval_secs = 5
849policy_path = "/etc/ironpost/container-policies"
850auto_isolate = true
851
852[sbom]
853enabled = true
854scan_dirs = ["/app", "/opt"]
855vuln_db_update_hours = 12
856vuln_db_path = "/opt/ironpost/vuln-db"
857min_severity = "high"
858output_format = "spdx"
859"#;
860 let config = IronpostConfig::parse(toml).unwrap();
861 assert_eq!(config.general.log_level, "warn");
862 assert_eq!(config.ebpf.ring_buffer_size, 524288);
863 assert_eq!(config.log_pipeline.sources.len(), 3);
864 assert_eq!(config.log_pipeline.storage.retention_days, 90);
865 assert!(config.container.auto_isolate);
866 assert_eq!(config.sbom.output_format, "spdx");
867 }
868
869 #[test]
870 fn from_str_invalid_toml_returns_error() {
871 let result = IronpostConfig::parse("invalid = [[[toml");
872 assert!(result.is_err());
873 let err = result.unwrap_err();
874 assert!(matches!(
875 err,
876 IronpostError::Config(ConfigError::ParseFailed { .. })
877 ));
878 }
879
880 #[test]
881 fn validate_rejects_invalid_log_level() {
882 let mut config = IronpostConfig::default();
883 config.general.log_level = "verbose".to_owned();
884 let err = config.validate().unwrap_err();
885 assert!(err.to_string().contains("log_level"));
886 }
887
888 #[test]
889 fn validate_rejects_invalid_log_format() {
890 let mut config = IronpostConfig::default();
891 config.general.log_format = "xml".to_owned();
892 let err = config.validate().unwrap_err();
893 assert!(err.to_string().contains("log_format"));
894 }
895
896 #[test]
897 fn validate_rejects_invalid_xdp_mode_when_enabled() {
898 let mut config = IronpostConfig::default();
899 config.ebpf.enabled = true;
900 config.ebpf.xdp_mode = "turbo".to_owned();
901 let err = config.validate().unwrap_err();
902 assert!(err.to_string().contains("xdp_mode"));
903 }
904
905 #[test]
906 fn validate_accepts_invalid_xdp_mode_when_disabled() {
907 let mut config = IronpostConfig::default();
908 config.ebpf.enabled = false;
909 config.ebpf.xdp_mode = "turbo".to_owned();
910 config.validate().unwrap();
912 }
913
914 #[test]
915 fn validate_rejects_empty_interface_when_enabled() {
916 let mut config = IronpostConfig::default();
917 config.ebpf.enabled = true;
918 config.ebpf.interface = String::new();
919 let err = config.validate().unwrap_err();
920 assert!(err.to_string().contains("interface"));
921 }
922
923 #[test]
924 fn validate_rejects_invalid_sbom_format_when_enabled() {
925 let mut config = IronpostConfig::default();
926 config.sbom.enabled = true;
927 config.sbom.output_format = "xml".to_owned();
928 let err = config.validate().unwrap_err();
929 assert!(err.to_string().contains("output_format"));
930 }
931
932 #[test]
933 #[serial]
934 fn env_override_string() {
935 let mut val = "original".to_owned();
936 unsafe { std::env::set_var("TEST_IRONPOST_STR", "overridden") };
938 override_string(&mut val, "TEST_IRONPOST_STR");
939 assert_eq!(val, "overridden");
940 unsafe { std::env::remove_var("TEST_IRONPOST_STR") };
942 }
943
944 #[test]
945 #[serial]
946 fn env_override_bool_valid() {
947 let mut val = false;
948 unsafe { std::env::set_var("TEST_IRONPOST_BOOL", "true") };
950 override_bool(&mut val, "TEST_IRONPOST_BOOL");
951 assert!(val);
952 unsafe { std::env::remove_var("TEST_IRONPOST_BOOL") };
954 }
955
956 #[test]
957 #[serial]
958 fn env_override_bool_invalid_keeps_original() {
959 let mut val = false;
960 unsafe { std::env::set_var("TEST_IRONPOST_BOOL_BAD", "not-a-bool") };
962 override_bool(&mut val, "TEST_IRONPOST_BOOL_BAD");
963 assert!(!val); unsafe { std::env::remove_var("TEST_IRONPOST_BOOL_BAD") };
966 }
967
968 #[test]
969 #[serial]
970 fn env_override_csv() {
971 let mut val = vec!["a".to_owned()];
972 unsafe { std::env::set_var("TEST_IRONPOST_CSV", "x, y, z") };
974 override_csv(&mut val, "TEST_IRONPOST_CSV");
975 assert_eq!(val, vec!["x", "y", "z"]);
976 unsafe { std::env::remove_var("TEST_IRONPOST_CSV") };
978 }
979
980 #[test]
981 fn env_override_missing_var_keeps_original() {
982 let mut val = "original".to_owned();
983 override_string(&mut val, "TEST_IRONPOST_NONEXISTENT_12345");
984 assert_eq!(val, "original");
985 }
986
987 #[test]
988 fn config_serialize_roundtrip() {
989 let config = IronpostConfig::default();
990 let toml_str = toml::to_string_pretty(&config).unwrap();
991 let parsed = IronpostConfig::parse(&toml_str).unwrap();
992 assert_eq!(config.general.log_level, parsed.general.log_level);
993 assert_eq!(config.ebpf.interface, parsed.ebpf.interface);
994 assert_eq!(
995 config.log_pipeline.storage.retention_days,
996 parsed.log_pipeline.storage.retention_days
997 );
998 }
999
1000 #[tokio::test]
1001 async fn from_file_not_found() {
1002 let result = IronpostConfig::from_file("/nonexistent/path/ironpost.toml").await;
1003 assert!(result.is_err());
1004 let err = result.unwrap_err();
1005 assert!(matches!(
1006 err,
1007 IronpostError::Config(ConfigError::FileNotFound { .. })
1008 ));
1009 }
1010
1011 #[test]
1014 fn metrics_config_default() {
1015 let config = MetricsConfig::default();
1016 assert!(config.enabled);
1017 assert_eq!(config.listen_addr, "127.0.0.1");
1018 assert_eq!(config.port, 9100);
1019 assert_eq!(config.endpoint, "/metrics");
1020 }
1021
1022 #[test]
1023 fn metrics_config_default_passes_validation() {
1024 let config = MetricsConfig::default();
1025 config.validate().unwrap();
1026 }
1027
1028 #[test]
1029 fn config_without_metrics_section_uses_defaults() {
1030 let toml = r#"
1031[general]
1032log_level = "info"
1033"#;
1034 let config = IronpostConfig::parse(toml).unwrap();
1035 assert!(config.metrics.enabled);
1036 assert_eq!(config.metrics.port, 9100);
1037 assert_eq!(config.metrics.endpoint, "/metrics");
1038 }
1039
1040 #[test]
1041 fn config_with_metrics_section() {
1042 let toml = r#"
1043[metrics]
1044enabled = false
1045listen_addr = "127.0.0.1"
1046port = 9101
1047endpoint = "/prometheus"
1048"#;
1049 let config = IronpostConfig::parse(toml).unwrap();
1050 assert!(!config.metrics.enabled);
1051 assert_eq!(config.metrics.listen_addr, "127.0.0.1");
1052 assert_eq!(config.metrics.port, 9101);
1053 assert_eq!(config.metrics.endpoint, "/prometheus");
1054 }
1055
1056 #[test]
1057 fn metrics_config_validate_rejects_zero_port() {
1058 let config = MetricsConfig {
1059 port: 0,
1060 ..MetricsConfig::default()
1061 };
1062 let err = config.validate().unwrap_err();
1063 assert!(err.to_string().contains("metrics.port"));
1064 }
1065
1066 #[test]
1067 fn metrics_config_validate_rejects_empty_listen_addr() {
1068 let config = MetricsConfig {
1069 listen_addr: String::new(),
1070 ..MetricsConfig::default()
1071 };
1072 let err = config.validate().unwrap_err();
1073 assert!(err.to_string().contains("metrics.listen_addr"));
1074 }
1075
1076 #[test]
1077 fn metrics_config_validate_rejects_endpoint_without_slash() {
1078 let config = MetricsConfig {
1079 endpoint: "metrics".to_owned(),
1080 ..MetricsConfig::default()
1081 };
1082 let err = config.validate().unwrap_err();
1083 assert!(err.to_string().contains("metrics.endpoint"));
1084 assert!(err.to_string().contains("start with '/'"));
1085 }
1086
1087 #[test]
1088 fn metrics_config_validate_rejects_non_default_endpoint() {
1089 let config = MetricsConfig {
1090 endpoint: "/prometheus/metrics".to_owned(),
1091 ..MetricsConfig::default()
1092 };
1093 let err = config.validate().unwrap_err();
1094 assert!(err.to_string().contains("metrics.endpoint"));
1095 assert!(err.to_string().contains("only '/metrics'"));
1096 }
1097
1098 #[test]
1099 fn ironpost_config_rejects_non_default_metrics_endpoint_when_enabled() {
1100 let mut config = IronpostConfig::default();
1101 config.metrics.enabled = true;
1102 config.metrics.endpoint = "/custom".to_owned();
1103
1104 let err = config.validate().unwrap_err();
1105 assert!(err.to_string().contains("metrics.endpoint"));
1106 assert!(err.to_string().contains("only '/metrics'"));
1107 }
1108
1109 #[test]
1110 #[serial]
1111 fn metrics_env_override_enabled() {
1112 let mut config = IronpostConfig::default();
1113 unsafe { std::env::set_var("IRONPOST_METRICS_ENABLED", "false") };
1115 config.apply_env_overrides();
1116 assert!(!config.metrics.enabled);
1117 unsafe { std::env::remove_var("IRONPOST_METRICS_ENABLED") };
1119 }
1120
1121 #[test]
1122 #[serial]
1123 fn metrics_env_override_listen_addr() {
1124 let mut config = IronpostConfig::default();
1125 unsafe { std::env::set_var("IRONPOST_METRICS_LISTEN_ADDR", "127.0.0.1") };
1127 config.apply_env_overrides();
1128 assert_eq!(config.metrics.listen_addr, "127.0.0.1");
1129 unsafe { std::env::remove_var("IRONPOST_METRICS_LISTEN_ADDR") };
1131 }
1132
1133 #[test]
1134 #[serial]
1135 fn metrics_env_override_port() {
1136 let mut config = IronpostConfig::default();
1137 unsafe { std::env::set_var("IRONPOST_METRICS_PORT", "9999") };
1139 config.apply_env_overrides();
1140 assert_eq!(config.metrics.port, 9999);
1141 unsafe { std::env::remove_var("IRONPOST_METRICS_PORT") };
1143 }
1144
1145 #[test]
1146 #[serial]
1147 fn metrics_env_override_endpoint() {
1148 let mut config = IronpostConfig::default();
1149 unsafe { std::env::set_var("IRONPOST_METRICS_ENDPOINT", "/custom") };
1151 config.apply_env_overrides();
1152 assert_eq!(config.metrics.endpoint, "/custom");
1153 unsafe { std::env::remove_var("IRONPOST_METRICS_ENDPOINT") };
1155 }
1156
1157 #[test]
1158 #[serial]
1159 fn metrics_env_override_port_invalid_keeps_original() {
1160 let mut config = IronpostConfig::default();
1161 let original_port = config.metrics.port;
1162 unsafe { std::env::set_var("IRONPOST_METRICS_PORT", "not-a-number") };
1164 config.apply_env_overrides();
1165 assert_eq!(config.metrics.port, original_port);
1166 unsafe { std::env::remove_var("IRONPOST_METRICS_PORT") };
1168 }
1169
1170 #[test]
1171 fn ironpost_config_validates_metrics_when_enabled() {
1172 let mut config = IronpostConfig::default();
1173 config.metrics.enabled = true;
1174 config.metrics.port = 0; let err = config.validate().unwrap_err();
1176 assert!(err.to_string().contains("metrics.port"));
1177 }
1178
1179 #[test]
1180 fn ironpost_config_skips_metrics_validation_when_disabled() {
1181 let mut config = IronpostConfig::default();
1182 config.metrics.enabled = false;
1183 config.metrics.port = 0; config.validate().unwrap(); }
1186}