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