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(flatten)]
415 ignored: BTreeMap<String, serde_json::Value>,
416 #[serde(default)]
417 reserved_prefixes: Vec<String>,
418 #[serde(default)]
422 protocol: Option<String>,
423 #[serde(default)]
425 overlap: Option<String>,
426 #[serde(default)]
427 health: Option<RawHealthConfig>,
428 #[serde(default)]
429 drain_timeout_ms: Option<u64>,
430 #[serde(default)]
431 route_bind_relay_timeout_ms: Option<u64>,
432 #[serde(default)]
433 restart: Option<RawRestartConfig>,
434}
435
436#[derive(Debug, Clone, Deserialize)]
437struct RawLoggingConfig {
438 #[serde(default)]
439 level: Option<String>,
440 #[serde(default)]
441 tags: BTreeMap<String, String>,
442 #[serde(default)]
443 alarm_segment_mb: Option<u32>,
444 #[serde(default)]
445 max_file_mb: Option<u32>,
446 #[serde(default)]
447 keep: Option<u8>,
448 #[serde(default)]
449 max_age_days: Option<u32>,
450}
451
452#[derive(Debug, Deserialize)]
453struct RawRestartConfig {
454 #[serde(default)]
455 max_restarts: Option<u32>,
456 #[serde(default)]
457 window_secs: Option<u64>,
458 #[serde(default)]
459 backoff_ms: Option<u64>,
460 #[serde(default)]
461 max_backoff_ms: Option<u64>,
462}
463
464#[derive(Debug, Deserialize)]
465struct RawHealthConfig {
466 #[serde(default)]
467 cadence_ms: Option<u64>,
468 #[serde(default)]
469 deadline_ms: Option<u64>,
470 #[serde(default)]
471 failure_threshold: Option<u32>,
472 #[serde(default)]
473 on_degraded: Option<RawHealthAction>,
474 #[serde(default)]
475 on_failing: Option<RawHealthAction>,
476 #[serde(default)]
477 critical: bool,
478}
479
480#[derive(Debug, Deserialize)]
481#[serde(rename_all = "snake_case")]
482enum RawHealthAction {
483 Report,
484 Restart,
485 Alert,
486}
487
488pub fn default_config_path() -> PathBuf {
489 default_config_home().join(DAEMON_CONFIG_RELATIVE_PATH)
490}
491
492pub fn default_config_home() -> PathBuf {
514 if let Some(config_home) = non_empty_os_var("XDG_CONFIG_HOME") {
515 return PathBuf::from(config_home);
516 }
517
518 #[cfg(windows)]
519 {
520 if let Some(app_data) = non_empty_os_var("APPDATA") {
521 return PathBuf::from(app_data);
522 }
523 if let Some(user_profile) = non_empty_os_var("USERPROFILE") {
524 return PathBuf::from(user_profile).join("AppData").join("Roaming");
525 }
526 }
527
528 if let Some(home) = non_empty_os_var("HOME") {
529 return PathBuf::from(home).join(".config");
530 }
531
532 PathBuf::from(".config")
533}
534
535pub fn load(path: impl AsRef<Path>) -> Result<Option<DaemonConfig>, DaemonConfigError> {
536 let path = path.as_ref();
537 let Some(doc) = read_config_doc(path)? else {
538 return Ok(None);
539 };
540 parse_doc(&doc, path).map(Some)
541}
542
543pub fn load_logging(path: impl AsRef<Path>) -> Result<Option<LoggingConfig>, DaemonConfigError> {
549 let path = path.as_ref();
550 let Some(doc) = read_config_doc(path)? else {
551 return Ok(None);
552 };
553 let json = jsonc_to_json(&doc).map_err(|message| DaemonConfigError::InvalidJsonc {
554 path: path.to_path_buf(),
555 message,
556 })?;
557 let raw: RawDaemonConfig =
558 serde_json::from_str(&json).map_err(|source| DaemonConfigError::InvalidJson {
559 path: path.to_path_buf(),
560 source,
561 })?;
562 if raw.version != SUPPORTED_CONFIG_VERSION {
563 return Err(DaemonConfigError::UnsupportedVersion {
564 path: path.to_path_buf(),
565 version: raw.version,
566 });
567 }
568 raw.log
569 .map(|log| parse_logging_config(log, path, "daemon log"))
570 .transpose()
571}
572
573pub fn ensure_daemon_run_dir_private() -> Result<PathBuf, io::Error> {
599 let path = daemon_run_dir()
600 .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
601 ensure_directory_private(&path)?;
602 Ok(path)
603}
604
605#[cfg(unix)]
610fn ensure_directory_private(path: &Path) -> Result<(), io::Error> {
611 use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
612
613 if !path.exists() {
614 fs::DirBuilder::new()
615 .recursive(true)
616 .mode(0o700)
617 .create(path)?;
618 return Ok(());
619 }
620 let mode = fs::metadata(path)?.permissions().mode() & 0o777;
621 if mode & 0o077 != 0 {
622 fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
623 }
624 Ok(())
625}
626
627#[cfg(not(unix))]
629fn ensure_directory_private(path: &Path) -> Result<(), io::Error> {
630 if !path.exists() {
631 fs::create_dir_all(path)?;
632 }
633 Ok(())
634}
635
636pub fn daemon_run_dir() -> Result<PathBuf, DaemonRunDirError> {
646 daemon_run_dir_from(default_data_home())
647}
648
649fn daemon_run_dir_from(data_home: PathBuf) -> Result<PathBuf, DaemonRunDirError> {
652 if !data_home.is_absolute() {
653 return Err(DaemonRunDirError::RelativeDataHome { data_home });
654 }
655 Ok(data_home.join("cortexkit").join("run"))
656}
657
658#[derive(Debug, Clone, PartialEq, Eq)]
660pub enum DaemonRunDirError {
661 RelativeDataHome { data_home: PathBuf },
664}
665
666impl fmt::Display for DaemonRunDirError {
667 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
668 match self {
669 Self::RelativeDataHome { data_home } => write!(
670 f,
671 "cannot resolve the daemon run directory: the data home `{}` is relative, \
672 so it would land under the current working directory; {}",
673 data_home.display(),
674 DATA_HOME_REMEDY
675 ),
676 }
677 }
678}
679
680impl std::error::Error for DaemonRunDirError {}
681
682#[cfg(windows)]
684const DATA_HOME_REMEDY: &str =
685 "set XDG_DATA_HOME to an absolute path, or set APPDATA, USERPROFILE or HOME";
686#[cfg(not(windows))]
687const DATA_HOME_REMEDY: &str = "set XDG_DATA_HOME to an absolute path, or set HOME";
688
689fn read_config_doc(path: &Path) -> Result<Option<String>, DaemonConfigError> {
690 match fs::read_to_string(path) {
691 Ok(doc) => Ok(Some(doc)),
692 Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(None),
693 Err(source) => Err(DaemonConfigError::Read {
694 path: path.to_path_buf(),
695 source,
696 }),
697 }
698}
699
700fn parse_doc(doc: &str, path: &Path) -> Result<DaemonConfig, DaemonConfigError> {
701 let json = jsonc_to_json(doc).map_err(|message| DaemonConfigError::InvalidJsonc {
702 path: path.to_path_buf(),
703 message,
704 })?;
705 let raw: RawDaemonConfig =
706 serde_json::from_str(&json).map_err(|source| DaemonConfigError::InvalidJson {
707 path: path.to_path_buf(),
708 source,
709 })?;
710
711 if raw.version != SUPPORTED_CONFIG_VERSION {
712 return Err(DaemonConfigError::UnsupportedVersion {
713 path: path.to_path_buf(),
714 version: raw.version,
715 });
716 }
717
718 let daemon_logging = raw
719 .log
720 .map(|log| parse_logging_config(log, path, "daemon log"))
721 .transpose()?;
722 let default_drain_timeout_ms = raw.drain_timeout_ms;
723 let default_route_bind_relay_timeout_ms = match raw.route_bind_relay_timeout_ms {
729 Some(0) => {
730 return Err(DaemonConfigError::InvalidValue {
731 path: path.to_path_buf(),
732 message: ROUTE_BIND_RELAY_ZERO_MESSAGE.to_string(),
733 });
734 }
735 Some(value) => Some(value),
736 None => None,
737 };
738 let modules = raw
739 .modules
740 .into_iter()
741 .map(|(module_id, module)| {
742 let health = module
743 .health
744 .map(|health| parse_health_config(health, path, &module_id))
745 .transpose()?
746 .unwrap_or_default();
747 if let Err(reason) = crate::registry::module_id_path_hazard(&module_id) {
748 return Err(DaemonConfigError::InvalidValue {
749 path: path.to_path_buf(),
750 message: format!(
751 "module id '{}' is not usable as a path component ({reason}): \
752 the daemon derives each module's store path from its id",
753 module_id.escape_debug()
754 ),
755 });
756 }
757 let per_module_route_bind_relay_timeout_ms = match module.route_bind_relay_timeout_ms {
762 Some(0) => {
763 return Err(DaemonConfigError::InvalidValue {
764 path: path.to_path_buf(),
765 message: format!(
766 "module '{module_id}' {ROUTE_BIND_RELAY_ZERO_MESSAGE}",
767 module_id = module_id.escape_debug()
768 ),
769 });
770 }
771 Some(value) => Some(value),
772 None => default_route_bind_relay_timeout_ms,
773 };
774 if module.ignored.contains_key("launch_nonce_env") {
775 eprintln!("module '{}': launch_nonce_env is deprecated and ignored; nonce delivery is determined by the platform", module_id.escape_debug());
776 }
777 let protocol = parse_module_protocol(module.protocol.as_deref(), path, &module_id)?;
778 let overlap = parse_module_overlap(module.overlap.as_deref(), path, &module_id)?;
779 if module.env.contains_key(SUBC_SPAWN_ROLE_ENV) {
783 return Err(DaemonConfigError::InvalidValue {
784 path: path.to_path_buf(),
785 message: format!(
786 "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",
787 module_id = module_id.escape_debug()
788 ),
789 });
790 }
791 if protocol == ModuleProtocol::None && module.reserved {
798 return Err(DaemonConfigError::InvalidValue {
799 path: path.to_path_buf(),
800 message: format!(
801 "module '{module_id}' sets reserved: true with protocol: \"none\"; \
802 reserved is enforced on the module's HELLO and a protocol: \"none\" \
803 module never registers, so the reservation could never be checked",
804 module_id = module_id.escape_debug()
805 ),
806 });
807 }
808 let restart = parse_restart_config(module.restart, path, &module_id)?;
809 let log = module
810 .log
811 .map(|log| parse_logging_config(log, path, &format!("module '{module_id}' log")))
812 .transpose()?
813 .or_else(|| daemon_logging.clone());
814 Ok(ConfiguredModule {
815 module_id,
816 program: module.program,
817 args: module.args,
818 env: module.env.into_iter().collect(),
819 log,
820 enabled: module.enabled,
821 reserved: module.reserved,
822 reserved_prefixes: module.reserved_prefixes,
823 protocol,
824 overlap,
825 health,
826 drain_timeout_ms: module.drain_timeout_ms.or(default_drain_timeout_ms),
829 route_bind_relay_timeout_ms: per_module_route_bind_relay_timeout_ms,
834 restart,
835 })
836 })
837 .collect::<Result<Vec<_>, DaemonConfigError>>()?;
838
839 validate_reserved_prefixes(&modules, path)?;
840 validate_reserved_capabilities(&raw.reserved_capabilities, path)?;
841 validate_admission_facts_config(
842 &modules,
843 raw.admission_facts_carrier_module_id.as_deref(),
844 raw.admission_facts_targets.as_deref(),
845 path,
846 )?;
847 let scope_authority_owners = raw
848 .scope_authority_owners
849 .unwrap_or_else(default_scope_authority_owners);
850 if scope_authority_owners.iter().any(|owner| owner.is_empty()) {
851 return Err(DaemonConfigError::InvalidValue {
852 path: path.to_path_buf(),
853 message: "scope_authority_owners must not contain empty module ids".to_string(),
854 });
855 }
856
857 let storage = raw
858 .storage
859 .map(|s| match s {
860 RawStorageConfig::Sqlite { data_home } => {
861 let data_home = data_home.unwrap_or_else(default_data_home);
862 if !data_home.is_absolute() {
871 return Err(DaemonConfigError::InvalidValue {
872 path: path.to_path_buf(),
873 message: format!(
874 "storage data home resolved to the relative path {} \
875 (no absolute XDG_DATA_HOME, APPDATA, USERPROFILE, or HOME \
876 in the daemon's environment); refusing to serve a \
877 cwd-relative storage descriptor to modules. Set \
878 XDG_DATA_HOME or HOME to an absolute path, or set \
879 storage.data_home in this file.",
880 data_home.display()
881 ),
882 });
883 }
884 Ok(StorageConfig::Sqlite { data_home })
885 }
886 })
887 .transpose()?;
888
889 Ok(DaemonConfig {
890 path: path.to_path_buf(),
891 port: raw.port,
892 drain_timeout_ms: default_drain_timeout_ms,
893 route_bind_relay_timeout_ms: default_route_bind_relay_timeout_ms,
894 modules,
895 storage,
896 admission_facts_carrier_module_id: raw.admission_facts_carrier_module_id,
897 admission_facts_targets: raw.admission_facts_targets,
898 scope_authority_owners,
899 reserved_capabilities: raw.reserved_capabilities,
900 })
901}
902
903fn parse_logging_config(
904 raw: RawLoggingConfig,
905 path: &Path,
906 owner: &str,
907) -> Result<LoggingConfig, DaemonConfigError> {
908 fn valid_level(level: &str) -> bool {
909 matches!(level, "off" | "error" | "warn" | "info" | "debug" | "trace")
910 }
911
912 let level = raw.level.unwrap_or_else(|| "info".to_string());
913 if !valid_level(&level) {
914 return Err(DaemonConfigError::InvalidValue {
915 path: path.to_path_buf(),
916 message: format!(
917 "{owner}.level must be one of off, error, warn, info, debug, trace; got {level:?}"
918 ),
919 });
920 }
921 for (tag, tag_level) in &raw.tags {
922 let well_formed = !tag.is_empty()
927 && tag.split('.').all(|segment| {
928 let mut chars = segment.chars();
929 matches!(chars.next(), Some('a'..='z'))
930 && chars.all(|c| matches!(c, 'a'..='z' | '0'..='9' | '-'))
931 });
932 if !well_formed {
933 return Err(DaemonConfigError::InvalidValue {
934 path: path.to_path_buf(),
935 message: format!(
936 "{owner}.tags key {tag:?} is not a logger name (dotted segments of [a-z][a-z0-9-]*)"
937 ),
938 });
939 }
940 if !valid_level(tag_level) {
941 return Err(DaemonConfigError::InvalidValue {
942 path: path.to_path_buf(),
943 message: format!(
944 "{owner}.tags.{tag} must be one of off, error, warn, info, debug, trace; got {tag_level:?}"
945 ),
946 });
947 }
948 }
949
950 let defaults = Retention::default();
951 let retention = Retention {
952 max_file_mb: raw.max_file_mb.unwrap_or(defaults.max_file_mb),
953 keep: raw.keep.unwrap_or(defaults.keep),
954 max_age_days: raw.max_age_days.unwrap_or(defaults.max_age_days),
955 };
956 if retention.max_file_mb == 0 {
957 return Err(DaemonConfigError::InvalidValue {
958 path: path.to_path_buf(),
959 message: format!("{owner}.max_file_mb must be greater than 0"),
960 });
961 }
962
963 let alarm_segment_mb = raw
964 .alarm_segment_mb
965 .unwrap_or(cortexkit_log::SegmentRetention::default().alarm_segment_mb);
966 if alarm_segment_mb == 0 {
967 return Err(DaemonConfigError::InvalidValue {
968 path: path.to_path_buf(),
969 message: format!("{owner}.alarm_segment_mb must be greater than 0"),
970 });
971 }
972
973 Ok(LoggingConfig {
974 level,
975 tags: raw.tags,
976 retention,
977 alarm_segment_mb,
978 })
979}
980
981fn parse_module_protocol(
990 raw: Option<&str>,
991 path: &Path,
992 module_id: &str,
993) -> Result<ModuleProtocol, DaemonConfigError> {
994 match raw {
995 None | Some("subc") => Ok(ModuleProtocol::Subc),
996 Some("none") => Ok(ModuleProtocol::None),
997 Some(other) => Err(DaemonConfigError::InvalidValue {
1001 path: path.to_path_buf(),
1002 message: format!(
1003 "module '{module_id}' declares protocol {other:?}; supported values are \
1004 \"subc\" (the default when the key is absent) and \"none\"",
1005 module_id = module_id.escape_debug(),
1006 ),
1007 }),
1008 }
1009}
1010
1011fn parse_module_overlap(
1015 raw: Option<&str>,
1016 path: &Path,
1017 module_id: &str,
1018) -> Result<ModuleOverlap, DaemonConfigError> {
1019 match raw {
1020 None | Some("exclusive") => Ok(ModuleOverlap::Exclusive),
1021 Some("safe") => Ok(ModuleOverlap::Safe),
1022 Some(other) => Err(DaemonConfigError::InvalidValue {
1023 path: path.to_path_buf(),
1024 message: format!(
1025 "module '{module_id}' declares overlap {other:?}; supported values are \
1026 \"exclusive\" (the default when the key is absent) and \"safe\"",
1027 module_id = module_id.escape_debug(),
1028 ),
1029 }),
1030 }
1031}
1032
1033fn validate_reserved_capabilities(
1034 bindings: &BTreeMap<String, String>,
1035 path: &Path,
1036) -> Result<(), DaemonConfigError> {
1037 for (capability, module_id) in bindings {
1038 if !is_valid_capability_identifier(capability) {
1039 return Err(DaemonConfigError::InvalidValue {
1040 path: path.to_path_buf(),
1041 message: format!(
1042 "reserved_capabilities key {:?} is not a valid capability identifier",
1043 capability
1044 ),
1045 });
1046 }
1047 if module_id.trim().is_empty() {
1048 return Err(DaemonConfigError::InvalidValue {
1049 path: path.to_path_buf(),
1050 message: format!(
1051 "reserved_capabilities binding for {:?} has an empty module id",
1052 capability
1053 ),
1054 });
1055 }
1056 if let Err(reason) = crate::registry::module_id_path_hazard(module_id) {
1057 return Err(DaemonConfigError::InvalidValue {
1058 path: path.to_path_buf(),
1059 message: format!(
1060 "reserved_capabilities binding for {:?} has an unusable module id {:?}: {reason}",
1061 capability, module_id
1062 ),
1063 });
1064 }
1065 }
1066 Ok(())
1067}
1068
1069fn validate_admission_facts_config(
1070 modules: &[ConfiguredModule],
1071 carrier_module_id: Option<&str>,
1072 targets: Option<&[String]>,
1073 path: &Path,
1074) -> Result<(), DaemonConfigError> {
1075 let Some(carrier_module_id) = carrier_module_id else {
1076 return Ok(());
1077 };
1078
1079 let Some(carrier) = modules
1080 .iter()
1081 .find(|module| module.module_id == carrier_module_id)
1082 else {
1083 return Err(DaemonConfigError::InvalidValue {
1084 path: path.to_path_buf(),
1085 message: format!(
1086 "admission_facts_carrier_module_id '{carrier_module_id}' must name a configured module"
1087 ),
1088 });
1089 };
1090 if !carrier.enabled || !carrier.reserved {
1091 return Err(DaemonConfigError::InvalidValue {
1092 path: path.to_path_buf(),
1093 message: format!(
1094 "admission_facts_carrier_module_id '{carrier_module_id}' must name an enabled reserved module"
1095 ),
1096 });
1097 }
1098
1099 let Some(targets) = targets else {
1100 return Err(DaemonConfigError::InvalidValue {
1101 path: path.to_path_buf(),
1102 message: "admission_facts_targets must be present when an admission facts carrier is configured".to_string(),
1103 });
1104 };
1105 if targets.is_empty() || targets.iter().any(String::is_empty) {
1106 return Err(DaemonConfigError::InvalidValue {
1107 path: path.to_path_buf(),
1108 message:
1109 "admission_facts_targets must be non-empty and must not contain empty module ids"
1110 .to_string(),
1111 });
1112 }
1113
1114 Ok(())
1115}
1116
1117fn default_enabled() -> bool {
1118 true
1119}
1120
1121fn validate_reserved_prefixes(
1122 modules: &[ConfiguredModule],
1123 path: &Path,
1124) -> Result<(), DaemonConfigError> {
1125 for module in modules {
1126 if module.reserved_prefixes.is_empty() {
1127 continue;
1128 }
1129 if !module.reserved {
1130 return Err(DaemonConfigError::InvalidValue {
1131 path: path.to_path_buf(),
1132 message: format!(
1133 "module '{}' reserved_prefixes require reserved=true so the owner is spawn-nonce protected",
1134 module.module_id
1135 ),
1136 });
1137 }
1138 for prefix in &module.reserved_prefixes {
1139 if !prefix.ends_with(':') {
1140 return Err(DaemonConfigError::InvalidValue {
1141 path: path.to_path_buf(),
1142 message: format!(
1143 "module '{}' reserved prefix '{}' must end with ':'",
1144 module.module_id, prefix
1145 ),
1146 });
1147 }
1148 }
1149 }
1150
1151 for module in modules {
1152 for prefix in &module.reserved_prefixes {
1153 if let Some(colliding) = modules
1154 .iter()
1155 .find(|candidate| candidate.module_id.starts_with(prefix))
1156 {
1157 return Err(DaemonConfigError::InvalidValue {
1158 path: path.to_path_buf(),
1159 message: format!(
1160 "reserved prefix '{}' owned by '{}' collides with configured module id '{}'",
1161 prefix, module.module_id, colliding.module_id
1162 ),
1163 });
1164 }
1165 }
1166 }
1167
1168 for (left_index, left) in modules.iter().enumerate() {
1169 for right in modules.iter().skip(left_index + 1) {
1170 if left.module_id == right.module_id {
1171 continue;
1172 }
1173 for left_prefix in &left.reserved_prefixes {
1174 for right_prefix in &right.reserved_prefixes {
1175 if left_prefix.starts_with(right_prefix)
1176 || right_prefix.starts_with(left_prefix)
1177 {
1178 return Err(DaemonConfigError::InvalidValue {
1179 path: path.to_path_buf(),
1180 message: format!(
1181 "reserved prefixes '{}' owned by '{}' and '{}' owned by '{}' overlap",
1182 left_prefix, left.module_id, right_prefix, right.module_id
1183 ),
1184 });
1185 }
1186 }
1187 }
1188 }
1189 }
1190
1191 Ok(())
1192}
1193
1194fn parse_health_config(
1195 raw: RawHealthConfig,
1196 path: &Path,
1197 module_id: &str,
1198) -> Result<HealthConfig, DaemonConfigError> {
1199 let defaults = HealthConfig::default();
1200 let cadence = positive_millis(
1201 raw.cadence_ms,
1202 defaults.cadence,
1203 path,
1204 module_id,
1205 "cadence_ms",
1206 )?;
1207 let deadline = positive_millis(
1208 raw.deadline_ms,
1209 defaults.deadline,
1210 path,
1211 module_id,
1212 "deadline_ms",
1213 )?;
1214 let failure_threshold = match raw.failure_threshold {
1215 Some(0) => {
1216 return Err(DaemonConfigError::InvalidValue {
1217 path: path.to_path_buf(),
1218 message: format!("module '{module_id}' health.failure_threshold must be positive"),
1219 })
1220 }
1221 Some(value) => value,
1222 None => defaults.failure_threshold,
1223 };
1224
1225 Ok(HealthConfig {
1226 cadence,
1227 deadline,
1228 failure_threshold,
1229 on_degraded: match raw.on_degraded {
1230 Some(RawHealthAction::Restart) => {
1231 return Err(DaemonConfigError::InvalidValue {
1232 path: path.to_path_buf(),
1233 message: format!(
1234 "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)."
1235 ),
1236 });
1237 }
1238 Some(action) => health_action(action),
1239 None => defaults.on_degraded,
1240 },
1241 on_failing: raw
1242 .on_failing
1243 .map(health_action)
1244 .unwrap_or(defaults.on_failing),
1245 critical: raw.critical,
1246 })
1247}
1248
1249fn parse_restart_config(
1256 raw: Option<RawRestartConfig>,
1257 path: &Path,
1258 module_id: &str,
1259) -> Result<RestartPolicy, DaemonConfigError> {
1260 let defaults = RestartPolicy::default();
1261 let Some(raw) = raw else {
1262 return Ok(defaults);
1263 };
1264
1265 let window = match raw.window_secs {
1266 Some(0) => {
1267 return Err(DaemonConfigError::InvalidValue {
1268 path: path.to_path_buf(),
1269 message: format!(
1270 "module '{module_id}' {RESTART_WINDOW_ZERO_MESSAGE}",
1271 module_id = module_id.escape_debug()
1272 ),
1273 });
1274 }
1275 Some(secs) => Duration::from_secs(secs),
1276 None => defaults.window,
1277 };
1278 let backoff = raw
1279 .backoff_ms
1280 .map(Duration::from_millis)
1281 .unwrap_or(defaults.backoff);
1282 let max_backoff = raw
1283 .max_backoff_ms
1284 .map(Duration::from_millis)
1285 .unwrap_or(defaults.max_backoff);
1286 if max_backoff < backoff {
1287 return Err(DaemonConfigError::InvalidValue {
1288 path: path.to_path_buf(),
1289 message: format!(
1290 "module '{}' restart.max_backoff_ms must be greater than or equal to restart.backoff_ms (max_backoff_ms={max_backoff:?}, backoff_ms={backoff:?})",
1291 module_id.escape_debug()
1292 ),
1293 });
1294 }
1295
1296 Ok(RestartPolicy {
1297 max_restarts: raw.max_restarts.unwrap_or(defaults.max_restarts),
1300 backoff,
1301 max_backoff,
1302 window,
1303 })
1304}
1305
1306fn positive_millis(
1307 value: Option<u64>,
1308 default: std::time::Duration,
1309 path: &Path,
1310 module_id: &str,
1311 field: &str,
1312) -> Result<std::time::Duration, DaemonConfigError> {
1313 match value {
1314 Some(0) => Err(DaemonConfigError::InvalidValue {
1315 path: path.to_path_buf(),
1316 message: format!("module '{module_id}' health.{field} must be positive"),
1317 }),
1318 Some(value) => Ok(std::time::Duration::from_millis(value)),
1319 None => Ok(default),
1320 }
1321}
1322
1323fn health_action(action: RawHealthAction) -> HealthAction {
1324 match action {
1325 RawHealthAction::Report => HealthAction::Report,
1326 RawHealthAction::Restart => HealthAction::Restart,
1327 RawHealthAction::Alert => HealthAction::Alert,
1328 }
1329}
1330
1331pub(crate) fn default_data_home() -> PathBuf {
1334 if let Some(data_home) = non_empty_os_var("XDG_DATA_HOME") {
1335 return PathBuf::from(data_home);
1336 }
1337
1338 #[cfg(windows)]
1339 {
1340 if let Some(app_data) = non_empty_os_var("APPDATA") {
1341 return PathBuf::from(app_data);
1342 }
1343 if let Some(user_profile) = non_empty_os_var("USERPROFILE") {
1344 return PathBuf::from(user_profile).join("AppData").join("Roaming");
1345 }
1346 }
1347
1348 if let Some(home) = non_empty_os_var("HOME") {
1349 return PathBuf::from(home).join(".local").join("share");
1350 }
1351
1352 PathBuf::from(".local").join("share")
1353}
1354
1355fn non_empty_os_var(key: &str) -> Option<OsString> {
1356 let value = env::var_os(key)?;
1357 if value.is_empty() {
1358 None
1359 } else {
1360 Some(value)
1361 }
1362}
1363
1364impl fmt::Display for DaemonConfigError {
1365 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1366 match self {
1367 Self::Read { path, source } => {
1368 write!(f, "failed to read daemon config {}: {source}", path.display())
1369 }
1370 Self::InvalidJsonc { path, message } => {
1371 write!(f, "invalid JSONC in daemon config {}: {message}", path.display())
1372 }
1373 Self::InvalidJson { path, source } => {
1374 write!(f, "invalid daemon config {}: {source}", path.display())
1375 }
1376 Self::UnsupportedVersion { path, version } => write!(
1377 f,
1378 "invalid daemon config {}: version {version} is unsupported (expected {SUPPORTED_CONFIG_VERSION})",
1379 path.display()
1380 ),
1381 Self::InvalidValue { path, message } => {
1382 write!(f, "invalid daemon config {}: {message}", path.display())
1383 }
1384 }
1385 }
1386}
1387
1388impl Error for DaemonConfigError {
1389 fn source(&self) -> Option<&(dyn Error + 'static)> {
1390 match self {
1391 Self::Read { source, .. } => Some(source),
1392 Self::InvalidJson { source, .. } => Some(source),
1393 Self::InvalidJsonc { .. }
1394 | Self::UnsupportedVersion { .. }
1395 | Self::InvalidValue { .. } => None,
1396 }
1397 }
1398}
1399
1400#[cfg(all(test, unix))]
1401mod run_dir_privacy_tests {
1402 use std::fs;
1403 use std::os::unix::fs::PermissionsExt;
1404 use subc_test_support::TestTempDir;
1405
1406 #[test]
1412 fn run_dir_is_created_private_and_an_inherited_wide_one_is_tightened() {
1413 let temp = TestTempDir::new("subc-run-dir-privacy");
1414 let created = temp.path().join("cortexkit").join("run");
1415 super::ensure_directory_private(&created).expect("create run dir");
1416 let mode = fs::metadata(&created)
1417 .expect("stat created")
1418 .permissions()
1419 .mode()
1420 & 0o777;
1421 assert_eq!(
1422 mode, 0o700,
1423 "observable a run directory this code creates must be 0700, got {mode:o}"
1424 );
1425
1426 fs::set_permissions(&created, fs::Permissions::from_mode(0o755)).expect("widen");
1428 let widened = fs::metadata(&created)
1429 .expect("stat widened")
1430 .permissions()
1431 .mode()
1432 & 0o777;
1433 assert_eq!(
1434 widened, 0o755,
1435 "observable the fixture must actually be wide before the tighten"
1436 );
1437
1438 super::ensure_directory_private(&created).expect("tighten run dir");
1439 let mode = fs::metadata(&created)
1440 .expect("stat tightened")
1441 .permissions()
1442 .mode()
1443 & 0o777;
1444 assert_eq!(
1445 mode, 0o700,
1446 "observable an inherited group- or world-readable run directory must be tightened to 0700, got {mode:o}"
1447 );
1448 }
1449}
1450
1451#[cfg(test)]
1452mod tests {
1453 use super::*;
1454
1455 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1464
1465 fn abs(posix: &str) -> PathBuf {
1470 if cfg!(windows) {
1471 PathBuf::from(format!("C:{}", posix.replace('/', "\\")))
1472 } else {
1473 PathBuf::from(posix)
1474 }
1475 }
1476
1477 #[test]
1478 fn default_data_home_matches_golden_fixture() {
1479 let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1480 let doc: serde_json::Value =
1481 serde_json::from_str(include_str!("../tests/golden/data_home_resolution.json"))
1482 .expect("golden parses");
1483 let vars = ["XDG_DATA_HOME", "APPDATA", "USERPROFILE", "HOME"];
1484 let saved: Vec<(&str, Option<std::ffi::OsString>)> =
1485 vars.iter().map(|v| (*v, env::var_os(v))).collect();
1486 let platform_matches =
1487 |p: &str| p == "any" || p == if cfg!(windows) { "windows" } else { "unix" };
1488
1489 let mut ran = 0usize;
1490 for case in doc["cases"].as_array().expect("cases array") {
1491 let name = case["name"].as_str().expect("name");
1492 if !platform_matches(case["platform"].as_str().expect("platform")) {
1493 continue;
1494 }
1495 for v in vars {
1496 env::remove_var(v);
1497 }
1498 for (k, v) in case["env"].as_object().expect("env map") {
1499 env::set_var(k, v.as_str().expect("env value"));
1500 }
1501 let got = default_data_home();
1502 assert_eq!(
1503 got.to_string_lossy(),
1504 case["expect"].as_str().expect("expect"),
1505 "golden case '{name}' diverged"
1506 );
1507 ran += 1;
1508 }
1509 assert!(
1511 ran >= 6,
1512 "only {ran} golden cases ran; fixture or filter broken"
1513 );
1514
1515 for (k, v) in saved {
1516 match v {
1517 Some(val) => env::set_var(k, val),
1518 None => env::remove_var(k),
1519 }
1520 }
1521 }
1522
1523 #[test]
1528 fn daemon_run_dir_refuses_a_relative_data_home_and_names_the_variables() {
1529 for data_home in [PathBuf::from(".local/share"), PathBuf::from("relative-xdg")] {
1530 let error = daemon_run_dir_from(data_home.clone())
1531 .expect_err("a relative data home must be refused");
1532 assert_eq!(
1533 error,
1534 DaemonRunDirError::RelativeDataHome {
1535 data_home: data_home.clone()
1536 }
1537 );
1538 let message = error.to_string();
1539 assert!(
1540 message.contains("XDG_DATA_HOME") && message.contains("HOME"),
1541 "the refusal must name the variables to set: {message}"
1542 );
1543 }
1544 }
1545
1546 #[test]
1547 fn daemon_run_dir_under_an_absolute_data_home_is_cortexkit_run() {
1548 let data_home = env::temp_dir().join("subc-run-dir-probe").join("data");
1549 assert!(data_home.is_absolute());
1550 assert_eq!(
1551 daemon_run_dir_from(data_home.clone()),
1552 Ok(data_home.join("cortexkit").join("run"))
1553 );
1554 }
1555
1556 #[test]
1562 fn default_config_home_matches_golden_fixture() {
1563 let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1564 let doc: serde_json::Value =
1565 serde_json::from_str(include_str!("../tests/golden/config_home_resolution.json"))
1566 .expect("golden parses");
1567 let vars = ["XDG_CONFIG_HOME", "APPDATA", "USERPROFILE", "HOME"];
1568 let saved: Vec<(&str, Option<std::ffi::OsString>)> =
1569 vars.iter().map(|v| (*v, env::var_os(v))).collect();
1570 let platform_matches =
1571 |p: &str| p == "any" || p == if cfg!(windows) { "windows" } else { "unix" };
1572
1573 let mut ran = 0usize;
1574 for case in doc["cases"].as_array().expect("cases array") {
1575 let name = case["name"].as_str().expect("name");
1576 if !platform_matches(case["platform"].as_str().expect("platform")) {
1577 continue;
1578 }
1579 for v in vars {
1580 env::remove_var(v);
1581 }
1582 for (k, v) in case["env"].as_object().expect("env map") {
1583 env::set_var(k, v.as_str().expect("env value"));
1584 }
1585 let got = default_config_home();
1586 assert_eq!(
1587 got.to_string_lossy(),
1588 case["expect"].as_str().expect("expect"),
1589 "golden case '{name}' diverged"
1590 );
1591 ran += 1;
1592 }
1593 assert!(
1594 ran >= 6,
1595 "only {ran} golden cases ran; fixture or filter broken"
1596 );
1597
1598 for (k, v) in saved {
1599 match v {
1600 Some(val) => env::set_var(k, val),
1601 None => env::remove_var(k),
1602 }
1603 }
1604 }
1605
1606 #[test]
1613 fn relative_storage_data_home_is_refused_at_parse() {
1614 let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1615 let path = Path::new("/golden/subc.jsonc");
1616
1617 let doc =
1619 r#"{ "version": 1, "storage": { "backend": "sqlite", "data_home": "relative/home" } }"#;
1620 let err = parse_doc(doc, path).expect_err("relative data_home must refuse");
1621 assert!(
1622 matches!(&err, DaemonConfigError::InvalidValue { message, .. }
1623 if message.contains("relative path relative/home")),
1624 "wrong refusal: {err:?}"
1625 );
1626
1627 let vars = ["XDG_DATA_HOME", "APPDATA", "USERPROFILE", "HOME"];
1629 let saved: Vec<(&str, Option<std::ffi::OsString>)> =
1630 vars.iter().map(|v| (*v, env::var_os(v))).collect();
1631 for v in vars {
1632 env::remove_var(v);
1633 }
1634 let doc = r#"{ "version": 1, "storage": { "backend": "sqlite" } }"#;
1635 let err = parse_doc(doc, path).expect_err("no home in env must refuse");
1636 assert!(
1637 matches!(&err, DaemonConfigError::InvalidValue { message, .. }
1638 if message.contains("no absolute XDG_DATA_HOME")),
1639 "wrong refusal: {err:?}"
1640 );
1641
1642 let want = abs("/abs/home");
1646 let doc = format!(
1647 r#"{{ "version": 1, "storage": {{ "backend": "sqlite", "data_home": {} }} }}"#,
1648 serde_json::to_string(&want).expect("json path")
1649 );
1650 let cfg = parse_doc(&doc, path).expect("absolute data_home parses");
1651 assert!(matches!(
1652 cfg.storage,
1653 Some(StorageConfig::Sqlite { ref data_home }) if *data_home == want
1654 ));
1655
1656 for (k, v) in saved {
1657 match v {
1658 Some(val) => env::set_var(k, val),
1659 None => env::remove_var(k),
1660 }
1661 }
1662 }
1663
1664 #[test]
1665 fn restart_required_sections_are_the_rescan_cannot_apply_set() {
1666 assert_eq!(
1667 RestartRequiredSection::ALL.map(RestartRequiredSection::label),
1668 [
1669 "port",
1670 "storage",
1671 "admission_facts_carrier_module_id",
1672 "admission_facts_targets",
1673 "scope_authority_owners",
1674 ]
1675 );
1676 }
1677
1678 #[test]
1679 fn scope_authority_owners_defaults_to_the_session_runtime() {
1680 let config = parse_doc(r#"{ "version": 1 }"#, Path::new("/tmp/subc.jsonc")).unwrap();
1681 assert_eq!(config.scope_authority_owners, vec!["prefrontal-core"]);
1682 }
1683
1684 #[test]
1685 fn scope_authority_owners_is_read_when_set_and_refuses_an_empty_id() {
1686 let config = parse_doc(
1687 r#"{ "version": 1, "scope_authority_owners": ["a", "b"] }"#,
1688 Path::new("/tmp/subc.jsonc"),
1689 )
1690 .unwrap();
1691 assert_eq!(config.scope_authority_owners, vec!["a", "b"]);
1692 let config = parse_doc(
1695 r#"{ "version": 1, "scope_authority_owners": [] }"#,
1696 Path::new("/tmp/subc.jsonc"),
1697 )
1698 .unwrap();
1699 assert!(config.scope_authority_owners.is_empty());
1700 let error = parse_doc(
1701 r#"{ "version": 1, "scope_authority_owners": [""] }"#,
1702 Path::new("/tmp/subc.jsonc"),
1703 )
1704 .expect_err("an empty module id is refused");
1705 assert!(
1706 error.to_string().contains("scope_authority_owners"),
1707 "{error}"
1708 );
1709 }
1710
1711 #[test]
1712 fn no_storage_section_yields_none() {
1713 let config = parse_doc(
1714 r#"{ "version": 1, "modules": {} }"#,
1715 Path::new("/tmp/subc.jsonc"),
1716 )
1717 .expect("parse");
1718 assert_eq!(config.storage, None);
1719 }
1720
1721 #[test]
1722 fn sqlite_storage_parses_with_explicit_data_home() {
1723 let config = parse_doc(
1724 &format!(
1725 r#"{{ "version": 1, "storage": {{ "backend": "sqlite", "data_home": {} }} }}"#,
1726 serde_json::to_string(&abs("/data")).expect("json path")
1727 ),
1728 Path::new("/tmp/subc.jsonc"),
1729 )
1730 .expect("parse");
1731 assert_eq!(
1732 config.storage,
1733 Some(StorageConfig::Sqlite {
1734 data_home: abs("/data")
1735 })
1736 );
1737 }
1738
1739 #[test]
1740 fn sqlite_storage_defaults_data_home_when_omitted() {
1741 let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1747 std::env::set_var("XDG_DATA_HOME", abs("/forced/data/home"));
1748 let config = parse_doc(
1749 r#"{ "version": 1, "storage": { "backend": "sqlite" } }"#,
1750 Path::new("/tmp/subc.jsonc"),
1751 )
1752 .expect("parse");
1753 std::env::remove_var("XDG_DATA_HOME");
1754 assert_eq!(
1755 config.storage,
1756 Some(StorageConfig::Sqlite {
1757 data_home: abs("/forced/data/home")
1758 })
1759 );
1760 }
1761
1762 #[test]
1763 fn descriptor_for_matches_store_types_shape() {
1764 let cfg = StorageConfig::Sqlite {
1768 data_home: PathBuf::from("/data"),
1769 };
1770 let descriptor = cfg.descriptor_for("alfonso-routing");
1771 assert_eq!(
1772 descriptor,
1773 serde_json::json!({
1774 "module_id": "alfonso-routing",
1775 "storage_namespace": "default",
1776 "isolation": { "kind": "module" },
1777 "backend": {
1778 "backend": "sqlite",
1779 "path": "/data/cortexkit/alfonso-routing/store.db"
1780 }
1781 })
1782 );
1783 }
1784
1785 #[test]
1786 fn path_hazard_module_id_refuses_config_parse() {
1787 let path = Path::new("/tmp/subc.jsonc");
1788 let err = parse_doc(
1789 r#"{ "version": 1, "modules": { "../escape": { "program": "x" } } }"#,
1790 path,
1791 )
1792 .expect_err("separator-bearing module id must refuse");
1793 let text = format!("{err}");
1794 assert!(
1795 text.contains("not usable as a path component"),
1796 "refusal must name the hazard: {text}"
1797 );
1798 }
1799
1800 #[test]
1801 fn drain_timeout_resolves_module_over_daemon_over_absent() {
1802 let path = Path::new("/tmp/subc.jsonc");
1803 let config = parse_doc(
1804 r#"
1805 {
1806 "version": 1,
1807 "drain_timeout_ms": 45000,
1808 "modules": {
1809 "fast": { "program": "fast", "drain_timeout_ms": 0 },
1810 "slow": { "program": "slow", "drain_timeout_ms": 120000 },
1811 "inherits": { "program": "inherits" }
1812 }
1813 }
1814 "#,
1815 path,
1816 )
1817 .unwrap();
1818 let by_id = |id: &str| {
1819 config
1820 .modules
1821 .iter()
1822 .find(|m| m.module_id == id)
1823 .unwrap()
1824 .drain_timeout_ms
1825 };
1826 assert_eq!(by_id("fast"), Some(0));
1829 assert_eq!(by_id("slow"), Some(120_000));
1830 assert_eq!(by_id("inherits"), Some(45_000));
1832 assert_eq!(config.drain_timeout_ms, Some(45_000));
1833 }
1834
1835 #[test]
1836 fn drain_timeout_absent_everywhere_stays_none_for_builtin_default() {
1837 let path = Path::new("/tmp/subc.jsonc");
1838 let config = parse_doc(
1839 r#"{ "version": 1, "modules": { "m": { "program": "m" } } }"#,
1840 path,
1841 )
1842 .unwrap();
1843 assert_eq!(config.modules[0].drain_timeout_ms, None);
1847 assert_eq!(config.drain_timeout_ms, None);
1848 }
1849
1850 #[test]
1851 fn route_bind_relay_timeout_resolves_module_over_daemon_over_absent() {
1852 let path = Path::new("/tmp/subc.jsonc");
1858 let config = parse_doc(
1859 r#"
1860 {
1861 "version": 1,
1862 "route_bind_relay_timeout_ms": 30000,
1863 "modules": {
1864 "tight": { "program": "tight", "route_bind_relay_timeout_ms": 5000 },
1865 "loose": { "program": "loose", "route_bind_relay_timeout_ms": 60000 },
1866 "inherits": { "program": "inherits" }
1867 }
1868 }
1869 "#,
1870 path,
1871 )
1872 .unwrap();
1873 let by_id = |id: &str| {
1874 config
1875 .modules
1876 .iter()
1877 .find(|m| m.module_id == id)
1878 .unwrap()
1879 .route_bind_relay_timeout_ms
1880 };
1881 assert_eq!(by_id("tight"), Some(5_000));
1883 assert_eq!(by_id("loose"), Some(60_000));
1884 assert_eq!(by_id("inherits"), Some(30_000));
1886 assert_eq!(config.route_bind_relay_timeout_ms, Some(30_000));
1887 }
1888
1889 #[test]
1890 fn log_tag_keys_must_be_logger_names_and_the_error_names_the_key() {
1891 let path = Path::new("/tmp/subc.jsonc");
1892 for bad in ["Perf", "a b", "perf.", ".perf", "gc..walk", "a=b"] {
1893 let doc = format!(
1894 r#"{{ "version": 1, "modules": {{ "m": {{ "program": "m", "log": {{ "tags": {{ "{bad}": "debug" }} }} }} }} }}"#
1895 );
1896 let err = parse_doc(&doc, path).expect_err(bad);
1897 let text = format!("{err}");
1898 assert!(
1899 text.contains(&format!("{bad:?}")),
1900 "must name the key: {text}"
1901 );
1902 assert!(
1903 text.contains("logger name"),
1904 "must say what a key is: {text}"
1905 );
1906 }
1907 let ok = parse_doc(
1909 r#"{ "version": 1, "modules": { "m": { "program": "m", "log": { "tags": { "perf": "debug", "gc.walk": "trace", "m": "error", "a-b": "info" } } } } }"#,
1910 path,
1911 );
1912 assert!(ok.is_ok(), "{ok:?}");
1913 }
1914
1915 #[test]
1916 fn log_filter_spec_prefixes_bare_keys_with_the_module_and_passes_absolute_ones() {
1917 let path = Path::new("/tmp/subc.jsonc");
1918 let config = parse_doc(
1919 r#"{ "version": 1, "modules": { "synapse": { "program": "s", "log": { "level": "warn", "tags": { "perf": "debug", "gc.walk": "trace", "synapse": "error", "other.x": "info" } } } } }"#,
1920 path,
1921 )
1922 .unwrap();
1923 let log = config.modules[0].log.as_ref().unwrap();
1924 assert_eq!(
1926 log.filter_spec("synapse"),
1927 "warn,gc.walk=trace,other.x=info,synapse.perf=debug,synapse=error"
1928 );
1929 }
1930
1931 #[test]
1932 fn log_alarm_segment_mb_defaults_to_the_crate_default_and_refuses_zero() {
1933 let path = Path::new("/tmp/subc.jsonc");
1934 let config = parse_doc(
1935 r#"{ "version": 1, "modules": { "m": { "program": "m", "log": { "level": "info" } } } }"#,
1936 path,
1937 )
1938 .unwrap();
1939 assert_eq!(
1940 config.modules[0].log.as_ref().unwrap().alarm_segment_mb,
1941 cortexkit_log::SegmentRetention::default().alarm_segment_mb
1942 );
1943 let err = parse_doc(
1944 r#"{ "version": 1, "modules": { "m": { "program": "m", "log": { "alarm_segment_mb": 0 } } } }"#,
1945 path,
1946 )
1947 .expect_err("zero alarm must refuse");
1948 assert!(format!("{err}").contains("alarm_segment_mb"));
1949 }
1950
1951 #[test]
1952 fn route_bind_relay_timeout_zero_at_daemon_layer_is_refused() {
1953 let path = Path::new("/tmp/subc.jsonc");
1954 let err = parse_doc(
1955 r#"
1956 {
1957 "version": 1,
1958 "route_bind_relay_timeout_ms": 0,
1959 "modules": { "m": { "program": "m" } }
1960 }
1961 "#,
1962 path,
1963 )
1964 .expect_err("a daemon-wide zero budget must refuse parse");
1965 let text = format!("{err}");
1966 assert!(
1967 text.contains("route_bind_relay_timeout_ms"),
1968 "error must name the offending key: {text}"
1969 );
1970 assert!(
1971 text.contains("enabled: false"),
1972 "error must name the remedy (enable false): {text}"
1973 );
1974 }
1975
1976 #[test]
1977 fn route_bind_relay_timeout_zero_at_module_layer_is_refused() {
1978 let path = Path::new("/tmp/subc.jsonc");
1979 let err = parse_doc(
1980 r#"
1981 {
1982 "version": 1,
1983 "modules": {
1984 "good": { "program": "good" },
1985 "broken": { "program": "broken", "route_bind_relay_timeout_ms": 0 }
1986 }
1987 }
1988 "#,
1989 path,
1990 )
1991 .expect_err("a per-module zero budget must refuse parse");
1992 let text = format!("{err}");
1993 assert!(
1994 text.contains("route_bind_relay_timeout_ms"),
1995 "error must name the offending key: {text}"
1996 );
1997 assert!(
1998 text.contains("broken"),
1999 "error must name the offending module id: {text}"
2000 );
2001 assert!(
2002 text.contains("enabled: false"),
2003 "error must name the remedy (enable false): {text}"
2004 );
2005 }
2006
2007 #[test]
2008 fn drain_timeout_zero_still_parses_for_wedge_bounces() {
2009 let path = Path::new("/tmp/subc.jsonc");
2015 let config = parse_doc(
2016 r#"
2017 {
2018 "version": 1,
2019 "drain_timeout_ms": 0,
2020 "modules": {
2021 "wedge": { "program": "wedge", "drain_timeout_ms": 0 }
2022 }
2023 }
2024 "#,
2025 path,
2026 )
2027 .expect("drain_timeout_ms: 0 must still parse; wedge-bounce uses it");
2028 let wedge = config
2029 .modules
2030 .iter()
2031 .find(|m| m.module_id == "wedge")
2032 .unwrap();
2033 assert_eq!(wedge.drain_timeout_ms, Some(0));
2034 assert_eq!(config.drain_timeout_ms, Some(0));
2035 }
2036
2037 #[test]
2038 fn route_bind_relay_timeout_absent_everywhere_stays_none_for_builtin_default() {
2039 let path = Path::new("/tmp/subc.jsonc");
2044 let config = parse_doc(
2045 r#"{ "version": 1, "modules": { "m": { "program": "m" } } }"#,
2046 path,
2047 )
2048 .unwrap();
2049 assert_eq!(config.modules[0].route_bind_relay_timeout_ms, None);
2050 assert_eq!(config.route_bind_relay_timeout_ms, None);
2051 }
2052
2053 #[test]
2059 fn a_config_without_a_restart_block_keeps_the_supervisor_defaults() {
2060 let path = Path::new("/tmp/subc.jsonc");
2061 let config = parse_doc(
2062 r#"{ "version": 1, "modules": { "m": { "program": "m" } } }"#,
2063 path,
2064 )
2065 .unwrap();
2066 assert_eq!(config.modules[0].restart.max_restarts, 3);
2067 assert_eq!(config.modules[0].restart.window, Duration::from_secs(600));
2068 assert_eq!(
2069 config.modules[0].restart.backoff,
2070 Duration::from_millis(100)
2071 );
2072 assert_eq!(
2073 config.modules[0].restart.max_backoff,
2074 Duration::from_secs(30)
2075 );
2076 }
2077
2078 #[test]
2079 fn a_restart_block_resolves_each_key_independently() {
2080 let path = Path::new("/tmp/subc.jsonc");
2081 let config = parse_doc(
2082 r#"
2083 {
2084 "version": 1,
2085 "modules": {
2086 "all": {
2087 "program": "all",
2088 "restart": { "max_restarts": 5, "window_secs": 60, "backoff_ms": 250, "max_backoff_ms": 5000 }
2089 },
2090 "window-only": {
2091 "program": "window-only",
2092 "restart": { "window_secs": 7200 }
2093 },
2094 "never": {
2095 "program": "never",
2096 "restart": { "max_restarts": 0 }
2097 }
2098 }
2099 }
2100 "#,
2101 path,
2102 )
2103 .unwrap();
2104 let by_id = |id: &str| {
2105 config
2106 .modules
2107 .iter()
2108 .find(|m| m.module_id == id)
2109 .unwrap()
2110 .restart
2111 };
2112
2113 let all = by_id("all");
2114 assert_eq!(all.max_restarts, 5);
2115 assert_eq!(all.window, Duration::from_secs(60));
2116 assert_eq!(all.backoff, Duration::from_millis(250));
2117 assert_eq!(all.max_backoff, Duration::from_secs(5));
2118
2119 let window_only = by_id("window-only");
2122 assert_eq!(window_only.max_restarts, 3);
2123 assert_eq!(window_only.window, Duration::from_secs(7_200));
2124 assert_eq!(window_only.backoff, Duration::from_millis(100));
2125 assert_eq!(window_only.max_backoff, Duration::from_secs(30));
2126
2127 assert_eq!(by_id("never").max_restarts, 0);
2130 }
2131
2132 #[test]
2136 fn restart_window_zero_is_refused_by_name() {
2137 let path = Path::new("/tmp/subc.jsonc");
2138 let err = parse_doc(
2139 r#"
2140 {
2141 "version": 1,
2142 "modules": {
2143 "good": { "program": "good" },
2144 "broken": { "program": "broken", "restart": { "window_secs": 0 } }
2145 }
2146 }
2147 "#,
2148 path,
2149 )
2150 .expect_err("a zero crash window must refuse parse");
2151 assert!(
2152 matches!(err, DaemonConfigError::InvalidValue { .. }),
2153 "a zero window is an invalid value, not a parse failure: {err:?}"
2154 );
2155 let text = format!("{err}");
2156 assert!(
2157 text.contains("restart.window_secs"),
2158 "error must name the offending key: {text}"
2159 );
2160 assert!(
2161 text.contains("broken"),
2162 "error must name the offending module id: {text}"
2163 );
2164 assert!(
2165 text.contains("max_restarts: 0"),
2166 "error must name the setting that actually stops restarts: {text}"
2167 );
2168 }
2169
2170 #[test]
2171 fn restart_max_backoff_below_backoff_is_refused_by_name() {
2172 let path = Path::new("/tmp/subc.jsonc");
2173 let err = parse_doc(
2174 r#"
2175 {
2176 "version": 1,
2177 "modules": {
2178 "broken": {
2179 "program": "broken",
2180 "restart": { "backoff_ms": 1000, "max_backoff_ms": 999 }
2181 }
2182 }
2183 }
2184 "#,
2185 path,
2186 )
2187 .expect_err("a maximum below the base backoff must refuse parse");
2188 assert!(
2189 matches!(err, DaemonConfigError::InvalidValue { .. }),
2190 "an invalid restart bound must be an InvalidValue: {err:?}"
2191 );
2192 let text = format!("{err}");
2193 assert!(
2194 text.contains("restart.max_backoff_ms"),
2195 "error must name max_backoff_ms: {text}"
2196 );
2197 assert!(
2198 text.contains("restart.backoff_ms"),
2199 "error must name backoff_ms: {text}"
2200 );
2201 assert!(
2202 text.contains("broken"),
2203 "error must name the offending module id: {text}"
2204 );
2205 }
2206
2207 #[test]
2208 fn parse_jsonc_defaults_and_ignores_unknown_fields() {
2209 let path = Path::new("/tmp/subc.jsonc");
2210 let config = parse_doc(
2211 r#"
2212 {
2213 // forward-compatible root field
2214 "version": 1,
2215 "unknown": { "ignored": true },
2216 "modules": {
2217 "aft": {
2218 "program": "aft",
2219 "args": ["module",],
2220 "env": { "A": "B", },
2221 "future": 42,
2222 },
2223 "disabled": { "program": "disabled", "enabled": false }
2224 },
2225 }
2226 "#,
2227 path,
2228 )
2229 .unwrap();
2230
2231 assert_eq!(config.port, None);
2232 assert_eq!(config.modules.len(), 2);
2233 assert_eq!(config.modules[0].module_id, "aft");
2234 assert_eq!(config.modules[0].program, PathBuf::from("aft"));
2235 assert_eq!(config.modules[0].args, ["module"]);
2236 assert_eq!(config.modules[0].env, [("A".to_string(), "B".to_string())]);
2237 assert!(config.modules[0].enabled);
2238 assert!(config.modules[0].reserved_prefixes.is_empty());
2239 assert_eq!(config.modules[0].health, HealthConfig::default());
2240 assert!(!config.modules[1].enabled);
2241 }
2242
2243 #[test]
2244 fn reserved_capabilities_accept_unknown_bound_modules_and_refuse_bad_identifiers() {
2245 let path = Path::new("/tmp/subc.jsonc");
2246 let config = parse_doc(
2247 r#"{
2248 "version": 1,
2249 "reserved_capabilities": {
2250 "credentials-provider/v1": "future-vault"
2251 },
2252 "modules": {}
2253 }"#,
2254 path,
2255 )
2256 .expect("a binding may predate its provider installation");
2257 assert_eq!(
2258 config.reserved_capabilities,
2259 BTreeMap::from([(
2260 "credentials-provider/v1".to_string(),
2261 "future-vault".to_string()
2262 )])
2263 );
2264
2265 let error = parse_doc(
2266 r#"{
2267 "version": 1,
2268 "reserved_capabilities": { "Credentials/v1": "vault" },
2269 "modules": {}
2270 }"#,
2271 path,
2272 )
2273 .expect_err("reserved capabilities use the capability identifier grammar");
2274 assert!(error.to_string().contains("reserved_capabilities key"));
2275 }
2276
2277 #[test]
2278 fn retired_launch_nonce_env_is_ignored_for_any_value() {
2279 let parse = |field: &str| {
2280 parse_doc(
2281 &format!(r#"{{"version":1,"modules":{{"probe":{{"program":"probe"{field}}}}}}}"#),
2282 Path::new("subc.jsonc"),
2283 )
2284 .unwrap()
2285 .modules
2286 .remove(0)
2287 .module_spec()
2288 };
2289 for value in ["true", "false", "null", "0", "\"false\"", "[]", "{}"] {
2290 assert_eq!(
2291 parse(""),
2292 parse(&format!(",\"launch_nonce_env\":{value}")),
2293 "the retired key must not change the launch spec or require a restart"
2294 );
2295 }
2296 }
2297
2298 #[test]
2301 fn an_absent_protocol_key_and_an_explicit_subc_are_the_same_module() {
2302 let parse = |module_body: &str| {
2303 parse_doc(
2304 &format!(
2305 r#"{{
2306 "version": 1,
2307 "modules": {{ "aft": {{ "program": "aft"{module_body} }} }}
2308 }}"#
2309 ),
2310 Path::new("subc.jsonc"),
2311 )
2312 .expect("module parses")
2313 .modules
2314 .remove(0)
2315 };
2316
2317 let absent = parse("");
2318 let explicit = parse(r#", "protocol": "subc""#);
2319 let none = parse(r#", "protocol": "none""#);
2320
2321 assert_eq!(absent.protocol, ModuleProtocol::Subc);
2322 assert_eq!(explicit.protocol, ModuleProtocol::Subc);
2323 assert_eq!(
2324 absent, explicit,
2325 "an absent protocol key must produce exactly the module an explicit subc does"
2326 );
2327 assert_eq!(none.protocol, ModuleProtocol::None);
2328 assert_eq!(none.module_spec().protocol, ModuleProtocol::None);
2332 }
2333
2334 #[test]
2338 fn overlap_defaults_to_exclusive_and_only_safe_opts_in() {
2339 let parse = |module_body: &str| {
2340 parse_doc(
2341 &format!(
2342 r#"{{
2343 "version": 1,
2344 "modules": {{ "aft": {{ "program": "aft"{module_body} }} }}
2345 }}"#
2346 ),
2347 Path::new("subc.jsonc"),
2348 )
2349 };
2350
2351 let absent = parse("").unwrap().modules.remove(0);
2352 assert_eq!(absent.overlap, ModuleOverlap::Exclusive);
2353 assert_eq!(absent.module_spec().overlap, ModuleOverlap::Exclusive);
2354 let safe = parse(r#", "overlap": "safe""#).unwrap().modules.remove(0);
2355 assert_eq!(safe.module_spec().overlap, ModuleOverlap::Safe);
2356 let typo = parse(r#", "overlap": "sfae""#).expect_err("an unknown overlap is refused");
2357 assert!(typo.to_string().contains("sfae"), "{typo}");
2358 }
2359
2360 #[test]
2364 fn the_spawn_role_is_refused_as_a_configured_env_key() {
2365 let error = parse_doc(
2366 r#"{
2367 "version": 1,
2368 "modules": { "aft": { "program": "aft", "env": { "SUBC_SPAWN_ROLE": "swap_candidate" } } }
2369 }"#,
2370 Path::new("subc.jsonc"),
2371 )
2372 .expect_err("SUBC_SPAWN_ROLE must not be configurable");
2373 assert!(
2374 matches!(error, DaemonConfigError::InvalidValue { .. }),
2375 "expected InvalidValue, got {error:?}"
2376 );
2377 assert!(error.to_string().contains("SUBC_SPAWN_ROLE"), "{error}");
2378 }
2379
2380 #[test]
2386 fn an_unsupported_protocol_value_is_refused_by_name() {
2387 let error = parse_doc(
2388 r#"{
2389 "version": 1,
2390 "modules": { "nats": { "program": "nats-server", "protocol": "grpc" } }
2391 }"#,
2392 Path::new("subc.jsonc"),
2393 )
2394 .expect_err("an unknown protocol must not fall back to a default");
2395
2396 assert!(
2397 matches!(error, DaemonConfigError::InvalidValue { .. }),
2398 "expected InvalidValue, got {error:?}"
2399 );
2400 let message = error.to_string();
2401 assert!(
2402 message.contains("grpc"),
2403 "the refusal must name the offending value: {message}"
2404 );
2405 assert!(
2406 message.contains("nats"),
2407 "the refusal must name the module so it can be found in the file: {message}"
2408 );
2409 }
2410
2411 #[test]
2415 fn reserved_true_with_protocol_none_is_refused_with_the_reason() {
2416 let error = parse_doc(
2417 r#"{
2418 "version": 1,
2419 "modules": {
2420 "nats": { "program": "nats-server", "protocol": "none", "reserved": true }
2421 }
2422 }"#,
2423 Path::new("subc.jsonc"),
2424 )
2425 .expect_err("a reservation that can never be checked must not parse");
2426
2427 assert!(
2428 matches!(error, DaemonConfigError::InvalidValue { .. }),
2429 "expected InvalidValue, got {error:?}"
2430 );
2431 let message = error.to_string();
2432 assert!(
2433 message.contains("nats") && message.contains("reserved"),
2434 "the refusal must name the module and the offending key: {message}"
2435 );
2436 assert!(
2437 message.contains("HELLO") || message.contains("never registers"),
2438 "the refusal must say WHY the pair cannot work: {message}"
2439 );
2440 }
2441
2442 #[test]
2443 fn reserved_prefixes_parse_for_reserved_modules() {
2444 let config = parse_doc(
2445 r#"
2446 {
2447 "version": 1,
2448 "modules": {
2449 "federation": {
2450 "program": "fed",
2451 "reserved": true,
2452 "reserved_prefixes": ["fed:"]
2453 }
2454 }
2455 }
2456 "#,
2457 Path::new("subc.jsonc"),
2458 )
2459 .unwrap();
2460
2461 assert_eq!(config.modules[0].reserved_prefixes, ["fed:".to_string()]);
2462 }
2463
2464 #[test]
2465 fn reserved_prefixes_reject_bad_boundaries_and_owners() {
2466 let missing_delimiter = parse_doc(
2467 r#"{
2468 "version": 1,
2469 "modules": {
2470 "federation": { "program": "fed", "reserved": true, "reserved_prefixes": ["fed"] }
2471 }
2472 }"#,
2473 Path::new("subc.jsonc"),
2474 )
2475 .unwrap_err();
2476 assert!(matches!(
2477 missing_delimiter,
2478 DaemonConfigError::InvalidValue { .. }
2479 ));
2480
2481 let non_reserved_owner = parse_doc(
2482 r#"{
2483 "version": 1,
2484 "modules": {
2485 "federation": { "program": "fed", "reserved_prefixes": ["fed:"] }
2486 }
2487 }"#,
2488 Path::new("subc.jsonc"),
2489 )
2490 .unwrap_err();
2491 assert!(matches!(
2492 non_reserved_owner,
2493 DaemonConfigError::InvalidValue { .. }
2494 ));
2495 }
2496
2497 #[test]
2498 fn reserved_prefixes_reject_cross_owner_overlap_and_exact_id_collisions() {
2499 let overlap = parse_doc(
2500 r#"{
2501 "version": 1,
2502 "modules": {
2503 "fed-owner": { "program": "fed", "reserved": true, "reserved_prefixes": ["fed:"] },
2504 "sub-owner": { "program": "fed-sub", "reserved": true, "reserved_prefixes": ["fed:sub:"] }
2505 }
2506 }"#,
2507 Path::new("subc.jsonc"),
2508 )
2509 .unwrap_err();
2510 assert!(matches!(overlap, DaemonConfigError::InvalidValue { .. }));
2511
2512 let exact_collision = parse_doc(
2513 r#"{
2514 "version": 1,
2515 "modules": {
2516 "federation": { "program": "fed", "reserved": true, "reserved_prefixes": ["fed:"] },
2517 "fed:special": { "program": "special" }
2518 }
2519 }"#,
2520 Path::new("subc.jsonc"),
2521 )
2522 .unwrap_err();
2523 assert!(matches!(
2524 exact_collision,
2525 DaemonConfigError::InvalidValue { .. }
2526 ));
2527 }
2528
2529 #[test]
2530 fn health_config_parses_and_ignores_unknown_fields() {
2531 let config = parse_doc(
2532 r#"
2533 {
2534 "version": 1,
2535 "modules": {
2536 "aft": {
2537 "program": "aft",
2538 "health": {
2539 "cadence_ms": 100,
2540 "deadline_ms": 20,
2541 "failure_threshold": 2,
2542 "on_degraded": "report",
2543 "on_failing": "restart",
2544 "critical": true,
2545 "future": "ignored"
2546 }
2547 }
2548 }
2549 }
2550 "#,
2551 Path::new("subc.jsonc"),
2552 )
2553 .unwrap();
2554
2555 let health = config.modules[0].health;
2556 assert_eq!(health.cadence, std::time::Duration::from_millis(100));
2557 assert_eq!(health.deadline, std::time::Duration::from_millis(20));
2558 assert_eq!(health.failure_threshold, 2);
2559 assert_eq!(health.on_degraded, HealthAction::Report);
2560 assert_eq!(health.on_failing, HealthAction::Restart);
2561 assert!(health.critical);
2562 }
2563
2564 #[test]
2565 fn health_config_rejects_bad_enum_and_non_positive_numbers() {
2566 let bad_enum = parse_doc(
2567 r#"{
2568 "version": 1,
2569 "modules": { "aft": { "program": "aft", "health": { "on_failing": "page" } } }
2570 }"#,
2571 Path::new("subc.jsonc"),
2572 )
2573 .unwrap_err();
2574 assert!(matches!(bad_enum, DaemonConfigError::InvalidJson { .. }));
2575
2576 let zero = parse_doc(
2577 r#"{
2578 "version": 1,
2579 "modules": { "aft": { "program": "aft", "health": { "cadence_ms": 0 } } }
2580 }"#,
2581 Path::new("subc.jsonc"),
2582 )
2583 .unwrap_err();
2584 assert!(matches!(zero, DaemonConfigError::InvalidValue { .. }));
2585 }
2586
2587 #[test]
2588 fn admission_facts_carrier_requires_non_empty_targets() {
2589 let missing_targets = parse_doc(
2590 r#"{
2591 "version": 1,
2592 "admission_facts_carrier_module_id": "fed",
2593 "modules": { "fed": { "program": "fed", "reserved": true } }
2594 }"#,
2595 Path::new("subc.jsonc"),
2596 )
2597 .unwrap_err();
2598 assert!(
2604 matches!(&missing_targets, DaemonConfigError::InvalidValue { message, .. }
2605 if message.contains("must be present")),
2606 "expected the presence rule, got: {missing_targets:?}"
2607 );
2608
2609 let empty_targets = parse_doc(
2610 r#"{
2611 "version": 1,
2612 "admission_facts_carrier_module_id": "fed",
2613 "admission_facts_targets": [""],
2614 "modules": { "fed": { "program": "fed", "reserved": true } }
2615 }"#,
2616 Path::new("subc.jsonc"),
2617 )
2618 .unwrap_err();
2619 assert!(
2620 matches!(&empty_targets, DaemonConfigError::InvalidValue { message, .. }
2621 if message.contains("must be non-empty")),
2622 "expected the non-empty rule, got: {empty_targets:?}"
2623 );
2624 }
2625
2626 #[test]
2627 fn admission_facts_carrier_must_be_enabled_reserved_and_configured() {
2628 for module in [
2629 r#"{ "program": "fed", "enabled": false, "reserved": true }"#,
2630 r#"{ "program": "fed", "enabled": true, "reserved": false }"#,
2631 ] {
2632 let doc = format!(
2633 r#"{{
2634 "version": 1,
2635 "admission_facts_carrier_module_id": "fed",
2636 "admission_facts_targets": ["target"],
2637 "modules": {{ "fed": {module}, "target": {{ "program": "target" }} }}
2638 }}"#
2639 );
2640 let err = parse_doc(&doc, Path::new("subc.jsonc")).unwrap_err();
2641 assert!(
2645 matches!(&err, DaemonConfigError::InvalidValue { message, .. }
2646 if message.contains("enabled reserved module")),
2647 "expected the enabled-and-reserved rule, got: {err:?}"
2648 );
2649 }
2650
2651 let absent = parse_doc(
2652 r#"{
2653 "version": 1,
2654 "admission_facts_carrier_module_id": "missing",
2655 "admission_facts_targets": ["target"],
2656 "modules": { "target": { "program": "target" } }
2657 }"#,
2658 Path::new("subc.jsonc"),
2659 )
2660 .unwrap_err();
2661 assert!(
2662 matches!(&absent, DaemonConfigError::InvalidValue { message, .. }
2663 if message.contains("must name a configured module")),
2664 "expected the configured-module rule, got: {absent:?}"
2665 );
2666 }
2667
2668 #[test]
2669 fn reject_unsupported_version() {
2670 let err = parse_doc(
2671 r#"{ "version": 2, "modules": {} }"#,
2672 Path::new("subc.jsonc"),
2673 )
2674 .unwrap_err();
2675 assert!(matches!(
2676 err,
2677 DaemonConfigError::UnsupportedVersion { version: 2, .. }
2678 ));
2679 }
2680
2681 #[test]
2682 fn reject_unterminated_block_comment() {
2683 let err = parse_doc(r#"{ "version": 1, /*"#, Path::new("subc.jsonc")).unwrap_err();
2684 assert!(matches!(err, DaemonConfigError::InvalidJsonc { .. }));
2685 }
2686}