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