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 ScopeAuthorityOwners,
47}
48
49impl RestartRequiredSection {
50 pub const ALL: [Self; 5] = [
51 Self::Port,
52 Self::Storage,
53 Self::AdmissionFactsCarrierModuleId,
54 Self::AdmissionFactsTargets,
55 Self::ScopeAuthorityOwners,
56 ];
57
58 pub const fn label(self) -> &'static str {
59 match self {
60 Self::Port => "port",
61 Self::Storage => "storage",
62 Self::AdmissionFactsCarrierModuleId => "admission_facts_carrier_module_id",
63 Self::AdmissionFactsTargets => "admission_facts_targets",
64 Self::ScopeAuthorityOwners => "scope_authority_owners",
65 }
66 }
67}
68
69pub fn default_scope_authority_owners() -> Vec<String> {
72 vec!["prefrontal-core".to_string()]
73}
74
75const 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)";
86
87const 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)";
94
95#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct LoggingConfig {
103 pub level: String,
104 pub tags: BTreeMap<String, String>,
109 pub retention: Retention,
110 pub alarm_segment_mb: u32,
112}
113
114impl LoggingConfig {
115 pub fn filter_spec(&self, module_id: &str) -> String {
124 let mut directives = vec![self.level.clone()];
125 directives.extend(self.tags.iter().map(|(logger, level)| {
126 if logger == module_id || logger.contains('.') {
127 format!("{logger}={level}")
128 } else {
129 format!("{module_id}.{logger}={level}")
130 }
131 }));
132 directives.join(",")
133 }
134
135 pub fn segment_retention(&self) -> cortexkit_log::SegmentRetention {
136 cortexkit_log::SegmentRetention {
137 max_age_days: self.retention.max_age_days,
138 alarm_segment_mb: self.alarm_segment_mb,
139 }
140 }
141}
142
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct DaemonConfig {
145 pub path: PathBuf,
146 pub port: Option<u16>,
147 pub drain_timeout_ms: Option<u64>,
151 pub route_bind_relay_timeout_ms: Option<u64>,
160 pub modules: Vec<ConfiguredModule>,
161 pub storage: Option<StorageConfig>,
164 pub admission_facts_carrier_module_id: Option<String>,
166 pub admission_facts_targets: Option<Vec<String>>,
168 pub scope_authority_owners: Vec<String>,
174 pub reserved_capabilities: BTreeMap<String, String>,
178}
179
180#[derive(Debug, Clone, PartialEq, Eq)]
184pub enum StorageConfig {
185 Sqlite { data_home: PathBuf },
187}
188
189impl StorageConfig {
190 pub fn descriptor_for(&self, module_id: &str) -> serde_json::Value {
216 match self {
217 StorageConfig::Sqlite { data_home } => {
226 let data_home = data_home.to_string_lossy();
227 let path = format!(
228 "{}/cortexkit/{module_id}/store.db",
229 data_home.trim_end_matches('/')
230 );
231 serde_json::json!({
232 "module_id": module_id,
233 "storage_namespace": "default",
234 "isolation": { "kind": "module" },
235 "backend": { "backend": "sqlite", "path": path },
236 })
237 }
238 }
239 }
240}
241
242#[derive(Debug, Clone, PartialEq, Eq)]
243pub struct ConfiguredModule {
244 pub module_id: String,
245 pub program: PathBuf,
246 pub args: Vec<String>,
247 pub env: Vec<(String, String)>,
248 pub log: Option<LoggingConfig>,
252 pub enabled: bool,
253 pub reserved: bool,
259 pub reserved_prefixes: Vec<String>,
263 pub protocol: ModuleProtocol,
266 pub overlap: ModuleOverlap,
269 pub health: HealthConfig,
270 pub drain_timeout_ms: Option<u64>,
273 pub route_bind_relay_timeout_ms: Option<u64>,
278 pub restart: RestartPolicy,
289}
290
291impl ConfiguredModule {
292 pub fn module_spec(&self) -> ModuleSpec {
293 let mut env = self.env.clone();
294 if let Some(log) = &self.log {
295 env.retain(|(key, _)| {
296 key != CK_LOG_ENV
297 && key != CAPTURE_MAX_FILE_MB_ENV
298 && key != CAPTURE_KEEP_ENV
299 && key != CAPTURE_MAX_AGE_DAYS_ENV
300 });
301 env.retain(|(key, _)| {
302 key != CHILD_LOG_MAX_AGE_DAYS_ENV && key != CHILD_LOG_ALARM_SEGMENT_MB_ENV
303 });
304 env.push((CK_LOG_ENV.to_string(), log.filter_spec(&self.module_id)));
305 env.push((
306 CHILD_LOG_MAX_AGE_DAYS_ENV.to_string(),
307 log.retention.max_age_days.to_string(),
308 ));
309 env.push((
310 CHILD_LOG_ALARM_SEGMENT_MB_ENV.to_string(),
311 log.alarm_segment_mb.to_string(),
312 ));
313 env.push((
317 CAPTURE_MAX_FILE_MB_ENV.to_string(),
318 log.retention.max_file_mb.to_string(),
319 ));
320 env.push((CAPTURE_KEEP_ENV.to_string(), log.retention.keep.to_string()));
321 env.push((
322 CAPTURE_MAX_AGE_DAYS_ENV.to_string(),
323 log.retention.max_age_days.to_string(),
324 ));
325 }
326 ModuleSpec {
327 module_id: self.module_id.clone(),
328 program: self.program.clone(),
329 args: self.args.clone(),
330 env,
331 reserved: self.reserved,
332 reserved_prefixes: self.reserved_prefixes.clone(),
333 protocol: self.protocol,
334 overlap: self.overlap,
335 }
336 }
337}
338
339#[derive(Debug)]
340pub enum DaemonConfigError {
341 Read {
342 path: PathBuf,
343 source: io::Error,
344 },
345 InvalidJsonc {
346 path: PathBuf,
347 message: String,
348 },
349 InvalidJson {
350 path: PathBuf,
351 source: serde_json::Error,
352 },
353 UnsupportedVersion {
354 path: PathBuf,
355 version: u32,
356 },
357 InvalidValue {
358 path: PathBuf,
359 message: String,
360 },
361}
362
363#[derive(Debug, Deserialize)]
364struct RawDaemonConfig {
365 version: u32,
366 #[serde(default)]
367 port: Option<u16>,
368 #[serde(default)]
369 drain_timeout_ms: Option<u64>,
370 #[serde(default)]
371 route_bind_relay_timeout_ms: Option<u64>,
372 #[serde(default)]
373 log: Option<RawLoggingConfig>,
374 #[serde(default)]
375 modules: BTreeMap<String, RawModuleConfig>,
376 #[serde(default)]
377 storage: Option<RawStorageConfig>,
378 #[serde(default)]
379 admission_facts_carrier_module_id: Option<String>,
380 #[serde(default)]
381 admission_facts_targets: Option<Vec<String>>,
382 #[serde(default)]
383 scope_authority_owners: Option<Vec<String>>,
384 #[serde(default)]
385 reserved_capabilities: BTreeMap<String, String>,
386}
387
388#[derive(Debug, Deserialize)]
389#[serde(tag = "backend", rename_all = "snake_case")]
390enum RawStorageConfig {
391 Sqlite {
392 #[serde(default)]
395 data_home: Option<PathBuf>,
396 },
397}
398
399#[derive(Debug, Deserialize)]
400struct RawModuleConfig {
401 program: PathBuf,
402 #[serde(default)]
403 args: Vec<String>,
404 #[serde(default)]
405 env: BTreeMap<String, String>,
406 #[serde(default)]
407 log: Option<RawLoggingConfig>,
408 #[serde(default = "default_enabled")]
409 enabled: bool,
410 #[serde(default)]
411 reserved: bool,
412 #[serde(default)]
413 reserved_prefixes: Vec<String>,
414 #[serde(default)]
418 protocol: Option<String>,
419 #[serde(default)]
421 overlap: Option<String>,
422 #[serde(default)]
423 health: Option<RawHealthConfig>,
424 #[serde(default)]
425 drain_timeout_ms: Option<u64>,
426 #[serde(default)]
427 route_bind_relay_timeout_ms: Option<u64>,
428 #[serde(default)]
429 restart: Option<RawRestartConfig>,
430}
431
432#[derive(Debug, Clone, Deserialize)]
433struct RawLoggingConfig {
434 #[serde(default)]
435 level: Option<String>,
436 #[serde(default)]
437 tags: BTreeMap<String, String>,
438 #[serde(default)]
439 alarm_segment_mb: Option<u32>,
440 #[serde(default)]
441 max_file_mb: Option<u32>,
442 #[serde(default)]
443 keep: Option<u8>,
444 #[serde(default)]
445 max_age_days: Option<u32>,
446}
447
448#[derive(Debug, Deserialize)]
449struct RawRestartConfig {
450 #[serde(default)]
451 max_restarts: Option<u32>,
452 #[serde(default)]
453 window_secs: Option<u64>,
454 #[serde(default)]
455 backoff_ms: Option<u64>,
456 #[serde(default)]
457 max_backoff_ms: Option<u64>,
458}
459
460#[derive(Debug, Deserialize)]
461struct RawHealthConfig {
462 #[serde(default)]
463 cadence_ms: Option<u64>,
464 #[serde(default)]
465 deadline_ms: Option<u64>,
466 #[serde(default)]
467 failure_threshold: Option<u32>,
468 #[serde(default)]
469 on_degraded: Option<RawHealthAction>,
470 #[serde(default)]
471 on_failing: Option<RawHealthAction>,
472 #[serde(default)]
473 critical: bool,
474}
475
476#[derive(Debug, Deserialize)]
477#[serde(rename_all = "snake_case")]
478enum RawHealthAction {
479 Report,
480 Restart,
481 Alert,
482}
483
484pub fn default_config_path() -> PathBuf {
485 default_config_home().join(DAEMON_CONFIG_RELATIVE_PATH)
486}
487
488pub fn default_config_home() -> PathBuf {
510 if let Some(config_home) = non_empty_os_var("XDG_CONFIG_HOME") {
511 return PathBuf::from(config_home);
512 }
513
514 #[cfg(windows)]
515 {
516 if let Some(app_data) = non_empty_os_var("APPDATA") {
517 return PathBuf::from(app_data);
518 }
519 if let Some(user_profile) = non_empty_os_var("USERPROFILE") {
520 return PathBuf::from(user_profile).join("AppData").join("Roaming");
521 }
522 }
523
524 if let Some(home) = non_empty_os_var("HOME") {
525 return PathBuf::from(home).join(".config");
526 }
527
528 PathBuf::from(".config")
529}
530
531pub fn load(path: impl AsRef<Path>) -> Result<Option<DaemonConfig>, DaemonConfigError> {
532 let path = path.as_ref();
533 let Some(doc) = read_config_doc(path)? else {
534 return Ok(None);
535 };
536 parse_doc(&doc, path).map(Some)
537}
538
539pub fn load_logging(path: impl AsRef<Path>) -> Result<Option<LoggingConfig>, DaemonConfigError> {
545 let path = path.as_ref();
546 let Some(doc) = read_config_doc(path)? else {
547 return Ok(None);
548 };
549 let json = jsonc_to_json(&doc).map_err(|message| DaemonConfigError::InvalidJsonc {
550 path: path.to_path_buf(),
551 message,
552 })?;
553 let raw: RawDaemonConfig =
554 serde_json::from_str(&json).map_err(|source| DaemonConfigError::InvalidJson {
555 path: path.to_path_buf(),
556 source,
557 })?;
558 if raw.version != SUPPORTED_CONFIG_VERSION {
559 return Err(DaemonConfigError::UnsupportedVersion {
560 path: path.to_path_buf(),
561 version: raw.version,
562 });
563 }
564 raw.log
565 .map(|log| parse_logging_config(log, path, "daemon log"))
566 .transpose()
567}
568
569pub fn ensure_daemon_run_dir_private() -> Result<PathBuf, io::Error> {
595 let path = daemon_run_dir()
596 .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
597 ensure_directory_private(&path)?;
598 Ok(path)
599}
600
601#[cfg(unix)]
606fn ensure_directory_private(path: &Path) -> Result<(), io::Error> {
607 use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
608
609 if !path.exists() {
610 fs::DirBuilder::new()
611 .recursive(true)
612 .mode(0o700)
613 .create(path)?;
614 return Ok(());
615 }
616 let mode = fs::metadata(path)?.permissions().mode() & 0o777;
617 if mode & 0o077 != 0 {
618 fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
619 }
620 Ok(())
621}
622
623#[cfg(not(unix))]
625fn ensure_directory_private(path: &Path) -> Result<(), io::Error> {
626 if !path.exists() {
627 fs::create_dir_all(path)?;
628 }
629 Ok(())
630}
631
632pub fn daemon_run_dir() -> Result<PathBuf, DaemonRunDirError> {
642 daemon_run_dir_from(default_data_home())
643}
644
645fn daemon_run_dir_from(data_home: PathBuf) -> Result<PathBuf, DaemonRunDirError> {
648 if !data_home.is_absolute() {
649 return Err(DaemonRunDirError::RelativeDataHome { data_home });
650 }
651 Ok(data_home.join("cortexkit").join("run"))
652}
653
654#[derive(Debug, Clone, PartialEq, Eq)]
656pub enum DaemonRunDirError {
657 RelativeDataHome { data_home: PathBuf },
660}
661
662impl fmt::Display for DaemonRunDirError {
663 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
664 match self {
665 Self::RelativeDataHome { data_home } => write!(
666 f,
667 "cannot resolve the daemon run directory: the data home `{}` is relative, \
668 so it would land under the current working directory; {}",
669 data_home.display(),
670 DATA_HOME_REMEDY
671 ),
672 }
673 }
674}
675
676impl std::error::Error for DaemonRunDirError {}
677
678#[cfg(windows)]
680const DATA_HOME_REMEDY: &str =
681 "set XDG_DATA_HOME to an absolute path, or set APPDATA, USERPROFILE or HOME";
682#[cfg(not(windows))]
683const DATA_HOME_REMEDY: &str = "set XDG_DATA_HOME to an absolute path, or set HOME";
684
685fn read_config_doc(path: &Path) -> Result<Option<String>, DaemonConfigError> {
686 match fs::read_to_string(path) {
687 Ok(doc) => Ok(Some(doc)),
688 Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(None),
689 Err(source) => Err(DaemonConfigError::Read {
690 path: path.to_path_buf(),
691 source,
692 }),
693 }
694}
695
696fn parse_doc(doc: &str, path: &Path) -> Result<DaemonConfig, DaemonConfigError> {
697 let json = jsonc_to_json(doc).map_err(|message| DaemonConfigError::InvalidJsonc {
698 path: path.to_path_buf(),
699 message,
700 })?;
701 let raw: RawDaemonConfig =
702 serde_json::from_str(&json).map_err(|source| DaemonConfigError::InvalidJson {
703 path: path.to_path_buf(),
704 source,
705 })?;
706
707 if raw.version != SUPPORTED_CONFIG_VERSION {
708 return Err(DaemonConfigError::UnsupportedVersion {
709 path: path.to_path_buf(),
710 version: raw.version,
711 });
712 }
713
714 let daemon_logging = raw
715 .log
716 .map(|log| parse_logging_config(log, path, "daemon log"))
717 .transpose()?;
718 let default_drain_timeout_ms = raw.drain_timeout_ms;
719 let default_route_bind_relay_timeout_ms = match raw.route_bind_relay_timeout_ms {
725 Some(0) => {
726 return Err(DaemonConfigError::InvalidValue {
727 path: path.to_path_buf(),
728 message: ROUTE_BIND_RELAY_ZERO_MESSAGE.to_string(),
729 });
730 }
731 Some(value) => Some(value),
732 None => None,
733 };
734 let modules = raw
735 .modules
736 .into_iter()
737 .map(|(module_id, module)| {
738 let health = module
739 .health
740 .map(|health| parse_health_config(health, path, &module_id))
741 .transpose()?
742 .unwrap_or_default();
743 if let Err(reason) = crate::registry::module_id_path_hazard(&module_id) {
744 return Err(DaemonConfigError::InvalidValue {
745 path: path.to_path_buf(),
746 message: format!(
747 "module id '{}' is not usable as a path component ({reason}): \
748 the daemon derives each module's store path from its id",
749 module_id.escape_debug()
750 ),
751 });
752 }
753 let per_module_route_bind_relay_timeout_ms = match module.route_bind_relay_timeout_ms {
758 Some(0) => {
759 return Err(DaemonConfigError::InvalidValue {
760 path: path.to_path_buf(),
761 message: format!(
762 "module '{module_id}' {ROUTE_BIND_RELAY_ZERO_MESSAGE}",
763 module_id = module_id.escape_debug()
764 ),
765 });
766 }
767 Some(value) => Some(value),
768 None => default_route_bind_relay_timeout_ms,
769 };
770 let protocol = parse_module_protocol(module.protocol.as_deref(), path, &module_id)?;
771 let overlap = parse_module_overlap(module.overlap.as_deref(), path, &module_id)?;
772 if module.env.contains_key(SUBC_SPAWN_ROLE_ENV) {
776 return Err(DaemonConfigError::InvalidValue {
777 path: path.to_path_buf(),
778 message: format!(
779 "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",
780 module_id = module_id.escape_debug()
781 ),
782 });
783 }
784 if protocol == ModuleProtocol::None && module.reserved {
791 return Err(DaemonConfigError::InvalidValue {
792 path: path.to_path_buf(),
793 message: format!(
794 "module '{module_id}' sets reserved: true with protocol: \"none\"; \
795 reserved is enforced on the module's HELLO and a protocol: \"none\" \
796 module never registers, so the reservation could never be checked",
797 module_id = module_id.escape_debug()
798 ),
799 });
800 }
801 let restart = parse_restart_config(module.restart, path, &module_id)?;
802 let log = module
803 .log
804 .map(|log| parse_logging_config(log, path, &format!("module '{module_id}' log")))
805 .transpose()?
806 .or_else(|| daemon_logging.clone());
807 Ok(ConfiguredModule {
808 module_id,
809 program: module.program,
810 args: module.args,
811 env: module.env.into_iter().collect(),
812 log,
813 enabled: module.enabled,
814 reserved: module.reserved,
815 reserved_prefixes: module.reserved_prefixes,
816 protocol,
817 overlap,
818 health,
819 drain_timeout_ms: module.drain_timeout_ms.or(default_drain_timeout_ms),
822 route_bind_relay_timeout_ms: per_module_route_bind_relay_timeout_ms,
827 restart,
828 })
829 })
830 .collect::<Result<Vec<_>, DaemonConfigError>>()?;
831
832 validate_reserved_prefixes(&modules, path)?;
833 validate_reserved_capabilities(&raw.reserved_capabilities, path)?;
834 validate_admission_facts_config(
835 &modules,
836 raw.admission_facts_carrier_module_id.as_deref(),
837 raw.admission_facts_targets.as_deref(),
838 path,
839 )?;
840 let scope_authority_owners = raw
841 .scope_authority_owners
842 .unwrap_or_else(default_scope_authority_owners);
843 if scope_authority_owners.iter().any(|owner| owner.is_empty()) {
844 return Err(DaemonConfigError::InvalidValue {
845 path: path.to_path_buf(),
846 message: "scope_authority_owners must not contain empty module ids".to_string(),
847 });
848 }
849
850 let storage = raw
851 .storage
852 .map(|s| match s {
853 RawStorageConfig::Sqlite { data_home } => {
854 let data_home = data_home.unwrap_or_else(default_data_home);
855 if !data_home.is_absolute() {
864 return Err(DaemonConfigError::InvalidValue {
865 path: path.to_path_buf(),
866 message: format!(
867 "storage data home resolved to the relative path {} \
868 (no absolute XDG_DATA_HOME, APPDATA, USERPROFILE, or HOME \
869 in the daemon's environment); refusing to serve a \
870 cwd-relative storage descriptor to modules. Set \
871 XDG_DATA_HOME or HOME to an absolute path, or set \
872 storage.data_home in this file.",
873 data_home.display()
874 ),
875 });
876 }
877 Ok(StorageConfig::Sqlite { data_home })
878 }
879 })
880 .transpose()?;
881
882 Ok(DaemonConfig {
883 path: path.to_path_buf(),
884 port: raw.port,
885 drain_timeout_ms: default_drain_timeout_ms,
886 route_bind_relay_timeout_ms: default_route_bind_relay_timeout_ms,
887 modules,
888 storage,
889 admission_facts_carrier_module_id: raw.admission_facts_carrier_module_id,
890 admission_facts_targets: raw.admission_facts_targets,
891 scope_authority_owners,
892 reserved_capabilities: raw.reserved_capabilities,
893 })
894}
895
896fn parse_logging_config(
897 raw: RawLoggingConfig,
898 path: &Path,
899 owner: &str,
900) -> Result<LoggingConfig, DaemonConfigError> {
901 fn valid_level(level: &str) -> bool {
902 matches!(level, "off" | "error" | "warn" | "info" | "debug" | "trace")
903 }
904
905 let level = raw.level.unwrap_or_else(|| "info".to_string());
906 if !valid_level(&level) {
907 return Err(DaemonConfigError::InvalidValue {
908 path: path.to_path_buf(),
909 message: format!(
910 "{owner}.level must be one of off, error, warn, info, debug, trace; got {level:?}"
911 ),
912 });
913 }
914 for (tag, tag_level) in &raw.tags {
915 let well_formed = !tag.is_empty()
920 && tag.split('.').all(|segment| {
921 let mut chars = segment.chars();
922 matches!(chars.next(), Some('a'..='z'))
923 && chars.all(|c| matches!(c, 'a'..='z' | '0'..='9' | '-'))
924 });
925 if !well_formed {
926 return Err(DaemonConfigError::InvalidValue {
927 path: path.to_path_buf(),
928 message: format!(
929 "{owner}.tags key {tag:?} is not a logger name (dotted segments of [a-z][a-z0-9-]*)"
930 ),
931 });
932 }
933 if !valid_level(tag_level) {
934 return Err(DaemonConfigError::InvalidValue {
935 path: path.to_path_buf(),
936 message: format!(
937 "{owner}.tags.{tag} must be one of off, error, warn, info, debug, trace; got {tag_level:?}"
938 ),
939 });
940 }
941 }
942
943 let defaults = Retention::default();
944 let retention = Retention {
945 max_file_mb: raw.max_file_mb.unwrap_or(defaults.max_file_mb),
946 keep: raw.keep.unwrap_or(defaults.keep),
947 max_age_days: raw.max_age_days.unwrap_or(defaults.max_age_days),
948 };
949 if retention.max_file_mb == 0 {
950 return Err(DaemonConfigError::InvalidValue {
951 path: path.to_path_buf(),
952 message: format!("{owner}.max_file_mb must be greater than 0"),
953 });
954 }
955
956 let alarm_segment_mb = raw
957 .alarm_segment_mb
958 .unwrap_or(cortexkit_log::SegmentRetention::default().alarm_segment_mb);
959 if alarm_segment_mb == 0 {
960 return Err(DaemonConfigError::InvalidValue {
961 path: path.to_path_buf(),
962 message: format!("{owner}.alarm_segment_mb must be greater than 0"),
963 });
964 }
965
966 Ok(LoggingConfig {
967 level,
968 tags: raw.tags,
969 retention,
970 alarm_segment_mb,
971 })
972}
973
974fn parse_module_protocol(
983 raw: Option<&str>,
984 path: &Path,
985 module_id: &str,
986) -> Result<ModuleProtocol, DaemonConfigError> {
987 match raw {
988 None | Some("subc") => Ok(ModuleProtocol::Subc),
989 Some("none") => Ok(ModuleProtocol::None),
990 Some(other) => Err(DaemonConfigError::InvalidValue {
994 path: path.to_path_buf(),
995 message: format!(
996 "module '{module_id}' declares protocol {other:?}; supported values are \
997 \"subc\" (the default when the key is absent) and \"none\"",
998 module_id = module_id.escape_debug(),
999 ),
1000 }),
1001 }
1002}
1003
1004fn parse_module_overlap(
1008 raw: Option<&str>,
1009 path: &Path,
1010 module_id: &str,
1011) -> Result<ModuleOverlap, DaemonConfigError> {
1012 match raw {
1013 None | Some("exclusive") => Ok(ModuleOverlap::Exclusive),
1014 Some("safe") => Ok(ModuleOverlap::Safe),
1015 Some(other) => Err(DaemonConfigError::InvalidValue {
1016 path: path.to_path_buf(),
1017 message: format!(
1018 "module '{module_id}' declares overlap {other:?}; supported values are \
1019 \"exclusive\" (the default when the key is absent) and \"safe\"",
1020 module_id = module_id.escape_debug(),
1021 ),
1022 }),
1023 }
1024}
1025
1026fn validate_reserved_capabilities(
1027 bindings: &BTreeMap<String, String>,
1028 path: &Path,
1029) -> Result<(), DaemonConfigError> {
1030 for (capability, module_id) in bindings {
1031 if !is_valid_capability_identifier(capability) {
1032 return Err(DaemonConfigError::InvalidValue {
1033 path: path.to_path_buf(),
1034 message: format!(
1035 "reserved_capabilities key {:?} is not a valid capability identifier",
1036 capability
1037 ),
1038 });
1039 }
1040 if module_id.trim().is_empty() {
1041 return Err(DaemonConfigError::InvalidValue {
1042 path: path.to_path_buf(),
1043 message: format!(
1044 "reserved_capabilities binding for {:?} has an empty module id",
1045 capability
1046 ),
1047 });
1048 }
1049 if let Err(reason) = crate::registry::module_id_path_hazard(module_id) {
1050 return Err(DaemonConfigError::InvalidValue {
1051 path: path.to_path_buf(),
1052 message: format!(
1053 "reserved_capabilities binding for {:?} has an unusable module id {:?}: {reason}",
1054 capability, module_id
1055 ),
1056 });
1057 }
1058 }
1059 Ok(())
1060}
1061
1062fn validate_admission_facts_config(
1063 modules: &[ConfiguredModule],
1064 carrier_module_id: Option<&str>,
1065 targets: Option<&[String]>,
1066 path: &Path,
1067) -> Result<(), DaemonConfigError> {
1068 let Some(carrier_module_id) = carrier_module_id else {
1069 return Ok(());
1070 };
1071
1072 let Some(carrier) = modules
1073 .iter()
1074 .find(|module| module.module_id == carrier_module_id)
1075 else {
1076 return Err(DaemonConfigError::InvalidValue {
1077 path: path.to_path_buf(),
1078 message: format!(
1079 "admission_facts_carrier_module_id '{carrier_module_id}' must name a configured module"
1080 ),
1081 });
1082 };
1083 if !carrier.enabled || !carrier.reserved {
1084 return Err(DaemonConfigError::InvalidValue {
1085 path: path.to_path_buf(),
1086 message: format!(
1087 "admission_facts_carrier_module_id '{carrier_module_id}' must name an enabled reserved module"
1088 ),
1089 });
1090 }
1091
1092 let Some(targets) = targets else {
1093 return Err(DaemonConfigError::InvalidValue {
1094 path: path.to_path_buf(),
1095 message: "admission_facts_targets must be present when an admission facts carrier is configured".to_string(),
1096 });
1097 };
1098 if targets.is_empty() || targets.iter().any(String::is_empty) {
1099 return Err(DaemonConfigError::InvalidValue {
1100 path: path.to_path_buf(),
1101 message:
1102 "admission_facts_targets must be non-empty and must not contain empty module ids"
1103 .to_string(),
1104 });
1105 }
1106
1107 Ok(())
1108}
1109
1110fn default_enabled() -> bool {
1111 true
1112}
1113
1114fn validate_reserved_prefixes(
1115 modules: &[ConfiguredModule],
1116 path: &Path,
1117) -> Result<(), DaemonConfigError> {
1118 for module in modules {
1119 if module.reserved_prefixes.is_empty() {
1120 continue;
1121 }
1122 if !module.reserved {
1123 return Err(DaemonConfigError::InvalidValue {
1124 path: path.to_path_buf(),
1125 message: format!(
1126 "module '{}' reserved_prefixes require reserved=true so the owner is spawn-nonce protected",
1127 module.module_id
1128 ),
1129 });
1130 }
1131 for prefix in &module.reserved_prefixes {
1132 if !prefix.ends_with(':') {
1133 return Err(DaemonConfigError::InvalidValue {
1134 path: path.to_path_buf(),
1135 message: format!(
1136 "module '{}' reserved prefix '{}' must end with ':'",
1137 module.module_id, prefix
1138 ),
1139 });
1140 }
1141 }
1142 }
1143
1144 for module in modules {
1145 for prefix in &module.reserved_prefixes {
1146 if let Some(colliding) = modules
1147 .iter()
1148 .find(|candidate| candidate.module_id.starts_with(prefix))
1149 {
1150 return Err(DaemonConfigError::InvalidValue {
1151 path: path.to_path_buf(),
1152 message: format!(
1153 "reserved prefix '{}' owned by '{}' collides with configured module id '{}'",
1154 prefix, module.module_id, colliding.module_id
1155 ),
1156 });
1157 }
1158 }
1159 }
1160
1161 for (left_index, left) in modules.iter().enumerate() {
1162 for right in modules.iter().skip(left_index + 1) {
1163 if left.module_id == right.module_id {
1164 continue;
1165 }
1166 for left_prefix in &left.reserved_prefixes {
1167 for right_prefix in &right.reserved_prefixes {
1168 if left_prefix.starts_with(right_prefix)
1169 || right_prefix.starts_with(left_prefix)
1170 {
1171 return Err(DaemonConfigError::InvalidValue {
1172 path: path.to_path_buf(),
1173 message: format!(
1174 "reserved prefixes '{}' owned by '{}' and '{}' owned by '{}' overlap",
1175 left_prefix, left.module_id, right_prefix, right.module_id
1176 ),
1177 });
1178 }
1179 }
1180 }
1181 }
1182 }
1183
1184 Ok(())
1185}
1186
1187fn parse_health_config(
1188 raw: RawHealthConfig,
1189 path: &Path,
1190 module_id: &str,
1191) -> Result<HealthConfig, DaemonConfigError> {
1192 let defaults = HealthConfig::default();
1193 let cadence = positive_millis(
1194 raw.cadence_ms,
1195 defaults.cadence,
1196 path,
1197 module_id,
1198 "cadence_ms",
1199 )?;
1200 let deadline = positive_millis(
1201 raw.deadline_ms,
1202 defaults.deadline,
1203 path,
1204 module_id,
1205 "deadline_ms",
1206 )?;
1207 let failure_threshold = match raw.failure_threshold {
1208 Some(0) => {
1209 return Err(DaemonConfigError::InvalidValue {
1210 path: path.to_path_buf(),
1211 message: format!("module '{module_id}' health.failure_threshold must be positive"),
1212 })
1213 }
1214 Some(value) => value,
1215 None => defaults.failure_threshold,
1216 };
1217
1218 Ok(HealthConfig {
1219 cadence,
1220 deadline,
1221 failure_threshold,
1222 on_degraded: match raw.on_degraded {
1223 Some(RawHealthAction::Restart) => {
1224 return Err(DaemonConfigError::InvalidValue {
1225 path: path.to_path_buf(),
1226 message: format!(
1227 "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)."
1228 ),
1229 });
1230 }
1231 Some(action) => health_action(action),
1232 None => defaults.on_degraded,
1233 },
1234 on_failing: raw
1235 .on_failing
1236 .map(health_action)
1237 .unwrap_or(defaults.on_failing),
1238 critical: raw.critical,
1239 })
1240}
1241
1242fn parse_restart_config(
1249 raw: Option<RawRestartConfig>,
1250 path: &Path,
1251 module_id: &str,
1252) -> Result<RestartPolicy, DaemonConfigError> {
1253 let defaults = RestartPolicy::default();
1254 let Some(raw) = raw else {
1255 return Ok(defaults);
1256 };
1257
1258 let window = match raw.window_secs {
1259 Some(0) => {
1260 return Err(DaemonConfigError::InvalidValue {
1261 path: path.to_path_buf(),
1262 message: format!(
1263 "module '{module_id}' {RESTART_WINDOW_ZERO_MESSAGE}",
1264 module_id = module_id.escape_debug()
1265 ),
1266 });
1267 }
1268 Some(secs) => Duration::from_secs(secs),
1269 None => defaults.window,
1270 };
1271 let backoff = raw
1272 .backoff_ms
1273 .map(Duration::from_millis)
1274 .unwrap_or(defaults.backoff);
1275 let max_backoff = raw
1276 .max_backoff_ms
1277 .map(Duration::from_millis)
1278 .unwrap_or(defaults.max_backoff);
1279 if max_backoff < backoff {
1280 return Err(DaemonConfigError::InvalidValue {
1281 path: path.to_path_buf(),
1282 message: format!(
1283 "module '{}' restart.max_backoff_ms must be greater than or equal to restart.backoff_ms (max_backoff_ms={max_backoff:?}, backoff_ms={backoff:?})",
1284 module_id.escape_debug()
1285 ),
1286 });
1287 }
1288
1289 Ok(RestartPolicy {
1290 max_restarts: raw.max_restarts.unwrap_or(defaults.max_restarts),
1293 backoff,
1294 max_backoff,
1295 window,
1296 })
1297}
1298
1299fn positive_millis(
1300 value: Option<u64>,
1301 default: std::time::Duration,
1302 path: &Path,
1303 module_id: &str,
1304 field: &str,
1305) -> Result<std::time::Duration, DaemonConfigError> {
1306 match value {
1307 Some(0) => Err(DaemonConfigError::InvalidValue {
1308 path: path.to_path_buf(),
1309 message: format!("module '{module_id}' health.{field} must be positive"),
1310 }),
1311 Some(value) => Ok(std::time::Duration::from_millis(value)),
1312 None => Ok(default),
1313 }
1314}
1315
1316fn health_action(action: RawHealthAction) -> HealthAction {
1317 match action {
1318 RawHealthAction::Report => HealthAction::Report,
1319 RawHealthAction::Restart => HealthAction::Restart,
1320 RawHealthAction::Alert => HealthAction::Alert,
1321 }
1322}
1323
1324pub(crate) fn default_data_home() -> PathBuf {
1327 if let Some(data_home) = non_empty_os_var("XDG_DATA_HOME") {
1328 return PathBuf::from(data_home);
1329 }
1330
1331 #[cfg(windows)]
1332 {
1333 if let Some(app_data) = non_empty_os_var("APPDATA") {
1334 return PathBuf::from(app_data);
1335 }
1336 if let Some(user_profile) = non_empty_os_var("USERPROFILE") {
1337 return PathBuf::from(user_profile).join("AppData").join("Roaming");
1338 }
1339 }
1340
1341 if let Some(home) = non_empty_os_var("HOME") {
1342 return PathBuf::from(home).join(".local").join("share");
1343 }
1344
1345 PathBuf::from(".local").join("share")
1346}
1347
1348fn non_empty_os_var(key: &str) -> Option<OsString> {
1349 let value = env::var_os(key)?;
1350 if value.is_empty() {
1351 None
1352 } else {
1353 Some(value)
1354 }
1355}
1356
1357impl fmt::Display for DaemonConfigError {
1358 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1359 match self {
1360 Self::Read { path, source } => {
1361 write!(f, "failed to read daemon config {}: {source}", path.display())
1362 }
1363 Self::InvalidJsonc { path, message } => {
1364 write!(f, "invalid JSONC in daemon config {}: {message}", path.display())
1365 }
1366 Self::InvalidJson { path, source } => {
1367 write!(f, "invalid daemon config {}: {source}", path.display())
1368 }
1369 Self::UnsupportedVersion { path, version } => write!(
1370 f,
1371 "invalid daemon config {}: version {version} is unsupported (expected {SUPPORTED_CONFIG_VERSION})",
1372 path.display()
1373 ),
1374 Self::InvalidValue { path, message } => {
1375 write!(f, "invalid daemon config {}: {message}", path.display())
1376 }
1377 }
1378 }
1379}
1380
1381impl Error for DaemonConfigError {
1382 fn source(&self) -> Option<&(dyn Error + 'static)> {
1383 match self {
1384 Self::Read { source, .. } => Some(source),
1385 Self::InvalidJson { source, .. } => Some(source),
1386 Self::InvalidJsonc { .. }
1387 | Self::UnsupportedVersion { .. }
1388 | Self::InvalidValue { .. } => None,
1389 }
1390 }
1391}
1392
1393#[cfg(all(test, unix))]
1394mod run_dir_privacy_tests {
1395 use std::fs;
1396 use std::os::unix::fs::PermissionsExt;
1397 use subc_test_support::TestTempDir;
1398
1399 #[test]
1405 fn run_dir_is_created_private_and_an_inherited_wide_one_is_tightened() {
1406 let temp = TestTempDir::new("subc-run-dir-privacy");
1407 let created = temp.path().join("cortexkit").join("run");
1408 super::ensure_directory_private(&created).expect("create run dir");
1409 let mode = fs::metadata(&created)
1410 .expect("stat created")
1411 .permissions()
1412 .mode()
1413 & 0o777;
1414 assert_eq!(
1415 mode, 0o700,
1416 "observable a run directory this code creates must be 0700, got {mode:o}"
1417 );
1418
1419 fs::set_permissions(&created, fs::Permissions::from_mode(0o755)).expect("widen");
1421 let widened = fs::metadata(&created)
1422 .expect("stat widened")
1423 .permissions()
1424 .mode()
1425 & 0o777;
1426 assert_eq!(
1427 widened, 0o755,
1428 "observable the fixture must actually be wide before the tighten"
1429 );
1430
1431 super::ensure_directory_private(&created).expect("tighten run dir");
1432 let mode = fs::metadata(&created)
1433 .expect("stat tightened")
1434 .permissions()
1435 .mode()
1436 & 0o777;
1437 assert_eq!(
1438 mode, 0o700,
1439 "observable an inherited group- or world-readable run directory must be tightened to 0700, got {mode:o}"
1440 );
1441 }
1442}
1443
1444#[cfg(test)]
1445mod tests {
1446 use super::*;
1447
1448 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1457
1458 fn abs(posix: &str) -> PathBuf {
1463 if cfg!(windows) {
1464 PathBuf::from(format!("C:{}", posix.replace('/', "\\")))
1465 } else {
1466 PathBuf::from(posix)
1467 }
1468 }
1469
1470 #[test]
1471 fn default_data_home_matches_golden_fixture() {
1472 let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1473 let doc: serde_json::Value =
1474 serde_json::from_str(include_str!("../tests/golden/data_home_resolution.json"))
1475 .expect("golden parses");
1476 let vars = ["XDG_DATA_HOME", "APPDATA", "USERPROFILE", "HOME"];
1477 let saved: Vec<(&str, Option<std::ffi::OsString>)> =
1478 vars.iter().map(|v| (*v, env::var_os(v))).collect();
1479 let platform_matches =
1480 |p: &str| p == "any" || p == if cfg!(windows) { "windows" } else { "unix" };
1481
1482 let mut ran = 0usize;
1483 for case in doc["cases"].as_array().expect("cases array") {
1484 let name = case["name"].as_str().expect("name");
1485 if !platform_matches(case["platform"].as_str().expect("platform")) {
1486 continue;
1487 }
1488 for v in vars {
1489 env::remove_var(v);
1490 }
1491 for (k, v) in case["env"].as_object().expect("env map") {
1492 env::set_var(k, v.as_str().expect("env value"));
1493 }
1494 let got = default_data_home();
1495 assert_eq!(
1496 got.to_string_lossy(),
1497 case["expect"].as_str().expect("expect"),
1498 "golden case '{name}' diverged"
1499 );
1500 ran += 1;
1501 }
1502 assert!(
1504 ran >= 6,
1505 "only {ran} golden cases ran; fixture or filter broken"
1506 );
1507
1508 for (k, v) in saved {
1509 match v {
1510 Some(val) => env::set_var(k, val),
1511 None => env::remove_var(k),
1512 }
1513 }
1514 }
1515
1516 #[test]
1521 fn daemon_run_dir_refuses_a_relative_data_home_and_names_the_variables() {
1522 for data_home in [PathBuf::from(".local/share"), PathBuf::from("relative-xdg")] {
1523 let error = daemon_run_dir_from(data_home.clone())
1524 .expect_err("a relative data home must be refused");
1525 assert_eq!(
1526 error,
1527 DaemonRunDirError::RelativeDataHome {
1528 data_home: data_home.clone()
1529 }
1530 );
1531 let message = error.to_string();
1532 assert!(
1533 message.contains("XDG_DATA_HOME") && message.contains("HOME"),
1534 "the refusal must name the variables to set: {message}"
1535 );
1536 }
1537 }
1538
1539 #[test]
1540 fn daemon_run_dir_under_an_absolute_data_home_is_cortexkit_run() {
1541 let data_home = env::temp_dir().join("subc-run-dir-probe").join("data");
1542 assert!(data_home.is_absolute());
1543 assert_eq!(
1544 daemon_run_dir_from(data_home.clone()),
1545 Ok(data_home.join("cortexkit").join("run"))
1546 );
1547 }
1548
1549 #[test]
1555 fn default_config_home_matches_golden_fixture() {
1556 let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1557 let doc: serde_json::Value =
1558 serde_json::from_str(include_str!("../tests/golden/config_home_resolution.json"))
1559 .expect("golden parses");
1560 let vars = ["XDG_CONFIG_HOME", "APPDATA", "USERPROFILE", "HOME"];
1561 let saved: Vec<(&str, Option<std::ffi::OsString>)> =
1562 vars.iter().map(|v| (*v, env::var_os(v))).collect();
1563 let platform_matches =
1564 |p: &str| p == "any" || p == if cfg!(windows) { "windows" } else { "unix" };
1565
1566 let mut ran = 0usize;
1567 for case in doc["cases"].as_array().expect("cases array") {
1568 let name = case["name"].as_str().expect("name");
1569 if !platform_matches(case["platform"].as_str().expect("platform")) {
1570 continue;
1571 }
1572 for v in vars {
1573 env::remove_var(v);
1574 }
1575 for (k, v) in case["env"].as_object().expect("env map") {
1576 env::set_var(k, v.as_str().expect("env value"));
1577 }
1578 let got = default_config_home();
1579 assert_eq!(
1580 got.to_string_lossy(),
1581 case["expect"].as_str().expect("expect"),
1582 "golden case '{name}' diverged"
1583 );
1584 ran += 1;
1585 }
1586 assert!(
1587 ran >= 6,
1588 "only {ran} golden cases ran; fixture or filter broken"
1589 );
1590
1591 for (k, v) in saved {
1592 match v {
1593 Some(val) => env::set_var(k, val),
1594 None => env::remove_var(k),
1595 }
1596 }
1597 }
1598
1599 #[test]
1606 fn relative_storage_data_home_is_refused_at_parse() {
1607 let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1608 let path = Path::new("/golden/subc.jsonc");
1609
1610 let doc =
1612 r#"{ "version": 1, "storage": { "backend": "sqlite", "data_home": "relative/home" } }"#;
1613 let err = parse_doc(doc, path).expect_err("relative data_home must refuse");
1614 assert!(
1615 matches!(&err, DaemonConfigError::InvalidValue { message, .. }
1616 if message.contains("relative path relative/home")),
1617 "wrong refusal: {err:?}"
1618 );
1619
1620 let vars = ["XDG_DATA_HOME", "APPDATA", "USERPROFILE", "HOME"];
1622 let saved: Vec<(&str, Option<std::ffi::OsString>)> =
1623 vars.iter().map(|v| (*v, env::var_os(v))).collect();
1624 for v in vars {
1625 env::remove_var(v);
1626 }
1627 let doc = r#"{ "version": 1, "storage": { "backend": "sqlite" } }"#;
1628 let err = parse_doc(doc, path).expect_err("no home in env must refuse");
1629 assert!(
1630 matches!(&err, DaemonConfigError::InvalidValue { message, .. }
1631 if message.contains("no absolute XDG_DATA_HOME")),
1632 "wrong refusal: {err:?}"
1633 );
1634
1635 let want = abs("/abs/home");
1639 let doc = format!(
1640 r#"{{ "version": 1, "storage": {{ "backend": "sqlite", "data_home": {} }} }}"#,
1641 serde_json::to_string(&want).expect("json path")
1642 );
1643 let cfg = parse_doc(&doc, path).expect("absolute data_home parses");
1644 assert!(matches!(
1645 cfg.storage,
1646 Some(StorageConfig::Sqlite { ref data_home }) if *data_home == want
1647 ));
1648
1649 for (k, v) in saved {
1650 match v {
1651 Some(val) => env::set_var(k, val),
1652 None => env::remove_var(k),
1653 }
1654 }
1655 }
1656
1657 #[test]
1658 fn restart_required_sections_are_the_rescan_cannot_apply_set() {
1659 assert_eq!(
1660 RestartRequiredSection::ALL.map(RestartRequiredSection::label),
1661 [
1662 "port",
1663 "storage",
1664 "admission_facts_carrier_module_id",
1665 "admission_facts_targets",
1666 "scope_authority_owners",
1667 ]
1668 );
1669 }
1670
1671 #[test]
1672 fn scope_authority_owners_defaults_to_the_session_runtime() {
1673 let config = parse_doc(r#"{ "version": 1 }"#, Path::new("/tmp/subc.jsonc")).unwrap();
1674 assert_eq!(config.scope_authority_owners, vec!["prefrontal-core"]);
1675 }
1676
1677 #[test]
1678 fn scope_authority_owners_is_read_when_set_and_refuses_an_empty_id() {
1679 let config = parse_doc(
1680 r#"{ "version": 1, "scope_authority_owners": ["a", "b"] }"#,
1681 Path::new("/tmp/subc.jsonc"),
1682 )
1683 .unwrap();
1684 assert_eq!(config.scope_authority_owners, vec!["a", "b"]);
1685 let config = parse_doc(
1688 r#"{ "version": 1, "scope_authority_owners": [] }"#,
1689 Path::new("/tmp/subc.jsonc"),
1690 )
1691 .unwrap();
1692 assert!(config.scope_authority_owners.is_empty());
1693 let error = parse_doc(
1694 r#"{ "version": 1, "scope_authority_owners": [""] }"#,
1695 Path::new("/tmp/subc.jsonc"),
1696 )
1697 .expect_err("an empty module id is refused");
1698 assert!(
1699 error.to_string().contains("scope_authority_owners"),
1700 "{error}"
1701 );
1702 }
1703
1704 #[test]
1705 fn no_storage_section_yields_none() {
1706 let config = parse_doc(
1707 r#"{ "version": 1, "modules": {} }"#,
1708 Path::new("/tmp/subc.jsonc"),
1709 )
1710 .expect("parse");
1711 assert_eq!(config.storage, None);
1712 }
1713
1714 #[test]
1715 fn sqlite_storage_parses_with_explicit_data_home() {
1716 let config = parse_doc(
1717 &format!(
1718 r#"{{ "version": 1, "storage": {{ "backend": "sqlite", "data_home": {} }} }}"#,
1719 serde_json::to_string(&abs("/data")).expect("json path")
1720 ),
1721 Path::new("/tmp/subc.jsonc"),
1722 )
1723 .expect("parse");
1724 assert_eq!(
1725 config.storage,
1726 Some(StorageConfig::Sqlite {
1727 data_home: abs("/data")
1728 })
1729 );
1730 }
1731
1732 #[test]
1733 fn sqlite_storage_defaults_data_home_when_omitted() {
1734 let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1740 std::env::set_var("XDG_DATA_HOME", abs("/forced/data/home"));
1741 let config = parse_doc(
1742 r#"{ "version": 1, "storage": { "backend": "sqlite" } }"#,
1743 Path::new("/tmp/subc.jsonc"),
1744 )
1745 .expect("parse");
1746 std::env::remove_var("XDG_DATA_HOME");
1747 assert_eq!(
1748 config.storage,
1749 Some(StorageConfig::Sqlite {
1750 data_home: abs("/forced/data/home")
1751 })
1752 );
1753 }
1754
1755 #[test]
1756 fn descriptor_for_matches_store_types_shape() {
1757 let cfg = StorageConfig::Sqlite {
1761 data_home: PathBuf::from("/data"),
1762 };
1763 let descriptor = cfg.descriptor_for("alfonso-routing");
1764 assert_eq!(
1765 descriptor,
1766 serde_json::json!({
1767 "module_id": "alfonso-routing",
1768 "storage_namespace": "default",
1769 "isolation": { "kind": "module" },
1770 "backend": {
1771 "backend": "sqlite",
1772 "path": "/data/cortexkit/alfonso-routing/store.db"
1773 }
1774 })
1775 );
1776 }
1777
1778 #[test]
1779 fn path_hazard_module_id_refuses_config_parse() {
1780 let path = Path::new("/tmp/subc.jsonc");
1781 let err = parse_doc(
1782 r#"{ "version": 1, "modules": { "../escape": { "program": "x" } } }"#,
1783 path,
1784 )
1785 .expect_err("separator-bearing module id must refuse");
1786 let text = format!("{err}");
1787 assert!(
1788 text.contains("not usable as a path component"),
1789 "refusal must name the hazard: {text}"
1790 );
1791 }
1792
1793 #[test]
1794 fn drain_timeout_resolves_module_over_daemon_over_absent() {
1795 let path = Path::new("/tmp/subc.jsonc");
1796 let config = parse_doc(
1797 r#"
1798 {
1799 "version": 1,
1800 "drain_timeout_ms": 45000,
1801 "modules": {
1802 "fast": { "program": "fast", "drain_timeout_ms": 0 },
1803 "slow": { "program": "slow", "drain_timeout_ms": 120000 },
1804 "inherits": { "program": "inherits" }
1805 }
1806 }
1807 "#,
1808 path,
1809 )
1810 .unwrap();
1811 let by_id = |id: &str| {
1812 config
1813 .modules
1814 .iter()
1815 .find(|m| m.module_id == id)
1816 .unwrap()
1817 .drain_timeout_ms
1818 };
1819 assert_eq!(by_id("fast"), Some(0));
1822 assert_eq!(by_id("slow"), Some(120_000));
1823 assert_eq!(by_id("inherits"), Some(45_000));
1825 assert_eq!(config.drain_timeout_ms, Some(45_000));
1826 }
1827
1828 #[test]
1829 fn drain_timeout_absent_everywhere_stays_none_for_builtin_default() {
1830 let path = Path::new("/tmp/subc.jsonc");
1831 let config = parse_doc(
1832 r#"{ "version": 1, "modules": { "m": { "program": "m" } } }"#,
1833 path,
1834 )
1835 .unwrap();
1836 assert_eq!(config.modules[0].drain_timeout_ms, None);
1840 assert_eq!(config.drain_timeout_ms, None);
1841 }
1842
1843 #[test]
1844 fn route_bind_relay_timeout_resolves_module_over_daemon_over_absent() {
1845 let path = Path::new("/tmp/subc.jsonc");
1851 let config = parse_doc(
1852 r#"
1853 {
1854 "version": 1,
1855 "route_bind_relay_timeout_ms": 30000,
1856 "modules": {
1857 "tight": { "program": "tight", "route_bind_relay_timeout_ms": 5000 },
1858 "loose": { "program": "loose", "route_bind_relay_timeout_ms": 60000 },
1859 "inherits": { "program": "inherits" }
1860 }
1861 }
1862 "#,
1863 path,
1864 )
1865 .unwrap();
1866 let by_id = |id: &str| {
1867 config
1868 .modules
1869 .iter()
1870 .find(|m| m.module_id == id)
1871 .unwrap()
1872 .route_bind_relay_timeout_ms
1873 };
1874 assert_eq!(by_id("tight"), Some(5_000));
1876 assert_eq!(by_id("loose"), Some(60_000));
1877 assert_eq!(by_id("inherits"), Some(30_000));
1879 assert_eq!(config.route_bind_relay_timeout_ms, Some(30_000));
1880 }
1881
1882 #[test]
1883 fn log_tag_keys_must_be_logger_names_and_the_error_names_the_key() {
1884 let path = Path::new("/tmp/subc.jsonc");
1885 for bad in ["Perf", "a b", "perf.", ".perf", "gc..walk", "a=b"] {
1886 let doc = format!(
1887 r#"{{ "version": 1, "modules": {{ "m": {{ "program": "m", "log": {{ "tags": {{ "{bad}": "debug" }} }} }} }} }}"#
1888 );
1889 let err = parse_doc(&doc, path).expect_err(bad);
1890 let text = format!("{err}");
1891 assert!(
1892 text.contains(&format!("{bad:?}")),
1893 "must name the key: {text}"
1894 );
1895 assert!(
1896 text.contains("logger name"),
1897 "must say what a key is: {text}"
1898 );
1899 }
1900 let ok = parse_doc(
1902 r#"{ "version": 1, "modules": { "m": { "program": "m", "log": { "tags": { "perf": "debug", "gc.walk": "trace", "m": "error", "a-b": "info" } } } } }"#,
1903 path,
1904 );
1905 assert!(ok.is_ok(), "{ok:?}");
1906 }
1907
1908 #[test]
1909 fn log_filter_spec_prefixes_bare_keys_with_the_module_and_passes_absolute_ones() {
1910 let path = Path::new("/tmp/subc.jsonc");
1911 let config = parse_doc(
1912 r#"{ "version": 1, "modules": { "synapse": { "program": "s", "log": { "level": "warn", "tags": { "perf": "debug", "gc.walk": "trace", "synapse": "error", "other.x": "info" } } } } }"#,
1913 path,
1914 )
1915 .unwrap();
1916 let log = config.modules[0].log.as_ref().unwrap();
1917 assert_eq!(
1919 log.filter_spec("synapse"),
1920 "warn,gc.walk=trace,other.x=info,synapse.perf=debug,synapse=error"
1921 );
1922 }
1923
1924 #[test]
1925 fn log_alarm_segment_mb_defaults_to_the_crate_default_and_refuses_zero() {
1926 let path = Path::new("/tmp/subc.jsonc");
1927 let config = parse_doc(
1928 r#"{ "version": 1, "modules": { "m": { "program": "m", "log": { "level": "info" } } } }"#,
1929 path,
1930 )
1931 .unwrap();
1932 assert_eq!(
1933 config.modules[0].log.as_ref().unwrap().alarm_segment_mb,
1934 cortexkit_log::SegmentRetention::default().alarm_segment_mb
1935 );
1936 let err = parse_doc(
1937 r#"{ "version": 1, "modules": { "m": { "program": "m", "log": { "alarm_segment_mb": 0 } } } }"#,
1938 path,
1939 )
1940 .expect_err("zero alarm must refuse");
1941 assert!(format!("{err}").contains("alarm_segment_mb"));
1942 }
1943
1944 #[test]
1945 fn route_bind_relay_timeout_zero_at_daemon_layer_is_refused() {
1946 let path = Path::new("/tmp/subc.jsonc");
1947 let err = parse_doc(
1948 r#"
1949 {
1950 "version": 1,
1951 "route_bind_relay_timeout_ms": 0,
1952 "modules": { "m": { "program": "m" } }
1953 }
1954 "#,
1955 path,
1956 )
1957 .expect_err("a daemon-wide zero budget must refuse parse");
1958 let text = format!("{err}");
1959 assert!(
1960 text.contains("route_bind_relay_timeout_ms"),
1961 "error must name the offending key: {text}"
1962 );
1963 assert!(
1964 text.contains("enabled: false"),
1965 "error must name the remedy (enable false): {text}"
1966 );
1967 }
1968
1969 #[test]
1970 fn route_bind_relay_timeout_zero_at_module_layer_is_refused() {
1971 let path = Path::new("/tmp/subc.jsonc");
1972 let err = parse_doc(
1973 r#"
1974 {
1975 "version": 1,
1976 "modules": {
1977 "good": { "program": "good" },
1978 "broken": { "program": "broken", "route_bind_relay_timeout_ms": 0 }
1979 }
1980 }
1981 "#,
1982 path,
1983 )
1984 .expect_err("a per-module zero budget must refuse parse");
1985 let text = format!("{err}");
1986 assert!(
1987 text.contains("route_bind_relay_timeout_ms"),
1988 "error must name the offending key: {text}"
1989 );
1990 assert!(
1991 text.contains("broken"),
1992 "error must name the offending module id: {text}"
1993 );
1994 assert!(
1995 text.contains("enabled: false"),
1996 "error must name the remedy (enable false): {text}"
1997 );
1998 }
1999
2000 #[test]
2001 fn drain_timeout_zero_still_parses_for_wedge_bounces() {
2002 let path = Path::new("/tmp/subc.jsonc");
2008 let config = parse_doc(
2009 r#"
2010 {
2011 "version": 1,
2012 "drain_timeout_ms": 0,
2013 "modules": {
2014 "wedge": { "program": "wedge", "drain_timeout_ms": 0 }
2015 }
2016 }
2017 "#,
2018 path,
2019 )
2020 .expect("drain_timeout_ms: 0 must still parse; wedge-bounce uses it");
2021 let wedge = config
2022 .modules
2023 .iter()
2024 .find(|m| m.module_id == "wedge")
2025 .unwrap();
2026 assert_eq!(wedge.drain_timeout_ms, Some(0));
2027 assert_eq!(config.drain_timeout_ms, Some(0));
2028 }
2029
2030 #[test]
2031 fn route_bind_relay_timeout_absent_everywhere_stays_none_for_builtin_default() {
2032 let path = Path::new("/tmp/subc.jsonc");
2037 let config = parse_doc(
2038 r#"{ "version": 1, "modules": { "m": { "program": "m" } } }"#,
2039 path,
2040 )
2041 .unwrap();
2042 assert_eq!(config.modules[0].route_bind_relay_timeout_ms, None);
2043 assert_eq!(config.route_bind_relay_timeout_ms, None);
2044 }
2045
2046 #[test]
2052 fn a_config_without_a_restart_block_keeps_the_supervisor_defaults() {
2053 let path = Path::new("/tmp/subc.jsonc");
2054 let config = parse_doc(
2055 r#"{ "version": 1, "modules": { "m": { "program": "m" } } }"#,
2056 path,
2057 )
2058 .unwrap();
2059 assert_eq!(config.modules[0].restart.max_restarts, 3);
2060 assert_eq!(config.modules[0].restart.window, Duration::from_secs(600));
2061 assert_eq!(
2062 config.modules[0].restart.backoff,
2063 Duration::from_millis(100)
2064 );
2065 assert_eq!(
2066 config.modules[0].restart.max_backoff,
2067 Duration::from_secs(30)
2068 );
2069 }
2070
2071 #[test]
2072 fn a_restart_block_resolves_each_key_independently() {
2073 let path = Path::new("/tmp/subc.jsonc");
2074 let config = parse_doc(
2075 r#"
2076 {
2077 "version": 1,
2078 "modules": {
2079 "all": {
2080 "program": "all",
2081 "restart": { "max_restarts": 5, "window_secs": 60, "backoff_ms": 250, "max_backoff_ms": 5000 }
2082 },
2083 "window-only": {
2084 "program": "window-only",
2085 "restart": { "window_secs": 7200 }
2086 },
2087 "never": {
2088 "program": "never",
2089 "restart": { "max_restarts": 0 }
2090 }
2091 }
2092 }
2093 "#,
2094 path,
2095 )
2096 .unwrap();
2097 let by_id = |id: &str| {
2098 config
2099 .modules
2100 .iter()
2101 .find(|m| m.module_id == id)
2102 .unwrap()
2103 .restart
2104 };
2105
2106 let all = by_id("all");
2107 assert_eq!(all.max_restarts, 5);
2108 assert_eq!(all.window, Duration::from_secs(60));
2109 assert_eq!(all.backoff, Duration::from_millis(250));
2110 assert_eq!(all.max_backoff, Duration::from_secs(5));
2111
2112 let window_only = by_id("window-only");
2115 assert_eq!(window_only.max_restarts, 3);
2116 assert_eq!(window_only.window, Duration::from_secs(7_200));
2117 assert_eq!(window_only.backoff, Duration::from_millis(100));
2118 assert_eq!(window_only.max_backoff, Duration::from_secs(30));
2119
2120 assert_eq!(by_id("never").max_restarts, 0);
2123 }
2124
2125 #[test]
2129 fn restart_window_zero_is_refused_by_name() {
2130 let path = Path::new("/tmp/subc.jsonc");
2131 let err = parse_doc(
2132 r#"
2133 {
2134 "version": 1,
2135 "modules": {
2136 "good": { "program": "good" },
2137 "broken": { "program": "broken", "restart": { "window_secs": 0 } }
2138 }
2139 }
2140 "#,
2141 path,
2142 )
2143 .expect_err("a zero crash window must refuse parse");
2144 assert!(
2145 matches!(err, DaemonConfigError::InvalidValue { .. }),
2146 "a zero window is an invalid value, not a parse failure: {err:?}"
2147 );
2148 let text = format!("{err}");
2149 assert!(
2150 text.contains("restart.window_secs"),
2151 "error must name the offending key: {text}"
2152 );
2153 assert!(
2154 text.contains("broken"),
2155 "error must name the offending module id: {text}"
2156 );
2157 assert!(
2158 text.contains("max_restarts: 0"),
2159 "error must name the setting that actually stops restarts: {text}"
2160 );
2161 }
2162
2163 #[test]
2164 fn restart_max_backoff_below_backoff_is_refused_by_name() {
2165 let path = Path::new("/tmp/subc.jsonc");
2166 let err = parse_doc(
2167 r#"
2168 {
2169 "version": 1,
2170 "modules": {
2171 "broken": {
2172 "program": "broken",
2173 "restart": { "backoff_ms": 1000, "max_backoff_ms": 999 }
2174 }
2175 }
2176 }
2177 "#,
2178 path,
2179 )
2180 .expect_err("a maximum below the base backoff must refuse parse");
2181 assert!(
2182 matches!(err, DaemonConfigError::InvalidValue { .. }),
2183 "an invalid restart bound must be an InvalidValue: {err:?}"
2184 );
2185 let text = format!("{err}");
2186 assert!(
2187 text.contains("restart.max_backoff_ms"),
2188 "error must name max_backoff_ms: {text}"
2189 );
2190 assert!(
2191 text.contains("restart.backoff_ms"),
2192 "error must name backoff_ms: {text}"
2193 );
2194 assert!(
2195 text.contains("broken"),
2196 "error must name the offending module id: {text}"
2197 );
2198 }
2199
2200 #[test]
2201 fn parse_jsonc_defaults_and_ignores_unknown_fields() {
2202 let path = Path::new("/tmp/subc.jsonc");
2203 let config = parse_doc(
2204 r#"
2205 {
2206 // forward-compatible root field
2207 "version": 1,
2208 "unknown": { "ignored": true },
2209 "modules": {
2210 "aft": {
2211 "program": "aft",
2212 "args": ["module",],
2213 "env": { "A": "B", },
2214 "future": 42,
2215 },
2216 "disabled": { "program": "disabled", "enabled": false }
2217 },
2218 }
2219 "#,
2220 path,
2221 )
2222 .unwrap();
2223
2224 assert_eq!(config.port, None);
2225 assert_eq!(config.modules.len(), 2);
2226 assert_eq!(config.modules[0].module_id, "aft");
2227 assert_eq!(config.modules[0].program, PathBuf::from("aft"));
2228 assert_eq!(config.modules[0].args, ["module"]);
2229 assert_eq!(config.modules[0].env, [("A".to_string(), "B".to_string())]);
2230 assert!(config.modules[0].enabled);
2231 assert!(config.modules[0].reserved_prefixes.is_empty());
2232 assert_eq!(config.modules[0].health, HealthConfig::default());
2233 assert!(!config.modules[1].enabled);
2234 }
2235
2236 #[test]
2237 fn reserved_capabilities_accept_unknown_bound_modules_and_refuse_bad_identifiers() {
2238 let path = Path::new("/tmp/subc.jsonc");
2239 let config = parse_doc(
2240 r#"{
2241 "version": 1,
2242 "reserved_capabilities": {
2243 "credentials-provider/v1": "future-vault"
2244 },
2245 "modules": {}
2246 }"#,
2247 path,
2248 )
2249 .expect("a binding may predate its provider installation");
2250 assert_eq!(
2251 config.reserved_capabilities,
2252 BTreeMap::from([(
2253 "credentials-provider/v1".to_string(),
2254 "future-vault".to_string()
2255 )])
2256 );
2257
2258 let error = parse_doc(
2259 r#"{
2260 "version": 1,
2261 "reserved_capabilities": { "Credentials/v1": "vault" },
2262 "modules": {}
2263 }"#,
2264 path,
2265 )
2266 .expect_err("reserved capabilities use the capability identifier grammar");
2267 assert!(error.to_string().contains("reserved_capabilities key"));
2268 }
2269
2270 #[test]
2276 fn an_absent_protocol_key_and_an_explicit_subc_are_the_same_module() {
2277 let parse = |module_body: &str| {
2278 parse_doc(
2279 &format!(
2280 r#"{{
2281 "version": 1,
2282 "modules": {{ "aft": {{ "program": "aft"{module_body} }} }}
2283 }}"#
2284 ),
2285 Path::new("subc.jsonc"),
2286 )
2287 .expect("module parses")
2288 .modules
2289 .remove(0)
2290 };
2291
2292 let absent = parse("");
2293 let explicit = parse(r#", "protocol": "subc""#);
2294 let none = parse(r#", "protocol": "none""#);
2295
2296 assert_eq!(absent.protocol, ModuleProtocol::Subc);
2297 assert_eq!(explicit.protocol, ModuleProtocol::Subc);
2298 assert_eq!(
2299 absent, explicit,
2300 "an absent protocol key must produce exactly the module an explicit subc does"
2301 );
2302 assert_eq!(none.protocol, ModuleProtocol::None);
2303 assert_eq!(none.module_spec().protocol, ModuleProtocol::None);
2307 }
2308
2309 #[test]
2313 fn overlap_defaults_to_exclusive_and_only_safe_opts_in() {
2314 let parse = |module_body: &str| {
2315 parse_doc(
2316 &format!(
2317 r#"{{
2318 "version": 1,
2319 "modules": {{ "aft": {{ "program": "aft"{module_body} }} }}
2320 }}"#
2321 ),
2322 Path::new("subc.jsonc"),
2323 )
2324 };
2325
2326 let absent = parse("").unwrap().modules.remove(0);
2327 assert_eq!(absent.overlap, ModuleOverlap::Exclusive);
2328 assert_eq!(absent.module_spec().overlap, ModuleOverlap::Exclusive);
2329 let safe = parse(r#", "overlap": "safe""#).unwrap().modules.remove(0);
2330 assert_eq!(safe.module_spec().overlap, ModuleOverlap::Safe);
2331 let typo = parse(r#", "overlap": "sfae""#).expect_err("an unknown overlap is refused");
2332 assert!(typo.to_string().contains("sfae"), "{typo}");
2333 }
2334
2335 #[test]
2339 fn the_spawn_role_is_refused_as_a_configured_env_key() {
2340 let error = parse_doc(
2341 r#"{
2342 "version": 1,
2343 "modules": { "aft": { "program": "aft", "env": { "SUBC_SPAWN_ROLE": "swap_candidate" } } }
2344 }"#,
2345 Path::new("subc.jsonc"),
2346 )
2347 .expect_err("SUBC_SPAWN_ROLE must not be configurable");
2348 assert!(
2349 matches!(error, DaemonConfigError::InvalidValue { .. }),
2350 "expected InvalidValue, got {error:?}"
2351 );
2352 assert!(error.to_string().contains("SUBC_SPAWN_ROLE"), "{error}");
2353 }
2354
2355 #[test]
2361 fn an_unsupported_protocol_value_is_refused_by_name() {
2362 let error = parse_doc(
2363 r#"{
2364 "version": 1,
2365 "modules": { "nats": { "program": "nats-server", "protocol": "grpc" } }
2366 }"#,
2367 Path::new("subc.jsonc"),
2368 )
2369 .expect_err("an unknown protocol must not fall back to a default");
2370
2371 assert!(
2372 matches!(error, DaemonConfigError::InvalidValue { .. }),
2373 "expected InvalidValue, got {error:?}"
2374 );
2375 let message = error.to_string();
2376 assert!(
2377 message.contains("grpc"),
2378 "the refusal must name the offending value: {message}"
2379 );
2380 assert!(
2381 message.contains("nats"),
2382 "the refusal must name the module so it can be found in the file: {message}"
2383 );
2384 }
2385
2386 #[test]
2390 fn reserved_true_with_protocol_none_is_refused_with_the_reason() {
2391 let error = parse_doc(
2392 r#"{
2393 "version": 1,
2394 "modules": {
2395 "nats": { "program": "nats-server", "protocol": "none", "reserved": true }
2396 }
2397 }"#,
2398 Path::new("subc.jsonc"),
2399 )
2400 .expect_err("a reservation that can never be checked must not parse");
2401
2402 assert!(
2403 matches!(error, DaemonConfigError::InvalidValue { .. }),
2404 "expected InvalidValue, got {error:?}"
2405 );
2406 let message = error.to_string();
2407 assert!(
2408 message.contains("nats") && message.contains("reserved"),
2409 "the refusal must name the module and the offending key: {message}"
2410 );
2411 assert!(
2412 message.contains("HELLO") || message.contains("never registers"),
2413 "the refusal must say WHY the pair cannot work: {message}"
2414 );
2415 }
2416
2417 #[test]
2418 fn reserved_prefixes_parse_for_reserved_modules() {
2419 let config = parse_doc(
2420 r#"
2421 {
2422 "version": 1,
2423 "modules": {
2424 "federation": {
2425 "program": "fed",
2426 "reserved": true,
2427 "reserved_prefixes": ["fed:"]
2428 }
2429 }
2430 }
2431 "#,
2432 Path::new("subc.jsonc"),
2433 )
2434 .unwrap();
2435
2436 assert_eq!(config.modules[0].reserved_prefixes, ["fed:".to_string()]);
2437 }
2438
2439 #[test]
2440 fn reserved_prefixes_reject_bad_boundaries_and_owners() {
2441 let missing_delimiter = parse_doc(
2442 r#"{
2443 "version": 1,
2444 "modules": {
2445 "federation": { "program": "fed", "reserved": true, "reserved_prefixes": ["fed"] }
2446 }
2447 }"#,
2448 Path::new("subc.jsonc"),
2449 )
2450 .unwrap_err();
2451 assert!(matches!(
2452 missing_delimiter,
2453 DaemonConfigError::InvalidValue { .. }
2454 ));
2455
2456 let non_reserved_owner = parse_doc(
2457 r#"{
2458 "version": 1,
2459 "modules": {
2460 "federation": { "program": "fed", "reserved_prefixes": ["fed:"] }
2461 }
2462 }"#,
2463 Path::new("subc.jsonc"),
2464 )
2465 .unwrap_err();
2466 assert!(matches!(
2467 non_reserved_owner,
2468 DaemonConfigError::InvalidValue { .. }
2469 ));
2470 }
2471
2472 #[test]
2473 fn reserved_prefixes_reject_cross_owner_overlap_and_exact_id_collisions() {
2474 let overlap = parse_doc(
2475 r#"{
2476 "version": 1,
2477 "modules": {
2478 "fed-owner": { "program": "fed", "reserved": true, "reserved_prefixes": ["fed:"] },
2479 "sub-owner": { "program": "fed-sub", "reserved": true, "reserved_prefixes": ["fed:sub:"] }
2480 }
2481 }"#,
2482 Path::new("subc.jsonc"),
2483 )
2484 .unwrap_err();
2485 assert!(matches!(overlap, DaemonConfigError::InvalidValue { .. }));
2486
2487 let exact_collision = parse_doc(
2488 r#"{
2489 "version": 1,
2490 "modules": {
2491 "federation": { "program": "fed", "reserved": true, "reserved_prefixes": ["fed:"] },
2492 "fed:special": { "program": "special" }
2493 }
2494 }"#,
2495 Path::new("subc.jsonc"),
2496 )
2497 .unwrap_err();
2498 assert!(matches!(
2499 exact_collision,
2500 DaemonConfigError::InvalidValue { .. }
2501 ));
2502 }
2503
2504 #[test]
2505 fn health_config_parses_and_ignores_unknown_fields() {
2506 let config = parse_doc(
2507 r#"
2508 {
2509 "version": 1,
2510 "modules": {
2511 "aft": {
2512 "program": "aft",
2513 "health": {
2514 "cadence_ms": 100,
2515 "deadline_ms": 20,
2516 "failure_threshold": 2,
2517 "on_degraded": "report",
2518 "on_failing": "restart",
2519 "critical": true,
2520 "future": "ignored"
2521 }
2522 }
2523 }
2524 }
2525 "#,
2526 Path::new("subc.jsonc"),
2527 )
2528 .unwrap();
2529
2530 let health = config.modules[0].health;
2531 assert_eq!(health.cadence, std::time::Duration::from_millis(100));
2532 assert_eq!(health.deadline, std::time::Duration::from_millis(20));
2533 assert_eq!(health.failure_threshold, 2);
2534 assert_eq!(health.on_degraded, HealthAction::Report);
2535 assert_eq!(health.on_failing, HealthAction::Restart);
2536 assert!(health.critical);
2537 }
2538
2539 #[test]
2540 fn health_config_rejects_bad_enum_and_non_positive_numbers() {
2541 let bad_enum = parse_doc(
2542 r#"{
2543 "version": 1,
2544 "modules": { "aft": { "program": "aft", "health": { "on_failing": "page" } } }
2545 }"#,
2546 Path::new("subc.jsonc"),
2547 )
2548 .unwrap_err();
2549 assert!(matches!(bad_enum, DaemonConfigError::InvalidJson { .. }));
2550
2551 let zero = parse_doc(
2552 r#"{
2553 "version": 1,
2554 "modules": { "aft": { "program": "aft", "health": { "cadence_ms": 0 } } }
2555 }"#,
2556 Path::new("subc.jsonc"),
2557 )
2558 .unwrap_err();
2559 assert!(matches!(zero, DaemonConfigError::InvalidValue { .. }));
2560 }
2561
2562 #[test]
2563 fn admission_facts_carrier_requires_non_empty_targets() {
2564 let missing_targets = parse_doc(
2565 r#"{
2566 "version": 1,
2567 "admission_facts_carrier_module_id": "fed",
2568 "modules": { "fed": { "program": "fed", "reserved": true } }
2569 }"#,
2570 Path::new("subc.jsonc"),
2571 )
2572 .unwrap_err();
2573 assert!(
2579 matches!(&missing_targets, DaemonConfigError::InvalidValue { message, .. }
2580 if message.contains("must be present")),
2581 "expected the presence rule, got: {missing_targets:?}"
2582 );
2583
2584 let empty_targets = parse_doc(
2585 r#"{
2586 "version": 1,
2587 "admission_facts_carrier_module_id": "fed",
2588 "admission_facts_targets": [""],
2589 "modules": { "fed": { "program": "fed", "reserved": true } }
2590 }"#,
2591 Path::new("subc.jsonc"),
2592 )
2593 .unwrap_err();
2594 assert!(
2595 matches!(&empty_targets, DaemonConfigError::InvalidValue { message, .. }
2596 if message.contains("must be non-empty")),
2597 "expected the non-empty rule, got: {empty_targets:?}"
2598 );
2599 }
2600
2601 #[test]
2602 fn admission_facts_carrier_must_be_enabled_reserved_and_configured() {
2603 for module in [
2604 r#"{ "program": "fed", "enabled": false, "reserved": true }"#,
2605 r#"{ "program": "fed", "enabled": true, "reserved": false }"#,
2606 ] {
2607 let doc = format!(
2608 r#"{{
2609 "version": 1,
2610 "admission_facts_carrier_module_id": "fed",
2611 "admission_facts_targets": ["target"],
2612 "modules": {{ "fed": {module}, "target": {{ "program": "target" }} }}
2613 }}"#
2614 );
2615 let err = parse_doc(&doc, Path::new("subc.jsonc")).unwrap_err();
2616 assert!(
2620 matches!(&err, DaemonConfigError::InvalidValue { message, .. }
2621 if message.contains("enabled reserved module")),
2622 "expected the enabled-and-reserved rule, got: {err:?}"
2623 );
2624 }
2625
2626 let absent = parse_doc(
2627 r#"{
2628 "version": 1,
2629 "admission_facts_carrier_module_id": "missing",
2630 "admission_facts_targets": ["target"],
2631 "modules": { "target": { "program": "target" } }
2632 }"#,
2633 Path::new("subc.jsonc"),
2634 )
2635 .unwrap_err();
2636 assert!(
2637 matches!(&absent, DaemonConfigError::InvalidValue { message, .. }
2638 if message.contains("must name a configured module")),
2639 "expected the configured-module rule, got: {absent:?}"
2640 );
2641 }
2642
2643 #[test]
2644 fn reject_unsupported_version() {
2645 let err = parse_doc(
2646 r#"{ "version": 2, "modules": {} }"#,
2647 Path::new("subc.jsonc"),
2648 )
2649 .unwrap_err();
2650 assert!(matches!(
2651 err,
2652 DaemonConfigError::UnsupportedVersion { version: 2, .. }
2653 ));
2654 }
2655
2656 #[test]
2657 fn reject_unterminated_block_comment() {
2658 let err = parse_doc(r#"{ "version": 1, /*"#, Path::new("subc.jsonc")).unwrap_err();
2659 assert!(matches!(err, DaemonConfigError::InvalidJsonc { .. }));
2660 }
2661}