1use std::{
2 collections::BTreeMap,
3 env,
4 error::Error,
5 ffi::OsString,
6 fmt, fs, io,
7 path::{Path, PathBuf},
8 time::Duration,
9};
10
11use cortexkit_log::Retention;
12use serde::Deserialize;
13use subc_control::ModuleProtocol;
14use subc_jsonc::jsonc_to_json;
15use subc_protocol::manifest::is_valid_capability_identifier;
16
17use crate::{
18 supervise::{ModuleOverlap, SUBC_SPAWN_ROLE_ENV},
19 HealthAction, HealthConfig, ModuleSpec, RestartPolicy,
20};
21
22const DAEMON_CONFIG_RELATIVE_PATH: &str = "cortexkit/subc.jsonc";
23const SUPPORTED_CONFIG_VERSION: u32 = 1;
24pub(crate) const CK_LOG_ENV: &str = "CK_LOG";
25pub(crate) const CAPTURE_MAX_FILE_MB_ENV: &str = "__SUBC_CAPTURE_LOG_MAX_FILE_MB";
26pub(crate) const CAPTURE_KEEP_ENV: &str = "__SUBC_CAPTURE_LOG_KEEP";
27pub(crate) const CAPTURE_MAX_AGE_DAYS_ENV: &str = "__SUBC_CAPTURE_LOG_MAX_AGE_DAYS";
28pub(crate) const CHILD_LOG_MAX_AGE_DAYS_ENV: &str = "CK_LOG_MAX_AGE_DAYS";
32pub(crate) const CHILD_LOG_ALARM_SEGMENT_MB_ENV: &str = "CK_LOG_ALARM_SEGMENT_MB";
33
34#[derive(Clone, Copy, Debug, Eq, PartialEq)]
41pub enum RestartRequiredSection {
42 Port,
43 Storage,
44 AdmissionFactsCarrierModuleId,
45 AdmissionFactsTargets,
46}
47
48impl RestartRequiredSection {
49 pub const ALL: [Self; 4] = [
50 Self::Port,
51 Self::Storage,
52 Self::AdmissionFactsCarrierModuleId,
53 Self::AdmissionFactsTargets,
54 ];
55
56 pub const fn label(self) -> &'static str {
57 match self {
58 Self::Port => "port",
59 Self::Storage => "storage",
60 Self::AdmissionFactsCarrierModuleId => "admission_facts_carrier_module_id",
61 Self::AdmissionFactsTargets => "admission_facts_targets",
62 }
63 }
64}
65
66const ROUTE_BIND_RELAY_ZERO_MESSAGE: &str = "route_bind_relay_timeout_ms must be greater than 0 (a zero budget fails every bind to the module; to make a module unreachable use enabled: false)";
77
78const RESTART_WINDOW_ZERO_MESSAGE: &str = "restart.window_secs must be greater than 0 (a zero window holds no crash, so the budget can never be spent; for effectively unlimited restarts set a deliberately large window_secs, and to stop restarting entirely set restart.max_restarts: 0)";
85
86#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct LoggingConfig {
94 pub level: String,
95 pub tags: BTreeMap<String, String>,
100 pub retention: Retention,
101 pub alarm_segment_mb: u32,
103}
104
105impl LoggingConfig {
106 pub fn filter_spec(&self, module_id: &str) -> String {
115 let mut directives = vec![self.level.clone()];
116 directives.extend(self.tags.iter().map(|(logger, level)| {
117 if logger == module_id || logger.contains('.') {
118 format!("{logger}={level}")
119 } else {
120 format!("{module_id}.{logger}={level}")
121 }
122 }));
123 directives.join(",")
124 }
125
126 pub fn segment_retention(&self) -> cortexkit_log::SegmentRetention {
127 cortexkit_log::SegmentRetention {
128 max_age_days: self.retention.max_age_days,
129 alarm_segment_mb: self.alarm_segment_mb,
130 }
131 }
132}
133
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct DaemonConfig {
136 pub path: PathBuf,
137 pub port: Option<u16>,
138 pub drain_timeout_ms: Option<u64>,
142 pub route_bind_relay_timeout_ms: Option<u64>,
151 pub modules: Vec<ConfiguredModule>,
152 pub storage: Option<StorageConfig>,
155 pub admission_facts_carrier_module_id: Option<String>,
157 pub admission_facts_targets: Option<Vec<String>>,
159 pub reserved_capabilities: BTreeMap<String, String>,
163}
164
165#[derive(Debug, Clone, PartialEq, Eq)]
169pub enum StorageConfig {
170 Sqlite { data_home: PathBuf },
172}
173
174impl StorageConfig {
175 pub fn descriptor_for(&self, module_id: &str) -> serde_json::Value {
201 match self {
202 StorageConfig::Sqlite { data_home } => {
211 let data_home = data_home.to_string_lossy();
212 let path = format!(
213 "{}/cortexkit/{module_id}/store.db",
214 data_home.trim_end_matches('/')
215 );
216 serde_json::json!({
217 "module_id": module_id,
218 "storage_namespace": "default",
219 "isolation": { "kind": "module" },
220 "backend": { "backend": "sqlite", "path": path },
221 })
222 }
223 }
224 }
225}
226
227#[derive(Debug, Clone, PartialEq, Eq)]
228pub struct ConfiguredModule {
229 pub module_id: String,
230 pub program: PathBuf,
231 pub args: Vec<String>,
232 pub env: Vec<(String, String)>,
233 pub log: Option<LoggingConfig>,
237 pub enabled: bool,
238 pub reserved: bool,
244 pub reserved_prefixes: Vec<String>,
248 pub protocol: ModuleProtocol,
251 pub overlap: ModuleOverlap,
254 pub health: HealthConfig,
255 pub drain_timeout_ms: Option<u64>,
258 pub route_bind_relay_timeout_ms: Option<u64>,
263 pub restart: RestartPolicy,
274}
275
276impl ConfiguredModule {
277 pub fn module_spec(&self) -> ModuleSpec {
278 let mut env = self.env.clone();
279 if let Some(log) = &self.log {
280 env.retain(|(key, _)| {
281 key != CK_LOG_ENV
282 && key != CAPTURE_MAX_FILE_MB_ENV
283 && key != CAPTURE_KEEP_ENV
284 && key != CAPTURE_MAX_AGE_DAYS_ENV
285 });
286 env.retain(|(key, _)| {
287 key != CHILD_LOG_MAX_AGE_DAYS_ENV && key != CHILD_LOG_ALARM_SEGMENT_MB_ENV
288 });
289 env.push((CK_LOG_ENV.to_string(), log.filter_spec(&self.module_id)));
290 env.push((
291 CHILD_LOG_MAX_AGE_DAYS_ENV.to_string(),
292 log.retention.max_age_days.to_string(),
293 ));
294 env.push((
295 CHILD_LOG_ALARM_SEGMENT_MB_ENV.to_string(),
296 log.alarm_segment_mb.to_string(),
297 ));
298 env.push((
302 CAPTURE_MAX_FILE_MB_ENV.to_string(),
303 log.retention.max_file_mb.to_string(),
304 ));
305 env.push((CAPTURE_KEEP_ENV.to_string(), log.retention.keep.to_string()));
306 env.push((
307 CAPTURE_MAX_AGE_DAYS_ENV.to_string(),
308 log.retention.max_age_days.to_string(),
309 ));
310 }
311 ModuleSpec {
312 module_id: self.module_id.clone(),
313 program: self.program.clone(),
314 args: self.args.clone(),
315 env,
316 reserved: self.reserved,
317 reserved_prefixes: self.reserved_prefixes.clone(),
318 protocol: self.protocol,
319 overlap: self.overlap,
320 }
321 }
322}
323
324#[derive(Debug)]
325pub enum DaemonConfigError {
326 Read {
327 path: PathBuf,
328 source: io::Error,
329 },
330 InvalidJsonc {
331 path: PathBuf,
332 message: String,
333 },
334 InvalidJson {
335 path: PathBuf,
336 source: serde_json::Error,
337 },
338 UnsupportedVersion {
339 path: PathBuf,
340 version: u32,
341 },
342 InvalidValue {
343 path: PathBuf,
344 message: String,
345 },
346}
347
348#[derive(Debug, Deserialize)]
349struct RawDaemonConfig {
350 version: u32,
351 #[serde(default)]
352 port: Option<u16>,
353 #[serde(default)]
354 drain_timeout_ms: Option<u64>,
355 #[serde(default)]
356 route_bind_relay_timeout_ms: Option<u64>,
357 #[serde(default)]
358 log: Option<RawLoggingConfig>,
359 #[serde(default)]
360 modules: BTreeMap<String, RawModuleConfig>,
361 #[serde(default)]
362 storage: Option<RawStorageConfig>,
363 #[serde(default)]
364 admission_facts_carrier_module_id: Option<String>,
365 #[serde(default)]
366 admission_facts_targets: Option<Vec<String>>,
367 #[serde(default)]
368 reserved_capabilities: BTreeMap<String, String>,
369}
370
371#[derive(Debug, Deserialize)]
372#[serde(tag = "backend", rename_all = "snake_case")]
373enum RawStorageConfig {
374 Sqlite {
375 #[serde(default)]
378 data_home: Option<PathBuf>,
379 },
380}
381
382#[derive(Debug, Deserialize)]
383struct RawModuleConfig {
384 program: PathBuf,
385 #[serde(default)]
386 args: Vec<String>,
387 #[serde(default)]
388 env: BTreeMap<String, String>,
389 #[serde(default)]
390 log: Option<RawLoggingConfig>,
391 #[serde(default = "default_enabled")]
392 enabled: bool,
393 #[serde(default)]
394 reserved: bool,
395 #[serde(default)]
396 reserved_prefixes: Vec<String>,
397 #[serde(default)]
401 protocol: Option<String>,
402 #[serde(default)]
404 overlap: Option<String>,
405 #[serde(default)]
406 health: Option<RawHealthConfig>,
407 #[serde(default)]
408 drain_timeout_ms: Option<u64>,
409 #[serde(default)]
410 route_bind_relay_timeout_ms: Option<u64>,
411 #[serde(default)]
412 restart: Option<RawRestartConfig>,
413}
414
415#[derive(Debug, Clone, Deserialize)]
416struct RawLoggingConfig {
417 #[serde(default)]
418 level: Option<String>,
419 #[serde(default)]
420 tags: BTreeMap<String, String>,
421 #[serde(default)]
422 alarm_segment_mb: Option<u32>,
423 #[serde(default)]
424 max_file_mb: Option<u32>,
425 #[serde(default)]
426 keep: Option<u8>,
427 #[serde(default)]
428 max_age_days: Option<u32>,
429}
430
431#[derive(Debug, Deserialize)]
432struct RawRestartConfig {
433 #[serde(default)]
434 max_restarts: Option<u32>,
435 #[serde(default)]
436 window_secs: Option<u64>,
437 #[serde(default)]
438 backoff_ms: Option<u64>,
439 #[serde(default)]
440 max_backoff_ms: Option<u64>,
441}
442
443#[derive(Debug, Deserialize)]
444struct RawHealthConfig {
445 #[serde(default)]
446 cadence_ms: Option<u64>,
447 #[serde(default)]
448 deadline_ms: Option<u64>,
449 #[serde(default)]
450 failure_threshold: Option<u32>,
451 #[serde(default)]
452 on_degraded: Option<RawHealthAction>,
453 #[serde(default)]
454 on_failing: Option<RawHealthAction>,
455 #[serde(default)]
456 critical: bool,
457}
458
459#[derive(Debug, Deserialize)]
460#[serde(rename_all = "snake_case")]
461enum RawHealthAction {
462 Report,
463 Restart,
464 Alert,
465}
466
467pub fn default_config_path() -> PathBuf {
468 default_config_home().join(DAEMON_CONFIG_RELATIVE_PATH)
469}
470
471pub fn default_config_home() -> PathBuf {
493 if let Some(config_home) = non_empty_os_var("XDG_CONFIG_HOME") {
494 return PathBuf::from(config_home);
495 }
496
497 #[cfg(windows)]
498 {
499 if let Some(app_data) = non_empty_os_var("APPDATA") {
500 return PathBuf::from(app_data);
501 }
502 if let Some(user_profile) = non_empty_os_var("USERPROFILE") {
503 return PathBuf::from(user_profile).join("AppData").join("Roaming");
504 }
505 }
506
507 if let Some(home) = non_empty_os_var("HOME") {
508 return PathBuf::from(home).join(".config");
509 }
510
511 PathBuf::from(".config")
512}
513
514pub fn load(path: impl AsRef<Path>) -> Result<Option<DaemonConfig>, DaemonConfigError> {
515 let path = path.as_ref();
516 let Some(doc) = read_config_doc(path)? else {
517 return Ok(None);
518 };
519 parse_doc(&doc, path).map(Some)
520}
521
522pub fn load_logging(path: impl AsRef<Path>) -> Result<Option<LoggingConfig>, DaemonConfigError> {
528 let path = path.as_ref();
529 let Some(doc) = read_config_doc(path)? else {
530 return Ok(None);
531 };
532 let json = jsonc_to_json(&doc).map_err(|message| DaemonConfigError::InvalidJsonc {
533 path: path.to_path_buf(),
534 message,
535 })?;
536 let raw: RawDaemonConfig =
537 serde_json::from_str(&json).map_err(|source| DaemonConfigError::InvalidJson {
538 path: path.to_path_buf(),
539 source,
540 })?;
541 if raw.version != SUPPORTED_CONFIG_VERSION {
542 return Err(DaemonConfigError::UnsupportedVersion {
543 path: path.to_path_buf(),
544 version: raw.version,
545 });
546 }
547 raw.log
548 .map(|log| parse_logging_config(log, path, "daemon log"))
549 .transpose()
550}
551
552pub fn ensure_daemon_run_dir_private() -> Result<PathBuf, io::Error> {
578 let path = daemon_run_dir()
579 .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
580 ensure_directory_private(&path)?;
581 Ok(path)
582}
583
584#[cfg(unix)]
589fn ensure_directory_private(path: &Path) -> Result<(), io::Error> {
590 use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
591
592 if !path.exists() {
593 fs::DirBuilder::new()
594 .recursive(true)
595 .mode(0o700)
596 .create(path)?;
597 return Ok(());
598 }
599 let mode = fs::metadata(path)?.permissions().mode() & 0o777;
600 if mode & 0o077 != 0 {
601 fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
602 }
603 Ok(())
604}
605
606#[cfg(not(unix))]
608fn ensure_directory_private(path: &Path) -> Result<(), io::Error> {
609 if !path.exists() {
610 fs::create_dir_all(path)?;
611 }
612 Ok(())
613}
614
615pub fn daemon_run_dir() -> Result<PathBuf, DaemonRunDirError> {
625 daemon_run_dir_from(default_data_home())
626}
627
628fn daemon_run_dir_from(data_home: PathBuf) -> Result<PathBuf, DaemonRunDirError> {
631 if !data_home.is_absolute() {
632 return Err(DaemonRunDirError::RelativeDataHome { data_home });
633 }
634 Ok(data_home.join("cortexkit").join("run"))
635}
636
637#[derive(Debug, Clone, PartialEq, Eq)]
639pub enum DaemonRunDirError {
640 RelativeDataHome { data_home: PathBuf },
643}
644
645impl fmt::Display for DaemonRunDirError {
646 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
647 match self {
648 Self::RelativeDataHome { data_home } => write!(
649 f,
650 "cannot resolve the daemon run directory: the data home `{}` is relative, \
651 so it would land under the current working directory; {}",
652 data_home.display(),
653 DATA_HOME_REMEDY
654 ),
655 }
656 }
657}
658
659impl std::error::Error for DaemonRunDirError {}
660
661#[cfg(windows)]
663const DATA_HOME_REMEDY: &str =
664 "set XDG_DATA_HOME to an absolute path, or set APPDATA, USERPROFILE or HOME";
665#[cfg(not(windows))]
666const DATA_HOME_REMEDY: &str = "set XDG_DATA_HOME to an absolute path, or set HOME";
667
668fn read_config_doc(path: &Path) -> Result<Option<String>, DaemonConfigError> {
669 match fs::read_to_string(path) {
670 Ok(doc) => Ok(Some(doc)),
671 Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(None),
672 Err(source) => Err(DaemonConfigError::Read {
673 path: path.to_path_buf(),
674 source,
675 }),
676 }
677}
678
679fn parse_doc(doc: &str, path: &Path) -> Result<DaemonConfig, DaemonConfigError> {
680 let json = jsonc_to_json(doc).map_err(|message| DaemonConfigError::InvalidJsonc {
681 path: path.to_path_buf(),
682 message,
683 })?;
684 let raw: RawDaemonConfig =
685 serde_json::from_str(&json).map_err(|source| DaemonConfigError::InvalidJson {
686 path: path.to_path_buf(),
687 source,
688 })?;
689
690 if raw.version != SUPPORTED_CONFIG_VERSION {
691 return Err(DaemonConfigError::UnsupportedVersion {
692 path: path.to_path_buf(),
693 version: raw.version,
694 });
695 }
696
697 let daemon_logging = raw
698 .log
699 .map(|log| parse_logging_config(log, path, "daemon log"))
700 .transpose()?;
701 let default_drain_timeout_ms = raw.drain_timeout_ms;
702 let default_route_bind_relay_timeout_ms = match raw.route_bind_relay_timeout_ms {
708 Some(0) => {
709 return Err(DaemonConfigError::InvalidValue {
710 path: path.to_path_buf(),
711 message: ROUTE_BIND_RELAY_ZERO_MESSAGE.to_string(),
712 });
713 }
714 Some(value) => Some(value),
715 None => None,
716 };
717 let modules = raw
718 .modules
719 .into_iter()
720 .map(|(module_id, module)| {
721 let health = module
722 .health
723 .map(|health| parse_health_config(health, path, &module_id))
724 .transpose()?
725 .unwrap_or_default();
726 if let Err(reason) = crate::registry::module_id_path_hazard(&module_id) {
727 return Err(DaemonConfigError::InvalidValue {
728 path: path.to_path_buf(),
729 message: format!(
730 "module id '{}' is not usable as a path component ({reason}): \
731 the daemon derives each module's store path from its id",
732 module_id.escape_debug()
733 ),
734 });
735 }
736 let per_module_route_bind_relay_timeout_ms = match module.route_bind_relay_timeout_ms {
741 Some(0) => {
742 return Err(DaemonConfigError::InvalidValue {
743 path: path.to_path_buf(),
744 message: format!(
745 "module '{module_id}' {ROUTE_BIND_RELAY_ZERO_MESSAGE}",
746 module_id = module_id.escape_debug()
747 ),
748 });
749 }
750 Some(value) => Some(value),
751 None => default_route_bind_relay_timeout_ms,
752 };
753 let protocol = parse_module_protocol(module.protocol.as_deref(), path, &module_id)?;
754 let overlap = parse_module_overlap(module.overlap.as_deref(), path, &module_id)?;
755 if module.env.contains_key(SUBC_SPAWN_ROLE_ENV) {
759 return Err(DaemonConfigError::InvalidValue {
760 path: path.to_path_buf(),
761 message: format!(
762 "module '{module_id}' sets {SUBC_SPAWN_ROLE_ENV} in env; that variable is set by the supervisor on a swap candidate only and cannot be configured",
763 module_id = module_id.escape_debug()
764 ),
765 });
766 }
767 if protocol == ModuleProtocol::None && module.reserved {
774 return Err(DaemonConfigError::InvalidValue {
775 path: path.to_path_buf(),
776 message: format!(
777 "module '{module_id}' sets reserved: true with protocol: \"none\"; \
778 reserved is enforced on the module's HELLO and a protocol: \"none\" \
779 module never registers, so the reservation could never be checked",
780 module_id = module_id.escape_debug()
781 ),
782 });
783 }
784 let restart = parse_restart_config(module.restart, path, &module_id)?;
785 let log = module
786 .log
787 .map(|log| parse_logging_config(log, path, &format!("module '{module_id}' log")))
788 .transpose()?
789 .or_else(|| daemon_logging.clone());
790 Ok(ConfiguredModule {
791 module_id,
792 program: module.program,
793 args: module.args,
794 env: module.env.into_iter().collect(),
795 log,
796 enabled: module.enabled,
797 reserved: module.reserved,
798 reserved_prefixes: module.reserved_prefixes,
799 protocol,
800 overlap,
801 health,
802 drain_timeout_ms: module.drain_timeout_ms.or(default_drain_timeout_ms),
805 route_bind_relay_timeout_ms: per_module_route_bind_relay_timeout_ms,
810 restart,
811 })
812 })
813 .collect::<Result<Vec<_>, DaemonConfigError>>()?;
814
815 validate_reserved_prefixes(&modules, path)?;
816 validate_reserved_capabilities(&raw.reserved_capabilities, path)?;
817 validate_admission_facts_config(
818 &modules,
819 raw.admission_facts_carrier_module_id.as_deref(),
820 raw.admission_facts_targets.as_deref(),
821 path,
822 )?;
823
824 let storage = raw
825 .storage
826 .map(|s| match s {
827 RawStorageConfig::Sqlite { data_home } => {
828 let data_home = data_home.unwrap_or_else(default_data_home);
829 if !data_home.is_absolute() {
838 return Err(DaemonConfigError::InvalidValue {
839 path: path.to_path_buf(),
840 message: format!(
841 "storage data home resolved to the relative path {} \
842 (no absolute XDG_DATA_HOME, APPDATA, USERPROFILE, or HOME \
843 in the daemon's environment); refusing to serve a \
844 cwd-relative storage descriptor to modules. Set \
845 XDG_DATA_HOME or HOME to an absolute path, or set \
846 storage.data_home in this file.",
847 data_home.display()
848 ),
849 });
850 }
851 Ok(StorageConfig::Sqlite { data_home })
852 }
853 })
854 .transpose()?;
855
856 Ok(DaemonConfig {
857 path: path.to_path_buf(),
858 port: raw.port,
859 drain_timeout_ms: default_drain_timeout_ms,
860 route_bind_relay_timeout_ms: default_route_bind_relay_timeout_ms,
861 modules,
862 storage,
863 admission_facts_carrier_module_id: raw.admission_facts_carrier_module_id,
864 admission_facts_targets: raw.admission_facts_targets,
865 reserved_capabilities: raw.reserved_capabilities,
866 })
867}
868
869fn parse_logging_config(
870 raw: RawLoggingConfig,
871 path: &Path,
872 owner: &str,
873) -> Result<LoggingConfig, DaemonConfigError> {
874 fn valid_level(level: &str) -> bool {
875 matches!(level, "off" | "error" | "warn" | "info" | "debug" | "trace")
876 }
877
878 let level = raw.level.unwrap_or_else(|| "info".to_string());
879 if !valid_level(&level) {
880 return Err(DaemonConfigError::InvalidValue {
881 path: path.to_path_buf(),
882 message: format!(
883 "{owner}.level must be one of off, error, warn, info, debug, trace; got {level:?}"
884 ),
885 });
886 }
887 for (tag, tag_level) in &raw.tags {
888 let well_formed = !tag.is_empty()
893 && tag.split('.').all(|segment| {
894 let mut chars = segment.chars();
895 matches!(chars.next(), Some('a'..='z'))
896 && chars.all(|c| matches!(c, 'a'..='z' | '0'..='9' | '-'))
897 });
898 if !well_formed {
899 return Err(DaemonConfigError::InvalidValue {
900 path: path.to_path_buf(),
901 message: format!(
902 "{owner}.tags key {tag:?} is not a logger name (dotted segments of [a-z][a-z0-9-]*)"
903 ),
904 });
905 }
906 if !valid_level(tag_level) {
907 return Err(DaemonConfigError::InvalidValue {
908 path: path.to_path_buf(),
909 message: format!(
910 "{owner}.tags.{tag} must be one of off, error, warn, info, debug, trace; got {tag_level:?}"
911 ),
912 });
913 }
914 }
915
916 let defaults = Retention::default();
917 let retention = Retention {
918 max_file_mb: raw.max_file_mb.unwrap_or(defaults.max_file_mb),
919 keep: raw.keep.unwrap_or(defaults.keep),
920 max_age_days: raw.max_age_days.unwrap_or(defaults.max_age_days),
921 };
922 if retention.max_file_mb == 0 {
923 return Err(DaemonConfigError::InvalidValue {
924 path: path.to_path_buf(),
925 message: format!("{owner}.max_file_mb must be greater than 0"),
926 });
927 }
928
929 let alarm_segment_mb = raw
930 .alarm_segment_mb
931 .unwrap_or(cortexkit_log::SegmentRetention::default().alarm_segment_mb);
932 if alarm_segment_mb == 0 {
933 return Err(DaemonConfigError::InvalidValue {
934 path: path.to_path_buf(),
935 message: format!("{owner}.alarm_segment_mb must be greater than 0"),
936 });
937 }
938
939 Ok(LoggingConfig {
940 level,
941 tags: raw.tags,
942 retention,
943 alarm_segment_mb,
944 })
945}
946
947fn parse_module_protocol(
956 raw: Option<&str>,
957 path: &Path,
958 module_id: &str,
959) -> Result<ModuleProtocol, DaemonConfigError> {
960 match raw {
961 None | Some("subc") => Ok(ModuleProtocol::Subc),
962 Some("none") => Ok(ModuleProtocol::None),
963 Some(other) => Err(DaemonConfigError::InvalidValue {
967 path: path.to_path_buf(),
968 message: format!(
969 "module '{module_id}' declares protocol {other:?}; supported values are \
970 \"subc\" (the default when the key is absent) and \"none\"",
971 module_id = module_id.escape_debug(),
972 ),
973 }),
974 }
975}
976
977fn parse_module_overlap(
981 raw: Option<&str>,
982 path: &Path,
983 module_id: &str,
984) -> Result<ModuleOverlap, DaemonConfigError> {
985 match raw {
986 None | Some("exclusive") => Ok(ModuleOverlap::Exclusive),
987 Some("safe") => Ok(ModuleOverlap::Safe),
988 Some(other) => Err(DaemonConfigError::InvalidValue {
989 path: path.to_path_buf(),
990 message: format!(
991 "module '{module_id}' declares overlap {other:?}; supported values are \
992 \"exclusive\" (the default when the key is absent) and \"safe\"",
993 module_id = module_id.escape_debug(),
994 ),
995 }),
996 }
997}
998
999fn validate_reserved_capabilities(
1000 bindings: &BTreeMap<String, String>,
1001 path: &Path,
1002) -> Result<(), DaemonConfigError> {
1003 for (capability, module_id) in bindings {
1004 if !is_valid_capability_identifier(capability) {
1005 return Err(DaemonConfigError::InvalidValue {
1006 path: path.to_path_buf(),
1007 message: format!(
1008 "reserved_capabilities key {:?} is not a valid capability identifier",
1009 capability
1010 ),
1011 });
1012 }
1013 if module_id.trim().is_empty() {
1014 return Err(DaemonConfigError::InvalidValue {
1015 path: path.to_path_buf(),
1016 message: format!(
1017 "reserved_capabilities binding for {:?} has an empty module id",
1018 capability
1019 ),
1020 });
1021 }
1022 if let Err(reason) = crate::registry::module_id_path_hazard(module_id) {
1023 return Err(DaemonConfigError::InvalidValue {
1024 path: path.to_path_buf(),
1025 message: format!(
1026 "reserved_capabilities binding for {:?} has an unusable module id {:?}: {reason}",
1027 capability, module_id
1028 ),
1029 });
1030 }
1031 }
1032 Ok(())
1033}
1034
1035fn validate_admission_facts_config(
1036 modules: &[ConfiguredModule],
1037 carrier_module_id: Option<&str>,
1038 targets: Option<&[String]>,
1039 path: &Path,
1040) -> Result<(), DaemonConfigError> {
1041 let Some(carrier_module_id) = carrier_module_id else {
1042 return Ok(());
1043 };
1044
1045 let Some(carrier) = modules
1046 .iter()
1047 .find(|module| module.module_id == carrier_module_id)
1048 else {
1049 return Err(DaemonConfigError::InvalidValue {
1050 path: path.to_path_buf(),
1051 message: format!(
1052 "admission_facts_carrier_module_id '{carrier_module_id}' must name a configured module"
1053 ),
1054 });
1055 };
1056 if !carrier.enabled || !carrier.reserved {
1057 return Err(DaemonConfigError::InvalidValue {
1058 path: path.to_path_buf(),
1059 message: format!(
1060 "admission_facts_carrier_module_id '{carrier_module_id}' must name an enabled reserved module"
1061 ),
1062 });
1063 }
1064
1065 let Some(targets) = targets else {
1066 return Err(DaemonConfigError::InvalidValue {
1067 path: path.to_path_buf(),
1068 message: "admission_facts_targets must be present when an admission facts carrier is configured".to_string(),
1069 });
1070 };
1071 if targets.is_empty() || targets.iter().any(String::is_empty) {
1072 return Err(DaemonConfigError::InvalidValue {
1073 path: path.to_path_buf(),
1074 message:
1075 "admission_facts_targets must be non-empty and must not contain empty module ids"
1076 .to_string(),
1077 });
1078 }
1079
1080 Ok(())
1081}
1082
1083fn default_enabled() -> bool {
1084 true
1085}
1086
1087fn validate_reserved_prefixes(
1088 modules: &[ConfiguredModule],
1089 path: &Path,
1090) -> Result<(), DaemonConfigError> {
1091 for module in modules {
1092 if module.reserved_prefixes.is_empty() {
1093 continue;
1094 }
1095 if !module.reserved {
1096 return Err(DaemonConfigError::InvalidValue {
1097 path: path.to_path_buf(),
1098 message: format!(
1099 "module '{}' reserved_prefixes require reserved=true so the owner is spawn-nonce protected",
1100 module.module_id
1101 ),
1102 });
1103 }
1104 for prefix in &module.reserved_prefixes {
1105 if !prefix.ends_with(':') {
1106 return Err(DaemonConfigError::InvalidValue {
1107 path: path.to_path_buf(),
1108 message: format!(
1109 "module '{}' reserved prefix '{}' must end with ':'",
1110 module.module_id, prefix
1111 ),
1112 });
1113 }
1114 }
1115 }
1116
1117 for module in modules {
1118 for prefix in &module.reserved_prefixes {
1119 if let Some(colliding) = modules
1120 .iter()
1121 .find(|candidate| candidate.module_id.starts_with(prefix))
1122 {
1123 return Err(DaemonConfigError::InvalidValue {
1124 path: path.to_path_buf(),
1125 message: format!(
1126 "reserved prefix '{}' owned by '{}' collides with configured module id '{}'",
1127 prefix, module.module_id, colliding.module_id
1128 ),
1129 });
1130 }
1131 }
1132 }
1133
1134 for (left_index, left) in modules.iter().enumerate() {
1135 for right in modules.iter().skip(left_index + 1) {
1136 if left.module_id == right.module_id {
1137 continue;
1138 }
1139 for left_prefix in &left.reserved_prefixes {
1140 for right_prefix in &right.reserved_prefixes {
1141 if left_prefix.starts_with(right_prefix)
1142 || right_prefix.starts_with(left_prefix)
1143 {
1144 return Err(DaemonConfigError::InvalidValue {
1145 path: path.to_path_buf(),
1146 message: format!(
1147 "reserved prefixes '{}' owned by '{}' and '{}' owned by '{}' overlap",
1148 left_prefix, left.module_id, right_prefix, right.module_id
1149 ),
1150 });
1151 }
1152 }
1153 }
1154 }
1155 }
1156
1157 Ok(())
1158}
1159
1160fn parse_health_config(
1161 raw: RawHealthConfig,
1162 path: &Path,
1163 module_id: &str,
1164) -> Result<HealthConfig, DaemonConfigError> {
1165 let defaults = HealthConfig::default();
1166 let cadence = positive_millis(
1167 raw.cadence_ms,
1168 defaults.cadence,
1169 path,
1170 module_id,
1171 "cadence_ms",
1172 )?;
1173 let deadline = positive_millis(
1174 raw.deadline_ms,
1175 defaults.deadline,
1176 path,
1177 module_id,
1178 "deadline_ms",
1179 )?;
1180 let failure_threshold = match raw.failure_threshold {
1181 Some(0) => {
1182 return Err(DaemonConfigError::InvalidValue {
1183 path: path.to_path_buf(),
1184 message: format!("module '{module_id}' health.failure_threshold must be positive"),
1185 })
1186 }
1187 Some(value) => value,
1188 None => defaults.failure_threshold,
1189 };
1190
1191 Ok(HealthConfig {
1192 cadence,
1193 deadline,
1194 failure_threshold,
1195 on_degraded: match raw.on_degraded {
1196 Some(RawHealthAction::Restart) => {
1197 return Err(DaemonConfigError::InvalidValue {
1198 path: path.to_path_buf(),
1199 message: format!(
1200 "module '{module_id}' health.on_degraded may not be 'restart': a degraded module is slow-but-moving, so restarting it converts transient load into an outage. Use 'report' or 'alert' (Health-Path v2: only total wreckage or reported-unresponsiveness restarts)."
1201 ),
1202 });
1203 }
1204 Some(action) => health_action(action),
1205 None => defaults.on_degraded,
1206 },
1207 on_failing: raw
1208 .on_failing
1209 .map(health_action)
1210 .unwrap_or(defaults.on_failing),
1211 critical: raw.critical,
1212 })
1213}
1214
1215fn parse_restart_config(
1222 raw: Option<RawRestartConfig>,
1223 path: &Path,
1224 module_id: &str,
1225) -> Result<RestartPolicy, DaemonConfigError> {
1226 let defaults = RestartPolicy::default();
1227 let Some(raw) = raw else {
1228 return Ok(defaults);
1229 };
1230
1231 let window = match raw.window_secs {
1232 Some(0) => {
1233 return Err(DaemonConfigError::InvalidValue {
1234 path: path.to_path_buf(),
1235 message: format!(
1236 "module '{module_id}' {RESTART_WINDOW_ZERO_MESSAGE}",
1237 module_id = module_id.escape_debug()
1238 ),
1239 });
1240 }
1241 Some(secs) => Duration::from_secs(secs),
1242 None => defaults.window,
1243 };
1244 let backoff = raw
1245 .backoff_ms
1246 .map(Duration::from_millis)
1247 .unwrap_or(defaults.backoff);
1248 let max_backoff = raw
1249 .max_backoff_ms
1250 .map(Duration::from_millis)
1251 .unwrap_or(defaults.max_backoff);
1252 if max_backoff < backoff {
1253 return Err(DaemonConfigError::InvalidValue {
1254 path: path.to_path_buf(),
1255 message: format!(
1256 "module '{}' restart.max_backoff_ms must be greater than or equal to restart.backoff_ms (max_backoff_ms={max_backoff:?}, backoff_ms={backoff:?})",
1257 module_id.escape_debug()
1258 ),
1259 });
1260 }
1261
1262 Ok(RestartPolicy {
1263 max_restarts: raw.max_restarts.unwrap_or(defaults.max_restarts),
1266 backoff,
1267 max_backoff,
1268 window,
1269 })
1270}
1271
1272fn positive_millis(
1273 value: Option<u64>,
1274 default: std::time::Duration,
1275 path: &Path,
1276 module_id: &str,
1277 field: &str,
1278) -> Result<std::time::Duration, DaemonConfigError> {
1279 match value {
1280 Some(0) => Err(DaemonConfigError::InvalidValue {
1281 path: path.to_path_buf(),
1282 message: format!("module '{module_id}' health.{field} must be positive"),
1283 }),
1284 Some(value) => Ok(std::time::Duration::from_millis(value)),
1285 None => Ok(default),
1286 }
1287}
1288
1289fn health_action(action: RawHealthAction) -> HealthAction {
1290 match action {
1291 RawHealthAction::Report => HealthAction::Report,
1292 RawHealthAction::Restart => HealthAction::Restart,
1293 RawHealthAction::Alert => HealthAction::Alert,
1294 }
1295}
1296
1297pub(crate) fn default_data_home() -> PathBuf {
1300 if let Some(data_home) = non_empty_os_var("XDG_DATA_HOME") {
1301 return PathBuf::from(data_home);
1302 }
1303
1304 #[cfg(windows)]
1305 {
1306 if let Some(app_data) = non_empty_os_var("APPDATA") {
1307 return PathBuf::from(app_data);
1308 }
1309 if let Some(user_profile) = non_empty_os_var("USERPROFILE") {
1310 return PathBuf::from(user_profile).join("AppData").join("Roaming");
1311 }
1312 }
1313
1314 if let Some(home) = non_empty_os_var("HOME") {
1315 return PathBuf::from(home).join(".local").join("share");
1316 }
1317
1318 PathBuf::from(".local").join("share")
1319}
1320
1321fn non_empty_os_var(key: &str) -> Option<OsString> {
1322 let value = env::var_os(key)?;
1323 if value.is_empty() {
1324 None
1325 } else {
1326 Some(value)
1327 }
1328}
1329
1330impl fmt::Display for DaemonConfigError {
1331 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1332 match self {
1333 Self::Read { path, source } => {
1334 write!(f, "failed to read daemon config {}: {source}", path.display())
1335 }
1336 Self::InvalidJsonc { path, message } => {
1337 write!(f, "invalid JSONC in daemon config {}: {message}", path.display())
1338 }
1339 Self::InvalidJson { path, source } => {
1340 write!(f, "invalid daemon config {}: {source}", path.display())
1341 }
1342 Self::UnsupportedVersion { path, version } => write!(
1343 f,
1344 "invalid daemon config {}: version {version} is unsupported (expected {SUPPORTED_CONFIG_VERSION})",
1345 path.display()
1346 ),
1347 Self::InvalidValue { path, message } => {
1348 write!(f, "invalid daemon config {}: {message}", path.display())
1349 }
1350 }
1351 }
1352}
1353
1354impl Error for DaemonConfigError {
1355 fn source(&self) -> Option<&(dyn Error + 'static)> {
1356 match self {
1357 Self::Read { source, .. } => Some(source),
1358 Self::InvalidJson { source, .. } => Some(source),
1359 Self::InvalidJsonc { .. }
1360 | Self::UnsupportedVersion { .. }
1361 | Self::InvalidValue { .. } => None,
1362 }
1363 }
1364}
1365
1366#[cfg(all(test, unix))]
1367mod run_dir_privacy_tests {
1368 use std::fs;
1369 use std::os::unix::fs::PermissionsExt;
1370
1371 use crate::test_support::TestTempDir;
1372
1373 #[test]
1379 fn run_dir_is_created_private_and_an_inherited_wide_one_is_tightened() {
1380 let temp = TestTempDir::new("subc-run-dir-privacy");
1381 let created = temp.path().join("cortexkit").join("run");
1382 super::ensure_directory_private(&created).expect("create run dir");
1383 let mode = fs::metadata(&created)
1384 .expect("stat created")
1385 .permissions()
1386 .mode()
1387 & 0o777;
1388 assert_eq!(
1389 mode, 0o700,
1390 "observable a run directory this code creates must be 0700, got {mode:o}"
1391 );
1392
1393 fs::set_permissions(&created, fs::Permissions::from_mode(0o755)).expect("widen");
1395 let widened = fs::metadata(&created)
1396 .expect("stat widened")
1397 .permissions()
1398 .mode()
1399 & 0o777;
1400 assert_eq!(
1401 widened, 0o755,
1402 "observable the fixture must actually be wide before the tighten"
1403 );
1404
1405 super::ensure_directory_private(&created).expect("tighten run dir");
1406 let mode = fs::metadata(&created)
1407 .expect("stat tightened")
1408 .permissions()
1409 .mode()
1410 & 0o777;
1411 assert_eq!(
1412 mode, 0o700,
1413 "observable an inherited group- or world-readable run directory must be tightened to 0700, got {mode:o}"
1414 );
1415 }
1416}
1417
1418#[cfg(test)]
1419mod tests {
1420 use super::*;
1421
1422 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1431
1432 fn abs(posix: &str) -> PathBuf {
1437 if cfg!(windows) {
1438 PathBuf::from(format!("C:{}", posix.replace('/', "\\")))
1439 } else {
1440 PathBuf::from(posix)
1441 }
1442 }
1443
1444 #[test]
1445 fn default_data_home_matches_golden_fixture() {
1446 let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1447 let doc: serde_json::Value =
1448 serde_json::from_str(include_str!("../tests/golden/data_home_resolution.json"))
1449 .expect("golden parses");
1450 let vars = ["XDG_DATA_HOME", "APPDATA", "USERPROFILE", "HOME"];
1451 let saved: Vec<(&str, Option<std::ffi::OsString>)> =
1452 vars.iter().map(|v| (*v, env::var_os(v))).collect();
1453 let platform_matches =
1454 |p: &str| p == "any" || p == if cfg!(windows) { "windows" } else { "unix" };
1455
1456 let mut ran = 0usize;
1457 for case in doc["cases"].as_array().expect("cases array") {
1458 let name = case["name"].as_str().expect("name");
1459 if !platform_matches(case["platform"].as_str().expect("platform")) {
1460 continue;
1461 }
1462 for v in vars {
1463 env::remove_var(v);
1464 }
1465 for (k, v) in case["env"].as_object().expect("env map") {
1466 env::set_var(k, v.as_str().expect("env value"));
1467 }
1468 let got = default_data_home();
1469 assert_eq!(
1470 got.to_string_lossy(),
1471 case["expect"].as_str().expect("expect"),
1472 "golden case '{name}' diverged"
1473 );
1474 ran += 1;
1475 }
1476 assert!(
1478 ran >= 6,
1479 "only {ran} golden cases ran; fixture or filter broken"
1480 );
1481
1482 for (k, v) in saved {
1483 match v {
1484 Some(val) => env::set_var(k, val),
1485 None => env::remove_var(k),
1486 }
1487 }
1488 }
1489
1490 #[test]
1495 fn daemon_run_dir_refuses_a_relative_data_home_and_names_the_variables() {
1496 for data_home in [PathBuf::from(".local/share"), PathBuf::from("relative-xdg")] {
1497 let error = daemon_run_dir_from(data_home.clone())
1498 .expect_err("a relative data home must be refused");
1499 assert_eq!(
1500 error,
1501 DaemonRunDirError::RelativeDataHome {
1502 data_home: data_home.clone()
1503 }
1504 );
1505 let message = error.to_string();
1506 assert!(
1507 message.contains("XDG_DATA_HOME") && message.contains("HOME"),
1508 "the refusal must name the variables to set: {message}"
1509 );
1510 }
1511 }
1512
1513 #[test]
1514 fn daemon_run_dir_under_an_absolute_data_home_is_cortexkit_run() {
1515 let data_home = env::temp_dir().join("subc-run-dir-probe").join("data");
1516 assert!(data_home.is_absolute());
1517 assert_eq!(
1518 daemon_run_dir_from(data_home.clone()),
1519 Ok(data_home.join("cortexkit").join("run"))
1520 );
1521 }
1522
1523 #[test]
1529 fn default_config_home_matches_golden_fixture() {
1530 let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1531 let doc: serde_json::Value =
1532 serde_json::from_str(include_str!("../tests/golden/config_home_resolution.json"))
1533 .expect("golden parses");
1534 let vars = ["XDG_CONFIG_HOME", "APPDATA", "USERPROFILE", "HOME"];
1535 let saved: Vec<(&str, Option<std::ffi::OsString>)> =
1536 vars.iter().map(|v| (*v, env::var_os(v))).collect();
1537 let platform_matches =
1538 |p: &str| p == "any" || p == if cfg!(windows) { "windows" } else { "unix" };
1539
1540 let mut ran = 0usize;
1541 for case in doc["cases"].as_array().expect("cases array") {
1542 let name = case["name"].as_str().expect("name");
1543 if !platform_matches(case["platform"].as_str().expect("platform")) {
1544 continue;
1545 }
1546 for v in vars {
1547 env::remove_var(v);
1548 }
1549 for (k, v) in case["env"].as_object().expect("env map") {
1550 env::set_var(k, v.as_str().expect("env value"));
1551 }
1552 let got = default_config_home();
1553 assert_eq!(
1554 got.to_string_lossy(),
1555 case["expect"].as_str().expect("expect"),
1556 "golden case '{name}' diverged"
1557 );
1558 ran += 1;
1559 }
1560 assert!(
1561 ran >= 6,
1562 "only {ran} golden cases ran; fixture or filter broken"
1563 );
1564
1565 for (k, v) in saved {
1566 match v {
1567 Some(val) => env::set_var(k, val),
1568 None => env::remove_var(k),
1569 }
1570 }
1571 }
1572
1573 #[test]
1580 fn relative_storage_data_home_is_refused_at_parse() {
1581 let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1582 let path = Path::new("/golden/subc.jsonc");
1583
1584 let doc =
1586 r#"{ "version": 1, "storage": { "backend": "sqlite", "data_home": "relative/home" } }"#;
1587 let err = parse_doc(doc, path).expect_err("relative data_home must refuse");
1588 assert!(
1589 matches!(&err, DaemonConfigError::InvalidValue { message, .. }
1590 if message.contains("relative path relative/home")),
1591 "wrong refusal: {err:?}"
1592 );
1593
1594 let vars = ["XDG_DATA_HOME", "APPDATA", "USERPROFILE", "HOME"];
1596 let saved: Vec<(&str, Option<std::ffi::OsString>)> =
1597 vars.iter().map(|v| (*v, env::var_os(v))).collect();
1598 for v in vars {
1599 env::remove_var(v);
1600 }
1601 let doc = r#"{ "version": 1, "storage": { "backend": "sqlite" } }"#;
1602 let err = parse_doc(doc, path).expect_err("no home in env must refuse");
1603 assert!(
1604 matches!(&err, DaemonConfigError::InvalidValue { message, .. }
1605 if message.contains("no absolute XDG_DATA_HOME")),
1606 "wrong refusal: {err:?}"
1607 );
1608
1609 let want = abs("/abs/home");
1613 let doc = format!(
1614 r#"{{ "version": 1, "storage": {{ "backend": "sqlite", "data_home": {} }} }}"#,
1615 serde_json::to_string(&want).expect("json path")
1616 );
1617 let cfg = parse_doc(&doc, path).expect("absolute data_home parses");
1618 assert!(matches!(
1619 cfg.storage,
1620 Some(StorageConfig::Sqlite { ref data_home }) if *data_home == want
1621 ));
1622
1623 for (k, v) in saved {
1624 match v {
1625 Some(val) => env::set_var(k, val),
1626 None => env::remove_var(k),
1627 }
1628 }
1629 }
1630
1631 #[test]
1632 fn restart_required_sections_are_the_rescan_cannot_apply_set() {
1633 assert_eq!(
1634 RestartRequiredSection::ALL.map(RestartRequiredSection::label),
1635 [
1636 "port",
1637 "storage",
1638 "admission_facts_carrier_module_id",
1639 "admission_facts_targets",
1640 ]
1641 );
1642 }
1643
1644 #[test]
1645 fn no_storage_section_yields_none() {
1646 let config = parse_doc(
1647 r#"{ "version": 1, "modules": {} }"#,
1648 Path::new("/tmp/subc.jsonc"),
1649 )
1650 .expect("parse");
1651 assert_eq!(config.storage, None);
1652 }
1653
1654 #[test]
1655 fn sqlite_storage_parses_with_explicit_data_home() {
1656 let config = parse_doc(
1657 &format!(
1658 r#"{{ "version": 1, "storage": {{ "backend": "sqlite", "data_home": {} }} }}"#,
1659 serde_json::to_string(&abs("/data")).expect("json path")
1660 ),
1661 Path::new("/tmp/subc.jsonc"),
1662 )
1663 .expect("parse");
1664 assert_eq!(
1665 config.storage,
1666 Some(StorageConfig::Sqlite {
1667 data_home: abs("/data")
1668 })
1669 );
1670 }
1671
1672 #[test]
1673 fn sqlite_storage_defaults_data_home_when_omitted() {
1674 let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1680 std::env::set_var("XDG_DATA_HOME", abs("/forced/data/home"));
1681 let config = parse_doc(
1682 r#"{ "version": 1, "storage": { "backend": "sqlite" } }"#,
1683 Path::new("/tmp/subc.jsonc"),
1684 )
1685 .expect("parse");
1686 std::env::remove_var("XDG_DATA_HOME");
1687 assert_eq!(
1688 config.storage,
1689 Some(StorageConfig::Sqlite {
1690 data_home: abs("/forced/data/home")
1691 })
1692 );
1693 }
1694
1695 #[test]
1696 fn descriptor_for_matches_store_types_shape() {
1697 let cfg = StorageConfig::Sqlite {
1701 data_home: PathBuf::from("/data"),
1702 };
1703 let descriptor = cfg.descriptor_for("alfonso-routing");
1704 assert_eq!(
1705 descriptor,
1706 serde_json::json!({
1707 "module_id": "alfonso-routing",
1708 "storage_namespace": "default",
1709 "isolation": { "kind": "module" },
1710 "backend": {
1711 "backend": "sqlite",
1712 "path": "/data/cortexkit/alfonso-routing/store.db"
1713 }
1714 })
1715 );
1716 }
1717
1718 #[test]
1719 fn path_hazard_module_id_refuses_config_parse() {
1720 let path = Path::new("/tmp/subc.jsonc");
1721 let err = parse_doc(
1722 r#"{ "version": 1, "modules": { "../escape": { "program": "x" } } }"#,
1723 path,
1724 )
1725 .expect_err("separator-bearing module id must refuse");
1726 let text = format!("{err}");
1727 assert!(
1728 text.contains("not usable as a path component"),
1729 "refusal must name the hazard: {text}"
1730 );
1731 }
1732
1733 #[test]
1734 fn drain_timeout_resolves_module_over_daemon_over_absent() {
1735 let path = Path::new("/tmp/subc.jsonc");
1736 let config = parse_doc(
1737 r#"
1738 {
1739 "version": 1,
1740 "drain_timeout_ms": 45000,
1741 "modules": {
1742 "fast": { "program": "fast", "drain_timeout_ms": 0 },
1743 "slow": { "program": "slow", "drain_timeout_ms": 120000 },
1744 "inherits": { "program": "inherits" }
1745 }
1746 }
1747 "#,
1748 path,
1749 )
1750 .unwrap();
1751 let by_id = |id: &str| {
1752 config
1753 .modules
1754 .iter()
1755 .find(|m| m.module_id == id)
1756 .unwrap()
1757 .drain_timeout_ms
1758 };
1759 assert_eq!(by_id("fast"), Some(0));
1762 assert_eq!(by_id("slow"), Some(120_000));
1763 assert_eq!(by_id("inherits"), Some(45_000));
1765 assert_eq!(config.drain_timeout_ms, Some(45_000));
1766 }
1767
1768 #[test]
1769 fn drain_timeout_absent_everywhere_stays_none_for_builtin_default() {
1770 let path = Path::new("/tmp/subc.jsonc");
1771 let config = parse_doc(
1772 r#"{ "version": 1, "modules": { "m": { "program": "m" } } }"#,
1773 path,
1774 )
1775 .unwrap();
1776 assert_eq!(config.modules[0].drain_timeout_ms, None);
1780 assert_eq!(config.drain_timeout_ms, None);
1781 }
1782
1783 #[test]
1784 fn route_bind_relay_timeout_resolves_module_over_daemon_over_absent() {
1785 let path = Path::new("/tmp/subc.jsonc");
1791 let config = parse_doc(
1792 r#"
1793 {
1794 "version": 1,
1795 "route_bind_relay_timeout_ms": 30000,
1796 "modules": {
1797 "tight": { "program": "tight", "route_bind_relay_timeout_ms": 5000 },
1798 "loose": { "program": "loose", "route_bind_relay_timeout_ms": 60000 },
1799 "inherits": { "program": "inherits" }
1800 }
1801 }
1802 "#,
1803 path,
1804 )
1805 .unwrap();
1806 let by_id = |id: &str| {
1807 config
1808 .modules
1809 .iter()
1810 .find(|m| m.module_id == id)
1811 .unwrap()
1812 .route_bind_relay_timeout_ms
1813 };
1814 assert_eq!(by_id("tight"), Some(5_000));
1816 assert_eq!(by_id("loose"), Some(60_000));
1817 assert_eq!(by_id("inherits"), Some(30_000));
1819 assert_eq!(config.route_bind_relay_timeout_ms, Some(30_000));
1820 }
1821
1822 #[test]
1823 fn log_tag_keys_must_be_logger_names_and_the_error_names_the_key() {
1824 let path = Path::new("/tmp/subc.jsonc");
1825 for bad in ["Perf", "a b", "perf.", ".perf", "gc..walk", "a=b"] {
1826 let doc = format!(
1827 r#"{{ "version": 1, "modules": {{ "m": {{ "program": "m", "log": {{ "tags": {{ "{bad}": "debug" }} }} }} }} }}"#
1828 );
1829 let err = parse_doc(&doc, path).expect_err(bad);
1830 let text = format!("{err}");
1831 assert!(
1832 text.contains(&format!("{bad:?}")),
1833 "must name the key: {text}"
1834 );
1835 assert!(
1836 text.contains("logger name"),
1837 "must say what a key is: {text}"
1838 );
1839 }
1840 let ok = parse_doc(
1842 r#"{ "version": 1, "modules": { "m": { "program": "m", "log": { "tags": { "perf": "debug", "gc.walk": "trace", "m": "error", "a-b": "info" } } } } }"#,
1843 path,
1844 );
1845 assert!(ok.is_ok(), "{ok:?}");
1846 }
1847
1848 #[test]
1849 fn log_filter_spec_prefixes_bare_keys_with_the_module_and_passes_absolute_ones() {
1850 let path = Path::new("/tmp/subc.jsonc");
1851 let config = parse_doc(
1852 r#"{ "version": 1, "modules": { "synapse": { "program": "s", "log": { "level": "warn", "tags": { "perf": "debug", "gc.walk": "trace", "synapse": "error", "other.x": "info" } } } } }"#,
1853 path,
1854 )
1855 .unwrap();
1856 let log = config.modules[0].log.as_ref().unwrap();
1857 assert_eq!(
1859 log.filter_spec("synapse"),
1860 "warn,gc.walk=trace,other.x=info,synapse.perf=debug,synapse=error"
1861 );
1862 }
1863
1864 #[test]
1865 fn log_alarm_segment_mb_defaults_to_the_crate_default_and_refuses_zero() {
1866 let path = Path::new("/tmp/subc.jsonc");
1867 let config = parse_doc(
1868 r#"{ "version": 1, "modules": { "m": { "program": "m", "log": { "level": "info" } } } }"#,
1869 path,
1870 )
1871 .unwrap();
1872 assert_eq!(
1873 config.modules[0].log.as_ref().unwrap().alarm_segment_mb,
1874 cortexkit_log::SegmentRetention::default().alarm_segment_mb
1875 );
1876 let err = parse_doc(
1877 r#"{ "version": 1, "modules": { "m": { "program": "m", "log": { "alarm_segment_mb": 0 } } } }"#,
1878 path,
1879 )
1880 .expect_err("zero alarm must refuse");
1881 assert!(format!("{err}").contains("alarm_segment_mb"));
1882 }
1883
1884 #[test]
1885 fn route_bind_relay_timeout_zero_at_daemon_layer_is_refused() {
1886 let path = Path::new("/tmp/subc.jsonc");
1887 let err = parse_doc(
1888 r#"
1889 {
1890 "version": 1,
1891 "route_bind_relay_timeout_ms": 0,
1892 "modules": { "m": { "program": "m" } }
1893 }
1894 "#,
1895 path,
1896 )
1897 .expect_err("a daemon-wide zero budget must refuse parse");
1898 let text = format!("{err}");
1899 assert!(
1900 text.contains("route_bind_relay_timeout_ms"),
1901 "error must name the offending key: {text}"
1902 );
1903 assert!(
1904 text.contains("enabled: false"),
1905 "error must name the remedy (enable false): {text}"
1906 );
1907 }
1908
1909 #[test]
1910 fn route_bind_relay_timeout_zero_at_module_layer_is_refused() {
1911 let path = Path::new("/tmp/subc.jsonc");
1912 let err = parse_doc(
1913 r#"
1914 {
1915 "version": 1,
1916 "modules": {
1917 "good": { "program": "good" },
1918 "broken": { "program": "broken", "route_bind_relay_timeout_ms": 0 }
1919 }
1920 }
1921 "#,
1922 path,
1923 )
1924 .expect_err("a per-module zero budget must refuse parse");
1925 let text = format!("{err}");
1926 assert!(
1927 text.contains("route_bind_relay_timeout_ms"),
1928 "error must name the offending key: {text}"
1929 );
1930 assert!(
1931 text.contains("broken"),
1932 "error must name the offending module id: {text}"
1933 );
1934 assert!(
1935 text.contains("enabled: false"),
1936 "error must name the remedy (enable false): {text}"
1937 );
1938 }
1939
1940 #[test]
1941 fn drain_timeout_zero_still_parses_for_wedge_bounces() {
1942 let path = Path::new("/tmp/subc.jsonc");
1948 let config = parse_doc(
1949 r#"
1950 {
1951 "version": 1,
1952 "drain_timeout_ms": 0,
1953 "modules": {
1954 "wedge": { "program": "wedge", "drain_timeout_ms": 0 }
1955 }
1956 }
1957 "#,
1958 path,
1959 )
1960 .expect("drain_timeout_ms: 0 must still parse; wedge-bounce uses it");
1961 let wedge = config
1962 .modules
1963 .iter()
1964 .find(|m| m.module_id == "wedge")
1965 .unwrap();
1966 assert_eq!(wedge.drain_timeout_ms, Some(0));
1967 assert_eq!(config.drain_timeout_ms, Some(0));
1968 }
1969
1970 #[test]
1971 fn route_bind_relay_timeout_absent_everywhere_stays_none_for_builtin_default() {
1972 let path = Path::new("/tmp/subc.jsonc");
1977 let config = parse_doc(
1978 r#"{ "version": 1, "modules": { "m": { "program": "m" } } }"#,
1979 path,
1980 )
1981 .unwrap();
1982 assert_eq!(config.modules[0].route_bind_relay_timeout_ms, None);
1983 assert_eq!(config.route_bind_relay_timeout_ms, None);
1984 }
1985
1986 #[test]
1992 fn a_config_without_a_restart_block_keeps_the_supervisor_defaults() {
1993 let path = Path::new("/tmp/subc.jsonc");
1994 let config = parse_doc(
1995 r#"{ "version": 1, "modules": { "m": { "program": "m" } } }"#,
1996 path,
1997 )
1998 .unwrap();
1999 assert_eq!(config.modules[0].restart.max_restarts, 3);
2000 assert_eq!(config.modules[0].restart.window, Duration::from_secs(600));
2001 assert_eq!(
2002 config.modules[0].restart.backoff,
2003 Duration::from_millis(100)
2004 );
2005 assert_eq!(
2006 config.modules[0].restart.max_backoff,
2007 Duration::from_secs(30)
2008 );
2009 }
2010
2011 #[test]
2012 fn a_restart_block_resolves_each_key_independently() {
2013 let path = Path::new("/tmp/subc.jsonc");
2014 let config = parse_doc(
2015 r#"
2016 {
2017 "version": 1,
2018 "modules": {
2019 "all": {
2020 "program": "all",
2021 "restart": { "max_restarts": 5, "window_secs": 60, "backoff_ms": 250, "max_backoff_ms": 5000 }
2022 },
2023 "window-only": {
2024 "program": "window-only",
2025 "restart": { "window_secs": 7200 }
2026 },
2027 "never": {
2028 "program": "never",
2029 "restart": { "max_restarts": 0 }
2030 }
2031 }
2032 }
2033 "#,
2034 path,
2035 )
2036 .unwrap();
2037 let by_id = |id: &str| {
2038 config
2039 .modules
2040 .iter()
2041 .find(|m| m.module_id == id)
2042 .unwrap()
2043 .restart
2044 };
2045
2046 let all = by_id("all");
2047 assert_eq!(all.max_restarts, 5);
2048 assert_eq!(all.window, Duration::from_secs(60));
2049 assert_eq!(all.backoff, Duration::from_millis(250));
2050 assert_eq!(all.max_backoff, Duration::from_secs(5));
2051
2052 let window_only = by_id("window-only");
2055 assert_eq!(window_only.max_restarts, 3);
2056 assert_eq!(window_only.window, Duration::from_secs(7_200));
2057 assert_eq!(window_only.backoff, Duration::from_millis(100));
2058 assert_eq!(window_only.max_backoff, Duration::from_secs(30));
2059
2060 assert_eq!(by_id("never").max_restarts, 0);
2063 }
2064
2065 #[test]
2069 fn restart_window_zero_is_refused_by_name() {
2070 let path = Path::new("/tmp/subc.jsonc");
2071 let err = parse_doc(
2072 r#"
2073 {
2074 "version": 1,
2075 "modules": {
2076 "good": { "program": "good" },
2077 "broken": { "program": "broken", "restart": { "window_secs": 0 } }
2078 }
2079 }
2080 "#,
2081 path,
2082 )
2083 .expect_err("a zero crash window must refuse parse");
2084 assert!(
2085 matches!(err, DaemonConfigError::InvalidValue { .. }),
2086 "a zero window is an invalid value, not a parse failure: {err:?}"
2087 );
2088 let text = format!("{err}");
2089 assert!(
2090 text.contains("restart.window_secs"),
2091 "error must name the offending key: {text}"
2092 );
2093 assert!(
2094 text.contains("broken"),
2095 "error must name the offending module id: {text}"
2096 );
2097 assert!(
2098 text.contains("max_restarts: 0"),
2099 "error must name the setting that actually stops restarts: {text}"
2100 );
2101 }
2102
2103 #[test]
2104 fn restart_max_backoff_below_backoff_is_refused_by_name() {
2105 let path = Path::new("/tmp/subc.jsonc");
2106 let err = parse_doc(
2107 r#"
2108 {
2109 "version": 1,
2110 "modules": {
2111 "broken": {
2112 "program": "broken",
2113 "restart": { "backoff_ms": 1000, "max_backoff_ms": 999 }
2114 }
2115 }
2116 }
2117 "#,
2118 path,
2119 )
2120 .expect_err("a maximum below the base backoff must refuse parse");
2121 assert!(
2122 matches!(err, DaemonConfigError::InvalidValue { .. }),
2123 "an invalid restart bound must be an InvalidValue: {err:?}"
2124 );
2125 let text = format!("{err}");
2126 assert!(
2127 text.contains("restart.max_backoff_ms"),
2128 "error must name max_backoff_ms: {text}"
2129 );
2130 assert!(
2131 text.contains("restart.backoff_ms"),
2132 "error must name backoff_ms: {text}"
2133 );
2134 assert!(
2135 text.contains("broken"),
2136 "error must name the offending module id: {text}"
2137 );
2138 }
2139
2140 #[test]
2141 fn parse_jsonc_defaults_and_ignores_unknown_fields() {
2142 let path = Path::new("/tmp/subc.jsonc");
2143 let config = parse_doc(
2144 r#"
2145 {
2146 // forward-compatible root field
2147 "version": 1,
2148 "unknown": { "ignored": true },
2149 "modules": {
2150 "aft": {
2151 "program": "aft",
2152 "args": ["module",],
2153 "env": { "A": "B", },
2154 "future": 42,
2155 },
2156 "disabled": { "program": "disabled", "enabled": false }
2157 },
2158 }
2159 "#,
2160 path,
2161 )
2162 .unwrap();
2163
2164 assert_eq!(config.port, None);
2165 assert_eq!(config.modules.len(), 2);
2166 assert_eq!(config.modules[0].module_id, "aft");
2167 assert_eq!(config.modules[0].program, PathBuf::from("aft"));
2168 assert_eq!(config.modules[0].args, ["module"]);
2169 assert_eq!(config.modules[0].env, [("A".to_string(), "B".to_string())]);
2170 assert!(config.modules[0].enabled);
2171 assert!(config.modules[0].reserved_prefixes.is_empty());
2172 assert_eq!(config.modules[0].health, HealthConfig::default());
2173 assert!(!config.modules[1].enabled);
2174 }
2175
2176 #[test]
2177 fn reserved_capabilities_accept_unknown_bound_modules_and_refuse_bad_identifiers() {
2178 let path = Path::new("/tmp/subc.jsonc");
2179 let config = parse_doc(
2180 r#"{
2181 "version": 1,
2182 "reserved_capabilities": {
2183 "credentials-provider/v1": "future-vault"
2184 },
2185 "modules": {}
2186 }"#,
2187 path,
2188 )
2189 .expect("a binding may predate its provider installation");
2190 assert_eq!(
2191 config.reserved_capabilities,
2192 BTreeMap::from([(
2193 "credentials-provider/v1".to_string(),
2194 "future-vault".to_string()
2195 )])
2196 );
2197
2198 let error = parse_doc(
2199 r#"{
2200 "version": 1,
2201 "reserved_capabilities": { "Credentials/v1": "vault" },
2202 "modules": {}
2203 }"#,
2204 path,
2205 )
2206 .expect_err("reserved capabilities use the capability identifier grammar");
2207 assert!(error.to_string().contains("reserved_capabilities key"));
2208 }
2209
2210 #[test]
2216 fn an_absent_protocol_key_and_an_explicit_subc_are_the_same_module() {
2217 let parse = |module_body: &str| {
2218 parse_doc(
2219 &format!(
2220 r#"{{
2221 "version": 1,
2222 "modules": {{ "aft": {{ "program": "aft"{module_body} }} }}
2223 }}"#
2224 ),
2225 Path::new("subc.jsonc"),
2226 )
2227 .expect("module parses")
2228 .modules
2229 .remove(0)
2230 };
2231
2232 let absent = parse("");
2233 let explicit = parse(r#", "protocol": "subc""#);
2234 let none = parse(r#", "protocol": "none""#);
2235
2236 assert_eq!(absent.protocol, ModuleProtocol::Subc);
2237 assert_eq!(explicit.protocol, ModuleProtocol::Subc);
2238 assert_eq!(
2239 absent, explicit,
2240 "an absent protocol key must produce exactly the module an explicit subc does"
2241 );
2242 assert_eq!(none.protocol, ModuleProtocol::None);
2243 assert_eq!(none.module_spec().protocol, ModuleProtocol::None);
2247 }
2248
2249 #[test]
2253 fn overlap_defaults_to_exclusive_and_only_safe_opts_in() {
2254 let parse = |module_body: &str| {
2255 parse_doc(
2256 &format!(
2257 r#"{{
2258 "version": 1,
2259 "modules": {{ "aft": {{ "program": "aft"{module_body} }} }}
2260 }}"#
2261 ),
2262 Path::new("subc.jsonc"),
2263 )
2264 };
2265
2266 let absent = parse("").unwrap().modules.remove(0);
2267 assert_eq!(absent.overlap, ModuleOverlap::Exclusive);
2268 assert_eq!(absent.module_spec().overlap, ModuleOverlap::Exclusive);
2269 let safe = parse(r#", "overlap": "safe""#).unwrap().modules.remove(0);
2270 assert_eq!(safe.module_spec().overlap, ModuleOverlap::Safe);
2271 let typo = parse(r#", "overlap": "sfae""#).expect_err("an unknown overlap is refused");
2272 assert!(typo.to_string().contains("sfae"), "{typo}");
2273 }
2274
2275 #[test]
2279 fn the_spawn_role_is_refused_as_a_configured_env_key() {
2280 let error = parse_doc(
2281 r#"{
2282 "version": 1,
2283 "modules": { "aft": { "program": "aft", "env": { "SUBC_SPAWN_ROLE": "swap_candidate" } } }
2284 }"#,
2285 Path::new("subc.jsonc"),
2286 )
2287 .expect_err("SUBC_SPAWN_ROLE must not be configurable");
2288 assert!(
2289 matches!(error, DaemonConfigError::InvalidValue { .. }),
2290 "expected InvalidValue, got {error:?}"
2291 );
2292 assert!(error.to_string().contains("SUBC_SPAWN_ROLE"), "{error}");
2293 }
2294
2295 #[test]
2301 fn an_unsupported_protocol_value_is_refused_by_name() {
2302 let error = parse_doc(
2303 r#"{
2304 "version": 1,
2305 "modules": { "nats": { "program": "nats-server", "protocol": "grpc" } }
2306 }"#,
2307 Path::new("subc.jsonc"),
2308 )
2309 .expect_err("an unknown protocol must not fall back to a default");
2310
2311 assert!(
2312 matches!(error, DaemonConfigError::InvalidValue { .. }),
2313 "expected InvalidValue, got {error:?}"
2314 );
2315 let message = error.to_string();
2316 assert!(
2317 message.contains("grpc"),
2318 "the refusal must name the offending value: {message}"
2319 );
2320 assert!(
2321 message.contains("nats"),
2322 "the refusal must name the module so it can be found in the file: {message}"
2323 );
2324 }
2325
2326 #[test]
2330 fn reserved_true_with_protocol_none_is_refused_with_the_reason() {
2331 let error = parse_doc(
2332 r#"{
2333 "version": 1,
2334 "modules": {
2335 "nats": { "program": "nats-server", "protocol": "none", "reserved": true }
2336 }
2337 }"#,
2338 Path::new("subc.jsonc"),
2339 )
2340 .expect_err("a reservation that can never be checked must not parse");
2341
2342 assert!(
2343 matches!(error, DaemonConfigError::InvalidValue { .. }),
2344 "expected InvalidValue, got {error:?}"
2345 );
2346 let message = error.to_string();
2347 assert!(
2348 message.contains("nats") && message.contains("reserved"),
2349 "the refusal must name the module and the offending key: {message}"
2350 );
2351 assert!(
2352 message.contains("HELLO") || message.contains("never registers"),
2353 "the refusal must say WHY the pair cannot work: {message}"
2354 );
2355 }
2356
2357 #[test]
2358 fn reserved_prefixes_parse_for_reserved_modules() {
2359 let config = parse_doc(
2360 r#"
2361 {
2362 "version": 1,
2363 "modules": {
2364 "federation": {
2365 "program": "fed",
2366 "reserved": true,
2367 "reserved_prefixes": ["fed:"]
2368 }
2369 }
2370 }
2371 "#,
2372 Path::new("subc.jsonc"),
2373 )
2374 .unwrap();
2375
2376 assert_eq!(config.modules[0].reserved_prefixes, ["fed:".to_string()]);
2377 }
2378
2379 #[test]
2380 fn reserved_prefixes_reject_bad_boundaries_and_owners() {
2381 let missing_delimiter = parse_doc(
2382 r#"{
2383 "version": 1,
2384 "modules": {
2385 "federation": { "program": "fed", "reserved": true, "reserved_prefixes": ["fed"] }
2386 }
2387 }"#,
2388 Path::new("subc.jsonc"),
2389 )
2390 .unwrap_err();
2391 assert!(matches!(
2392 missing_delimiter,
2393 DaemonConfigError::InvalidValue { .. }
2394 ));
2395
2396 let non_reserved_owner = parse_doc(
2397 r#"{
2398 "version": 1,
2399 "modules": {
2400 "federation": { "program": "fed", "reserved_prefixes": ["fed:"] }
2401 }
2402 }"#,
2403 Path::new("subc.jsonc"),
2404 )
2405 .unwrap_err();
2406 assert!(matches!(
2407 non_reserved_owner,
2408 DaemonConfigError::InvalidValue { .. }
2409 ));
2410 }
2411
2412 #[test]
2413 fn reserved_prefixes_reject_cross_owner_overlap_and_exact_id_collisions() {
2414 let overlap = parse_doc(
2415 r#"{
2416 "version": 1,
2417 "modules": {
2418 "fed-owner": { "program": "fed", "reserved": true, "reserved_prefixes": ["fed:"] },
2419 "sub-owner": { "program": "fed-sub", "reserved": true, "reserved_prefixes": ["fed:sub:"] }
2420 }
2421 }"#,
2422 Path::new("subc.jsonc"),
2423 )
2424 .unwrap_err();
2425 assert!(matches!(overlap, DaemonConfigError::InvalidValue { .. }));
2426
2427 let exact_collision = parse_doc(
2428 r#"{
2429 "version": 1,
2430 "modules": {
2431 "federation": { "program": "fed", "reserved": true, "reserved_prefixes": ["fed:"] },
2432 "fed:special": { "program": "special" }
2433 }
2434 }"#,
2435 Path::new("subc.jsonc"),
2436 )
2437 .unwrap_err();
2438 assert!(matches!(
2439 exact_collision,
2440 DaemonConfigError::InvalidValue { .. }
2441 ));
2442 }
2443
2444 #[test]
2445 fn health_config_parses_and_ignores_unknown_fields() {
2446 let config = parse_doc(
2447 r#"
2448 {
2449 "version": 1,
2450 "modules": {
2451 "aft": {
2452 "program": "aft",
2453 "health": {
2454 "cadence_ms": 100,
2455 "deadline_ms": 20,
2456 "failure_threshold": 2,
2457 "on_degraded": "report",
2458 "on_failing": "restart",
2459 "critical": true,
2460 "future": "ignored"
2461 }
2462 }
2463 }
2464 }
2465 "#,
2466 Path::new("subc.jsonc"),
2467 )
2468 .unwrap();
2469
2470 let health = config.modules[0].health;
2471 assert_eq!(health.cadence, std::time::Duration::from_millis(100));
2472 assert_eq!(health.deadline, std::time::Duration::from_millis(20));
2473 assert_eq!(health.failure_threshold, 2);
2474 assert_eq!(health.on_degraded, HealthAction::Report);
2475 assert_eq!(health.on_failing, HealthAction::Restart);
2476 assert!(health.critical);
2477 }
2478
2479 #[test]
2480 fn health_config_rejects_bad_enum_and_non_positive_numbers() {
2481 let bad_enum = parse_doc(
2482 r#"{
2483 "version": 1,
2484 "modules": { "aft": { "program": "aft", "health": { "on_failing": "page" } } }
2485 }"#,
2486 Path::new("subc.jsonc"),
2487 )
2488 .unwrap_err();
2489 assert!(matches!(bad_enum, DaemonConfigError::InvalidJson { .. }));
2490
2491 let zero = parse_doc(
2492 r#"{
2493 "version": 1,
2494 "modules": { "aft": { "program": "aft", "health": { "cadence_ms": 0 } } }
2495 }"#,
2496 Path::new("subc.jsonc"),
2497 )
2498 .unwrap_err();
2499 assert!(matches!(zero, DaemonConfigError::InvalidValue { .. }));
2500 }
2501
2502 #[test]
2503 fn admission_facts_carrier_requires_non_empty_targets() {
2504 let missing_targets = parse_doc(
2505 r#"{
2506 "version": 1,
2507 "admission_facts_carrier_module_id": "fed",
2508 "modules": { "fed": { "program": "fed", "reserved": true } }
2509 }"#,
2510 Path::new("subc.jsonc"),
2511 )
2512 .unwrap_err();
2513 assert!(
2519 matches!(&missing_targets, DaemonConfigError::InvalidValue { message, .. }
2520 if message.contains("must be present")),
2521 "expected the presence rule, got: {missing_targets:?}"
2522 );
2523
2524 let empty_targets = parse_doc(
2525 r#"{
2526 "version": 1,
2527 "admission_facts_carrier_module_id": "fed",
2528 "admission_facts_targets": [""],
2529 "modules": { "fed": { "program": "fed", "reserved": true } }
2530 }"#,
2531 Path::new("subc.jsonc"),
2532 )
2533 .unwrap_err();
2534 assert!(
2535 matches!(&empty_targets, DaemonConfigError::InvalidValue { message, .. }
2536 if message.contains("must be non-empty")),
2537 "expected the non-empty rule, got: {empty_targets:?}"
2538 );
2539 }
2540
2541 #[test]
2542 fn admission_facts_carrier_must_be_enabled_reserved_and_configured() {
2543 for module in [
2544 r#"{ "program": "fed", "enabled": false, "reserved": true }"#,
2545 r#"{ "program": "fed", "enabled": true, "reserved": false }"#,
2546 ] {
2547 let doc = format!(
2548 r#"{{
2549 "version": 1,
2550 "admission_facts_carrier_module_id": "fed",
2551 "admission_facts_targets": ["target"],
2552 "modules": {{ "fed": {module}, "target": {{ "program": "target" }} }}
2553 }}"#
2554 );
2555 let err = parse_doc(&doc, Path::new("subc.jsonc")).unwrap_err();
2556 assert!(
2560 matches!(&err, DaemonConfigError::InvalidValue { message, .. }
2561 if message.contains("enabled reserved module")),
2562 "expected the enabled-and-reserved rule, got: {err:?}"
2563 );
2564 }
2565
2566 let absent = parse_doc(
2567 r#"{
2568 "version": 1,
2569 "admission_facts_carrier_module_id": "missing",
2570 "admission_facts_targets": ["target"],
2571 "modules": { "target": { "program": "target" } }
2572 }"#,
2573 Path::new("subc.jsonc"),
2574 )
2575 .unwrap_err();
2576 assert!(
2577 matches!(&absent, DaemonConfigError::InvalidValue { message, .. }
2578 if message.contains("must name a configured module")),
2579 "expected the configured-module rule, got: {absent:?}"
2580 );
2581 }
2582
2583 #[test]
2584 fn reject_unsupported_version() {
2585 let err = parse_doc(
2586 r#"{ "version": 2, "modules": {} }"#,
2587 Path::new("subc.jsonc"),
2588 )
2589 .unwrap_err();
2590 assert!(matches!(
2591 err,
2592 DaemonConfigError::UnsupportedVersion { version: 2, .. }
2593 ));
2594 }
2595
2596 #[test]
2597 fn reject_unterminated_block_comment() {
2598 let err = parse_doc(r#"{ "version": 1, /*"#, Path::new("subc.jsonc")).unwrap_err();
2599 assert!(matches!(err, DaemonConfigError::InvalidJsonc { .. }));
2600 }
2601}