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(windows)]
639fn ensure_directory_private(path: &Path) -> Result<(), io::Error> {
640 subc_os::windows_acl::create_private_dir(path)
641}
642
643#[cfg(not(any(unix, windows)))]
644fn ensure_directory_private(path: &Path) -> Result<(), io::Error> {
645 if !path.exists() {
646 fs::create_dir_all(path)?;
647 }
648 Ok(())
649}
650
651pub fn daemon_run_dir() -> Result<PathBuf, DaemonRunDirError> {
661 daemon_run_dir_from(default_data_home())
662}
663
664fn daemon_run_dir_from(data_home: PathBuf) -> Result<PathBuf, DaemonRunDirError> {
667 if !data_home.is_absolute() {
668 return Err(DaemonRunDirError::RelativeDataHome { data_home });
669 }
670 Ok(data_home.join("cortexkit").join("run"))
671}
672
673#[derive(Debug, Clone, PartialEq, Eq)]
675pub enum DaemonRunDirError {
676 RelativeDataHome { data_home: PathBuf },
679}
680
681impl fmt::Display for DaemonRunDirError {
682 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
683 match self {
684 Self::RelativeDataHome { data_home } => write!(
685 f,
686 "cannot resolve the daemon run directory: the data home `{}` is relative, \
687 so it would land under the current working directory; {}",
688 data_home.display(),
689 DATA_HOME_REMEDY
690 ),
691 }
692 }
693}
694
695impl std::error::Error for DaemonRunDirError {}
696
697#[cfg(windows)]
699const DATA_HOME_REMEDY: &str =
700 "set XDG_DATA_HOME to an absolute path, or set APPDATA, USERPROFILE or HOME";
701#[cfg(not(windows))]
702const DATA_HOME_REMEDY: &str = "set XDG_DATA_HOME to an absolute path, or set HOME";
703
704fn read_config_doc(path: &Path) -> Result<Option<String>, DaemonConfigError> {
705 match fs::read_to_string(path) {
706 Ok(doc) => Ok(Some(doc)),
707 Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(None),
708 Err(source) => Err(DaemonConfigError::Read {
709 path: path.to_path_buf(),
710 source,
711 }),
712 }
713}
714
715fn parse_doc(doc: &str, path: &Path) -> Result<DaemonConfig, DaemonConfigError> {
716 let json = jsonc_to_json(doc).map_err(|message| DaemonConfigError::InvalidJsonc {
717 path: path.to_path_buf(),
718 message,
719 })?;
720 let raw: RawDaemonConfig =
721 serde_json::from_str(&json).map_err(|source| DaemonConfigError::InvalidJson {
722 path: path.to_path_buf(),
723 source,
724 })?;
725
726 if raw.version != SUPPORTED_CONFIG_VERSION {
727 return Err(DaemonConfigError::UnsupportedVersion {
728 path: path.to_path_buf(),
729 version: raw.version,
730 });
731 }
732
733 let daemon_logging = raw
734 .log
735 .map(|log| parse_logging_config(log, path, "daemon log"))
736 .transpose()?;
737 let default_drain_timeout_ms = raw.drain_timeout_ms;
738 let default_route_bind_relay_timeout_ms = match raw.route_bind_relay_timeout_ms {
744 Some(0) => {
745 return Err(DaemonConfigError::InvalidValue {
746 path: path.to_path_buf(),
747 message: ROUTE_BIND_RELAY_ZERO_MESSAGE.to_string(),
748 });
749 }
750 Some(value) => Some(value),
751 None => None,
752 };
753 let modules = raw
754 .modules
755 .into_iter()
756 .map(|(module_id, module)| {
757 let protocol = parse_module_protocol(module.protocol.as_deref(), path, &module_id)?;
758 let health = module
759 .health
760 .map(|health| parse_health_config(health, path, &module_id, protocol))
761 .transpose()?
762 .unwrap_or_default();
763 if let Err(reason) = crate::registry::module_id_path_hazard(&module_id) {
764 return Err(DaemonConfigError::InvalidValue {
765 path: path.to_path_buf(),
766 message: format!(
767 "module id '{}' is not usable as a path component ({reason}): \
768 the daemon derives each module's store path from its id",
769 module_id.escape_debug()
770 ),
771 });
772 }
773 let per_module_route_bind_relay_timeout_ms = match module.route_bind_relay_timeout_ms {
778 Some(0) => {
779 return Err(DaemonConfigError::InvalidValue {
780 path: path.to_path_buf(),
781 message: format!(
782 "module '{module_id}' {ROUTE_BIND_RELAY_ZERO_MESSAGE}",
783 module_id = module_id.escape_debug()
784 ),
785 });
786 }
787 Some(value) => Some(value),
788 None => default_route_bind_relay_timeout_ms,
789 };
790 if module.ignored.contains_key("launch_nonce_env") {
795 tracing::warn!(
796 module_id = %module_id.escape_debug(),
797 "launch_nonce_env is deprecated and ignored; nonce delivery is determined by the platform"
798 );
799 }
800 let overlap = parse_module_overlap(module.overlap.as_deref(), path, &module_id)?;
801 if module.env.contains_key(SUBC_SPAWN_ROLE_ENV) {
805 return Err(DaemonConfigError::InvalidValue {
806 path: path.to_path_buf(),
807 message: format!(
808 "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",
809 module_id = module_id.escape_debug()
810 ),
811 });
812 }
813 if protocol == ModuleProtocol::None && module.reserved {
820 return Err(DaemonConfigError::InvalidValue {
821 path: path.to_path_buf(),
822 message: format!(
823 "module '{module_id}' sets reserved: true with protocol: \"none\"; \
824 reserved is enforced on the module's HELLO and a protocol: \"none\" \
825 module never registers, so the reservation could never be checked",
826 module_id = module_id.escape_debug()
827 ),
828 });
829 }
830 let restart = parse_restart_config(module.restart, path, &module_id)?;
831 let log = module
832 .log
833 .map(|log| parse_logging_config(log, path, &format!("module '{module_id}' log")))
834 .transpose()?
835 .or_else(|| daemon_logging.clone());
836 Ok(ConfiguredModule {
837 module_id,
838 program: module.program,
839 args: module.args,
840 env: module.env.into_iter().collect(),
841 log,
842 enabled: module.enabled,
843 reserved: module.reserved,
844 reserved_prefixes: module.reserved_prefixes,
845 protocol,
846 overlap,
847 health,
848 drain_timeout_ms: module.drain_timeout_ms.or(default_drain_timeout_ms),
851 route_bind_relay_timeout_ms: per_module_route_bind_relay_timeout_ms,
856 restart,
857 })
858 })
859 .collect::<Result<Vec<_>, DaemonConfigError>>()?;
860
861 validate_reserved_prefixes(&modules, path)?;
862 validate_reserved_capabilities(&raw.reserved_capabilities, path)?;
863 validate_admission_facts_config(
864 &modules,
865 raw.admission_facts_carrier_module_id.as_deref(),
866 raw.admission_facts_targets.as_deref(),
867 path,
868 )?;
869 let scope_authority_owners = raw
870 .scope_authority_owners
871 .unwrap_or_else(default_scope_authority_owners);
872 if scope_authority_owners.iter().any(|owner| owner.is_empty()) {
873 return Err(DaemonConfigError::InvalidValue {
874 path: path.to_path_buf(),
875 message: "scope_authority_owners must not contain empty module ids".to_string(),
876 });
877 }
878
879 let storage = raw
880 .storage
881 .map(|s| match s {
882 RawStorageConfig::Sqlite { data_home } => {
883 let data_home = data_home.unwrap_or_else(default_data_home);
884 if !data_home.is_absolute() {
893 return Err(DaemonConfigError::InvalidValue {
894 path: path.to_path_buf(),
895 message: format!(
896 "storage data home resolved to the relative path {} \
897 (no absolute XDG_DATA_HOME, APPDATA, USERPROFILE, or HOME \
898 in the daemon's environment); refusing to serve a \
899 cwd-relative storage descriptor to modules. Set \
900 XDG_DATA_HOME or HOME to an absolute path, or set \
901 storage.data_home in this file.",
902 data_home.display()
903 ),
904 });
905 }
906 Ok(StorageConfig::Sqlite { data_home })
907 }
908 })
909 .transpose()?;
910
911 Ok(DaemonConfig {
912 path: path.to_path_buf(),
913 port: raw.port,
914 drain_timeout_ms: default_drain_timeout_ms,
915 route_bind_relay_timeout_ms: default_route_bind_relay_timeout_ms,
916 modules,
917 storage,
918 admission_facts_carrier_module_id: raw.admission_facts_carrier_module_id,
919 admission_facts_targets: raw.admission_facts_targets,
920 scope_authority_owners,
921 reserved_capabilities: raw.reserved_capabilities,
922 })
923}
924
925fn parse_logging_config(
926 raw: RawLoggingConfig,
927 path: &Path,
928 owner: &str,
929) -> Result<LoggingConfig, DaemonConfigError> {
930 fn valid_level(level: &str) -> bool {
931 matches!(level, "off" | "error" | "warn" | "info" | "debug" | "trace")
932 }
933
934 let level = raw.level.unwrap_or_else(|| "info".to_string());
935 if !valid_level(&level) {
936 return Err(DaemonConfigError::InvalidValue {
937 path: path.to_path_buf(),
938 message: format!(
939 "{owner}.level must be one of off, error, warn, info, debug, trace; got {level:?}"
940 ),
941 });
942 }
943 for (tag, tag_level) in &raw.tags {
944 let well_formed = !tag.is_empty()
949 && tag.split('.').all(|segment| {
950 let mut chars = segment.chars();
951 matches!(chars.next(), Some('a'..='z'))
952 && chars.all(|c| matches!(c, 'a'..='z' | '0'..='9' | '-'))
953 });
954 if !well_formed {
955 return Err(DaemonConfigError::InvalidValue {
956 path: path.to_path_buf(),
957 message: format!(
958 "{owner}.tags key {tag:?} is not a logger name (dotted segments of [a-z][a-z0-9-]*)"
959 ),
960 });
961 }
962 if !valid_level(tag_level) {
963 return Err(DaemonConfigError::InvalidValue {
964 path: path.to_path_buf(),
965 message: format!(
966 "{owner}.tags.{tag} must be one of off, error, warn, info, debug, trace; got {tag_level:?}"
967 ),
968 });
969 }
970 }
971
972 let defaults = Retention::default();
973 let retention = Retention {
974 max_file_mb: raw.max_file_mb.unwrap_or(defaults.max_file_mb),
975 keep: raw.keep.unwrap_or(defaults.keep),
976 max_age_days: raw.max_age_days.unwrap_or(defaults.max_age_days),
977 };
978 if retention.max_file_mb == 0 {
979 return Err(DaemonConfigError::InvalidValue {
980 path: path.to_path_buf(),
981 message: format!("{owner}.max_file_mb must be greater than 0"),
982 });
983 }
984
985 let alarm_segment_mb = raw
986 .alarm_segment_mb
987 .unwrap_or(cortexkit_log::SegmentRetention::default().alarm_segment_mb);
988 if alarm_segment_mb == 0 {
989 return Err(DaemonConfigError::InvalidValue {
990 path: path.to_path_buf(),
991 message: format!("{owner}.alarm_segment_mb must be greater than 0"),
992 });
993 }
994
995 Ok(LoggingConfig {
996 level,
997 tags: raw.tags,
998 retention,
999 alarm_segment_mb,
1000 })
1001}
1002
1003fn parse_module_protocol(
1012 raw: Option<&str>,
1013 path: &Path,
1014 module_id: &str,
1015) -> Result<ModuleProtocol, DaemonConfigError> {
1016 match raw {
1017 None | Some("subc") => Ok(ModuleProtocol::Subc),
1018 Some("none") => Ok(ModuleProtocol::None),
1019 Some(other) => Err(DaemonConfigError::InvalidValue {
1023 path: path.to_path_buf(),
1024 message: format!(
1025 "module '{module_id}' declares protocol {other:?}; supported values are \
1026 \"subc\" (the default when the key is absent) and \"none\"",
1027 module_id = module_id.escape_debug(),
1028 ),
1029 }),
1030 }
1031}
1032
1033fn parse_module_overlap(
1037 raw: Option<&str>,
1038 path: &Path,
1039 module_id: &str,
1040) -> Result<ModuleOverlap, DaemonConfigError> {
1041 match raw {
1042 None | Some("exclusive") => Ok(ModuleOverlap::Exclusive),
1043 Some("safe") => Ok(ModuleOverlap::Safe),
1044 Some(other) => Err(DaemonConfigError::InvalidValue {
1045 path: path.to_path_buf(),
1046 message: format!(
1047 "module '{module_id}' declares overlap {other:?}; supported values are \
1048 \"exclusive\" (the default when the key is absent) and \"safe\"",
1049 module_id = module_id.escape_debug(),
1050 ),
1051 }),
1052 }
1053}
1054
1055fn validate_reserved_capabilities(
1056 bindings: &BTreeMap<String, String>,
1057 path: &Path,
1058) -> Result<(), DaemonConfigError> {
1059 for (capability, module_id) in bindings {
1060 if !is_valid_capability_identifier(capability) {
1061 return Err(DaemonConfigError::InvalidValue {
1062 path: path.to_path_buf(),
1063 message: format!(
1064 "reserved_capabilities key {:?} is not a valid capability identifier",
1065 capability
1066 ),
1067 });
1068 }
1069 if module_id.trim().is_empty() {
1070 return Err(DaemonConfigError::InvalidValue {
1071 path: path.to_path_buf(),
1072 message: format!(
1073 "reserved_capabilities binding for {:?} has an empty module id",
1074 capability
1075 ),
1076 });
1077 }
1078 if let Err(reason) = crate::registry::module_id_path_hazard(module_id) {
1079 return Err(DaemonConfigError::InvalidValue {
1080 path: path.to_path_buf(),
1081 message: format!(
1082 "reserved_capabilities binding for {:?} has an unusable module id {:?}: {reason}",
1083 capability, module_id
1084 ),
1085 });
1086 }
1087 }
1088 Ok(())
1089}
1090
1091fn validate_admission_facts_config(
1092 modules: &[ConfiguredModule],
1093 carrier_module_id: Option<&str>,
1094 targets: Option<&[String]>,
1095 path: &Path,
1096) -> Result<(), DaemonConfigError> {
1097 let Some(carrier_module_id) = carrier_module_id else {
1098 return Ok(());
1099 };
1100
1101 let Some(carrier) = modules
1102 .iter()
1103 .find(|module| module.module_id == carrier_module_id)
1104 else {
1105 return Err(DaemonConfigError::InvalidValue {
1106 path: path.to_path_buf(),
1107 message: format!(
1108 "admission_facts_carrier_module_id '{carrier_module_id}' must name a configured module"
1109 ),
1110 });
1111 };
1112 if !carrier.enabled || !carrier.reserved {
1113 return Err(DaemonConfigError::InvalidValue {
1114 path: path.to_path_buf(),
1115 message: format!(
1116 "admission_facts_carrier_module_id '{carrier_module_id}' must name an enabled reserved module"
1117 ),
1118 });
1119 }
1120
1121 let Some(targets) = targets else {
1122 return Err(DaemonConfigError::InvalidValue {
1123 path: path.to_path_buf(),
1124 message: "admission_facts_targets must be present when an admission facts carrier is configured".to_string(),
1125 });
1126 };
1127 if targets.is_empty() || targets.iter().any(String::is_empty) {
1128 return Err(DaemonConfigError::InvalidValue {
1129 path: path.to_path_buf(),
1130 message:
1131 "admission_facts_targets must be non-empty and must not contain empty module ids"
1132 .to_string(),
1133 });
1134 }
1135
1136 Ok(())
1137}
1138
1139fn default_enabled() -> bool {
1140 true
1141}
1142
1143fn validate_reserved_prefixes(
1144 modules: &[ConfiguredModule],
1145 path: &Path,
1146) -> Result<(), DaemonConfigError> {
1147 for module in modules {
1148 if module.reserved_prefixes.is_empty() {
1149 continue;
1150 }
1151 if !module.reserved {
1152 return Err(DaemonConfigError::InvalidValue {
1153 path: path.to_path_buf(),
1154 message: format!(
1155 "module '{}' reserved_prefixes require reserved=true so the owner is spawn-nonce protected",
1156 module.module_id
1157 ),
1158 });
1159 }
1160 for prefix in &module.reserved_prefixes {
1161 if !prefix.ends_with(':') {
1162 return Err(DaemonConfigError::InvalidValue {
1163 path: path.to_path_buf(),
1164 message: format!(
1165 "module '{}' reserved prefix '{}' must end with ':'",
1166 module.module_id, prefix
1167 ),
1168 });
1169 }
1170 }
1171 }
1172
1173 for module in modules {
1174 for prefix in &module.reserved_prefixes {
1175 if let Some(colliding) = modules
1176 .iter()
1177 .find(|candidate| candidate.module_id.starts_with(prefix))
1178 {
1179 return Err(DaemonConfigError::InvalidValue {
1180 path: path.to_path_buf(),
1181 message: format!(
1182 "reserved prefix '{}' owned by '{}' collides with configured module id '{}'",
1183 prefix, module.module_id, colliding.module_id
1184 ),
1185 });
1186 }
1187 }
1188 }
1189
1190 for (left_index, left) in modules.iter().enumerate() {
1191 for right in modules.iter().skip(left_index + 1) {
1192 if left.module_id == right.module_id {
1193 continue;
1194 }
1195 for left_prefix in &left.reserved_prefixes {
1196 for right_prefix in &right.reserved_prefixes {
1197 if left_prefix.starts_with(right_prefix)
1198 || right_prefix.starts_with(left_prefix)
1199 {
1200 return Err(DaemonConfigError::InvalidValue {
1201 path: path.to_path_buf(),
1202 message: format!(
1203 "reserved prefixes '{}' owned by '{}' and '{}' owned by '{}' overlap",
1204 left_prefix, left.module_id, right_prefix, right.module_id
1205 ),
1206 });
1207 }
1208 }
1209 }
1210 }
1211 }
1212
1213 Ok(())
1214}
1215
1216fn parse_health_config(
1217 raw: RawHealthConfig,
1218 path: &Path,
1219 module_id: &str,
1220 protocol: ModuleProtocol,
1221) -> Result<HealthConfig, DaemonConfigError> {
1222 if let Some(url) = raw.http.as_deref() {
1223 let reason = if protocol != ModuleProtocol::None {
1224 Some("is valid only for protocol: \"none\" modules".to_string())
1225 } else {
1226 crate::supervise::parse_http_probe_url(url).err()
1227 };
1228 if let Some(reason) = reason {
1229 return Err(DaemonConfigError::InvalidValue {
1230 path: path.to_path_buf(),
1231 message: format!("module '{module_id}' health.http {reason}"),
1232 });
1233 }
1234 }
1235 let defaults = HealthConfig::default();
1236 let cadence = positive_millis(
1237 raw.cadence_ms,
1238 defaults.cadence,
1239 path,
1240 module_id,
1241 "cadence_ms",
1242 )?;
1243 let deadline = positive_millis(
1244 raw.deadline_ms,
1245 defaults.deadline,
1246 path,
1247 module_id,
1248 "deadline_ms",
1249 )?;
1250 let failure_threshold = match raw.failure_threshold {
1251 Some(0) => {
1252 return Err(DaemonConfigError::InvalidValue {
1253 path: path.to_path_buf(),
1254 message: format!("module '{module_id}' health.failure_threshold must be positive"),
1255 })
1256 }
1257 Some(value) => value,
1258 None => defaults.failure_threshold,
1259 };
1260
1261 Ok(HealthConfig {
1262 http: raw.http,
1263 cadence,
1264 deadline,
1265 failure_threshold,
1266 on_degraded: match raw.on_degraded {
1267 Some(RawHealthAction::Restart) => {
1268 return Err(DaemonConfigError::InvalidValue {
1269 path: path.to_path_buf(),
1270 message: format!(
1271 "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)."
1272 ),
1273 });
1274 }
1275 Some(action) => health_action(action),
1276 None => defaults.on_degraded,
1277 },
1278 on_failing: raw
1279 .on_failing
1280 .map(health_action)
1281 .unwrap_or(defaults.on_failing),
1282 critical: raw.critical,
1283 })
1284}
1285
1286fn parse_restart_config(
1293 raw: Option<RawRestartConfig>,
1294 path: &Path,
1295 module_id: &str,
1296) -> Result<RestartPolicy, DaemonConfigError> {
1297 let defaults = RestartPolicy::default();
1298 let Some(raw) = raw else {
1299 return Ok(defaults);
1300 };
1301
1302 let window = match raw.window_secs {
1303 Some(0) => {
1304 return Err(DaemonConfigError::InvalidValue {
1305 path: path.to_path_buf(),
1306 message: format!(
1307 "module '{module_id}' {RESTART_WINDOW_ZERO_MESSAGE}",
1308 module_id = module_id.escape_debug()
1309 ),
1310 });
1311 }
1312 Some(secs) => Duration::from_secs(secs),
1313 None => defaults.window,
1314 };
1315 let backoff = raw
1316 .backoff_ms
1317 .map(Duration::from_millis)
1318 .unwrap_or(defaults.backoff);
1319 let max_backoff = raw
1320 .max_backoff_ms
1321 .map(Duration::from_millis)
1322 .unwrap_or(defaults.max_backoff);
1323 if max_backoff < backoff {
1324 return Err(DaemonConfigError::InvalidValue {
1325 path: path.to_path_buf(),
1326 message: format!(
1327 "module '{}' restart.max_backoff_ms must be greater than or equal to restart.backoff_ms (max_backoff_ms={max_backoff:?}, backoff_ms={backoff:?})",
1328 module_id.escape_debug()
1329 ),
1330 });
1331 }
1332
1333 Ok(RestartPolicy {
1334 max_restarts: raw.max_restarts.unwrap_or(defaults.max_restarts),
1337 backoff,
1338 max_backoff,
1339 window,
1340 })
1341}
1342
1343fn positive_millis(
1344 value: Option<u64>,
1345 default: std::time::Duration,
1346 path: &Path,
1347 module_id: &str,
1348 field: &str,
1349) -> Result<std::time::Duration, DaemonConfigError> {
1350 match value {
1351 Some(0) => Err(DaemonConfigError::InvalidValue {
1352 path: path.to_path_buf(),
1353 message: format!("module '{module_id}' health.{field} must be positive"),
1354 }),
1355 Some(value) => Ok(std::time::Duration::from_millis(value)),
1356 None => Ok(default),
1357 }
1358}
1359
1360fn health_action(action: RawHealthAction) -> HealthAction {
1361 match action {
1362 RawHealthAction::Report => HealthAction::Report,
1363 RawHealthAction::Restart => HealthAction::Restart,
1364 RawHealthAction::Alert => HealthAction::Alert,
1365 }
1366}
1367
1368pub(crate) fn default_data_home() -> PathBuf {
1371 if let Some(data_home) = non_empty_os_var("XDG_DATA_HOME") {
1372 return PathBuf::from(data_home);
1373 }
1374
1375 #[cfg(windows)]
1376 {
1377 if let Some(app_data) = non_empty_os_var("APPDATA") {
1378 return PathBuf::from(app_data);
1379 }
1380 if let Some(user_profile) = non_empty_os_var("USERPROFILE") {
1381 return PathBuf::from(user_profile).join("AppData").join("Roaming");
1382 }
1383 }
1384
1385 if let Some(home) = non_empty_os_var("HOME") {
1386 return PathBuf::from(home).join(".local").join("share");
1387 }
1388
1389 PathBuf::from(".local").join("share")
1390}
1391
1392fn non_empty_os_var(key: &str) -> Option<OsString> {
1393 let value = env::var_os(key)?;
1394 if value.is_empty() {
1395 None
1396 } else {
1397 Some(value)
1398 }
1399}
1400
1401impl fmt::Display for DaemonConfigError {
1402 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1403 match self {
1404 Self::Read { path, source } => {
1405 write!(f, "failed to read daemon config {}: {source}", path.display())
1406 }
1407 Self::InvalidJsonc { path, message } => {
1408 write!(f, "invalid JSONC in daemon config {}: {message}", path.display())
1409 }
1410 Self::InvalidJson { path, source } => {
1411 write!(f, "invalid daemon config {}: {source}", path.display())
1412 }
1413 Self::UnsupportedVersion { path, version } => write!(
1414 f,
1415 "invalid daemon config {}: version {version} is unsupported (expected {SUPPORTED_CONFIG_VERSION})",
1416 path.display()
1417 ),
1418 Self::InvalidValue { path, message } => {
1419 write!(f, "invalid daemon config {}: {message}", path.display())
1420 }
1421 }
1422 }
1423}
1424
1425impl Error for DaemonConfigError {
1426 fn source(&self) -> Option<&(dyn Error + 'static)> {
1427 match self {
1428 Self::Read { source, .. } => Some(source),
1429 Self::InvalidJson { source, .. } => Some(source),
1430 Self::InvalidJsonc { .. }
1431 | Self::UnsupportedVersion { .. }
1432 | Self::InvalidValue { .. } => None,
1433 }
1434 }
1435}
1436
1437#[cfg(all(test, windows))]
1438mod windows_run_dir_privacy_tests {
1439 use super::ensure_directory_private;
1440 use cortexkit_test_support::ScratchDir;
1441 use subc_os::windows_acl::test_support::{
1442 assert_dacl_owner_only, assert_owner_only, grant_everyone,
1443 };
1444
1445 #[test]
1446 fn windows_run_dir_has_exact_protected_dacl_at_creation() {
1447 let root = ScratchDir::new("windows-run-dir-create");
1448 grant_everyone(root.path());
1449 let path = root.join("run");
1450 ensure_directory_private(&path).unwrap();
1451 assert_owner_only(&path, true, true);
1452 }
1453
1454 #[test]
1455 fn windows_run_dir_narrows_existing_inherited_descendants() {
1456 let root = ScratchDir::new("windows-run-dir-existing");
1457 grant_everyone(root.path());
1458 let path = root.join("run");
1459 std::fs::create_dir(&path).unwrap();
1460 let child = path.join("old.log");
1461 std::fs::write(&child, b"old log").unwrap();
1462 ensure_directory_private(&path).unwrap();
1463 assert_dacl_owner_only(&path, true, true);
1466 assert_dacl_owner_only(&child, false, false);
1467 }
1468}
1469
1470#[cfg(all(test, unix))]
1471mod run_dir_privacy_tests {
1472 use cortexkit_test_support::ScratchDir;
1473 use std::fs;
1474 use std::os::unix::fs::PermissionsExt;
1475
1476 #[test]
1482 fn run_dir_is_created_private_and_an_inherited_wide_one_is_tightened() {
1483 let temp = ScratchDir::new("subc-run-dir-privacy");
1484 let created = temp.path().join("cortexkit").join("run");
1485 super::ensure_directory_private(&created).expect("create run dir");
1486 let mode = fs::metadata(&created)
1487 .expect("stat created")
1488 .permissions()
1489 .mode()
1490 & 0o777;
1491 assert_eq!(
1492 mode, 0o700,
1493 "observable a run directory this code creates must be 0700, got {mode:o}"
1494 );
1495
1496 fs::set_permissions(&created, fs::Permissions::from_mode(0o755)).expect("widen");
1498 let widened = fs::metadata(&created)
1499 .expect("stat widened")
1500 .permissions()
1501 .mode()
1502 & 0o777;
1503 assert_eq!(
1504 widened, 0o755,
1505 "observable the fixture must actually be wide before the tighten"
1506 );
1507
1508 super::ensure_directory_private(&created).expect("tighten run dir");
1509 let mode = fs::metadata(&created)
1510 .expect("stat tightened")
1511 .permissions()
1512 .mode()
1513 & 0o777;
1514 assert_eq!(
1515 mode, 0o700,
1516 "observable an inherited group- or world-readable run directory must be tightened to 0700, got {mode:o}"
1517 );
1518 }
1519}
1520
1521#[cfg(test)]
1522mod tests {
1523 use super::*;
1524
1525 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1534
1535 fn abs(posix: &str) -> PathBuf {
1540 if cfg!(windows) {
1541 PathBuf::from(format!("C:{}", posix.replace('/', "\\")))
1542 } else {
1543 PathBuf::from(posix)
1544 }
1545 }
1546
1547 #[test]
1548 fn default_data_home_matches_golden_fixture() {
1549 let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1550 let doc: serde_json::Value =
1551 serde_json::from_str(include_str!("../tests/golden/data_home_resolution.json"))
1552 .expect("golden parses");
1553 let vars = ["XDG_DATA_HOME", "APPDATA", "USERPROFILE", "HOME"];
1554 let saved: Vec<(&str, Option<std::ffi::OsString>)> =
1555 vars.iter().map(|v| (*v, env::var_os(v))).collect();
1556 let platform_matches =
1557 |p: &str| p == "any" || p == if cfg!(windows) { "windows" } else { "unix" };
1558
1559 let mut ran = 0usize;
1560 for case in doc["cases"].as_array().expect("cases array") {
1561 let name = case["name"].as_str().expect("name");
1562 if !platform_matches(case["platform"].as_str().expect("platform")) {
1563 continue;
1564 }
1565 for v in vars {
1566 env::remove_var(v);
1567 }
1568 for (k, v) in case["env"].as_object().expect("env map") {
1569 env::set_var(k, v.as_str().expect("env value"));
1570 }
1571 let got = default_data_home();
1572 assert_eq!(
1573 got.to_string_lossy(),
1574 case["expect"].as_str().expect("expect"),
1575 "golden case '{name}' diverged"
1576 );
1577 ran += 1;
1578 }
1579 assert!(
1581 ran >= 6,
1582 "only {ran} golden cases ran; fixture or filter broken"
1583 );
1584
1585 for (k, v) in saved {
1586 match v {
1587 Some(val) => env::set_var(k, val),
1588 None => env::remove_var(k),
1589 }
1590 }
1591 }
1592
1593 #[test]
1598 fn daemon_run_dir_refuses_a_relative_data_home_and_names_the_variables() {
1599 for data_home in [PathBuf::from(".local/share"), PathBuf::from("relative-xdg")] {
1600 let error = daemon_run_dir_from(data_home.clone())
1601 .expect_err("a relative data home must be refused");
1602 assert_eq!(
1603 error,
1604 DaemonRunDirError::RelativeDataHome {
1605 data_home: data_home.clone()
1606 }
1607 );
1608 let message = error.to_string();
1609 assert!(
1610 message.contains("XDG_DATA_HOME") && message.contains("HOME"),
1611 "the refusal must name the variables to set: {message}"
1612 );
1613 }
1614 }
1615
1616 #[test]
1617 fn daemon_run_dir_under_an_absolute_data_home_is_cortexkit_run() {
1618 let data_home = env::temp_dir().join("subc-run-dir-probe").join("data");
1619 assert!(data_home.is_absolute());
1620 assert_eq!(
1621 daemon_run_dir_from(data_home.clone()),
1622 Ok(data_home.join("cortexkit").join("run"))
1623 );
1624 }
1625
1626 #[test]
1632 fn default_config_home_matches_golden_fixture() {
1633 let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1634 let doc: serde_json::Value =
1635 serde_json::from_str(include_str!("../tests/golden/config_home_resolution.json"))
1636 .expect("golden parses");
1637 let vars = ["XDG_CONFIG_HOME", "APPDATA", "USERPROFILE", "HOME"];
1638 let saved: Vec<(&str, Option<std::ffi::OsString>)> =
1639 vars.iter().map(|v| (*v, env::var_os(v))).collect();
1640 let platform_matches =
1641 |p: &str| p == "any" || p == if cfg!(windows) { "windows" } else { "unix" };
1642
1643 let mut ran = 0usize;
1644 for case in doc["cases"].as_array().expect("cases array") {
1645 let name = case["name"].as_str().expect("name");
1646 if !platform_matches(case["platform"].as_str().expect("platform")) {
1647 continue;
1648 }
1649 for v in vars {
1650 env::remove_var(v);
1651 }
1652 for (k, v) in case["env"].as_object().expect("env map") {
1653 env::set_var(k, v.as_str().expect("env value"));
1654 }
1655 let got = default_config_home();
1656 assert_eq!(
1657 got.to_string_lossy(),
1658 case["expect"].as_str().expect("expect"),
1659 "golden case '{name}' diverged"
1660 );
1661 ran += 1;
1662 }
1663 assert!(
1664 ran >= 6,
1665 "only {ran} golden cases ran; fixture or filter broken"
1666 );
1667
1668 for (k, v) in saved {
1669 match v {
1670 Some(val) => env::set_var(k, val),
1671 None => env::remove_var(k),
1672 }
1673 }
1674 }
1675
1676 #[test]
1683 fn relative_storage_data_home_is_refused_at_parse() {
1684 let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1685 let path = Path::new("/golden/subc.jsonc");
1686
1687 let doc =
1689 r#"{ "version": 1, "storage": { "backend": "sqlite", "data_home": "relative/home" } }"#;
1690 let err = parse_doc(doc, path).expect_err("relative data_home must refuse");
1691 assert!(
1692 matches!(&err, DaemonConfigError::InvalidValue { message, .. }
1693 if message.contains("relative path relative/home")),
1694 "wrong refusal: {err:?}"
1695 );
1696
1697 let vars = ["XDG_DATA_HOME", "APPDATA", "USERPROFILE", "HOME"];
1699 let saved: Vec<(&str, Option<std::ffi::OsString>)> =
1700 vars.iter().map(|v| (*v, env::var_os(v))).collect();
1701 for v in vars {
1702 env::remove_var(v);
1703 }
1704 let doc = r#"{ "version": 1, "storage": { "backend": "sqlite" } }"#;
1705 let err = parse_doc(doc, path).expect_err("no home in env must refuse");
1706 assert!(
1707 matches!(&err, DaemonConfigError::InvalidValue { message, .. }
1708 if message.contains("no absolute XDG_DATA_HOME")),
1709 "wrong refusal: {err:?}"
1710 );
1711
1712 let want = abs("/abs/home");
1716 let doc = format!(
1717 r#"{{ "version": 1, "storage": {{ "backend": "sqlite", "data_home": {} }} }}"#,
1718 serde_json::to_string(&want).expect("json path")
1719 );
1720 let cfg = parse_doc(&doc, path).expect("absolute data_home parses");
1721 assert!(matches!(
1722 cfg.storage,
1723 Some(StorageConfig::Sqlite { ref data_home }) if *data_home == want
1724 ));
1725
1726 for (k, v) in saved {
1727 match v {
1728 Some(val) => env::set_var(k, val),
1729 None => env::remove_var(k),
1730 }
1731 }
1732 }
1733
1734 #[test]
1735 fn restart_required_sections_are_the_rescan_cannot_apply_set() {
1736 assert_eq!(
1737 RestartRequiredSection::ALL.map(RestartRequiredSection::label),
1738 [
1739 "port",
1740 "storage",
1741 "admission_facts_carrier_module_id",
1742 "admission_facts_targets",
1743 "scope_authority_owners",
1744 ]
1745 );
1746 }
1747
1748 #[test]
1749 fn scope_authority_owners_defaults_to_the_session_runtime() {
1750 let config = parse_doc(r#"{ "version": 1 }"#, Path::new("/tmp/subc.jsonc")).unwrap();
1751 assert_eq!(config.scope_authority_owners, vec!["prefrontal-core"]);
1752 }
1753
1754 #[test]
1755 fn scope_authority_owners_is_read_when_set_and_refuses_an_empty_id() {
1756 let config = parse_doc(
1757 r#"{ "version": 1, "scope_authority_owners": ["a", "b"] }"#,
1758 Path::new("/tmp/subc.jsonc"),
1759 )
1760 .unwrap();
1761 assert_eq!(config.scope_authority_owners, vec!["a", "b"]);
1762 let config = parse_doc(
1765 r#"{ "version": 1, "scope_authority_owners": [] }"#,
1766 Path::new("/tmp/subc.jsonc"),
1767 )
1768 .unwrap();
1769 assert!(config.scope_authority_owners.is_empty());
1770 let error = parse_doc(
1771 r#"{ "version": 1, "scope_authority_owners": [""] }"#,
1772 Path::new("/tmp/subc.jsonc"),
1773 )
1774 .expect_err("an empty module id is refused");
1775 assert!(
1776 error.to_string().contains("scope_authority_owners"),
1777 "{error}"
1778 );
1779 }
1780
1781 #[test]
1782 fn no_storage_section_yields_none() {
1783 let config = parse_doc(
1784 r#"{ "version": 1, "modules": {} }"#,
1785 Path::new("/tmp/subc.jsonc"),
1786 )
1787 .expect("parse");
1788 assert_eq!(config.storage, None);
1789 }
1790
1791 #[test]
1792 fn sqlite_storage_parses_with_explicit_data_home() {
1793 let config = parse_doc(
1794 &format!(
1795 r#"{{ "version": 1, "storage": {{ "backend": "sqlite", "data_home": {} }} }}"#,
1796 serde_json::to_string(&abs("/data")).expect("json path")
1797 ),
1798 Path::new("/tmp/subc.jsonc"),
1799 )
1800 .expect("parse");
1801 assert_eq!(
1802 config.storage,
1803 Some(StorageConfig::Sqlite {
1804 data_home: abs("/data")
1805 })
1806 );
1807 }
1808
1809 #[test]
1810 fn sqlite_storage_defaults_data_home_when_omitted() {
1811 let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1817 std::env::set_var("XDG_DATA_HOME", abs("/forced/data/home"));
1818 let config = parse_doc(
1819 r#"{ "version": 1, "storage": { "backend": "sqlite" } }"#,
1820 Path::new("/tmp/subc.jsonc"),
1821 )
1822 .expect("parse");
1823 std::env::remove_var("XDG_DATA_HOME");
1824 assert_eq!(
1825 config.storage,
1826 Some(StorageConfig::Sqlite {
1827 data_home: abs("/forced/data/home")
1828 })
1829 );
1830 }
1831
1832 #[test]
1833 fn descriptor_for_matches_store_types_shape() {
1834 let cfg = StorageConfig::Sqlite {
1838 data_home: PathBuf::from("/data"),
1839 };
1840 let descriptor = cfg.descriptor_for("alfonso-routing");
1841 assert_eq!(
1842 descriptor,
1843 serde_json::json!({
1844 "module_id": "alfonso-routing",
1845 "storage_namespace": "default",
1846 "isolation": { "kind": "module" },
1847 "backend": {
1848 "backend": "sqlite",
1849 "path": "/data/cortexkit/alfonso-routing/store.db"
1850 }
1851 })
1852 );
1853 }
1854
1855 #[test]
1856 fn path_hazard_module_id_refuses_config_parse() {
1857 let path = Path::new("/tmp/subc.jsonc");
1858 let err = parse_doc(
1859 r#"{ "version": 1, "modules": { "../escape": { "program": "x" } } }"#,
1860 path,
1861 )
1862 .expect_err("separator-bearing module id must refuse");
1863 let text = format!("{err}");
1864 assert!(
1865 text.contains("not usable as a path component"),
1866 "refusal must name the hazard: {text}"
1867 );
1868 }
1869
1870 #[test]
1871 fn drain_timeout_resolves_module_over_daemon_over_absent() {
1872 let path = Path::new("/tmp/subc.jsonc");
1873 let config = parse_doc(
1874 r#"
1875 {
1876 "version": 1,
1877 "drain_timeout_ms": 45000,
1878 "modules": {
1879 "fast": { "program": "fast", "drain_timeout_ms": 0 },
1880 "slow": { "program": "slow", "drain_timeout_ms": 120000 },
1881 "inherits": { "program": "inherits" }
1882 }
1883 }
1884 "#,
1885 path,
1886 )
1887 .unwrap();
1888 let by_id = |id: &str| {
1889 config
1890 .modules
1891 .iter()
1892 .find(|m| m.module_id == id)
1893 .unwrap()
1894 .drain_timeout_ms
1895 };
1896 assert_eq!(by_id("fast"), Some(0));
1899 assert_eq!(by_id("slow"), Some(120_000));
1900 assert_eq!(by_id("inherits"), Some(45_000));
1902 assert_eq!(config.drain_timeout_ms, Some(45_000));
1903 }
1904
1905 #[test]
1906 fn drain_timeout_absent_everywhere_stays_none_for_builtin_default() {
1907 let path = Path::new("/tmp/subc.jsonc");
1908 let config = parse_doc(
1909 r#"{ "version": 1, "modules": { "m": { "program": "m" } } }"#,
1910 path,
1911 )
1912 .unwrap();
1913 assert_eq!(config.modules[0].drain_timeout_ms, None);
1917 assert_eq!(config.drain_timeout_ms, None);
1918 }
1919
1920 #[test]
1921 fn route_bind_relay_timeout_resolves_module_over_daemon_over_absent() {
1922 let path = Path::new("/tmp/subc.jsonc");
1928 let config = parse_doc(
1929 r#"
1930 {
1931 "version": 1,
1932 "route_bind_relay_timeout_ms": 30000,
1933 "modules": {
1934 "tight": { "program": "tight", "route_bind_relay_timeout_ms": 5000 },
1935 "loose": { "program": "loose", "route_bind_relay_timeout_ms": 60000 },
1936 "inherits": { "program": "inherits" }
1937 }
1938 }
1939 "#,
1940 path,
1941 )
1942 .unwrap();
1943 let by_id = |id: &str| {
1944 config
1945 .modules
1946 .iter()
1947 .find(|m| m.module_id == id)
1948 .unwrap()
1949 .route_bind_relay_timeout_ms
1950 };
1951 assert_eq!(by_id("tight"), Some(5_000));
1953 assert_eq!(by_id("loose"), Some(60_000));
1954 assert_eq!(by_id("inherits"), Some(30_000));
1956 assert_eq!(config.route_bind_relay_timeout_ms, Some(30_000));
1957 }
1958
1959 #[test]
1960 fn log_tag_keys_must_be_logger_names_and_the_error_names_the_key() {
1961 let path = Path::new("/tmp/subc.jsonc");
1962 for bad in ["Perf", "a b", "perf.", ".perf", "gc..walk", "a=b"] {
1963 let doc = format!(
1964 r#"{{ "version": 1, "modules": {{ "m": {{ "program": "m", "log": {{ "tags": {{ "{bad}": "debug" }} }} }} }} }}"#
1965 );
1966 let err = parse_doc(&doc, path).expect_err(bad);
1967 let text = format!("{err}");
1968 assert!(
1969 text.contains(&format!("{bad:?}")),
1970 "must name the key: {text}"
1971 );
1972 assert!(
1973 text.contains("logger name"),
1974 "must say what a key is: {text}"
1975 );
1976 }
1977 let ok = parse_doc(
1979 r#"{ "version": 1, "modules": { "m": { "program": "m", "log": { "tags": { "perf": "debug", "gc.walk": "trace", "m": "error", "a-b": "info" } } } } }"#,
1980 path,
1981 );
1982 assert!(ok.is_ok(), "{ok:?}");
1983 }
1984
1985 #[test]
1986 fn log_filter_spec_prefixes_bare_keys_with_the_module_and_passes_absolute_ones() {
1987 let path = Path::new("/tmp/subc.jsonc");
1988 let config = parse_doc(
1989 r#"{ "version": 1, "modules": { "synapse": { "program": "s", "log": { "level": "warn", "tags": { "perf": "debug", "gc.walk": "trace", "synapse": "error", "other.x": "info" } } } } }"#,
1990 path,
1991 )
1992 .unwrap();
1993 let log = config.modules[0].log.as_ref().unwrap();
1994 assert_eq!(
1996 log.filter_spec("synapse"),
1997 "warn,gc.walk=trace,other.x=info,synapse.perf=debug,synapse=error"
1998 );
1999 }
2000
2001 #[test]
2002 fn log_alarm_segment_mb_defaults_to_the_crate_default_and_refuses_zero() {
2003 let path = Path::new("/tmp/subc.jsonc");
2004 let config = parse_doc(
2005 r#"{ "version": 1, "modules": { "m": { "program": "m", "log": { "level": "info" } } } }"#,
2006 path,
2007 )
2008 .unwrap();
2009 assert_eq!(
2010 config.modules[0].log.as_ref().unwrap().alarm_segment_mb,
2011 cortexkit_log::SegmentRetention::default().alarm_segment_mb
2012 );
2013 let err = parse_doc(
2014 r#"{ "version": 1, "modules": { "m": { "program": "m", "log": { "alarm_segment_mb": 0 } } } }"#,
2015 path,
2016 )
2017 .expect_err("zero alarm must refuse");
2018 assert!(format!("{err}").contains("alarm_segment_mb"));
2019 }
2020
2021 #[test]
2022 fn route_bind_relay_timeout_zero_at_daemon_layer_is_refused() {
2023 let path = Path::new("/tmp/subc.jsonc");
2024 let err = parse_doc(
2025 r#"
2026 {
2027 "version": 1,
2028 "route_bind_relay_timeout_ms": 0,
2029 "modules": { "m": { "program": "m" } }
2030 }
2031 "#,
2032 path,
2033 )
2034 .expect_err("a daemon-wide zero budget must refuse parse");
2035 let text = format!("{err}");
2036 assert!(
2037 text.contains("route_bind_relay_timeout_ms"),
2038 "error must name the offending key: {text}"
2039 );
2040 assert!(
2041 text.contains("enabled: false"),
2042 "error must name the remedy (enable false): {text}"
2043 );
2044 }
2045
2046 #[test]
2047 fn route_bind_relay_timeout_zero_at_module_layer_is_refused() {
2048 let path = Path::new("/tmp/subc.jsonc");
2049 let err = parse_doc(
2050 r#"
2051 {
2052 "version": 1,
2053 "modules": {
2054 "good": { "program": "good" },
2055 "broken": { "program": "broken", "route_bind_relay_timeout_ms": 0 }
2056 }
2057 }
2058 "#,
2059 path,
2060 )
2061 .expect_err("a per-module zero budget must refuse parse");
2062 let text = format!("{err}");
2063 assert!(
2064 text.contains("route_bind_relay_timeout_ms"),
2065 "error must name the offending key: {text}"
2066 );
2067 assert!(
2068 text.contains("broken"),
2069 "error must name the offending module id: {text}"
2070 );
2071 assert!(
2072 text.contains("enabled: false"),
2073 "error must name the remedy (enable false): {text}"
2074 );
2075 }
2076
2077 #[test]
2078 fn drain_timeout_zero_still_parses_for_wedge_bounces() {
2079 let path = Path::new("/tmp/subc.jsonc");
2085 let config = parse_doc(
2086 r#"
2087 {
2088 "version": 1,
2089 "drain_timeout_ms": 0,
2090 "modules": {
2091 "wedge": { "program": "wedge", "drain_timeout_ms": 0 }
2092 }
2093 }
2094 "#,
2095 path,
2096 )
2097 .expect("drain_timeout_ms: 0 must still parse; wedge-bounce uses it");
2098 let wedge = config
2099 .modules
2100 .iter()
2101 .find(|m| m.module_id == "wedge")
2102 .unwrap();
2103 assert_eq!(wedge.drain_timeout_ms, Some(0));
2104 assert_eq!(config.drain_timeout_ms, Some(0));
2105 }
2106
2107 #[test]
2108 fn route_bind_relay_timeout_absent_everywhere_stays_none_for_builtin_default() {
2109 let path = Path::new("/tmp/subc.jsonc");
2114 let config = parse_doc(
2115 r#"{ "version": 1, "modules": { "m": { "program": "m" } } }"#,
2116 path,
2117 )
2118 .unwrap();
2119 assert_eq!(config.modules[0].route_bind_relay_timeout_ms, None);
2120 assert_eq!(config.route_bind_relay_timeout_ms, None);
2121 }
2122
2123 #[test]
2129 fn a_config_without_a_restart_block_keeps_the_supervisor_defaults() {
2130 let path = Path::new("/tmp/subc.jsonc");
2131 let config = parse_doc(
2132 r#"{ "version": 1, "modules": { "m": { "program": "m" } } }"#,
2133 path,
2134 )
2135 .unwrap();
2136 assert_eq!(config.modules[0].restart.max_restarts, 3);
2137 assert_eq!(config.modules[0].restart.window, Duration::from_secs(600));
2138 assert_eq!(
2139 config.modules[0].restart.backoff,
2140 Duration::from_millis(100)
2141 );
2142 assert_eq!(
2143 config.modules[0].restart.max_backoff,
2144 Duration::from_secs(30)
2145 );
2146 }
2147
2148 #[test]
2149 fn a_restart_block_resolves_each_key_independently() {
2150 let path = Path::new("/tmp/subc.jsonc");
2151 let config = parse_doc(
2152 r#"
2153 {
2154 "version": 1,
2155 "modules": {
2156 "all": {
2157 "program": "all",
2158 "restart": { "max_restarts": 5, "window_secs": 60, "backoff_ms": 250, "max_backoff_ms": 5000 }
2159 },
2160 "window-only": {
2161 "program": "window-only",
2162 "restart": { "window_secs": 7200 }
2163 },
2164 "never": {
2165 "program": "never",
2166 "restart": { "max_restarts": 0 }
2167 }
2168 }
2169 }
2170 "#,
2171 path,
2172 )
2173 .unwrap();
2174 let by_id = |id: &str| {
2175 config
2176 .modules
2177 .iter()
2178 .find(|m| m.module_id == id)
2179 .unwrap()
2180 .restart
2181 };
2182
2183 let all = by_id("all");
2184 assert_eq!(all.max_restarts, 5);
2185 assert_eq!(all.window, Duration::from_secs(60));
2186 assert_eq!(all.backoff, Duration::from_millis(250));
2187 assert_eq!(all.max_backoff, Duration::from_secs(5));
2188
2189 let window_only = by_id("window-only");
2192 assert_eq!(window_only.max_restarts, 3);
2193 assert_eq!(window_only.window, Duration::from_secs(7_200));
2194 assert_eq!(window_only.backoff, Duration::from_millis(100));
2195 assert_eq!(window_only.max_backoff, Duration::from_secs(30));
2196
2197 assert_eq!(by_id("never").max_restarts, 0);
2200 }
2201
2202 #[test]
2206 fn restart_window_zero_is_refused_by_name() {
2207 let path = Path::new("/tmp/subc.jsonc");
2208 let err = parse_doc(
2209 r#"
2210 {
2211 "version": 1,
2212 "modules": {
2213 "good": { "program": "good" },
2214 "broken": { "program": "broken", "restart": { "window_secs": 0 } }
2215 }
2216 }
2217 "#,
2218 path,
2219 )
2220 .expect_err("a zero crash window must refuse parse");
2221 assert!(
2222 matches!(err, DaemonConfigError::InvalidValue { .. }),
2223 "a zero window is an invalid value, not a parse failure: {err:?}"
2224 );
2225 let text = format!("{err}");
2226 assert!(
2227 text.contains("restart.window_secs"),
2228 "error must name the offending key: {text}"
2229 );
2230 assert!(
2231 text.contains("broken"),
2232 "error must name the offending module id: {text}"
2233 );
2234 assert!(
2235 text.contains("max_restarts: 0"),
2236 "error must name the setting that actually stops restarts: {text}"
2237 );
2238 }
2239
2240 #[test]
2241 fn restart_max_backoff_below_backoff_is_refused_by_name() {
2242 let path = Path::new("/tmp/subc.jsonc");
2243 let err = parse_doc(
2244 r#"
2245 {
2246 "version": 1,
2247 "modules": {
2248 "broken": {
2249 "program": "broken",
2250 "restart": { "backoff_ms": 1000, "max_backoff_ms": 999 }
2251 }
2252 }
2253 }
2254 "#,
2255 path,
2256 )
2257 .expect_err("a maximum below the base backoff must refuse parse");
2258 assert!(
2259 matches!(err, DaemonConfigError::InvalidValue { .. }),
2260 "an invalid restart bound must be an InvalidValue: {err:?}"
2261 );
2262 let text = format!("{err}");
2263 assert!(
2264 text.contains("restart.max_backoff_ms"),
2265 "error must name max_backoff_ms: {text}"
2266 );
2267 assert!(
2268 text.contains("restart.backoff_ms"),
2269 "error must name backoff_ms: {text}"
2270 );
2271 assert!(
2272 text.contains("broken"),
2273 "error must name the offending module id: {text}"
2274 );
2275 }
2276
2277 #[test]
2278 fn parse_jsonc_defaults_and_ignores_unknown_fields() {
2279 let path = Path::new("/tmp/subc.jsonc");
2280 let config = parse_doc(
2281 r#"
2282 {
2283 // forward-compatible root field
2284 "version": 1,
2285 "unknown": { "ignored": true },
2286 "modules": {
2287 "aft": {
2288 "program": "aft",
2289 "args": ["module",],
2290 "env": { "A": "B", },
2291 "future": 42,
2292 },
2293 "disabled": { "program": "disabled", "enabled": false }
2294 },
2295 }
2296 "#,
2297 path,
2298 )
2299 .unwrap();
2300
2301 assert_eq!(config.port, None);
2302 assert_eq!(config.modules.len(), 2);
2303 assert_eq!(config.modules[0].module_id, "aft");
2304 assert_eq!(config.modules[0].program, PathBuf::from("aft"));
2305 assert_eq!(config.modules[0].args, ["module"]);
2306 assert_eq!(config.modules[0].env, [("A".to_string(), "B".to_string())]);
2307 assert!(config.modules[0].enabled);
2308 assert!(config.modules[0].reserved_prefixes.is_empty());
2309 assert_eq!(config.modules[0].health, HealthConfig::default());
2310 assert!(!config.modules[1].enabled);
2311 }
2312
2313 #[test]
2314 fn reserved_capabilities_accept_unknown_bound_modules_and_refuse_bad_identifiers() {
2315 let path = Path::new("/tmp/subc.jsonc");
2316 let config = parse_doc(
2317 r#"{
2318 "version": 1,
2319 "reserved_capabilities": {
2320 "credentials-provider/v1": "future-vault"
2321 },
2322 "modules": {}
2323 }"#,
2324 path,
2325 )
2326 .expect("a binding may predate its provider installation");
2327 assert_eq!(
2328 config.reserved_capabilities,
2329 BTreeMap::from([(
2330 "credentials-provider/v1".to_string(),
2331 "future-vault".to_string()
2332 )])
2333 );
2334
2335 let error = parse_doc(
2336 r#"{
2337 "version": 1,
2338 "reserved_capabilities": { "Credentials/v1": "vault" },
2339 "modules": {}
2340 }"#,
2341 path,
2342 )
2343 .expect_err("reserved capabilities use the capability identifier grammar");
2344 assert!(error.to_string().contains("reserved_capabilities key"));
2345 }
2346
2347 #[test]
2348 fn retired_launch_nonce_env_is_ignored_for_any_value() {
2349 let parse = |field: &str| {
2350 parse_doc(
2351 &format!(r#"{{"version":1,"modules":{{"probe":{{"program":"probe"{field}}}}}}}"#),
2352 Path::new("subc.jsonc"),
2353 )
2354 .unwrap()
2355 .modules
2356 .remove(0)
2357 .module_spec()
2358 };
2359 for value in ["true", "false", "null", "0", "\"false\"", "[]", "{}"] {
2360 assert_eq!(
2361 parse(""),
2362 parse(&format!(",\"launch_nonce_env\":{value}")),
2363 "the retired key must not change the launch spec or require a restart"
2364 );
2365 }
2366 }
2367
2368 #[test]
2371 fn an_absent_protocol_key_and_an_explicit_subc_are_the_same_module() {
2372 let parse = |module_body: &str| {
2373 parse_doc(
2374 &format!(
2375 r#"{{
2376 "version": 1,
2377 "modules": {{ "aft": {{ "program": "aft"{module_body} }} }}
2378 }}"#
2379 ),
2380 Path::new("subc.jsonc"),
2381 )
2382 .expect("module parses")
2383 .modules
2384 .remove(0)
2385 };
2386
2387 let absent = parse("");
2388 let explicit = parse(r#", "protocol": "subc""#);
2389 let none = parse(r#", "protocol": "none""#);
2390
2391 assert_eq!(absent.protocol, ModuleProtocol::Subc);
2392 assert_eq!(explicit.protocol, ModuleProtocol::Subc);
2393 assert_eq!(
2394 absent, explicit,
2395 "an absent protocol key must produce exactly the module an explicit subc does"
2396 );
2397 assert_eq!(none.protocol, ModuleProtocol::None);
2398 assert_eq!(none.module_spec().protocol, ModuleProtocol::None);
2402 }
2403
2404 #[test]
2408 fn overlap_defaults_to_exclusive_and_only_safe_opts_in() {
2409 let parse = |module_body: &str| {
2410 parse_doc(
2411 &format!(
2412 r#"{{
2413 "version": 1,
2414 "modules": {{ "aft": {{ "program": "aft"{module_body} }} }}
2415 }}"#
2416 ),
2417 Path::new("subc.jsonc"),
2418 )
2419 };
2420
2421 let absent = parse("").unwrap().modules.remove(0);
2422 assert_eq!(absent.overlap, ModuleOverlap::Exclusive);
2423 assert_eq!(absent.module_spec().overlap, ModuleOverlap::Exclusive);
2424 let safe = parse(r#", "overlap": "safe""#).unwrap().modules.remove(0);
2425 assert_eq!(safe.module_spec().overlap, ModuleOverlap::Safe);
2426 let typo = parse(r#", "overlap": "sfae""#).expect_err("an unknown overlap is refused");
2427 assert!(typo.to_string().contains("sfae"), "{typo}");
2428 }
2429
2430 #[test]
2434 fn the_spawn_role_is_refused_as_a_configured_env_key() {
2435 let error = parse_doc(
2436 r#"{
2437 "version": 1,
2438 "modules": { "aft": { "program": "aft", "env": { "SUBC_SPAWN_ROLE": "swap_candidate" } } }
2439 }"#,
2440 Path::new("subc.jsonc"),
2441 )
2442 .expect_err("SUBC_SPAWN_ROLE must not be configurable");
2443 assert!(
2444 matches!(error, DaemonConfigError::InvalidValue { .. }),
2445 "expected InvalidValue, got {error:?}"
2446 );
2447 assert!(error.to_string().contains("SUBC_SPAWN_ROLE"), "{error}");
2448 }
2449
2450 #[test]
2456 fn an_unsupported_protocol_value_is_refused_by_name() {
2457 let error = parse_doc(
2458 r#"{
2459 "version": 1,
2460 "modules": { "nats": { "program": "nats-server", "protocol": "grpc" } }
2461 }"#,
2462 Path::new("subc.jsonc"),
2463 )
2464 .expect_err("an unknown protocol must not fall back to a default");
2465
2466 assert!(
2467 matches!(error, DaemonConfigError::InvalidValue { .. }),
2468 "expected InvalidValue, got {error:?}"
2469 );
2470 let message = error.to_string();
2471 assert!(
2472 message.contains("grpc"),
2473 "the refusal must name the offending value: {message}"
2474 );
2475 assert!(
2476 message.contains("nats"),
2477 "the refusal must name the module so it can be found in the file: {message}"
2478 );
2479 }
2480
2481 #[test]
2485 fn reserved_true_with_protocol_none_is_refused_with_the_reason() {
2486 let error = parse_doc(
2487 r#"{
2488 "version": 1,
2489 "modules": {
2490 "nats": { "program": "nats-server", "protocol": "none", "reserved": true }
2491 }
2492 }"#,
2493 Path::new("subc.jsonc"),
2494 )
2495 .expect_err("a reservation that can never be checked must not parse");
2496
2497 assert!(
2498 matches!(error, DaemonConfigError::InvalidValue { .. }),
2499 "expected InvalidValue, got {error:?}"
2500 );
2501 let message = error.to_string();
2502 assert!(
2503 message.contains("nats") && message.contains("reserved"),
2504 "the refusal must name the module and the offending key: {message}"
2505 );
2506 assert!(
2507 message.contains("HELLO") || message.contains("never registers"),
2508 "the refusal must say WHY the pair cannot work: {message}"
2509 );
2510 }
2511
2512 #[test]
2513 fn reserved_prefixes_parse_for_reserved_modules() {
2514 let config = parse_doc(
2515 r#"
2516 {
2517 "version": 1,
2518 "modules": {
2519 "federation": {
2520 "program": "fed",
2521 "reserved": true,
2522 "reserved_prefixes": ["fed:"]
2523 }
2524 }
2525 }
2526 "#,
2527 Path::new("subc.jsonc"),
2528 )
2529 .unwrap();
2530
2531 assert_eq!(config.modules[0].reserved_prefixes, ["fed:".to_string()]);
2532 }
2533
2534 #[test]
2535 fn reserved_prefixes_reject_bad_boundaries_and_owners() {
2536 let missing_delimiter = parse_doc(
2537 r#"{
2538 "version": 1,
2539 "modules": {
2540 "federation": { "program": "fed", "reserved": true, "reserved_prefixes": ["fed"] }
2541 }
2542 }"#,
2543 Path::new("subc.jsonc"),
2544 )
2545 .unwrap_err();
2546 assert!(matches!(
2547 missing_delimiter,
2548 DaemonConfigError::InvalidValue { .. }
2549 ));
2550
2551 let non_reserved_owner = parse_doc(
2552 r#"{
2553 "version": 1,
2554 "modules": {
2555 "federation": { "program": "fed", "reserved_prefixes": ["fed:"] }
2556 }
2557 }"#,
2558 Path::new("subc.jsonc"),
2559 )
2560 .unwrap_err();
2561 assert!(matches!(
2562 non_reserved_owner,
2563 DaemonConfigError::InvalidValue { .. }
2564 ));
2565 }
2566
2567 #[test]
2568 fn reserved_prefixes_reject_cross_owner_overlap_and_exact_id_collisions() {
2569 let overlap = parse_doc(
2570 r#"{
2571 "version": 1,
2572 "modules": {
2573 "fed-owner": { "program": "fed", "reserved": true, "reserved_prefixes": ["fed:"] },
2574 "sub-owner": { "program": "fed-sub", "reserved": true, "reserved_prefixes": ["fed:sub:"] }
2575 }
2576 }"#,
2577 Path::new("subc.jsonc"),
2578 )
2579 .unwrap_err();
2580 assert!(matches!(overlap, DaemonConfigError::InvalidValue { .. }));
2581
2582 let exact_collision = parse_doc(
2583 r#"{
2584 "version": 1,
2585 "modules": {
2586 "federation": { "program": "fed", "reserved": true, "reserved_prefixes": ["fed:"] },
2587 "fed:special": { "program": "special" }
2588 }
2589 }"#,
2590 Path::new("subc.jsonc"),
2591 )
2592 .unwrap_err();
2593 assert!(matches!(
2594 exact_collision,
2595 DaemonConfigError::InvalidValue { .. }
2596 ));
2597 }
2598
2599 #[test]
2600 fn health_config_parses_and_ignores_unknown_fields() {
2601 let config = parse_doc(
2602 r#"
2603 {
2604 "version": 1,
2605 "modules": {
2606 "aft": {
2607 "program": "aft",
2608 "health": {
2609 "cadence_ms": 100,
2610 "deadline_ms": 20,
2611 "failure_threshold": 2,
2612 "on_degraded": "report",
2613 "on_failing": "restart",
2614 "critical": true,
2615 "future": "ignored"
2616 }
2617 }
2618 }
2619 }
2620 "#,
2621 Path::new("subc.jsonc"),
2622 )
2623 .unwrap();
2624
2625 let health = &config.modules[0].health;
2626 assert_eq!(health.cadence, std::time::Duration::from_millis(100));
2627 assert_eq!(health.deadline, std::time::Duration::from_millis(20));
2628 assert_eq!(health.failure_threshold, 2);
2629 assert_eq!(health.on_degraded, HealthAction::Report);
2630 assert_eq!(health.on_failing, HealthAction::Restart);
2631 assert!(health.critical);
2632 }
2633
2634 #[test]
2635 fn http_health_refuses_non_loopback_https_and_wire_modules() {
2636 for (protocol, url) in [
2637 ("none", "http://example.com/healthz"),
2638 ("none", "https://127.0.0.1/healthz"),
2639 ("subc", "http://127.0.0.1/healthz"),
2640 ] {
2641 let doc = serde_json::json!({"version": 1, "modules": {"bus": {
2642 "program": "nats-server", "protocol": protocol, "health": {"http": url}
2643 }}})
2644 .to_string();
2645 let error = parse_doc(&doc, Path::new("test.jsonc"))
2646 .unwrap_err()
2647 .to_string();
2648 assert!(
2649 error.contains("bus") && error.contains("health.http"),
2650 "{error}"
2651 );
2652 }
2653 }
2654
2655 #[test]
2656 fn health_config_rejects_bad_enum_and_non_positive_numbers() {
2657 let bad_enum = parse_doc(
2658 r#"{
2659 "version": 1,
2660 "modules": { "aft": { "program": "aft", "health": { "on_failing": "page" } } }
2661 }"#,
2662 Path::new("subc.jsonc"),
2663 )
2664 .unwrap_err();
2665 assert!(matches!(bad_enum, DaemonConfigError::InvalidJson { .. }));
2666
2667 let zero = parse_doc(
2668 r#"{
2669 "version": 1,
2670 "modules": { "aft": { "program": "aft", "health": { "cadence_ms": 0 } } }
2671 }"#,
2672 Path::new("subc.jsonc"),
2673 )
2674 .unwrap_err();
2675 assert!(matches!(zero, DaemonConfigError::InvalidValue { .. }));
2676 }
2677
2678 #[test]
2679 fn admission_facts_carrier_requires_non_empty_targets() {
2680 let missing_targets = parse_doc(
2681 r#"{
2682 "version": 1,
2683 "admission_facts_carrier_module_id": "fed",
2684 "modules": { "fed": { "program": "fed", "reserved": true } }
2685 }"#,
2686 Path::new("subc.jsonc"),
2687 )
2688 .unwrap_err();
2689 assert!(
2695 matches!(&missing_targets, DaemonConfigError::InvalidValue { message, .. }
2696 if message.contains("must be present")),
2697 "expected the presence rule, got: {missing_targets:?}"
2698 );
2699
2700 let empty_targets = parse_doc(
2701 r#"{
2702 "version": 1,
2703 "admission_facts_carrier_module_id": "fed",
2704 "admission_facts_targets": [""],
2705 "modules": { "fed": { "program": "fed", "reserved": true } }
2706 }"#,
2707 Path::new("subc.jsonc"),
2708 )
2709 .unwrap_err();
2710 assert!(
2711 matches!(&empty_targets, DaemonConfigError::InvalidValue { message, .. }
2712 if message.contains("must be non-empty")),
2713 "expected the non-empty rule, got: {empty_targets:?}"
2714 );
2715 }
2716
2717 #[test]
2718 fn admission_facts_carrier_must_be_enabled_reserved_and_configured() {
2719 for module in [
2720 r#"{ "program": "fed", "enabled": false, "reserved": true }"#,
2721 r#"{ "program": "fed", "enabled": true, "reserved": false }"#,
2722 ] {
2723 let doc = format!(
2724 r#"{{
2725 "version": 1,
2726 "admission_facts_carrier_module_id": "fed",
2727 "admission_facts_targets": ["target"],
2728 "modules": {{ "fed": {module}, "target": {{ "program": "target" }} }}
2729 }}"#
2730 );
2731 let err = parse_doc(&doc, Path::new("subc.jsonc")).unwrap_err();
2732 assert!(
2736 matches!(&err, DaemonConfigError::InvalidValue { message, .. }
2737 if message.contains("enabled reserved module")),
2738 "expected the enabled-and-reserved rule, got: {err:?}"
2739 );
2740 }
2741
2742 let absent = parse_doc(
2743 r#"{
2744 "version": 1,
2745 "admission_facts_carrier_module_id": "missing",
2746 "admission_facts_targets": ["target"],
2747 "modules": { "target": { "program": "target" } }
2748 }"#,
2749 Path::new("subc.jsonc"),
2750 )
2751 .unwrap_err();
2752 assert!(
2753 matches!(&absent, DaemonConfigError::InvalidValue { message, .. }
2754 if message.contains("must name a configured module")),
2755 "expected the configured-module rule, got: {absent:?}"
2756 );
2757 }
2758
2759 #[test]
2760 fn reject_unsupported_version() {
2761 let err = parse_doc(
2762 r#"{ "version": 2, "modules": {} }"#,
2763 Path::new("subc.jsonc"),
2764 )
2765 .unwrap_err();
2766 assert!(matches!(
2767 err,
2768 DaemonConfigError::UnsupportedVersion { version: 2, .. }
2769 ));
2770 }
2771
2772 #[test]
2773 fn reject_unterminated_block_comment() {
2774 let err = parse_doc(r#"{ "version": 1, /*"#, Path::new("subc.jsonc")).unwrap_err();
2775 assert!(matches!(err, DaemonConfigError::InvalidJsonc { .. }));
2776 }
2777}