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