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> {
574 let path = daemon_run_dir();
575 ensure_directory_private(&path)?;
576 Ok(path)
577}
578
579#[cfg(unix)]
584fn ensure_directory_private(path: &Path) -> Result<(), io::Error> {
585 use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
586
587 if !path.exists() {
588 fs::DirBuilder::new()
589 .recursive(true)
590 .mode(0o700)
591 .create(path)?;
592 return Ok(());
593 }
594 let mode = fs::metadata(path)?.permissions().mode() & 0o777;
595 if mode & 0o077 != 0 {
596 fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
597 }
598 Ok(())
599}
600
601#[cfg(not(unix))]
603fn ensure_directory_private(path: &Path) -> Result<(), io::Error> {
604 if !path.exists() {
605 fs::create_dir_all(path)?;
606 }
607 Ok(())
608}
609
610pub fn daemon_run_dir() -> PathBuf {
612 let path = default_data_home().join("cortexkit").join("run");
613 if path.is_absolute() {
614 path
615 } else {
616 env::current_dir()
617 .unwrap_or_else(|_| PathBuf::from("."))
618 .join(path)
619 }
620}
621
622fn read_config_doc(path: &Path) -> Result<Option<String>, DaemonConfigError> {
623 match fs::read_to_string(path) {
624 Ok(doc) => Ok(Some(doc)),
625 Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(None),
626 Err(source) => Err(DaemonConfigError::Read {
627 path: path.to_path_buf(),
628 source,
629 }),
630 }
631}
632
633fn parse_doc(doc: &str, path: &Path) -> Result<DaemonConfig, DaemonConfigError> {
634 let json = jsonc_to_json(doc).map_err(|message| DaemonConfigError::InvalidJsonc {
635 path: path.to_path_buf(),
636 message,
637 })?;
638 let raw: RawDaemonConfig =
639 serde_json::from_str(&json).map_err(|source| DaemonConfigError::InvalidJson {
640 path: path.to_path_buf(),
641 source,
642 })?;
643
644 if raw.version != SUPPORTED_CONFIG_VERSION {
645 return Err(DaemonConfigError::UnsupportedVersion {
646 path: path.to_path_buf(),
647 version: raw.version,
648 });
649 }
650
651 let daemon_logging = raw
652 .log
653 .map(|log| parse_logging_config(log, path, "daemon log"))
654 .transpose()?;
655 let default_drain_timeout_ms = raw.drain_timeout_ms;
656 let default_route_bind_relay_timeout_ms = match raw.route_bind_relay_timeout_ms {
662 Some(0) => {
663 return Err(DaemonConfigError::InvalidValue {
664 path: path.to_path_buf(),
665 message: ROUTE_BIND_RELAY_ZERO_MESSAGE.to_string(),
666 });
667 }
668 Some(value) => Some(value),
669 None => None,
670 };
671 let modules = raw
672 .modules
673 .into_iter()
674 .map(|(module_id, module)| {
675 let health = module
676 .health
677 .map(|health| parse_health_config(health, path, &module_id))
678 .transpose()?
679 .unwrap_or_default();
680 if let Err(reason) = crate::registry::module_id_path_hazard(&module_id) {
681 return Err(DaemonConfigError::InvalidValue {
682 path: path.to_path_buf(),
683 message: format!(
684 "module id '{}' is not usable as a path component ({reason}): \
685 the daemon derives each module's store path from its id",
686 module_id.escape_debug()
687 ),
688 });
689 }
690 let per_module_route_bind_relay_timeout_ms = match module.route_bind_relay_timeout_ms {
695 Some(0) => {
696 return Err(DaemonConfigError::InvalidValue {
697 path: path.to_path_buf(),
698 message: format!(
699 "module '{module_id}' {ROUTE_BIND_RELAY_ZERO_MESSAGE}",
700 module_id = module_id.escape_debug()
701 ),
702 });
703 }
704 Some(value) => Some(value),
705 None => default_route_bind_relay_timeout_ms,
706 };
707 let protocol = parse_module_protocol(module.protocol.as_deref(), path, &module_id)?;
708 let overlap = parse_module_overlap(module.overlap.as_deref(), path, &module_id)?;
709 if module.env.contains_key(SUBC_SPAWN_ROLE_ENV) {
713 return Err(DaemonConfigError::InvalidValue {
714 path: path.to_path_buf(),
715 message: format!(
716 "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",
717 module_id = module_id.escape_debug()
718 ),
719 });
720 }
721 if protocol == ModuleProtocol::None && module.reserved {
728 return Err(DaemonConfigError::InvalidValue {
729 path: path.to_path_buf(),
730 message: format!(
731 "module '{module_id}' sets reserved: true with protocol: \"none\"; \
732 reserved is enforced on the module's HELLO and a protocol: \"none\" \
733 module never registers, so the reservation could never be checked",
734 module_id = module_id.escape_debug()
735 ),
736 });
737 }
738 let restart = parse_restart_config(module.restart, path, &module_id)?;
739 let log = module
740 .log
741 .map(|log| parse_logging_config(log, path, &format!("module '{module_id}' log")))
742 .transpose()?
743 .or_else(|| daemon_logging.clone());
744 Ok(ConfiguredModule {
745 module_id,
746 program: module.program,
747 args: module.args,
748 env: module.env.into_iter().collect(),
749 log,
750 enabled: module.enabled,
751 reserved: module.reserved,
752 reserved_prefixes: module.reserved_prefixes,
753 protocol,
754 overlap,
755 health,
756 drain_timeout_ms: module.drain_timeout_ms.or(default_drain_timeout_ms),
759 route_bind_relay_timeout_ms: per_module_route_bind_relay_timeout_ms,
764 restart,
765 })
766 })
767 .collect::<Result<Vec<_>, DaemonConfigError>>()?;
768
769 validate_reserved_prefixes(&modules, path)?;
770 validate_reserved_capabilities(&raw.reserved_capabilities, path)?;
771 validate_admission_facts_config(
772 &modules,
773 raw.admission_facts_carrier_module_id.as_deref(),
774 raw.admission_facts_targets.as_deref(),
775 path,
776 )?;
777
778 let storage = raw
779 .storage
780 .map(|s| match s {
781 RawStorageConfig::Sqlite { data_home } => {
782 let data_home = data_home.unwrap_or_else(default_data_home);
783 if !data_home.is_absolute() {
792 return Err(DaemonConfigError::InvalidValue {
793 path: path.to_path_buf(),
794 message: format!(
795 "storage data home resolved to the relative path {} \
796 (no absolute XDG_DATA_HOME, APPDATA, USERPROFILE, or HOME \
797 in the daemon's environment); refusing to serve a \
798 cwd-relative storage descriptor to modules. Set \
799 XDG_DATA_HOME or HOME to an absolute path, or set \
800 storage.data_home in this file.",
801 data_home.display()
802 ),
803 });
804 }
805 Ok(StorageConfig::Sqlite { data_home })
806 }
807 })
808 .transpose()?;
809
810 Ok(DaemonConfig {
811 path: path.to_path_buf(),
812 port: raw.port,
813 drain_timeout_ms: default_drain_timeout_ms,
814 route_bind_relay_timeout_ms: default_route_bind_relay_timeout_ms,
815 modules,
816 storage,
817 admission_facts_carrier_module_id: raw.admission_facts_carrier_module_id,
818 admission_facts_targets: raw.admission_facts_targets,
819 reserved_capabilities: raw.reserved_capabilities,
820 })
821}
822
823fn parse_logging_config(
824 raw: RawLoggingConfig,
825 path: &Path,
826 owner: &str,
827) -> Result<LoggingConfig, DaemonConfigError> {
828 fn valid_level(level: &str) -> bool {
829 matches!(level, "off" | "error" | "warn" | "info" | "debug" | "trace")
830 }
831
832 let level = raw.level.unwrap_or_else(|| "info".to_string());
833 if !valid_level(&level) {
834 return Err(DaemonConfigError::InvalidValue {
835 path: path.to_path_buf(),
836 message: format!(
837 "{owner}.level must be one of off, error, warn, info, debug, trace; got {level:?}"
838 ),
839 });
840 }
841 for (tag, tag_level) in &raw.tags {
842 let well_formed = !tag.is_empty()
847 && tag.split('.').all(|segment| {
848 let mut chars = segment.chars();
849 matches!(chars.next(), Some('a'..='z'))
850 && chars.all(|c| matches!(c, 'a'..='z' | '0'..='9' | '-'))
851 });
852 if !well_formed {
853 return Err(DaemonConfigError::InvalidValue {
854 path: path.to_path_buf(),
855 message: format!(
856 "{owner}.tags key {tag:?} is not a logger name (dotted segments of [a-z][a-z0-9-]*)"
857 ),
858 });
859 }
860 if !valid_level(tag_level) {
861 return Err(DaemonConfigError::InvalidValue {
862 path: path.to_path_buf(),
863 message: format!(
864 "{owner}.tags.{tag} must be one of off, error, warn, info, debug, trace; got {tag_level:?}"
865 ),
866 });
867 }
868 }
869
870 let defaults = Retention::default();
871 let retention = Retention {
872 max_file_mb: raw.max_file_mb.unwrap_or(defaults.max_file_mb),
873 keep: raw.keep.unwrap_or(defaults.keep),
874 max_age_days: raw.max_age_days.unwrap_or(defaults.max_age_days),
875 };
876 if retention.max_file_mb == 0 {
877 return Err(DaemonConfigError::InvalidValue {
878 path: path.to_path_buf(),
879 message: format!("{owner}.max_file_mb must be greater than 0"),
880 });
881 }
882
883 let alarm_segment_mb = raw
884 .alarm_segment_mb
885 .unwrap_or(cortexkit_log::SegmentRetention::default().alarm_segment_mb);
886 if alarm_segment_mb == 0 {
887 return Err(DaemonConfigError::InvalidValue {
888 path: path.to_path_buf(),
889 message: format!("{owner}.alarm_segment_mb must be greater than 0"),
890 });
891 }
892
893 Ok(LoggingConfig {
894 level,
895 tags: raw.tags,
896 retention,
897 alarm_segment_mb,
898 })
899}
900
901fn parse_module_protocol(
910 raw: Option<&str>,
911 path: &Path,
912 module_id: &str,
913) -> Result<ModuleProtocol, DaemonConfigError> {
914 match raw {
915 None | Some("subc") => Ok(ModuleProtocol::Subc),
916 Some("none") => Ok(ModuleProtocol::None),
917 Some(other) => Err(DaemonConfigError::InvalidValue {
921 path: path.to_path_buf(),
922 message: format!(
923 "module '{module_id}' declares protocol {other:?}; supported values are \
924 \"subc\" (the default when the key is absent) and \"none\"",
925 module_id = module_id.escape_debug(),
926 ),
927 }),
928 }
929}
930
931fn parse_module_overlap(
935 raw: Option<&str>,
936 path: &Path,
937 module_id: &str,
938) -> Result<ModuleOverlap, DaemonConfigError> {
939 match raw {
940 None | Some("exclusive") => Ok(ModuleOverlap::Exclusive),
941 Some("safe") => Ok(ModuleOverlap::Safe),
942 Some(other) => Err(DaemonConfigError::InvalidValue {
943 path: path.to_path_buf(),
944 message: format!(
945 "module '{module_id}' declares overlap {other:?}; supported values are \
946 \"exclusive\" (the default when the key is absent) and \"safe\"",
947 module_id = module_id.escape_debug(),
948 ),
949 }),
950 }
951}
952
953fn validate_reserved_capabilities(
954 bindings: &BTreeMap<String, String>,
955 path: &Path,
956) -> Result<(), DaemonConfigError> {
957 for (capability, module_id) in bindings {
958 if !is_valid_capability_identifier(capability) {
959 return Err(DaemonConfigError::InvalidValue {
960 path: path.to_path_buf(),
961 message: format!(
962 "reserved_capabilities key {:?} is not a valid capability identifier",
963 capability
964 ),
965 });
966 }
967 if module_id.trim().is_empty() {
968 return Err(DaemonConfigError::InvalidValue {
969 path: path.to_path_buf(),
970 message: format!(
971 "reserved_capabilities binding for {:?} has an empty module id",
972 capability
973 ),
974 });
975 }
976 if let Err(reason) = crate::registry::module_id_path_hazard(module_id) {
977 return Err(DaemonConfigError::InvalidValue {
978 path: path.to_path_buf(),
979 message: format!(
980 "reserved_capabilities binding for {:?} has an unusable module id {:?}: {reason}",
981 capability, module_id
982 ),
983 });
984 }
985 }
986 Ok(())
987}
988
989fn validate_admission_facts_config(
990 modules: &[ConfiguredModule],
991 carrier_module_id: Option<&str>,
992 targets: Option<&[String]>,
993 path: &Path,
994) -> Result<(), DaemonConfigError> {
995 let Some(carrier_module_id) = carrier_module_id else {
996 return Ok(());
997 };
998
999 let Some(carrier) = modules
1000 .iter()
1001 .find(|module| module.module_id == carrier_module_id)
1002 else {
1003 return Err(DaemonConfigError::InvalidValue {
1004 path: path.to_path_buf(),
1005 message: format!(
1006 "admission_facts_carrier_module_id '{carrier_module_id}' must name a configured module"
1007 ),
1008 });
1009 };
1010 if !carrier.enabled || !carrier.reserved {
1011 return Err(DaemonConfigError::InvalidValue {
1012 path: path.to_path_buf(),
1013 message: format!(
1014 "admission_facts_carrier_module_id '{carrier_module_id}' must name an enabled reserved module"
1015 ),
1016 });
1017 }
1018
1019 let Some(targets) = targets else {
1020 return Err(DaemonConfigError::InvalidValue {
1021 path: path.to_path_buf(),
1022 message: "admission_facts_targets must be present when an admission facts carrier is configured".to_string(),
1023 });
1024 };
1025 if targets.is_empty() || targets.iter().any(String::is_empty) {
1026 return Err(DaemonConfigError::InvalidValue {
1027 path: path.to_path_buf(),
1028 message:
1029 "admission_facts_targets must be non-empty and must not contain empty module ids"
1030 .to_string(),
1031 });
1032 }
1033
1034 Ok(())
1035}
1036
1037fn default_enabled() -> bool {
1038 true
1039}
1040
1041fn validate_reserved_prefixes(
1042 modules: &[ConfiguredModule],
1043 path: &Path,
1044) -> Result<(), DaemonConfigError> {
1045 for module in modules {
1046 if module.reserved_prefixes.is_empty() {
1047 continue;
1048 }
1049 if !module.reserved {
1050 return Err(DaemonConfigError::InvalidValue {
1051 path: path.to_path_buf(),
1052 message: format!(
1053 "module '{}' reserved_prefixes require reserved=true so the owner is spawn-nonce protected",
1054 module.module_id
1055 ),
1056 });
1057 }
1058 for prefix in &module.reserved_prefixes {
1059 if !prefix.ends_with(':') {
1060 return Err(DaemonConfigError::InvalidValue {
1061 path: path.to_path_buf(),
1062 message: format!(
1063 "module '{}' reserved prefix '{}' must end with ':'",
1064 module.module_id, prefix
1065 ),
1066 });
1067 }
1068 }
1069 }
1070
1071 for module in modules {
1072 for prefix in &module.reserved_prefixes {
1073 if let Some(colliding) = modules
1074 .iter()
1075 .find(|candidate| candidate.module_id.starts_with(prefix))
1076 {
1077 return Err(DaemonConfigError::InvalidValue {
1078 path: path.to_path_buf(),
1079 message: format!(
1080 "reserved prefix '{}' owned by '{}' collides with configured module id '{}'",
1081 prefix, module.module_id, colliding.module_id
1082 ),
1083 });
1084 }
1085 }
1086 }
1087
1088 for (left_index, left) in modules.iter().enumerate() {
1089 for right in modules.iter().skip(left_index + 1) {
1090 if left.module_id == right.module_id {
1091 continue;
1092 }
1093 for left_prefix in &left.reserved_prefixes {
1094 for right_prefix in &right.reserved_prefixes {
1095 if left_prefix.starts_with(right_prefix)
1096 || right_prefix.starts_with(left_prefix)
1097 {
1098 return Err(DaemonConfigError::InvalidValue {
1099 path: path.to_path_buf(),
1100 message: format!(
1101 "reserved prefixes '{}' owned by '{}' and '{}' owned by '{}' overlap",
1102 left_prefix, left.module_id, right_prefix, right.module_id
1103 ),
1104 });
1105 }
1106 }
1107 }
1108 }
1109 }
1110
1111 Ok(())
1112}
1113
1114fn parse_health_config(
1115 raw: RawHealthConfig,
1116 path: &Path,
1117 module_id: &str,
1118) -> Result<HealthConfig, DaemonConfigError> {
1119 let defaults = HealthConfig::default();
1120 let cadence = positive_millis(
1121 raw.cadence_ms,
1122 defaults.cadence,
1123 path,
1124 module_id,
1125 "cadence_ms",
1126 )?;
1127 let deadline = positive_millis(
1128 raw.deadline_ms,
1129 defaults.deadline,
1130 path,
1131 module_id,
1132 "deadline_ms",
1133 )?;
1134 let failure_threshold = match raw.failure_threshold {
1135 Some(0) => {
1136 return Err(DaemonConfigError::InvalidValue {
1137 path: path.to_path_buf(),
1138 message: format!("module '{module_id}' health.failure_threshold must be positive"),
1139 })
1140 }
1141 Some(value) => value,
1142 None => defaults.failure_threshold,
1143 };
1144
1145 Ok(HealthConfig {
1146 cadence,
1147 deadline,
1148 failure_threshold,
1149 on_degraded: match raw.on_degraded {
1150 Some(RawHealthAction::Restart) => {
1151 return Err(DaemonConfigError::InvalidValue {
1152 path: path.to_path_buf(),
1153 message: format!(
1154 "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)."
1155 ),
1156 });
1157 }
1158 Some(action) => health_action(action),
1159 None => defaults.on_degraded,
1160 },
1161 on_failing: raw
1162 .on_failing
1163 .map(health_action)
1164 .unwrap_or(defaults.on_failing),
1165 critical: raw.critical,
1166 })
1167}
1168
1169fn parse_restart_config(
1176 raw: Option<RawRestartConfig>,
1177 path: &Path,
1178 module_id: &str,
1179) -> Result<RestartPolicy, DaemonConfigError> {
1180 let defaults = RestartPolicy::default();
1181 let Some(raw) = raw else {
1182 return Ok(defaults);
1183 };
1184
1185 let window = match raw.window_secs {
1186 Some(0) => {
1187 return Err(DaemonConfigError::InvalidValue {
1188 path: path.to_path_buf(),
1189 message: format!(
1190 "module '{module_id}' {RESTART_WINDOW_ZERO_MESSAGE}",
1191 module_id = module_id.escape_debug()
1192 ),
1193 });
1194 }
1195 Some(secs) => Duration::from_secs(secs),
1196 None => defaults.window,
1197 };
1198 let backoff = raw
1199 .backoff_ms
1200 .map(Duration::from_millis)
1201 .unwrap_or(defaults.backoff);
1202 let max_backoff = raw
1203 .max_backoff_ms
1204 .map(Duration::from_millis)
1205 .unwrap_or(defaults.max_backoff);
1206 if max_backoff < backoff {
1207 return Err(DaemonConfigError::InvalidValue {
1208 path: path.to_path_buf(),
1209 message: format!(
1210 "module '{}' restart.max_backoff_ms must be greater than or equal to restart.backoff_ms (max_backoff_ms={max_backoff:?}, backoff_ms={backoff:?})",
1211 module_id.escape_debug()
1212 ),
1213 });
1214 }
1215
1216 Ok(RestartPolicy {
1217 max_restarts: raw.max_restarts.unwrap_or(defaults.max_restarts),
1220 backoff,
1221 max_backoff,
1222 window,
1223 })
1224}
1225
1226fn positive_millis(
1227 value: Option<u64>,
1228 default: std::time::Duration,
1229 path: &Path,
1230 module_id: &str,
1231 field: &str,
1232) -> Result<std::time::Duration, DaemonConfigError> {
1233 match value {
1234 Some(0) => Err(DaemonConfigError::InvalidValue {
1235 path: path.to_path_buf(),
1236 message: format!("module '{module_id}' health.{field} must be positive"),
1237 }),
1238 Some(value) => Ok(std::time::Duration::from_millis(value)),
1239 None => Ok(default),
1240 }
1241}
1242
1243fn health_action(action: RawHealthAction) -> HealthAction {
1244 match action {
1245 RawHealthAction::Report => HealthAction::Report,
1246 RawHealthAction::Restart => HealthAction::Restart,
1247 RawHealthAction::Alert => HealthAction::Alert,
1248 }
1249}
1250
1251fn default_data_home() -> PathBuf {
1254 if let Some(data_home) = non_empty_os_var("XDG_DATA_HOME") {
1255 return PathBuf::from(data_home);
1256 }
1257
1258 #[cfg(windows)]
1259 {
1260 if let Some(app_data) = non_empty_os_var("APPDATA") {
1261 return PathBuf::from(app_data);
1262 }
1263 if let Some(user_profile) = non_empty_os_var("USERPROFILE") {
1264 return PathBuf::from(user_profile).join("AppData").join("Roaming");
1265 }
1266 }
1267
1268 if let Some(home) = non_empty_os_var("HOME") {
1269 return PathBuf::from(home).join(".local").join("share");
1270 }
1271
1272 PathBuf::from(".local").join("share")
1273}
1274
1275fn non_empty_os_var(key: &str) -> Option<OsString> {
1276 let value = env::var_os(key)?;
1277 if value.is_empty() {
1278 None
1279 } else {
1280 Some(value)
1281 }
1282}
1283
1284impl fmt::Display for DaemonConfigError {
1285 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1286 match self {
1287 Self::Read { path, source } => {
1288 write!(f, "failed to read daemon config {}: {source}", path.display())
1289 }
1290 Self::InvalidJsonc { path, message } => {
1291 write!(f, "invalid JSONC in daemon config {}: {message}", path.display())
1292 }
1293 Self::InvalidJson { path, source } => {
1294 write!(f, "invalid daemon config {}: {source}", path.display())
1295 }
1296 Self::UnsupportedVersion { path, version } => write!(
1297 f,
1298 "invalid daemon config {}: version {version} is unsupported (expected {SUPPORTED_CONFIG_VERSION})",
1299 path.display()
1300 ),
1301 Self::InvalidValue { path, message } => {
1302 write!(f, "invalid daemon config {}: {message}", path.display())
1303 }
1304 }
1305 }
1306}
1307
1308impl Error for DaemonConfigError {
1309 fn source(&self) -> Option<&(dyn Error + 'static)> {
1310 match self {
1311 Self::Read { source, .. } => Some(source),
1312 Self::InvalidJson { source, .. } => Some(source),
1313 Self::InvalidJsonc { .. }
1314 | Self::UnsupportedVersion { .. }
1315 | Self::InvalidValue { .. } => None,
1316 }
1317 }
1318}
1319
1320#[cfg(all(test, unix))]
1321mod run_dir_privacy_tests {
1322 use std::fs;
1323 use std::os::unix::fs::PermissionsExt;
1324
1325 use crate::test_support::TestTempDir;
1326
1327 #[test]
1333 fn run_dir_is_created_private_and_an_inherited_wide_one_is_tightened() {
1334 let temp = TestTempDir::new("subc-run-dir-privacy");
1335 let created = temp.path().join("cortexkit").join("run");
1336 super::ensure_directory_private(&created).expect("create run dir");
1337 let mode = fs::metadata(&created)
1338 .expect("stat created")
1339 .permissions()
1340 .mode()
1341 & 0o777;
1342 assert_eq!(
1343 mode, 0o700,
1344 "observable a run directory this code creates must be 0700, got {mode:o}"
1345 );
1346
1347 fs::set_permissions(&created, fs::Permissions::from_mode(0o755)).expect("widen");
1349 let widened = fs::metadata(&created)
1350 .expect("stat widened")
1351 .permissions()
1352 .mode()
1353 & 0o777;
1354 assert_eq!(
1355 widened, 0o755,
1356 "observable the fixture must actually be wide before the tighten"
1357 );
1358
1359 super::ensure_directory_private(&created).expect("tighten run dir");
1360 let mode = fs::metadata(&created)
1361 .expect("stat tightened")
1362 .permissions()
1363 .mode()
1364 & 0o777;
1365 assert_eq!(
1366 mode, 0o700,
1367 "observable an inherited group- or world-readable run directory must be tightened to 0700, got {mode:o}"
1368 );
1369 }
1370}
1371
1372#[cfg(test)]
1373mod tests {
1374 use super::*;
1375
1376 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1385
1386 fn abs(posix: &str) -> PathBuf {
1391 if cfg!(windows) {
1392 PathBuf::from(format!("C:{}", posix.replace('/', "\\")))
1393 } else {
1394 PathBuf::from(posix)
1395 }
1396 }
1397
1398 #[test]
1399 fn default_data_home_matches_golden_fixture() {
1400 let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1401 let doc: serde_json::Value =
1402 serde_json::from_str(include_str!("../tests/golden/data_home_resolution.json"))
1403 .expect("golden parses");
1404 let vars = ["XDG_DATA_HOME", "APPDATA", "USERPROFILE", "HOME"];
1405 let saved: Vec<(&str, Option<std::ffi::OsString>)> =
1406 vars.iter().map(|v| (*v, env::var_os(v))).collect();
1407 let platform_matches =
1408 |p: &str| p == "any" || p == if cfg!(windows) { "windows" } else { "unix" };
1409
1410 let mut ran = 0usize;
1411 for case in doc["cases"].as_array().expect("cases array") {
1412 let name = case["name"].as_str().expect("name");
1413 if !platform_matches(case["platform"].as_str().expect("platform")) {
1414 continue;
1415 }
1416 for v in vars {
1417 env::remove_var(v);
1418 }
1419 for (k, v) in case["env"].as_object().expect("env map") {
1420 env::set_var(k, v.as_str().expect("env value"));
1421 }
1422 let got = default_data_home();
1423 assert_eq!(
1424 got.to_string_lossy(),
1425 case["expect"].as_str().expect("expect"),
1426 "golden case '{name}' diverged"
1427 );
1428 ran += 1;
1429 }
1430 assert!(
1432 ran >= 6,
1433 "only {ran} golden cases ran; fixture or filter broken"
1434 );
1435
1436 for (k, v) in saved {
1437 match v {
1438 Some(val) => env::set_var(k, val),
1439 None => env::remove_var(k),
1440 }
1441 }
1442 }
1443
1444 #[test]
1450 fn default_config_home_matches_golden_fixture() {
1451 let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1452 let doc: serde_json::Value =
1453 serde_json::from_str(include_str!("../tests/golden/config_home_resolution.json"))
1454 .expect("golden parses");
1455 let vars = ["XDG_CONFIG_HOME", "APPDATA", "USERPROFILE", "HOME"];
1456 let saved: Vec<(&str, Option<std::ffi::OsString>)> =
1457 vars.iter().map(|v| (*v, env::var_os(v))).collect();
1458 let platform_matches =
1459 |p: &str| p == "any" || p == if cfg!(windows) { "windows" } else { "unix" };
1460
1461 let mut ran = 0usize;
1462 for case in doc["cases"].as_array().expect("cases array") {
1463 let name = case["name"].as_str().expect("name");
1464 if !platform_matches(case["platform"].as_str().expect("platform")) {
1465 continue;
1466 }
1467 for v in vars {
1468 env::remove_var(v);
1469 }
1470 for (k, v) in case["env"].as_object().expect("env map") {
1471 env::set_var(k, v.as_str().expect("env value"));
1472 }
1473 let got = default_config_home();
1474 assert_eq!(
1475 got.to_string_lossy(),
1476 case["expect"].as_str().expect("expect"),
1477 "golden case '{name}' diverged"
1478 );
1479 ran += 1;
1480 }
1481 assert!(
1482 ran >= 6,
1483 "only {ran} golden cases ran; fixture or filter broken"
1484 );
1485
1486 for (k, v) in saved {
1487 match v {
1488 Some(val) => env::set_var(k, val),
1489 None => env::remove_var(k),
1490 }
1491 }
1492 }
1493
1494 #[test]
1501 fn relative_storage_data_home_is_refused_at_parse() {
1502 let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1503 let path = Path::new("/golden/subc.jsonc");
1504
1505 let doc =
1507 r#"{ "version": 1, "storage": { "backend": "sqlite", "data_home": "relative/home" } }"#;
1508 let err = parse_doc(doc, path).expect_err("relative data_home must refuse");
1509 assert!(
1510 matches!(&err, DaemonConfigError::InvalidValue { message, .. }
1511 if message.contains("relative path relative/home")),
1512 "wrong refusal: {err:?}"
1513 );
1514
1515 let vars = ["XDG_DATA_HOME", "APPDATA", "USERPROFILE", "HOME"];
1517 let saved: Vec<(&str, Option<std::ffi::OsString>)> =
1518 vars.iter().map(|v| (*v, env::var_os(v))).collect();
1519 for v in vars {
1520 env::remove_var(v);
1521 }
1522 let doc = r#"{ "version": 1, "storage": { "backend": "sqlite" } }"#;
1523 let err = parse_doc(doc, path).expect_err("no home in env must refuse");
1524 assert!(
1525 matches!(&err, DaemonConfigError::InvalidValue { message, .. }
1526 if message.contains("no absolute XDG_DATA_HOME")),
1527 "wrong refusal: {err:?}"
1528 );
1529
1530 let want = abs("/abs/home");
1534 let doc = format!(
1535 r#"{{ "version": 1, "storage": {{ "backend": "sqlite", "data_home": {} }} }}"#,
1536 serde_json::to_string(&want).expect("json path")
1537 );
1538 let cfg = parse_doc(&doc, path).expect("absolute data_home parses");
1539 assert!(matches!(
1540 cfg.storage,
1541 Some(StorageConfig::Sqlite { ref data_home }) if *data_home == want
1542 ));
1543
1544 for (k, v) in saved {
1545 match v {
1546 Some(val) => env::set_var(k, val),
1547 None => env::remove_var(k),
1548 }
1549 }
1550 }
1551
1552 #[test]
1553 fn restart_required_sections_are_the_rescan_cannot_apply_set() {
1554 assert_eq!(
1555 RestartRequiredSection::ALL.map(RestartRequiredSection::label),
1556 [
1557 "port",
1558 "storage",
1559 "admission_facts_carrier_module_id",
1560 "admission_facts_targets",
1561 ]
1562 );
1563 }
1564
1565 #[test]
1566 fn no_storage_section_yields_none() {
1567 let config = parse_doc(
1568 r#"{ "version": 1, "modules": {} }"#,
1569 Path::new("/tmp/subc.jsonc"),
1570 )
1571 .expect("parse");
1572 assert_eq!(config.storage, None);
1573 }
1574
1575 #[test]
1576 fn sqlite_storage_parses_with_explicit_data_home() {
1577 let config = parse_doc(
1578 &format!(
1579 r#"{{ "version": 1, "storage": {{ "backend": "sqlite", "data_home": {} }} }}"#,
1580 serde_json::to_string(&abs("/data")).expect("json path")
1581 ),
1582 Path::new("/tmp/subc.jsonc"),
1583 )
1584 .expect("parse");
1585 assert_eq!(
1586 config.storage,
1587 Some(StorageConfig::Sqlite {
1588 data_home: abs("/data")
1589 })
1590 );
1591 }
1592
1593 #[test]
1594 fn sqlite_storage_defaults_data_home_when_omitted() {
1595 let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1601 std::env::set_var("XDG_DATA_HOME", abs("/forced/data/home"));
1602 let config = parse_doc(
1603 r#"{ "version": 1, "storage": { "backend": "sqlite" } }"#,
1604 Path::new("/tmp/subc.jsonc"),
1605 )
1606 .expect("parse");
1607 std::env::remove_var("XDG_DATA_HOME");
1608 assert_eq!(
1609 config.storage,
1610 Some(StorageConfig::Sqlite {
1611 data_home: abs("/forced/data/home")
1612 })
1613 );
1614 }
1615
1616 #[test]
1617 fn descriptor_for_matches_store_types_shape() {
1618 let cfg = StorageConfig::Sqlite {
1622 data_home: PathBuf::from("/data"),
1623 };
1624 let descriptor = cfg.descriptor_for("alfonso-routing");
1625 assert_eq!(
1626 descriptor,
1627 serde_json::json!({
1628 "module_id": "alfonso-routing",
1629 "storage_namespace": "default",
1630 "isolation": { "kind": "module" },
1631 "backend": {
1632 "backend": "sqlite",
1633 "path": "/data/cortexkit/alfonso-routing/store.db"
1634 }
1635 })
1636 );
1637 }
1638
1639 #[test]
1640 fn path_hazard_module_id_refuses_config_parse() {
1641 let path = Path::new("/tmp/subc.jsonc");
1642 let err = parse_doc(
1643 r#"{ "version": 1, "modules": { "../escape": { "program": "x" } } }"#,
1644 path,
1645 )
1646 .expect_err("separator-bearing module id must refuse");
1647 let text = format!("{err}");
1648 assert!(
1649 text.contains("not usable as a path component"),
1650 "refusal must name the hazard: {text}"
1651 );
1652 }
1653
1654 #[test]
1655 fn drain_timeout_resolves_module_over_daemon_over_absent() {
1656 let path = Path::new("/tmp/subc.jsonc");
1657 let config = parse_doc(
1658 r#"
1659 {
1660 "version": 1,
1661 "drain_timeout_ms": 45000,
1662 "modules": {
1663 "fast": { "program": "fast", "drain_timeout_ms": 0 },
1664 "slow": { "program": "slow", "drain_timeout_ms": 120000 },
1665 "inherits": { "program": "inherits" }
1666 }
1667 }
1668 "#,
1669 path,
1670 )
1671 .unwrap();
1672 let by_id = |id: &str| {
1673 config
1674 .modules
1675 .iter()
1676 .find(|m| m.module_id == id)
1677 .unwrap()
1678 .drain_timeout_ms
1679 };
1680 assert_eq!(by_id("fast"), Some(0));
1683 assert_eq!(by_id("slow"), Some(120_000));
1684 assert_eq!(by_id("inherits"), Some(45_000));
1686 assert_eq!(config.drain_timeout_ms, Some(45_000));
1687 }
1688
1689 #[test]
1690 fn drain_timeout_absent_everywhere_stays_none_for_builtin_default() {
1691 let path = Path::new("/tmp/subc.jsonc");
1692 let config = parse_doc(
1693 r#"{ "version": 1, "modules": { "m": { "program": "m" } } }"#,
1694 path,
1695 )
1696 .unwrap();
1697 assert_eq!(config.modules[0].drain_timeout_ms, None);
1701 assert_eq!(config.drain_timeout_ms, None);
1702 }
1703
1704 #[test]
1705 fn route_bind_relay_timeout_resolves_module_over_daemon_over_absent() {
1706 let path = Path::new("/tmp/subc.jsonc");
1712 let config = parse_doc(
1713 r#"
1714 {
1715 "version": 1,
1716 "route_bind_relay_timeout_ms": 30000,
1717 "modules": {
1718 "tight": { "program": "tight", "route_bind_relay_timeout_ms": 5000 },
1719 "loose": { "program": "loose", "route_bind_relay_timeout_ms": 60000 },
1720 "inherits": { "program": "inherits" }
1721 }
1722 }
1723 "#,
1724 path,
1725 )
1726 .unwrap();
1727 let by_id = |id: &str| {
1728 config
1729 .modules
1730 .iter()
1731 .find(|m| m.module_id == id)
1732 .unwrap()
1733 .route_bind_relay_timeout_ms
1734 };
1735 assert_eq!(by_id("tight"), Some(5_000));
1737 assert_eq!(by_id("loose"), Some(60_000));
1738 assert_eq!(by_id("inherits"), Some(30_000));
1740 assert_eq!(config.route_bind_relay_timeout_ms, Some(30_000));
1741 }
1742
1743 #[test]
1744 fn log_tag_keys_must_be_logger_names_and_the_error_names_the_key() {
1745 let path = Path::new("/tmp/subc.jsonc");
1746 for bad in ["Perf", "a b", "perf.", ".perf", "gc..walk", "a=b"] {
1747 let doc = format!(
1748 r#"{{ "version": 1, "modules": {{ "m": {{ "program": "m", "log": {{ "tags": {{ "{bad}": "debug" }} }} }} }} }}"#
1749 );
1750 let err = parse_doc(&doc, path).expect_err(bad);
1751 let text = format!("{err}");
1752 assert!(
1753 text.contains(&format!("{bad:?}")),
1754 "must name the key: {text}"
1755 );
1756 assert!(
1757 text.contains("logger name"),
1758 "must say what a key is: {text}"
1759 );
1760 }
1761 let ok = parse_doc(
1763 r#"{ "version": 1, "modules": { "m": { "program": "m", "log": { "tags": { "perf": "debug", "gc.walk": "trace", "m": "error", "a-b": "info" } } } } }"#,
1764 path,
1765 );
1766 assert!(ok.is_ok(), "{ok:?}");
1767 }
1768
1769 #[test]
1770 fn log_filter_spec_prefixes_bare_keys_with_the_module_and_passes_absolute_ones() {
1771 let path = Path::new("/tmp/subc.jsonc");
1772 let config = parse_doc(
1773 r#"{ "version": 1, "modules": { "synapse": { "program": "s", "log": { "level": "warn", "tags": { "perf": "debug", "gc.walk": "trace", "synapse": "error", "other.x": "info" } } } } }"#,
1774 path,
1775 )
1776 .unwrap();
1777 let log = config.modules[0].log.as_ref().unwrap();
1778 assert_eq!(
1780 log.filter_spec("synapse"),
1781 "warn,gc.walk=trace,other.x=info,synapse.perf=debug,synapse=error"
1782 );
1783 }
1784
1785 #[test]
1786 fn log_alarm_segment_mb_defaults_to_the_crate_default_and_refuses_zero() {
1787 let path = Path::new("/tmp/subc.jsonc");
1788 let config = parse_doc(
1789 r#"{ "version": 1, "modules": { "m": { "program": "m", "log": { "level": "info" } } } }"#,
1790 path,
1791 )
1792 .unwrap();
1793 assert_eq!(
1794 config.modules[0].log.as_ref().unwrap().alarm_segment_mb,
1795 cortexkit_log::SegmentRetention::default().alarm_segment_mb
1796 );
1797 let err = parse_doc(
1798 r#"{ "version": 1, "modules": { "m": { "program": "m", "log": { "alarm_segment_mb": 0 } } } }"#,
1799 path,
1800 )
1801 .expect_err("zero alarm must refuse");
1802 assert!(format!("{err}").contains("alarm_segment_mb"));
1803 }
1804
1805 #[test]
1806 fn route_bind_relay_timeout_zero_at_daemon_layer_is_refused() {
1807 let path = Path::new("/tmp/subc.jsonc");
1808 let err = parse_doc(
1809 r#"
1810 {
1811 "version": 1,
1812 "route_bind_relay_timeout_ms": 0,
1813 "modules": { "m": { "program": "m" } }
1814 }
1815 "#,
1816 path,
1817 )
1818 .expect_err("a daemon-wide zero budget must refuse parse");
1819 let text = format!("{err}");
1820 assert!(
1821 text.contains("route_bind_relay_timeout_ms"),
1822 "error must name the offending key: {text}"
1823 );
1824 assert!(
1825 text.contains("enabled: false"),
1826 "error must name the remedy (enable false): {text}"
1827 );
1828 }
1829
1830 #[test]
1831 fn route_bind_relay_timeout_zero_at_module_layer_is_refused() {
1832 let path = Path::new("/tmp/subc.jsonc");
1833 let err = parse_doc(
1834 r#"
1835 {
1836 "version": 1,
1837 "modules": {
1838 "good": { "program": "good" },
1839 "broken": { "program": "broken", "route_bind_relay_timeout_ms": 0 }
1840 }
1841 }
1842 "#,
1843 path,
1844 )
1845 .expect_err("a per-module zero budget must refuse parse");
1846 let text = format!("{err}");
1847 assert!(
1848 text.contains("route_bind_relay_timeout_ms"),
1849 "error must name the offending key: {text}"
1850 );
1851 assert!(
1852 text.contains("broken"),
1853 "error must name the offending module id: {text}"
1854 );
1855 assert!(
1856 text.contains("enabled: false"),
1857 "error must name the remedy (enable false): {text}"
1858 );
1859 }
1860
1861 #[test]
1862 fn drain_timeout_zero_still_parses_for_wedge_bounces() {
1863 let path = Path::new("/tmp/subc.jsonc");
1869 let config = parse_doc(
1870 r#"
1871 {
1872 "version": 1,
1873 "drain_timeout_ms": 0,
1874 "modules": {
1875 "wedge": { "program": "wedge", "drain_timeout_ms": 0 }
1876 }
1877 }
1878 "#,
1879 path,
1880 )
1881 .expect("drain_timeout_ms: 0 must still parse; wedge-bounce uses it");
1882 let wedge = config
1883 .modules
1884 .iter()
1885 .find(|m| m.module_id == "wedge")
1886 .unwrap();
1887 assert_eq!(wedge.drain_timeout_ms, Some(0));
1888 assert_eq!(config.drain_timeout_ms, Some(0));
1889 }
1890
1891 #[test]
1892 fn route_bind_relay_timeout_absent_everywhere_stays_none_for_builtin_default() {
1893 let path = Path::new("/tmp/subc.jsonc");
1898 let config = parse_doc(
1899 r#"{ "version": 1, "modules": { "m": { "program": "m" } } }"#,
1900 path,
1901 )
1902 .unwrap();
1903 assert_eq!(config.modules[0].route_bind_relay_timeout_ms, None);
1904 assert_eq!(config.route_bind_relay_timeout_ms, None);
1905 }
1906
1907 #[test]
1913 fn a_config_without_a_restart_block_keeps_the_supervisor_defaults() {
1914 let path = Path::new("/tmp/subc.jsonc");
1915 let config = parse_doc(
1916 r#"{ "version": 1, "modules": { "m": { "program": "m" } } }"#,
1917 path,
1918 )
1919 .unwrap();
1920 assert_eq!(config.modules[0].restart.max_restarts, 3);
1921 assert_eq!(config.modules[0].restart.window, Duration::from_secs(600));
1922 assert_eq!(
1923 config.modules[0].restart.backoff,
1924 Duration::from_millis(100)
1925 );
1926 assert_eq!(
1927 config.modules[0].restart.max_backoff,
1928 Duration::from_secs(30)
1929 );
1930 }
1931
1932 #[test]
1933 fn a_restart_block_resolves_each_key_independently() {
1934 let path = Path::new("/tmp/subc.jsonc");
1935 let config = parse_doc(
1936 r#"
1937 {
1938 "version": 1,
1939 "modules": {
1940 "all": {
1941 "program": "all",
1942 "restart": { "max_restarts": 5, "window_secs": 60, "backoff_ms": 250, "max_backoff_ms": 5000 }
1943 },
1944 "window-only": {
1945 "program": "window-only",
1946 "restart": { "window_secs": 7200 }
1947 },
1948 "never": {
1949 "program": "never",
1950 "restart": { "max_restarts": 0 }
1951 }
1952 }
1953 }
1954 "#,
1955 path,
1956 )
1957 .unwrap();
1958 let by_id = |id: &str| {
1959 config
1960 .modules
1961 .iter()
1962 .find(|m| m.module_id == id)
1963 .unwrap()
1964 .restart
1965 };
1966
1967 let all = by_id("all");
1968 assert_eq!(all.max_restarts, 5);
1969 assert_eq!(all.window, Duration::from_secs(60));
1970 assert_eq!(all.backoff, Duration::from_millis(250));
1971 assert_eq!(all.max_backoff, Duration::from_secs(5));
1972
1973 let window_only = by_id("window-only");
1976 assert_eq!(window_only.max_restarts, 3);
1977 assert_eq!(window_only.window, Duration::from_secs(7_200));
1978 assert_eq!(window_only.backoff, Duration::from_millis(100));
1979 assert_eq!(window_only.max_backoff, Duration::from_secs(30));
1980
1981 assert_eq!(by_id("never").max_restarts, 0);
1984 }
1985
1986 #[test]
1990 fn restart_window_zero_is_refused_by_name() {
1991 let path = Path::new("/tmp/subc.jsonc");
1992 let err = parse_doc(
1993 r#"
1994 {
1995 "version": 1,
1996 "modules": {
1997 "good": { "program": "good" },
1998 "broken": { "program": "broken", "restart": { "window_secs": 0 } }
1999 }
2000 }
2001 "#,
2002 path,
2003 )
2004 .expect_err("a zero crash window must refuse parse");
2005 assert!(
2006 matches!(err, DaemonConfigError::InvalidValue { .. }),
2007 "a zero window is an invalid value, not a parse failure: {err:?}"
2008 );
2009 let text = format!("{err}");
2010 assert!(
2011 text.contains("restart.window_secs"),
2012 "error must name the offending key: {text}"
2013 );
2014 assert!(
2015 text.contains("broken"),
2016 "error must name the offending module id: {text}"
2017 );
2018 assert!(
2019 text.contains("max_restarts: 0"),
2020 "error must name the setting that actually stops restarts: {text}"
2021 );
2022 }
2023
2024 #[test]
2025 fn restart_max_backoff_below_backoff_is_refused_by_name() {
2026 let path = Path::new("/tmp/subc.jsonc");
2027 let err = parse_doc(
2028 r#"
2029 {
2030 "version": 1,
2031 "modules": {
2032 "broken": {
2033 "program": "broken",
2034 "restart": { "backoff_ms": 1000, "max_backoff_ms": 999 }
2035 }
2036 }
2037 }
2038 "#,
2039 path,
2040 )
2041 .expect_err("a maximum below the base backoff must refuse parse");
2042 assert!(
2043 matches!(err, DaemonConfigError::InvalidValue { .. }),
2044 "an invalid restart bound must be an InvalidValue: {err:?}"
2045 );
2046 let text = format!("{err}");
2047 assert!(
2048 text.contains("restart.max_backoff_ms"),
2049 "error must name max_backoff_ms: {text}"
2050 );
2051 assert!(
2052 text.contains("restart.backoff_ms"),
2053 "error must name backoff_ms: {text}"
2054 );
2055 assert!(
2056 text.contains("broken"),
2057 "error must name the offending module id: {text}"
2058 );
2059 }
2060
2061 #[test]
2062 fn parse_jsonc_defaults_and_ignores_unknown_fields() {
2063 let path = Path::new("/tmp/subc.jsonc");
2064 let config = parse_doc(
2065 r#"
2066 {
2067 // forward-compatible root field
2068 "version": 1,
2069 "unknown": { "ignored": true },
2070 "modules": {
2071 "aft": {
2072 "program": "aft",
2073 "args": ["module",],
2074 "env": { "A": "B", },
2075 "future": 42,
2076 },
2077 "disabled": { "program": "disabled", "enabled": false }
2078 },
2079 }
2080 "#,
2081 path,
2082 )
2083 .unwrap();
2084
2085 assert_eq!(config.port, None);
2086 assert_eq!(config.modules.len(), 2);
2087 assert_eq!(config.modules[0].module_id, "aft");
2088 assert_eq!(config.modules[0].program, PathBuf::from("aft"));
2089 assert_eq!(config.modules[0].args, ["module"]);
2090 assert_eq!(config.modules[0].env, [("A".to_string(), "B".to_string())]);
2091 assert!(config.modules[0].enabled);
2092 assert!(config.modules[0].reserved_prefixes.is_empty());
2093 assert_eq!(config.modules[0].health, HealthConfig::default());
2094 assert!(!config.modules[1].enabled);
2095 }
2096
2097 #[test]
2098 fn reserved_capabilities_accept_unknown_bound_modules_and_refuse_bad_identifiers() {
2099 let path = Path::new("/tmp/subc.jsonc");
2100 let config = parse_doc(
2101 r#"{
2102 "version": 1,
2103 "reserved_capabilities": {
2104 "credentials-provider/v1": "future-vault"
2105 },
2106 "modules": {}
2107 }"#,
2108 path,
2109 )
2110 .expect("a binding may predate its provider installation");
2111 assert_eq!(
2112 config.reserved_capabilities,
2113 BTreeMap::from([(
2114 "credentials-provider/v1".to_string(),
2115 "future-vault".to_string()
2116 )])
2117 );
2118
2119 let error = parse_doc(
2120 r#"{
2121 "version": 1,
2122 "reserved_capabilities": { "Credentials/v1": "vault" },
2123 "modules": {}
2124 }"#,
2125 path,
2126 )
2127 .expect_err("reserved capabilities use the capability identifier grammar");
2128 assert!(error.to_string().contains("reserved_capabilities key"));
2129 }
2130
2131 #[test]
2137 fn an_absent_protocol_key_and_an_explicit_subc_are_the_same_module() {
2138 let parse = |module_body: &str| {
2139 parse_doc(
2140 &format!(
2141 r#"{{
2142 "version": 1,
2143 "modules": {{ "aft": {{ "program": "aft"{module_body} }} }}
2144 }}"#
2145 ),
2146 Path::new("subc.jsonc"),
2147 )
2148 .expect("module parses")
2149 .modules
2150 .remove(0)
2151 };
2152
2153 let absent = parse("");
2154 let explicit = parse(r#", "protocol": "subc""#);
2155 let none = parse(r#", "protocol": "none""#);
2156
2157 assert_eq!(absent.protocol, ModuleProtocol::Subc);
2158 assert_eq!(explicit.protocol, ModuleProtocol::Subc);
2159 assert_eq!(
2160 absent, explicit,
2161 "an absent protocol key must produce exactly the module an explicit subc does"
2162 );
2163 assert_eq!(none.protocol, ModuleProtocol::None);
2164 assert_eq!(none.module_spec().protocol, ModuleProtocol::None);
2168 }
2169
2170 #[test]
2174 fn overlap_defaults_to_exclusive_and_only_safe_opts_in() {
2175 let parse = |module_body: &str| {
2176 parse_doc(
2177 &format!(
2178 r#"{{
2179 "version": 1,
2180 "modules": {{ "aft": {{ "program": "aft"{module_body} }} }}
2181 }}"#
2182 ),
2183 Path::new("subc.jsonc"),
2184 )
2185 };
2186
2187 let absent = parse("").unwrap().modules.remove(0);
2188 assert_eq!(absent.overlap, ModuleOverlap::Exclusive);
2189 assert_eq!(absent.module_spec().overlap, ModuleOverlap::Exclusive);
2190 let safe = parse(r#", "overlap": "safe""#).unwrap().modules.remove(0);
2191 assert_eq!(safe.module_spec().overlap, ModuleOverlap::Safe);
2192 let typo = parse(r#", "overlap": "sfae""#).expect_err("an unknown overlap is refused");
2193 assert!(typo.to_string().contains("sfae"), "{typo}");
2194 }
2195
2196 #[test]
2200 fn the_spawn_role_is_refused_as_a_configured_env_key() {
2201 let error = parse_doc(
2202 r#"{
2203 "version": 1,
2204 "modules": { "aft": { "program": "aft", "env": { "SUBC_SPAWN_ROLE": "swap_candidate" } } }
2205 }"#,
2206 Path::new("subc.jsonc"),
2207 )
2208 .expect_err("SUBC_SPAWN_ROLE must not be configurable");
2209 assert!(
2210 matches!(error, DaemonConfigError::InvalidValue { .. }),
2211 "expected InvalidValue, got {error:?}"
2212 );
2213 assert!(error.to_string().contains("SUBC_SPAWN_ROLE"), "{error}");
2214 }
2215
2216 #[test]
2222 fn an_unsupported_protocol_value_is_refused_by_name() {
2223 let error = parse_doc(
2224 r#"{
2225 "version": 1,
2226 "modules": { "nats": { "program": "nats-server", "protocol": "grpc" } }
2227 }"#,
2228 Path::new("subc.jsonc"),
2229 )
2230 .expect_err("an unknown protocol must not fall back to a default");
2231
2232 assert!(
2233 matches!(error, DaemonConfigError::InvalidValue { .. }),
2234 "expected InvalidValue, got {error:?}"
2235 );
2236 let message = error.to_string();
2237 assert!(
2238 message.contains("grpc"),
2239 "the refusal must name the offending value: {message}"
2240 );
2241 assert!(
2242 message.contains("nats"),
2243 "the refusal must name the module so it can be found in the file: {message}"
2244 );
2245 }
2246
2247 #[test]
2251 fn reserved_true_with_protocol_none_is_refused_with_the_reason() {
2252 let error = parse_doc(
2253 r#"{
2254 "version": 1,
2255 "modules": {
2256 "nats": { "program": "nats-server", "protocol": "none", "reserved": true }
2257 }
2258 }"#,
2259 Path::new("subc.jsonc"),
2260 )
2261 .expect_err("a reservation that can never be checked must not parse");
2262
2263 assert!(
2264 matches!(error, DaemonConfigError::InvalidValue { .. }),
2265 "expected InvalidValue, got {error:?}"
2266 );
2267 let message = error.to_string();
2268 assert!(
2269 message.contains("nats") && message.contains("reserved"),
2270 "the refusal must name the module and the offending key: {message}"
2271 );
2272 assert!(
2273 message.contains("HELLO") || message.contains("never registers"),
2274 "the refusal must say WHY the pair cannot work: {message}"
2275 );
2276 }
2277
2278 #[test]
2279 fn reserved_prefixes_parse_for_reserved_modules() {
2280 let config = parse_doc(
2281 r#"
2282 {
2283 "version": 1,
2284 "modules": {
2285 "federation": {
2286 "program": "fed",
2287 "reserved": true,
2288 "reserved_prefixes": ["fed:"]
2289 }
2290 }
2291 }
2292 "#,
2293 Path::new("subc.jsonc"),
2294 )
2295 .unwrap();
2296
2297 assert_eq!(config.modules[0].reserved_prefixes, ["fed:".to_string()]);
2298 }
2299
2300 #[test]
2301 fn reserved_prefixes_reject_bad_boundaries_and_owners() {
2302 let missing_delimiter = parse_doc(
2303 r#"{
2304 "version": 1,
2305 "modules": {
2306 "federation": { "program": "fed", "reserved": true, "reserved_prefixes": ["fed"] }
2307 }
2308 }"#,
2309 Path::new("subc.jsonc"),
2310 )
2311 .unwrap_err();
2312 assert!(matches!(
2313 missing_delimiter,
2314 DaemonConfigError::InvalidValue { .. }
2315 ));
2316
2317 let non_reserved_owner = parse_doc(
2318 r#"{
2319 "version": 1,
2320 "modules": {
2321 "federation": { "program": "fed", "reserved_prefixes": ["fed:"] }
2322 }
2323 }"#,
2324 Path::new("subc.jsonc"),
2325 )
2326 .unwrap_err();
2327 assert!(matches!(
2328 non_reserved_owner,
2329 DaemonConfigError::InvalidValue { .. }
2330 ));
2331 }
2332
2333 #[test]
2334 fn reserved_prefixes_reject_cross_owner_overlap_and_exact_id_collisions() {
2335 let overlap = parse_doc(
2336 r#"{
2337 "version": 1,
2338 "modules": {
2339 "fed-owner": { "program": "fed", "reserved": true, "reserved_prefixes": ["fed:"] },
2340 "sub-owner": { "program": "fed-sub", "reserved": true, "reserved_prefixes": ["fed:sub:"] }
2341 }
2342 }"#,
2343 Path::new("subc.jsonc"),
2344 )
2345 .unwrap_err();
2346 assert!(matches!(overlap, DaemonConfigError::InvalidValue { .. }));
2347
2348 let exact_collision = parse_doc(
2349 r#"{
2350 "version": 1,
2351 "modules": {
2352 "federation": { "program": "fed", "reserved": true, "reserved_prefixes": ["fed:"] },
2353 "fed:special": { "program": "special" }
2354 }
2355 }"#,
2356 Path::new("subc.jsonc"),
2357 )
2358 .unwrap_err();
2359 assert!(matches!(
2360 exact_collision,
2361 DaemonConfigError::InvalidValue { .. }
2362 ));
2363 }
2364
2365 #[test]
2366 fn health_config_parses_and_ignores_unknown_fields() {
2367 let config = parse_doc(
2368 r#"
2369 {
2370 "version": 1,
2371 "modules": {
2372 "aft": {
2373 "program": "aft",
2374 "health": {
2375 "cadence_ms": 100,
2376 "deadline_ms": 20,
2377 "failure_threshold": 2,
2378 "on_degraded": "report",
2379 "on_failing": "restart",
2380 "critical": true,
2381 "future": "ignored"
2382 }
2383 }
2384 }
2385 }
2386 "#,
2387 Path::new("subc.jsonc"),
2388 )
2389 .unwrap();
2390
2391 let health = config.modules[0].health;
2392 assert_eq!(health.cadence, std::time::Duration::from_millis(100));
2393 assert_eq!(health.deadline, std::time::Duration::from_millis(20));
2394 assert_eq!(health.failure_threshold, 2);
2395 assert_eq!(health.on_degraded, HealthAction::Report);
2396 assert_eq!(health.on_failing, HealthAction::Restart);
2397 assert!(health.critical);
2398 }
2399
2400 #[test]
2401 fn health_config_rejects_bad_enum_and_non_positive_numbers() {
2402 let bad_enum = parse_doc(
2403 r#"{
2404 "version": 1,
2405 "modules": { "aft": { "program": "aft", "health": { "on_failing": "page" } } }
2406 }"#,
2407 Path::new("subc.jsonc"),
2408 )
2409 .unwrap_err();
2410 assert!(matches!(bad_enum, DaemonConfigError::InvalidJson { .. }));
2411
2412 let zero = parse_doc(
2413 r#"{
2414 "version": 1,
2415 "modules": { "aft": { "program": "aft", "health": { "cadence_ms": 0 } } }
2416 }"#,
2417 Path::new("subc.jsonc"),
2418 )
2419 .unwrap_err();
2420 assert!(matches!(zero, DaemonConfigError::InvalidValue { .. }));
2421 }
2422
2423 #[test]
2424 fn admission_facts_carrier_requires_non_empty_targets() {
2425 let missing_targets = parse_doc(
2426 r#"{
2427 "version": 1,
2428 "admission_facts_carrier_module_id": "fed",
2429 "modules": { "fed": { "program": "fed", "reserved": true } }
2430 }"#,
2431 Path::new("subc.jsonc"),
2432 )
2433 .unwrap_err();
2434 assert!(
2440 matches!(&missing_targets, DaemonConfigError::InvalidValue { message, .. }
2441 if message.contains("must be present")),
2442 "expected the presence rule, got: {missing_targets:?}"
2443 );
2444
2445 let empty_targets = parse_doc(
2446 r#"{
2447 "version": 1,
2448 "admission_facts_carrier_module_id": "fed",
2449 "admission_facts_targets": [""],
2450 "modules": { "fed": { "program": "fed", "reserved": true } }
2451 }"#,
2452 Path::new("subc.jsonc"),
2453 )
2454 .unwrap_err();
2455 assert!(
2456 matches!(&empty_targets, DaemonConfigError::InvalidValue { message, .. }
2457 if message.contains("must be non-empty")),
2458 "expected the non-empty rule, got: {empty_targets:?}"
2459 );
2460 }
2461
2462 #[test]
2463 fn admission_facts_carrier_must_be_enabled_reserved_and_configured() {
2464 for module in [
2465 r#"{ "program": "fed", "enabled": false, "reserved": true }"#,
2466 r#"{ "program": "fed", "enabled": true, "reserved": false }"#,
2467 ] {
2468 let doc = format!(
2469 r#"{{
2470 "version": 1,
2471 "admission_facts_carrier_module_id": "fed",
2472 "admission_facts_targets": ["target"],
2473 "modules": {{ "fed": {module}, "target": {{ "program": "target" }} }}
2474 }}"#
2475 );
2476 let err = parse_doc(&doc, Path::new("subc.jsonc")).unwrap_err();
2477 assert!(
2481 matches!(&err, DaemonConfigError::InvalidValue { message, .. }
2482 if message.contains("enabled reserved module")),
2483 "expected the enabled-and-reserved rule, got: {err:?}"
2484 );
2485 }
2486
2487 let absent = parse_doc(
2488 r#"{
2489 "version": 1,
2490 "admission_facts_carrier_module_id": "missing",
2491 "admission_facts_targets": ["target"],
2492 "modules": { "target": { "program": "target" } }
2493 }"#,
2494 Path::new("subc.jsonc"),
2495 )
2496 .unwrap_err();
2497 assert!(
2498 matches!(&absent, DaemonConfigError::InvalidValue { message, .. }
2499 if message.contains("must name a configured module")),
2500 "expected the configured-module rule, got: {absent:?}"
2501 );
2502 }
2503
2504 #[test]
2505 fn reject_unsupported_version() {
2506 let err = parse_doc(
2507 r#"{ "version": 2, "modules": {} }"#,
2508 Path::new("subc.jsonc"),
2509 )
2510 .unwrap_err();
2511 assert!(matches!(
2512 err,
2513 DaemonConfigError::UnsupportedVersion { version: 2, .. }
2514 ));
2515 }
2516
2517 #[test]
2518 fn reject_unterminated_block_comment() {
2519 let err = parse_doc(r#"{ "version": 1, /*"#, Path::new("subc.jsonc")).unwrap_err();
2520 assert!(matches!(err, DaemonConfigError::InvalidJsonc { .. }));
2521 }
2522}