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 use subc_test_support::TestTempDir;
1371
1372 #[test]
1378 fn run_dir_is_created_private_and_an_inherited_wide_one_is_tightened() {
1379 let temp = TestTempDir::new("subc-run-dir-privacy");
1380 let created = temp.path().join("cortexkit").join("run");
1381 super::ensure_directory_private(&created).expect("create run dir");
1382 let mode = fs::metadata(&created)
1383 .expect("stat created")
1384 .permissions()
1385 .mode()
1386 & 0o777;
1387 assert_eq!(
1388 mode, 0o700,
1389 "observable a run directory this code creates must be 0700, got {mode:o}"
1390 );
1391
1392 fs::set_permissions(&created, fs::Permissions::from_mode(0o755)).expect("widen");
1394 let widened = fs::metadata(&created)
1395 .expect("stat widened")
1396 .permissions()
1397 .mode()
1398 & 0o777;
1399 assert_eq!(
1400 widened, 0o755,
1401 "observable the fixture must actually be wide before the tighten"
1402 );
1403
1404 super::ensure_directory_private(&created).expect("tighten run dir");
1405 let mode = fs::metadata(&created)
1406 .expect("stat tightened")
1407 .permissions()
1408 .mode()
1409 & 0o777;
1410 assert_eq!(
1411 mode, 0o700,
1412 "observable an inherited group- or world-readable run directory must be tightened to 0700, got {mode:o}"
1413 );
1414 }
1415}
1416
1417#[cfg(test)]
1418mod tests {
1419 use super::*;
1420
1421 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1430
1431 fn abs(posix: &str) -> PathBuf {
1436 if cfg!(windows) {
1437 PathBuf::from(format!("C:{}", posix.replace('/', "\\")))
1438 } else {
1439 PathBuf::from(posix)
1440 }
1441 }
1442
1443 #[test]
1444 fn default_data_home_matches_golden_fixture() {
1445 let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1446 let doc: serde_json::Value =
1447 serde_json::from_str(include_str!("../tests/golden/data_home_resolution.json"))
1448 .expect("golden parses");
1449 let vars = ["XDG_DATA_HOME", "APPDATA", "USERPROFILE", "HOME"];
1450 let saved: Vec<(&str, Option<std::ffi::OsString>)> =
1451 vars.iter().map(|v| (*v, env::var_os(v))).collect();
1452 let platform_matches =
1453 |p: &str| p == "any" || p == if cfg!(windows) { "windows" } else { "unix" };
1454
1455 let mut ran = 0usize;
1456 for case in doc["cases"].as_array().expect("cases array") {
1457 let name = case["name"].as_str().expect("name");
1458 if !platform_matches(case["platform"].as_str().expect("platform")) {
1459 continue;
1460 }
1461 for v in vars {
1462 env::remove_var(v);
1463 }
1464 for (k, v) in case["env"].as_object().expect("env map") {
1465 env::set_var(k, v.as_str().expect("env value"));
1466 }
1467 let got = default_data_home();
1468 assert_eq!(
1469 got.to_string_lossy(),
1470 case["expect"].as_str().expect("expect"),
1471 "golden case '{name}' diverged"
1472 );
1473 ran += 1;
1474 }
1475 assert!(
1477 ran >= 6,
1478 "only {ran} golden cases ran; fixture or filter broken"
1479 );
1480
1481 for (k, v) in saved {
1482 match v {
1483 Some(val) => env::set_var(k, val),
1484 None => env::remove_var(k),
1485 }
1486 }
1487 }
1488
1489 #[test]
1494 fn daemon_run_dir_refuses_a_relative_data_home_and_names_the_variables() {
1495 for data_home in [PathBuf::from(".local/share"), PathBuf::from("relative-xdg")] {
1496 let error = daemon_run_dir_from(data_home.clone())
1497 .expect_err("a relative data home must be refused");
1498 assert_eq!(
1499 error,
1500 DaemonRunDirError::RelativeDataHome {
1501 data_home: data_home.clone()
1502 }
1503 );
1504 let message = error.to_string();
1505 assert!(
1506 message.contains("XDG_DATA_HOME") && message.contains("HOME"),
1507 "the refusal must name the variables to set: {message}"
1508 );
1509 }
1510 }
1511
1512 #[test]
1513 fn daemon_run_dir_under_an_absolute_data_home_is_cortexkit_run() {
1514 let data_home = env::temp_dir().join("subc-run-dir-probe").join("data");
1515 assert!(data_home.is_absolute());
1516 assert_eq!(
1517 daemon_run_dir_from(data_home.clone()),
1518 Ok(data_home.join("cortexkit").join("run"))
1519 );
1520 }
1521
1522 #[test]
1528 fn default_config_home_matches_golden_fixture() {
1529 let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1530 let doc: serde_json::Value =
1531 serde_json::from_str(include_str!("../tests/golden/config_home_resolution.json"))
1532 .expect("golden parses");
1533 let vars = ["XDG_CONFIG_HOME", "APPDATA", "USERPROFILE", "HOME"];
1534 let saved: Vec<(&str, Option<std::ffi::OsString>)> =
1535 vars.iter().map(|v| (*v, env::var_os(v))).collect();
1536 let platform_matches =
1537 |p: &str| p == "any" || p == if cfg!(windows) { "windows" } else { "unix" };
1538
1539 let mut ran = 0usize;
1540 for case in doc["cases"].as_array().expect("cases array") {
1541 let name = case["name"].as_str().expect("name");
1542 if !platform_matches(case["platform"].as_str().expect("platform")) {
1543 continue;
1544 }
1545 for v in vars {
1546 env::remove_var(v);
1547 }
1548 for (k, v) in case["env"].as_object().expect("env map") {
1549 env::set_var(k, v.as_str().expect("env value"));
1550 }
1551 let got = default_config_home();
1552 assert_eq!(
1553 got.to_string_lossy(),
1554 case["expect"].as_str().expect("expect"),
1555 "golden case '{name}' diverged"
1556 );
1557 ran += 1;
1558 }
1559 assert!(
1560 ran >= 6,
1561 "only {ran} golden cases ran; fixture or filter broken"
1562 );
1563
1564 for (k, v) in saved {
1565 match v {
1566 Some(val) => env::set_var(k, val),
1567 None => env::remove_var(k),
1568 }
1569 }
1570 }
1571
1572 #[test]
1579 fn relative_storage_data_home_is_refused_at_parse() {
1580 let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1581 let path = Path::new("/golden/subc.jsonc");
1582
1583 let doc =
1585 r#"{ "version": 1, "storage": { "backend": "sqlite", "data_home": "relative/home" } }"#;
1586 let err = parse_doc(doc, path).expect_err("relative data_home must refuse");
1587 assert!(
1588 matches!(&err, DaemonConfigError::InvalidValue { message, .. }
1589 if message.contains("relative path relative/home")),
1590 "wrong refusal: {err:?}"
1591 );
1592
1593 let vars = ["XDG_DATA_HOME", "APPDATA", "USERPROFILE", "HOME"];
1595 let saved: Vec<(&str, Option<std::ffi::OsString>)> =
1596 vars.iter().map(|v| (*v, env::var_os(v))).collect();
1597 for v in vars {
1598 env::remove_var(v);
1599 }
1600 let doc = r#"{ "version": 1, "storage": { "backend": "sqlite" } }"#;
1601 let err = parse_doc(doc, path).expect_err("no home in env must refuse");
1602 assert!(
1603 matches!(&err, DaemonConfigError::InvalidValue { message, .. }
1604 if message.contains("no absolute XDG_DATA_HOME")),
1605 "wrong refusal: {err:?}"
1606 );
1607
1608 let want = abs("/abs/home");
1612 let doc = format!(
1613 r#"{{ "version": 1, "storage": {{ "backend": "sqlite", "data_home": {} }} }}"#,
1614 serde_json::to_string(&want).expect("json path")
1615 );
1616 let cfg = parse_doc(&doc, path).expect("absolute data_home parses");
1617 assert!(matches!(
1618 cfg.storage,
1619 Some(StorageConfig::Sqlite { ref data_home }) if *data_home == want
1620 ));
1621
1622 for (k, v) in saved {
1623 match v {
1624 Some(val) => env::set_var(k, val),
1625 None => env::remove_var(k),
1626 }
1627 }
1628 }
1629
1630 #[test]
1631 fn restart_required_sections_are_the_rescan_cannot_apply_set() {
1632 assert_eq!(
1633 RestartRequiredSection::ALL.map(RestartRequiredSection::label),
1634 [
1635 "port",
1636 "storage",
1637 "admission_facts_carrier_module_id",
1638 "admission_facts_targets",
1639 ]
1640 );
1641 }
1642
1643 #[test]
1644 fn no_storage_section_yields_none() {
1645 let config = parse_doc(
1646 r#"{ "version": 1, "modules": {} }"#,
1647 Path::new("/tmp/subc.jsonc"),
1648 )
1649 .expect("parse");
1650 assert_eq!(config.storage, None);
1651 }
1652
1653 #[test]
1654 fn sqlite_storage_parses_with_explicit_data_home() {
1655 let config = parse_doc(
1656 &format!(
1657 r#"{{ "version": 1, "storage": {{ "backend": "sqlite", "data_home": {} }} }}"#,
1658 serde_json::to_string(&abs("/data")).expect("json path")
1659 ),
1660 Path::new("/tmp/subc.jsonc"),
1661 )
1662 .expect("parse");
1663 assert_eq!(
1664 config.storage,
1665 Some(StorageConfig::Sqlite {
1666 data_home: abs("/data")
1667 })
1668 );
1669 }
1670
1671 #[test]
1672 fn sqlite_storage_defaults_data_home_when_omitted() {
1673 let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1679 std::env::set_var("XDG_DATA_HOME", abs("/forced/data/home"));
1680 let config = parse_doc(
1681 r#"{ "version": 1, "storage": { "backend": "sqlite" } }"#,
1682 Path::new("/tmp/subc.jsonc"),
1683 )
1684 .expect("parse");
1685 std::env::remove_var("XDG_DATA_HOME");
1686 assert_eq!(
1687 config.storage,
1688 Some(StorageConfig::Sqlite {
1689 data_home: abs("/forced/data/home")
1690 })
1691 );
1692 }
1693
1694 #[test]
1695 fn descriptor_for_matches_store_types_shape() {
1696 let cfg = StorageConfig::Sqlite {
1700 data_home: PathBuf::from("/data"),
1701 };
1702 let descriptor = cfg.descriptor_for("alfonso-routing");
1703 assert_eq!(
1704 descriptor,
1705 serde_json::json!({
1706 "module_id": "alfonso-routing",
1707 "storage_namespace": "default",
1708 "isolation": { "kind": "module" },
1709 "backend": {
1710 "backend": "sqlite",
1711 "path": "/data/cortexkit/alfonso-routing/store.db"
1712 }
1713 })
1714 );
1715 }
1716
1717 #[test]
1718 fn path_hazard_module_id_refuses_config_parse() {
1719 let path = Path::new("/tmp/subc.jsonc");
1720 let err = parse_doc(
1721 r#"{ "version": 1, "modules": { "../escape": { "program": "x" } } }"#,
1722 path,
1723 )
1724 .expect_err("separator-bearing module id must refuse");
1725 let text = format!("{err}");
1726 assert!(
1727 text.contains("not usable as a path component"),
1728 "refusal must name the hazard: {text}"
1729 );
1730 }
1731
1732 #[test]
1733 fn drain_timeout_resolves_module_over_daemon_over_absent() {
1734 let path = Path::new("/tmp/subc.jsonc");
1735 let config = parse_doc(
1736 r#"
1737 {
1738 "version": 1,
1739 "drain_timeout_ms": 45000,
1740 "modules": {
1741 "fast": { "program": "fast", "drain_timeout_ms": 0 },
1742 "slow": { "program": "slow", "drain_timeout_ms": 120000 },
1743 "inherits": { "program": "inherits" }
1744 }
1745 }
1746 "#,
1747 path,
1748 )
1749 .unwrap();
1750 let by_id = |id: &str| {
1751 config
1752 .modules
1753 .iter()
1754 .find(|m| m.module_id == id)
1755 .unwrap()
1756 .drain_timeout_ms
1757 };
1758 assert_eq!(by_id("fast"), Some(0));
1761 assert_eq!(by_id("slow"), Some(120_000));
1762 assert_eq!(by_id("inherits"), Some(45_000));
1764 assert_eq!(config.drain_timeout_ms, Some(45_000));
1765 }
1766
1767 #[test]
1768 fn drain_timeout_absent_everywhere_stays_none_for_builtin_default() {
1769 let path = Path::new("/tmp/subc.jsonc");
1770 let config = parse_doc(
1771 r#"{ "version": 1, "modules": { "m": { "program": "m" } } }"#,
1772 path,
1773 )
1774 .unwrap();
1775 assert_eq!(config.modules[0].drain_timeout_ms, None);
1779 assert_eq!(config.drain_timeout_ms, None);
1780 }
1781
1782 #[test]
1783 fn route_bind_relay_timeout_resolves_module_over_daemon_over_absent() {
1784 let path = Path::new("/tmp/subc.jsonc");
1790 let config = parse_doc(
1791 r#"
1792 {
1793 "version": 1,
1794 "route_bind_relay_timeout_ms": 30000,
1795 "modules": {
1796 "tight": { "program": "tight", "route_bind_relay_timeout_ms": 5000 },
1797 "loose": { "program": "loose", "route_bind_relay_timeout_ms": 60000 },
1798 "inherits": { "program": "inherits" }
1799 }
1800 }
1801 "#,
1802 path,
1803 )
1804 .unwrap();
1805 let by_id = |id: &str| {
1806 config
1807 .modules
1808 .iter()
1809 .find(|m| m.module_id == id)
1810 .unwrap()
1811 .route_bind_relay_timeout_ms
1812 };
1813 assert_eq!(by_id("tight"), Some(5_000));
1815 assert_eq!(by_id("loose"), Some(60_000));
1816 assert_eq!(by_id("inherits"), Some(30_000));
1818 assert_eq!(config.route_bind_relay_timeout_ms, Some(30_000));
1819 }
1820
1821 #[test]
1822 fn log_tag_keys_must_be_logger_names_and_the_error_names_the_key() {
1823 let path = Path::new("/tmp/subc.jsonc");
1824 for bad in ["Perf", "a b", "perf.", ".perf", "gc..walk", "a=b"] {
1825 let doc = format!(
1826 r#"{{ "version": 1, "modules": {{ "m": {{ "program": "m", "log": {{ "tags": {{ "{bad}": "debug" }} }} }} }} }}"#
1827 );
1828 let err = parse_doc(&doc, path).expect_err(bad);
1829 let text = format!("{err}");
1830 assert!(
1831 text.contains(&format!("{bad:?}")),
1832 "must name the key: {text}"
1833 );
1834 assert!(
1835 text.contains("logger name"),
1836 "must say what a key is: {text}"
1837 );
1838 }
1839 let ok = parse_doc(
1841 r#"{ "version": 1, "modules": { "m": { "program": "m", "log": { "tags": { "perf": "debug", "gc.walk": "trace", "m": "error", "a-b": "info" } } } } }"#,
1842 path,
1843 );
1844 assert!(ok.is_ok(), "{ok:?}");
1845 }
1846
1847 #[test]
1848 fn log_filter_spec_prefixes_bare_keys_with_the_module_and_passes_absolute_ones() {
1849 let path = Path::new("/tmp/subc.jsonc");
1850 let config = parse_doc(
1851 r#"{ "version": 1, "modules": { "synapse": { "program": "s", "log": { "level": "warn", "tags": { "perf": "debug", "gc.walk": "trace", "synapse": "error", "other.x": "info" } } } } }"#,
1852 path,
1853 )
1854 .unwrap();
1855 let log = config.modules[0].log.as_ref().unwrap();
1856 assert_eq!(
1858 log.filter_spec("synapse"),
1859 "warn,gc.walk=trace,other.x=info,synapse.perf=debug,synapse=error"
1860 );
1861 }
1862
1863 #[test]
1864 fn log_alarm_segment_mb_defaults_to_the_crate_default_and_refuses_zero() {
1865 let path = Path::new("/tmp/subc.jsonc");
1866 let config = parse_doc(
1867 r#"{ "version": 1, "modules": { "m": { "program": "m", "log": { "level": "info" } } } }"#,
1868 path,
1869 )
1870 .unwrap();
1871 assert_eq!(
1872 config.modules[0].log.as_ref().unwrap().alarm_segment_mb,
1873 cortexkit_log::SegmentRetention::default().alarm_segment_mb
1874 );
1875 let err = parse_doc(
1876 r#"{ "version": 1, "modules": { "m": { "program": "m", "log": { "alarm_segment_mb": 0 } } } }"#,
1877 path,
1878 )
1879 .expect_err("zero alarm must refuse");
1880 assert!(format!("{err}").contains("alarm_segment_mb"));
1881 }
1882
1883 #[test]
1884 fn route_bind_relay_timeout_zero_at_daemon_layer_is_refused() {
1885 let path = Path::new("/tmp/subc.jsonc");
1886 let err = parse_doc(
1887 r#"
1888 {
1889 "version": 1,
1890 "route_bind_relay_timeout_ms": 0,
1891 "modules": { "m": { "program": "m" } }
1892 }
1893 "#,
1894 path,
1895 )
1896 .expect_err("a daemon-wide zero budget must refuse parse");
1897 let text = format!("{err}");
1898 assert!(
1899 text.contains("route_bind_relay_timeout_ms"),
1900 "error must name the offending key: {text}"
1901 );
1902 assert!(
1903 text.contains("enabled: false"),
1904 "error must name the remedy (enable false): {text}"
1905 );
1906 }
1907
1908 #[test]
1909 fn route_bind_relay_timeout_zero_at_module_layer_is_refused() {
1910 let path = Path::new("/tmp/subc.jsonc");
1911 let err = parse_doc(
1912 r#"
1913 {
1914 "version": 1,
1915 "modules": {
1916 "good": { "program": "good" },
1917 "broken": { "program": "broken", "route_bind_relay_timeout_ms": 0 }
1918 }
1919 }
1920 "#,
1921 path,
1922 )
1923 .expect_err("a per-module zero budget must refuse parse");
1924 let text = format!("{err}");
1925 assert!(
1926 text.contains("route_bind_relay_timeout_ms"),
1927 "error must name the offending key: {text}"
1928 );
1929 assert!(
1930 text.contains("broken"),
1931 "error must name the offending module id: {text}"
1932 );
1933 assert!(
1934 text.contains("enabled: false"),
1935 "error must name the remedy (enable false): {text}"
1936 );
1937 }
1938
1939 #[test]
1940 fn drain_timeout_zero_still_parses_for_wedge_bounces() {
1941 let path = Path::new("/tmp/subc.jsonc");
1947 let config = parse_doc(
1948 r#"
1949 {
1950 "version": 1,
1951 "drain_timeout_ms": 0,
1952 "modules": {
1953 "wedge": { "program": "wedge", "drain_timeout_ms": 0 }
1954 }
1955 }
1956 "#,
1957 path,
1958 )
1959 .expect("drain_timeout_ms: 0 must still parse; wedge-bounce uses it");
1960 let wedge = config
1961 .modules
1962 .iter()
1963 .find(|m| m.module_id == "wedge")
1964 .unwrap();
1965 assert_eq!(wedge.drain_timeout_ms, Some(0));
1966 assert_eq!(config.drain_timeout_ms, Some(0));
1967 }
1968
1969 #[test]
1970 fn route_bind_relay_timeout_absent_everywhere_stays_none_for_builtin_default() {
1971 let path = Path::new("/tmp/subc.jsonc");
1976 let config = parse_doc(
1977 r#"{ "version": 1, "modules": { "m": { "program": "m" } } }"#,
1978 path,
1979 )
1980 .unwrap();
1981 assert_eq!(config.modules[0].route_bind_relay_timeout_ms, None);
1982 assert_eq!(config.route_bind_relay_timeout_ms, None);
1983 }
1984
1985 #[test]
1991 fn a_config_without_a_restart_block_keeps_the_supervisor_defaults() {
1992 let path = Path::new("/tmp/subc.jsonc");
1993 let config = parse_doc(
1994 r#"{ "version": 1, "modules": { "m": { "program": "m" } } }"#,
1995 path,
1996 )
1997 .unwrap();
1998 assert_eq!(config.modules[0].restart.max_restarts, 3);
1999 assert_eq!(config.modules[0].restart.window, Duration::from_secs(600));
2000 assert_eq!(
2001 config.modules[0].restart.backoff,
2002 Duration::from_millis(100)
2003 );
2004 assert_eq!(
2005 config.modules[0].restart.max_backoff,
2006 Duration::from_secs(30)
2007 );
2008 }
2009
2010 #[test]
2011 fn a_restart_block_resolves_each_key_independently() {
2012 let path = Path::new("/tmp/subc.jsonc");
2013 let config = parse_doc(
2014 r#"
2015 {
2016 "version": 1,
2017 "modules": {
2018 "all": {
2019 "program": "all",
2020 "restart": { "max_restarts": 5, "window_secs": 60, "backoff_ms": 250, "max_backoff_ms": 5000 }
2021 },
2022 "window-only": {
2023 "program": "window-only",
2024 "restart": { "window_secs": 7200 }
2025 },
2026 "never": {
2027 "program": "never",
2028 "restart": { "max_restarts": 0 }
2029 }
2030 }
2031 }
2032 "#,
2033 path,
2034 )
2035 .unwrap();
2036 let by_id = |id: &str| {
2037 config
2038 .modules
2039 .iter()
2040 .find(|m| m.module_id == id)
2041 .unwrap()
2042 .restart
2043 };
2044
2045 let all = by_id("all");
2046 assert_eq!(all.max_restarts, 5);
2047 assert_eq!(all.window, Duration::from_secs(60));
2048 assert_eq!(all.backoff, Duration::from_millis(250));
2049 assert_eq!(all.max_backoff, Duration::from_secs(5));
2050
2051 let window_only = by_id("window-only");
2054 assert_eq!(window_only.max_restarts, 3);
2055 assert_eq!(window_only.window, Duration::from_secs(7_200));
2056 assert_eq!(window_only.backoff, Duration::from_millis(100));
2057 assert_eq!(window_only.max_backoff, Duration::from_secs(30));
2058
2059 assert_eq!(by_id("never").max_restarts, 0);
2062 }
2063
2064 #[test]
2068 fn restart_window_zero_is_refused_by_name() {
2069 let path = Path::new("/tmp/subc.jsonc");
2070 let err = parse_doc(
2071 r#"
2072 {
2073 "version": 1,
2074 "modules": {
2075 "good": { "program": "good" },
2076 "broken": { "program": "broken", "restart": { "window_secs": 0 } }
2077 }
2078 }
2079 "#,
2080 path,
2081 )
2082 .expect_err("a zero crash window must refuse parse");
2083 assert!(
2084 matches!(err, DaemonConfigError::InvalidValue { .. }),
2085 "a zero window is an invalid value, not a parse failure: {err:?}"
2086 );
2087 let text = format!("{err}");
2088 assert!(
2089 text.contains("restart.window_secs"),
2090 "error must name the offending key: {text}"
2091 );
2092 assert!(
2093 text.contains("broken"),
2094 "error must name the offending module id: {text}"
2095 );
2096 assert!(
2097 text.contains("max_restarts: 0"),
2098 "error must name the setting that actually stops restarts: {text}"
2099 );
2100 }
2101
2102 #[test]
2103 fn restart_max_backoff_below_backoff_is_refused_by_name() {
2104 let path = Path::new("/tmp/subc.jsonc");
2105 let err = parse_doc(
2106 r#"
2107 {
2108 "version": 1,
2109 "modules": {
2110 "broken": {
2111 "program": "broken",
2112 "restart": { "backoff_ms": 1000, "max_backoff_ms": 999 }
2113 }
2114 }
2115 }
2116 "#,
2117 path,
2118 )
2119 .expect_err("a maximum below the base backoff must refuse parse");
2120 assert!(
2121 matches!(err, DaemonConfigError::InvalidValue { .. }),
2122 "an invalid restart bound must be an InvalidValue: {err:?}"
2123 );
2124 let text = format!("{err}");
2125 assert!(
2126 text.contains("restart.max_backoff_ms"),
2127 "error must name max_backoff_ms: {text}"
2128 );
2129 assert!(
2130 text.contains("restart.backoff_ms"),
2131 "error must name backoff_ms: {text}"
2132 );
2133 assert!(
2134 text.contains("broken"),
2135 "error must name the offending module id: {text}"
2136 );
2137 }
2138
2139 #[test]
2140 fn parse_jsonc_defaults_and_ignores_unknown_fields() {
2141 let path = Path::new("/tmp/subc.jsonc");
2142 let config = parse_doc(
2143 r#"
2144 {
2145 // forward-compatible root field
2146 "version": 1,
2147 "unknown": { "ignored": true },
2148 "modules": {
2149 "aft": {
2150 "program": "aft",
2151 "args": ["module",],
2152 "env": { "A": "B", },
2153 "future": 42,
2154 },
2155 "disabled": { "program": "disabled", "enabled": false }
2156 },
2157 }
2158 "#,
2159 path,
2160 )
2161 .unwrap();
2162
2163 assert_eq!(config.port, None);
2164 assert_eq!(config.modules.len(), 2);
2165 assert_eq!(config.modules[0].module_id, "aft");
2166 assert_eq!(config.modules[0].program, PathBuf::from("aft"));
2167 assert_eq!(config.modules[0].args, ["module"]);
2168 assert_eq!(config.modules[0].env, [("A".to_string(), "B".to_string())]);
2169 assert!(config.modules[0].enabled);
2170 assert!(config.modules[0].reserved_prefixes.is_empty());
2171 assert_eq!(config.modules[0].health, HealthConfig::default());
2172 assert!(!config.modules[1].enabled);
2173 }
2174
2175 #[test]
2176 fn reserved_capabilities_accept_unknown_bound_modules_and_refuse_bad_identifiers() {
2177 let path = Path::new("/tmp/subc.jsonc");
2178 let config = parse_doc(
2179 r#"{
2180 "version": 1,
2181 "reserved_capabilities": {
2182 "credentials-provider/v1": "future-vault"
2183 },
2184 "modules": {}
2185 }"#,
2186 path,
2187 )
2188 .expect("a binding may predate its provider installation");
2189 assert_eq!(
2190 config.reserved_capabilities,
2191 BTreeMap::from([(
2192 "credentials-provider/v1".to_string(),
2193 "future-vault".to_string()
2194 )])
2195 );
2196
2197 let error = parse_doc(
2198 r#"{
2199 "version": 1,
2200 "reserved_capabilities": { "Credentials/v1": "vault" },
2201 "modules": {}
2202 }"#,
2203 path,
2204 )
2205 .expect_err("reserved capabilities use the capability identifier grammar");
2206 assert!(error.to_string().contains("reserved_capabilities key"));
2207 }
2208
2209 #[test]
2215 fn an_absent_protocol_key_and_an_explicit_subc_are_the_same_module() {
2216 let parse = |module_body: &str| {
2217 parse_doc(
2218 &format!(
2219 r#"{{
2220 "version": 1,
2221 "modules": {{ "aft": {{ "program": "aft"{module_body} }} }}
2222 }}"#
2223 ),
2224 Path::new("subc.jsonc"),
2225 )
2226 .expect("module parses")
2227 .modules
2228 .remove(0)
2229 };
2230
2231 let absent = parse("");
2232 let explicit = parse(r#", "protocol": "subc""#);
2233 let none = parse(r#", "protocol": "none""#);
2234
2235 assert_eq!(absent.protocol, ModuleProtocol::Subc);
2236 assert_eq!(explicit.protocol, ModuleProtocol::Subc);
2237 assert_eq!(
2238 absent, explicit,
2239 "an absent protocol key must produce exactly the module an explicit subc does"
2240 );
2241 assert_eq!(none.protocol, ModuleProtocol::None);
2242 assert_eq!(none.module_spec().protocol, ModuleProtocol::None);
2246 }
2247
2248 #[test]
2252 fn overlap_defaults_to_exclusive_and_only_safe_opts_in() {
2253 let parse = |module_body: &str| {
2254 parse_doc(
2255 &format!(
2256 r#"{{
2257 "version": 1,
2258 "modules": {{ "aft": {{ "program": "aft"{module_body} }} }}
2259 }}"#
2260 ),
2261 Path::new("subc.jsonc"),
2262 )
2263 };
2264
2265 let absent = parse("").unwrap().modules.remove(0);
2266 assert_eq!(absent.overlap, ModuleOverlap::Exclusive);
2267 assert_eq!(absent.module_spec().overlap, ModuleOverlap::Exclusive);
2268 let safe = parse(r#", "overlap": "safe""#).unwrap().modules.remove(0);
2269 assert_eq!(safe.module_spec().overlap, ModuleOverlap::Safe);
2270 let typo = parse(r#", "overlap": "sfae""#).expect_err("an unknown overlap is refused");
2271 assert!(typo.to_string().contains("sfae"), "{typo}");
2272 }
2273
2274 #[test]
2278 fn the_spawn_role_is_refused_as_a_configured_env_key() {
2279 let error = parse_doc(
2280 r#"{
2281 "version": 1,
2282 "modules": { "aft": { "program": "aft", "env": { "SUBC_SPAWN_ROLE": "swap_candidate" } } }
2283 }"#,
2284 Path::new("subc.jsonc"),
2285 )
2286 .expect_err("SUBC_SPAWN_ROLE must not be configurable");
2287 assert!(
2288 matches!(error, DaemonConfigError::InvalidValue { .. }),
2289 "expected InvalidValue, got {error:?}"
2290 );
2291 assert!(error.to_string().contains("SUBC_SPAWN_ROLE"), "{error}");
2292 }
2293
2294 #[test]
2300 fn an_unsupported_protocol_value_is_refused_by_name() {
2301 let error = parse_doc(
2302 r#"{
2303 "version": 1,
2304 "modules": { "nats": { "program": "nats-server", "protocol": "grpc" } }
2305 }"#,
2306 Path::new("subc.jsonc"),
2307 )
2308 .expect_err("an unknown protocol must not fall back to a default");
2309
2310 assert!(
2311 matches!(error, DaemonConfigError::InvalidValue { .. }),
2312 "expected InvalidValue, got {error:?}"
2313 );
2314 let message = error.to_string();
2315 assert!(
2316 message.contains("grpc"),
2317 "the refusal must name the offending value: {message}"
2318 );
2319 assert!(
2320 message.contains("nats"),
2321 "the refusal must name the module so it can be found in the file: {message}"
2322 );
2323 }
2324
2325 #[test]
2329 fn reserved_true_with_protocol_none_is_refused_with_the_reason() {
2330 let error = parse_doc(
2331 r#"{
2332 "version": 1,
2333 "modules": {
2334 "nats": { "program": "nats-server", "protocol": "none", "reserved": true }
2335 }
2336 }"#,
2337 Path::new("subc.jsonc"),
2338 )
2339 .expect_err("a reservation that can never be checked must not parse");
2340
2341 assert!(
2342 matches!(error, DaemonConfigError::InvalidValue { .. }),
2343 "expected InvalidValue, got {error:?}"
2344 );
2345 let message = error.to_string();
2346 assert!(
2347 message.contains("nats") && message.contains("reserved"),
2348 "the refusal must name the module and the offending key: {message}"
2349 );
2350 assert!(
2351 message.contains("HELLO") || message.contains("never registers"),
2352 "the refusal must say WHY the pair cannot work: {message}"
2353 );
2354 }
2355
2356 #[test]
2357 fn reserved_prefixes_parse_for_reserved_modules() {
2358 let config = parse_doc(
2359 r#"
2360 {
2361 "version": 1,
2362 "modules": {
2363 "federation": {
2364 "program": "fed",
2365 "reserved": true,
2366 "reserved_prefixes": ["fed:"]
2367 }
2368 }
2369 }
2370 "#,
2371 Path::new("subc.jsonc"),
2372 )
2373 .unwrap();
2374
2375 assert_eq!(config.modules[0].reserved_prefixes, ["fed:".to_string()]);
2376 }
2377
2378 #[test]
2379 fn reserved_prefixes_reject_bad_boundaries_and_owners() {
2380 let missing_delimiter = parse_doc(
2381 r#"{
2382 "version": 1,
2383 "modules": {
2384 "federation": { "program": "fed", "reserved": true, "reserved_prefixes": ["fed"] }
2385 }
2386 }"#,
2387 Path::new("subc.jsonc"),
2388 )
2389 .unwrap_err();
2390 assert!(matches!(
2391 missing_delimiter,
2392 DaemonConfigError::InvalidValue { .. }
2393 ));
2394
2395 let non_reserved_owner = parse_doc(
2396 r#"{
2397 "version": 1,
2398 "modules": {
2399 "federation": { "program": "fed", "reserved_prefixes": ["fed:"] }
2400 }
2401 }"#,
2402 Path::new("subc.jsonc"),
2403 )
2404 .unwrap_err();
2405 assert!(matches!(
2406 non_reserved_owner,
2407 DaemonConfigError::InvalidValue { .. }
2408 ));
2409 }
2410
2411 #[test]
2412 fn reserved_prefixes_reject_cross_owner_overlap_and_exact_id_collisions() {
2413 let overlap = parse_doc(
2414 r#"{
2415 "version": 1,
2416 "modules": {
2417 "fed-owner": { "program": "fed", "reserved": true, "reserved_prefixes": ["fed:"] },
2418 "sub-owner": { "program": "fed-sub", "reserved": true, "reserved_prefixes": ["fed:sub:"] }
2419 }
2420 }"#,
2421 Path::new("subc.jsonc"),
2422 )
2423 .unwrap_err();
2424 assert!(matches!(overlap, DaemonConfigError::InvalidValue { .. }));
2425
2426 let exact_collision = parse_doc(
2427 r#"{
2428 "version": 1,
2429 "modules": {
2430 "federation": { "program": "fed", "reserved": true, "reserved_prefixes": ["fed:"] },
2431 "fed:special": { "program": "special" }
2432 }
2433 }"#,
2434 Path::new("subc.jsonc"),
2435 )
2436 .unwrap_err();
2437 assert!(matches!(
2438 exact_collision,
2439 DaemonConfigError::InvalidValue { .. }
2440 ));
2441 }
2442
2443 #[test]
2444 fn health_config_parses_and_ignores_unknown_fields() {
2445 let config = parse_doc(
2446 r#"
2447 {
2448 "version": 1,
2449 "modules": {
2450 "aft": {
2451 "program": "aft",
2452 "health": {
2453 "cadence_ms": 100,
2454 "deadline_ms": 20,
2455 "failure_threshold": 2,
2456 "on_degraded": "report",
2457 "on_failing": "restart",
2458 "critical": true,
2459 "future": "ignored"
2460 }
2461 }
2462 }
2463 }
2464 "#,
2465 Path::new("subc.jsonc"),
2466 )
2467 .unwrap();
2468
2469 let health = config.modules[0].health;
2470 assert_eq!(health.cadence, std::time::Duration::from_millis(100));
2471 assert_eq!(health.deadline, std::time::Duration::from_millis(20));
2472 assert_eq!(health.failure_threshold, 2);
2473 assert_eq!(health.on_degraded, HealthAction::Report);
2474 assert_eq!(health.on_failing, HealthAction::Restart);
2475 assert!(health.critical);
2476 }
2477
2478 #[test]
2479 fn health_config_rejects_bad_enum_and_non_positive_numbers() {
2480 let bad_enum = parse_doc(
2481 r#"{
2482 "version": 1,
2483 "modules": { "aft": { "program": "aft", "health": { "on_failing": "page" } } }
2484 }"#,
2485 Path::new("subc.jsonc"),
2486 )
2487 .unwrap_err();
2488 assert!(matches!(bad_enum, DaemonConfigError::InvalidJson { .. }));
2489
2490 let zero = parse_doc(
2491 r#"{
2492 "version": 1,
2493 "modules": { "aft": { "program": "aft", "health": { "cadence_ms": 0 } } }
2494 }"#,
2495 Path::new("subc.jsonc"),
2496 )
2497 .unwrap_err();
2498 assert!(matches!(zero, DaemonConfigError::InvalidValue { .. }));
2499 }
2500
2501 #[test]
2502 fn admission_facts_carrier_requires_non_empty_targets() {
2503 let missing_targets = parse_doc(
2504 r#"{
2505 "version": 1,
2506 "admission_facts_carrier_module_id": "fed",
2507 "modules": { "fed": { "program": "fed", "reserved": true } }
2508 }"#,
2509 Path::new("subc.jsonc"),
2510 )
2511 .unwrap_err();
2512 assert!(
2518 matches!(&missing_targets, DaemonConfigError::InvalidValue { message, .. }
2519 if message.contains("must be present")),
2520 "expected the presence rule, got: {missing_targets:?}"
2521 );
2522
2523 let empty_targets = parse_doc(
2524 r#"{
2525 "version": 1,
2526 "admission_facts_carrier_module_id": "fed",
2527 "admission_facts_targets": [""],
2528 "modules": { "fed": { "program": "fed", "reserved": true } }
2529 }"#,
2530 Path::new("subc.jsonc"),
2531 )
2532 .unwrap_err();
2533 assert!(
2534 matches!(&empty_targets, DaemonConfigError::InvalidValue { message, .. }
2535 if message.contains("must be non-empty")),
2536 "expected the non-empty rule, got: {empty_targets:?}"
2537 );
2538 }
2539
2540 #[test]
2541 fn admission_facts_carrier_must_be_enabled_reserved_and_configured() {
2542 for module in [
2543 r#"{ "program": "fed", "enabled": false, "reserved": true }"#,
2544 r#"{ "program": "fed", "enabled": true, "reserved": false }"#,
2545 ] {
2546 let doc = format!(
2547 r#"{{
2548 "version": 1,
2549 "admission_facts_carrier_module_id": "fed",
2550 "admission_facts_targets": ["target"],
2551 "modules": {{ "fed": {module}, "target": {{ "program": "target" }} }}
2552 }}"#
2553 );
2554 let err = parse_doc(&doc, Path::new("subc.jsonc")).unwrap_err();
2555 assert!(
2559 matches!(&err, DaemonConfigError::InvalidValue { message, .. }
2560 if message.contains("enabled reserved module")),
2561 "expected the enabled-and-reserved rule, got: {err:?}"
2562 );
2563 }
2564
2565 let absent = parse_doc(
2566 r#"{
2567 "version": 1,
2568 "admission_facts_carrier_module_id": "missing",
2569 "admission_facts_targets": ["target"],
2570 "modules": { "target": { "program": "target" } }
2571 }"#,
2572 Path::new("subc.jsonc"),
2573 )
2574 .unwrap_err();
2575 assert!(
2576 matches!(&absent, DaemonConfigError::InvalidValue { message, .. }
2577 if message.contains("must name a configured module")),
2578 "expected the configured-module rule, got: {absent:?}"
2579 );
2580 }
2581
2582 #[test]
2583 fn reject_unsupported_version() {
2584 let err = parse_doc(
2585 r#"{ "version": 2, "modules": {} }"#,
2586 Path::new("subc.jsonc"),
2587 )
2588 .unwrap_err();
2589 assert!(matches!(
2590 err,
2591 DaemonConfigError::UnsupportedVersion { version: 2, .. }
2592 ));
2593 }
2594
2595 #[test]
2596 fn reject_unterminated_block_comment() {
2597 let err = parse_doc(r#"{ "version": 1, /*"#, Path::new("subc.jsonc")).unwrap_err();
2598 assert!(matches!(err, DaemonConfigError::InvalidJsonc { .. }));
2599 }
2600}