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