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