1use serde::{Deserialize, Serialize};
33
34use schemars::JsonSchema;
35use schemars::generate::SchemaSettings;
36
37use crate::incident_ledger::{IncidentLedgerConfig, default_ledger_path};
38use crate::proof_intent::{ReplayConstraints, StaleSourcePolicy};
39use crate::redaction::redact_path;
41
42pub const DEFAULT_BACKOFF_INITIAL_SECS: u64 = 30;
59pub const DEFAULT_BACKOFF_MAX_SECS: u64 = 900;
61
62pub const DEFAULT_REQUIRED_CONSECUTIVE_PASSES: u32 = 2;
64pub const DEFAULT_CHECK_INTERVAL_SECS: u64 = 30;
66pub const DEFAULT_PROBE_TIMEOUT_SECS: u64 = 10;
68pub const DEFAULT_MIN_DISK_FREE_GB: f64 = 5.0;
70pub const DEFAULT_MIN_DISK_INODES: u64 = 10_000;
72pub const DEFAULT_MAX_LOAD_PER_CORE: f64 = 4.0;
74pub const DEFAULT_MIN_PROTOCOL: u32 = 0;
76pub const DEFAULT_CANARY_COMMAND: &str = "rustc --version";
78
79pub const DEFAULT_RECONCILE_MAX_ATTEMPTS: u32 = 3;
81pub const DEFAULT_RECONCILE_TIME_BUDGET_SECS: u64 = 120;
83pub const DEFAULT_RECONCILE_HYSTERESIS_MS: u64 = 5_000;
85pub const DEFAULT_RECONCILE_MAX_TRANSITION_HISTORY: usize = 64;
87pub const DEFAULT_RECONCILE_MAX_OUTCOME_HISTORY: usize = 256;
89pub const DEFAULT_RECONCILE_STALENESS_SECS: u64 = 300;
91
92pub const DEFAULT_INCIDENT_MAX_ENTRIES: usize = 5_000;
94pub const DEFAULT_INCIDENT_MAX_BYTES: u64 = 4 * 1024 * 1024;
96
97pub const DEFAULT_BUILD_ROOT_MIN_FREE_BYTES: u64 = 2 * 1024 * 1024 * 1024;
99pub const DEFAULT_BUILD_ROOT_MIN_FREE_INODES: u64 = 50_000;
101
102pub const DEFAULT_POOLED_REAPER_IDLE_HOURS: u32 = 12;
104pub const DEFAULT_POOLED_REAPER_INTERVAL_MINS: u64 = 120;
106pub const DEFAULT_POOLED_REAPER_POOLED_IDLE_HOURS: u32 = 168;
114pub const DEFAULT_POOLED_REAPER_MAX_CACHE_GB: u32 = 0;
123pub const DEFAULT_POOLED_REMOTE_BASE: &str = "/data/projects";
125
126pub const DEFAULT_GC_CARGO_CACHE_IDLE_DAYS: u32 = 14;
136
137pub const DEFAULT_TELEMETRY_MAX_AGE_SECS: u64 = 120;
139
140pub const DEFAULT_LOG_MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
142pub const DEFAULT_LOG_KEEP_ROTATED: usize = 3;
144pub const DEFAULT_LOG_WARN_TOTAL_BYTES: u64 = 64 * 1024 * 1024;
146pub const DEFAULT_LOG_CRITICAL_TOTAL_BYTES: u64 = 256 * 1024 * 1024;
148
149pub const DEFAULT_PRESSURE_WARNING_AVAIL_PCT: f64 = 15.0;
151pub const DEFAULT_PRESSURE_CRITICAL_AVAIL_PCT: f64 = 5.0;
153pub const DEFAULT_PRESSURE_WARNING_AVAIL_INODES: u64 = 100_000;
155pub const DEFAULT_PRESSURE_CRITICAL_AVAIL_INODES: u64 = 10_000;
157
158pub const DEFAULT_SMOKE_ITERATIONS: usize = 20;
160pub const DEFAULT_SMOKE_SEED: u64 = 42;
162
163const RCH_MANAGED_ROOT_PREFIXES: &[&str] =
165 &["/tmp/rch", "/tmp", "/data/tmp/rch", "/data/projects", "/dp"];
166
167#[must_use]
169fn default_disk_roots() -> Vec<String> {
170 vec![
171 "/tmp".to_string(),
172 "/tmp/rch".to_string(),
173 crate::types::default_remote_base(),
174 ]
175}
176
177#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
184#[serde(default)]
185pub struct RemediationPolicy {
186 pub hook_exec_fail_open: bool,
188 pub proof_mode_fail_closed: bool,
190}
191
192impl Default for RemediationPolicy {
193 fn default() -> Self {
194 Self {
195 hook_exec_fail_open: true,
196 proof_mode_fail_closed: true,
197 }
198 }
199}
200
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
205#[serde(default)]
206pub struct TemporaryBypassConfig {
207 pub backoff_initial_secs: u64,
209 pub backoff_max_secs: u64,
211}
212
213impl Default for TemporaryBypassConfig {
214 fn default() -> Self {
215 Self {
216 backoff_initial_secs: DEFAULT_BACKOFF_INITIAL_SECS,
217 backoff_max_secs: DEFAULT_BACKOFF_MAX_SECS,
218 }
219 }
220}
221
222#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
226#[serde(default)]
227pub struct AutoRejoinConfig {
228 pub required_consecutive_passes: u32,
230 pub canary_required: bool,
232 pub check_interval_secs: u64,
234 pub probe_timeout_secs: u64,
236 pub min_disk_free_gb: f64,
238 pub min_disk_inodes: u64,
240 pub disk_roots: Vec<String>,
242 pub max_load_per_core: f64,
244 pub min_protocol: u32,
246 pub required_targets: Vec<String>,
248 pub required_toolchains: Vec<String>,
250 pub canary_command: String,
252}
253
254impl Default for AutoRejoinConfig {
255 fn default() -> Self {
256 Self {
257 required_consecutive_passes: DEFAULT_REQUIRED_CONSECUTIVE_PASSES,
258 canary_required: true,
259 check_interval_secs: DEFAULT_CHECK_INTERVAL_SECS,
260 probe_timeout_secs: DEFAULT_PROBE_TIMEOUT_SECS,
261 min_disk_free_gb: DEFAULT_MIN_DISK_FREE_GB,
262 min_disk_inodes: DEFAULT_MIN_DISK_INODES,
263 disk_roots: default_disk_roots(),
264 max_load_per_core: DEFAULT_MAX_LOAD_PER_CORE,
265 min_protocol: DEFAULT_MIN_PROTOCOL,
266 required_targets: Vec::new(),
267 required_toolchains: Vec::new(),
268 canary_command: DEFAULT_CANARY_COMMAND.to_string(),
269 }
270 }
271}
272
273#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
277#[serde(default)]
278pub struct ReconciliationConfig {
279 pub max_attempts: u32,
281 pub time_budget_secs: u64,
283 pub state_hysteresis_ms: u64,
285 pub max_transition_history: usize,
287 pub max_outcome_history: usize,
289 pub staleness_threshold_secs: u64,
291}
292
293impl Default for ReconciliationConfig {
294 fn default() -> Self {
295 Self {
296 max_attempts: DEFAULT_RECONCILE_MAX_ATTEMPTS,
297 time_budget_secs: DEFAULT_RECONCILE_TIME_BUDGET_SECS,
298 state_hysteresis_ms: DEFAULT_RECONCILE_HYSTERESIS_MS,
299 max_transition_history: DEFAULT_RECONCILE_MAX_TRANSITION_HISTORY,
300 max_outcome_history: DEFAULT_RECONCILE_MAX_OUTCOME_HISTORY,
301 staleness_threshold_secs: DEFAULT_RECONCILE_STALENESS_SECS,
302 }
303 }
304}
305
306#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
310#[serde(default)]
311pub struct ProofConfig {
312 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub store_path: Option<String>,
316 pub default_stale_source_policy: StaleSourcePolicy,
318 pub default_replay_constraints: ReplayConstraints,
322}
323
324impl Default for ProofConfig {
325 fn default() -> Self {
326 Self {
327 store_path: None,
328 default_replay_constraints: ReplayConstraints::default(),
329 default_stale_source_policy: StaleSourcePolicy::RejectIfChanged,
331 }
332 }
333}
334
335#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
339#[serde(default)]
340pub struct IncidentLedgerPolicy {
341 #[serde(default, skip_serializing_if = "Option::is_none")]
343 pub path: Option<String>,
344 pub max_entries: usize,
346 pub max_bytes: u64,
348}
349
350impl Default for IncidentLedgerPolicy {
351 fn default() -> Self {
352 Self {
353 path: None,
354 max_entries: DEFAULT_INCIDENT_MAX_ENTRIES,
355 max_bytes: DEFAULT_INCIDENT_MAX_BYTES,
356 }
357 }
358}
359
360#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
364#[serde(default)]
365pub struct BuildRootConfig {
366 pub min_free_bytes: u64,
368 pub min_free_inodes: u64,
370}
371
372impl Default for BuildRootConfig {
373 fn default() -> Self {
374 Self {
375 min_free_bytes: DEFAULT_BUILD_ROOT_MIN_FREE_BYTES,
376 min_free_inodes: DEFAULT_BUILD_ROOT_MIN_FREE_INODES,
377 }
378 }
379}
380
381#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
385#[serde(default)]
386pub struct PooledTargetConfig {
387 pub pooling_enabled: bool,
389 pub reaper_enabled: bool,
391 pub reaper_idle_hours: u32,
393 pub reaper_interval_mins: u64,
395 pub remote_base: String,
397 pub reaper_pooled_idle_hours: u32,
401 pub reaper_max_cache_gb: u32,
405 #[serde(default, skip_serializing_if = "Option::is_none")]
420 pub store_base: Option<String>,
421
422 #[serde(default, skip_serializing_if = "Vec::is_empty")]
437 pub gc_extra_roots: Vec<String>,
438
439 pub gc_cargo_cache_idle_days: u32,
443}
444
445impl Default for PooledTargetConfig {
446 fn default() -> Self {
447 Self {
448 pooling_enabled: true,
449 reaper_enabled: false,
450 reaper_idle_hours: DEFAULT_POOLED_REAPER_IDLE_HOURS,
451 reaper_interval_mins: DEFAULT_POOLED_REAPER_INTERVAL_MINS,
452 remote_base: DEFAULT_POOLED_REMOTE_BASE.to_string(),
453 reaper_pooled_idle_hours: DEFAULT_POOLED_REAPER_POOLED_IDLE_HOURS,
454 reaper_max_cache_gb: DEFAULT_POOLED_REAPER_MAX_CACHE_GB,
455 store_base: None,
456 gc_extra_roots: Vec::new(),
457 gc_cargo_cache_idle_days: DEFAULT_GC_CARGO_CACHE_IDLE_DAYS,
458 }
459 }
460}
461
462#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
470#[serde(default)]
471pub struct TelemetryFreshnessConfig {
472 pub max_age_secs: u64,
474}
475
476impl Default for TelemetryFreshnessConfig {
477 fn default() -> Self {
478 Self {
479 max_age_secs: DEFAULT_TELEMETRY_MAX_AGE_SECS,
480 }
481 }
482}
483
484#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
488#[serde(default)]
489pub struct LogRetentionConfig {
490 pub max_file_bytes: u64,
492 pub keep_rotated: usize,
494 pub warn_total_bytes: u64,
496 pub critical_total_bytes: u64,
498}
499
500impl Default for LogRetentionConfig {
501 fn default() -> Self {
502 Self {
503 max_file_bytes: DEFAULT_LOG_MAX_FILE_BYTES,
504 keep_rotated: DEFAULT_LOG_KEEP_ROTATED,
505 warn_total_bytes: DEFAULT_LOG_WARN_TOTAL_BYTES,
506 critical_total_bytes: DEFAULT_LOG_CRITICAL_TOTAL_BYTES,
507 }
508 }
509}
510
511#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)]
515#[serde(default)]
516pub struct DiskPressureConfig {
517 pub warning_avail_pct: f64,
519 pub critical_avail_pct: f64,
521 pub warning_avail_inodes: u64,
523 pub critical_avail_inodes: u64,
525}
526
527impl Default for DiskPressureConfig {
528 fn default() -> Self {
529 Self {
530 warning_avail_pct: DEFAULT_PRESSURE_WARNING_AVAIL_PCT,
531 critical_avail_pct: DEFAULT_PRESSURE_CRITICAL_AVAIL_PCT,
532 warning_avail_inodes: DEFAULT_PRESSURE_WARNING_AVAIL_INODES,
533 critical_avail_inodes: DEFAULT_PRESSURE_CRITICAL_AVAIL_INODES,
534 }
535 }
536}
537
538#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
542#[serde(default)]
543pub struct SmokeConfig {
544 pub iterations: usize,
546 pub seed: u64,
548}
549
550impl Default for SmokeConfig {
551 fn default() -> Self {
552 Self {
553 iterations: DEFAULT_SMOKE_ITERATIONS,
554 seed: DEFAULT_SMOKE_SEED,
555 }
556 }
557}
558
559#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
564#[serde(default)]
565pub struct RemediationConfig {
566 pub policy: RemediationPolicy,
568 pub temporary_bypass: TemporaryBypassConfig,
570 pub auto_rejoin: AutoRejoinConfig,
572 pub reconciliation: ReconciliationConfig,
574 pub proof: ProofConfig,
576 pub incident_ledger: IncidentLedgerPolicy,
578 pub build_root: BuildRootConfig,
580 pub pooled_target: PooledTargetConfig,
582 pub telemetry_freshness: TelemetryFreshnessConfig,
584 pub log_retention: LogRetentionConfig,
586 pub disk_pressure: DiskPressureConfig,
588 pub smoke: SmokeConfig,
590}
591
592#[derive(Debug, Clone, Copy, PartialEq, Eq)]
594pub enum IssueSeverity {
595 Error,
597 Warning,
600}
601
602#[derive(Debug, Clone, PartialEq, Eq)]
604pub struct RemediationIssue {
605 pub field: String,
607 pub severity: IssueSeverity,
609 pub message: String,
611}
612
613impl RemediationIssue {
614 fn error(field: impl Into<String>, message: impl Into<String>) -> Self {
615 Self {
616 field: field.into(),
617 severity: IssueSeverity::Error,
618 message: message.into(),
619 }
620 }
621
622 fn warning(field: impl Into<String>, message: impl Into<String>) -> Self {
623 Self {
624 field: field.into(),
625 severity: IssueSeverity::Warning,
626 message: message.into(),
627 }
628 }
629}
630
631impl RemediationConfig {
632 #[must_use]
634 pub fn hook_exec_is_fail_open(&self) -> bool {
635 self.policy.hook_exec_fail_open
636 }
637
638 #[must_use]
640 pub fn proof_mode_is_fail_closed(&self) -> bool {
641 self.policy.proof_mode_fail_closed
642 }
643
644 #[must_use]
650 pub fn validate(&self) -> Vec<RemediationIssue> {
651 let mut issues = Vec::new();
652
653 if self.temporary_bypass.backoff_initial_secs == 0 {
655 issues.push(RemediationIssue::error(
656 "remediation.temporary_bypass.backoff_initial_secs",
657 "backoff_initial_secs must be greater than 0",
658 ));
659 }
660 if self.temporary_bypass.backoff_max_secs < self.temporary_bypass.backoff_initial_secs {
661 issues.push(RemediationIssue::error(
662 "remediation.temporary_bypass.backoff_max_secs",
663 "backoff_max_secs must be >= backoff_initial_secs",
664 ));
665 }
666
667 if self.auto_rejoin.required_consecutive_passes == 0 {
669 issues.push(RemediationIssue::error(
670 "remediation.auto_rejoin.required_consecutive_passes",
671 "required_consecutive_passes must be at least 1",
672 ));
673 }
674 if self.auto_rejoin.check_interval_secs == 0 {
675 issues.push(RemediationIssue::error(
676 "remediation.auto_rejoin.check_interval_secs",
677 "check_interval_secs must be greater than 0",
678 ));
679 }
680 if self.auto_rejoin.probe_timeout_secs == 0 {
681 issues.push(RemediationIssue::error(
682 "remediation.auto_rejoin.probe_timeout_secs",
683 "probe_timeout_secs must be greater than 0",
684 ));
685 }
686 if !self.auto_rejoin.min_disk_free_gb.is_finite() || self.auto_rejoin.min_disk_free_gb < 0.0
687 {
688 issues.push(RemediationIssue::error(
689 "remediation.auto_rejoin.min_disk_free_gb",
690 "min_disk_free_gb must be finite and >= 0",
691 ));
692 }
693 if !self.auto_rejoin.max_load_per_core.is_finite()
694 || self.auto_rejoin.max_load_per_core <= 0.0
695 {
696 issues.push(RemediationIssue::error(
697 "remediation.auto_rejoin.max_load_per_core",
698 "max_load_per_core must be finite and > 0",
699 ));
700 }
701 if self.auto_rejoin.canary_command.trim().is_empty() {
702 issues.push(RemediationIssue::error(
703 "remediation.auto_rejoin.canary_command",
704 "canary_command must not be empty",
705 ));
706 }
707 for root in &self.auto_rejoin.disk_roots {
708 check_managed_path("remediation.auto_rejoin.disk_roots", root, &mut issues);
709 }
710
711 if self.reconciliation.max_attempts == 0 {
713 issues.push(RemediationIssue::error(
714 "remediation.reconciliation.max_attempts",
715 "max_attempts must be at least 1",
716 ));
717 }
718 if self.reconciliation.time_budget_secs == 0 {
719 issues.push(RemediationIssue::error(
720 "remediation.reconciliation.time_budget_secs",
721 "time_budget_secs must be greater than 0",
722 ));
723 }
724
725 if let Some(path) = &self.proof.store_path {
727 check_managed_path("remediation.proof.store_path", path, &mut issues);
728 }
729
730 if self.incident_ledger.max_entries == 0 {
732 issues.push(RemediationIssue::error(
733 "remediation.incident_ledger.max_entries",
734 "max_entries must be at least 1",
735 ));
736 }
737 if self.incident_ledger.max_bytes == 0 {
738 issues.push(RemediationIssue::error(
739 "remediation.incident_ledger.max_bytes",
740 "max_bytes must be greater than 0",
741 ));
742 }
743 if let Some(path) = &self.incident_ledger.path {
744 check_managed_path("remediation.incident_ledger.path", path, &mut issues);
745 }
746
747 if self.build_root.min_free_bytes == 0 {
749 issues.push(RemediationIssue::error(
750 "remediation.build_root.min_free_bytes",
751 "min_free_bytes must be greater than 0",
752 ));
753 }
754
755 if self.pooled_target.reaper_enabled && self.pooled_target.reaper_interval_mins == 0 {
757 issues.push(RemediationIssue::error(
758 "remediation.pooled_target.reaper_interval_mins",
759 "reaper_interval_mins must be > 0 when the reaper is enabled",
760 ));
761 }
762 check_managed_path(
763 "remediation.pooled_target.remote_base",
764 &self.pooled_target.remote_base,
765 &mut issues,
766 );
767 if let Some(store_base) = self.pooled_target.store_base.as_deref() {
768 check_managed_path(
769 "remediation.pooled_target.store_base",
770 store_base,
771 &mut issues,
772 );
773 if !crate::stale_target_reap::is_safe_reap_base(store_base) {
777 issues.push(RemediationIssue::error(
778 "remediation.pooled_target.store_base",
779 format!(
780 "{store_base:?} is not a usable pooled-store base (absolute, no `..`, at least two levels deep)"
781 ),
782 ));
783 }
784 }
785 if self.pooled_target.reaper_pooled_idle_hours != 0
786 && self.pooled_target.reaper_pooled_idle_hours < 24
787 {
788 issues.push(RemediationIssue::error(
789 "remediation.pooled_target.reaper_pooled_idle_hours",
790 "reaper_pooled_idle_hours must be 0 (disabled) or >= 24 — pooled dirs are warm caches, not short-TTL targets",
791 ));
792 }
793 for (idx, root) in self.pooled_target.gc_extra_roots.iter().enumerate() {
794 check_managed_path(
795 "remediation.pooled_target.gc_extra_roots",
796 root,
797 &mut issues,
798 );
799 if !crate::stale_target_reap::is_safe_reap_base(root) {
800 issues.push(RemediationIssue::error(
801 "remediation.pooled_target.gc_extra_roots",
802 format!(
803 "gc_extra_roots[{idx}] {root:?} is not a usable gc scan root (absolute, no `..`, no shell metacharacters)"
804 ),
805 ));
806 }
807 }
808
809 if self.telemetry_freshness.max_age_secs == 0 {
811 issues.push(RemediationIssue::error(
812 "remediation.telemetry_freshness.max_age_secs",
813 "max_age_secs must be greater than 0",
814 ));
815 }
816
817 if self.log_retention.max_file_bytes == 0 {
819 issues.push(RemediationIssue::error(
820 "remediation.log_retention.max_file_bytes",
821 "max_file_bytes must be greater than 0",
822 ));
823 }
824 if self.log_retention.critical_total_bytes < self.log_retention.warn_total_bytes {
825 issues.push(RemediationIssue::error(
826 "remediation.log_retention.critical_total_bytes",
827 "critical_total_bytes must be >= warn_total_bytes",
828 ));
829 }
830
831 let dp = &self.disk_pressure;
833 for (field, pct) in [
834 (
835 "remediation.disk_pressure.warning_avail_pct",
836 dp.warning_avail_pct,
837 ),
838 (
839 "remediation.disk_pressure.critical_avail_pct",
840 dp.critical_avail_pct,
841 ),
842 ] {
843 if !pct.is_finite() || !(0.0..=100.0).contains(&pct) {
844 issues.push(RemediationIssue::error(
845 field,
846 "available-percent thresholds must be within [0.0, 100.0]",
847 ));
848 }
849 }
850 if dp.critical_avail_pct > dp.warning_avail_pct {
851 issues.push(RemediationIssue::error(
852 "remediation.disk_pressure.critical_avail_pct",
853 "critical_avail_pct must be <= warning_avail_pct",
854 ));
855 }
856 if dp.critical_avail_inodes > dp.warning_avail_inodes {
857 issues.push(RemediationIssue::error(
858 "remediation.disk_pressure.critical_avail_inodes",
859 "critical_avail_inodes must be <= warning_avail_inodes",
860 ));
861 }
862
863 if self.smoke.iterations == 0 {
865 issues.push(RemediationIssue::error(
866 "remediation.smoke.iterations",
867 "iterations must be at least 1",
868 ));
869 }
870
871 issues
872 }
873
874 #[must_use]
877 pub fn redacted(&self) -> Self {
878 let mut out = self.clone();
879 out.auto_rejoin.disk_roots = out
880 .auto_rejoin
881 .disk_roots
882 .iter()
883 .map(|p| redact_path(p))
884 .collect();
885 out.proof.store_path = out.proof.store_path.as_deref().map(redact_path);
886 out.incident_ledger.path = out.incident_ledger.path.as_deref().map(redact_path);
887 out.pooled_target.remote_base = redact_path(&out.pooled_target.remote_base);
888 out.pooled_target.store_base = out.pooled_target.store_base.as_deref().map(redact_path);
889 out.pooled_target.gc_extra_roots = out
890 .pooled_target
891 .gc_extra_roots
892 .iter()
893 .map(|root| redact_path(root.as_str()))
894 .collect();
895 out
896 }
897
898 #[must_use]
903 pub fn schema_json() -> serde_json::Value {
904 serde_json::to_value(
905 SchemaSettings::draft07()
906 .into_generator()
907 .into_root_schema_for::<RemediationConfig>(),
908 )
909 .expect("RemediationConfig schema serializes")
910 }
911
912 #[must_use]
914 pub fn human_help() -> String {
915 let d = RemediationConfig::default();
916 let mut s = String::new();
917 s.push_str("[remediation] — session-history remediation defaults\n");
918 s.push_str(" policy.hook_exec_fail_open = ");
919 s.push_str(&d.policy.hook_exec_fail_open.to_string());
920 s.push_str(" (ordinary hook/exec falls back to local on error)\n");
921 s.push_str(" policy.proof_mode_fail_closed = ");
922 s.push_str(&d.policy.proof_mode_fail_closed.to_string());
923 s.push_str(" (proof mode refuses local fallback)\n");
924 s.push_str(&format!(
925 " temporary_bypass.backoff_initial_secs = {}\n",
926 d.temporary_bypass.backoff_initial_secs
927 ));
928 s.push_str(&format!(
929 " temporary_bypass.backoff_max_secs = {}\n",
930 d.temporary_bypass.backoff_max_secs
931 ));
932 s.push_str(&format!(
933 " auto_rejoin.required_consecutive_passes = {}\n",
934 d.auto_rejoin.required_consecutive_passes
935 ));
936 s.push_str(&format!(
937 " auto_rejoin.canary_required = {}\n",
938 d.auto_rejoin.canary_required
939 ));
940 s.push_str(&format!(
941 " auto_rejoin.check_interval_secs = {}\n",
942 d.auto_rejoin.check_interval_secs
943 ));
944 s.push_str(&format!(
945 " auto_rejoin.probe_timeout_secs = {}\n",
946 d.auto_rejoin.probe_timeout_secs
947 ));
948 s.push_str(&format!(
949 " reconciliation.max_attempts = {}\n",
950 d.reconciliation.max_attempts
951 ));
952 s.push_str(&format!(
953 " reconciliation.time_budget_secs = {}\n",
954 d.reconciliation.time_budget_secs
955 ));
956 s.push_str(&format!(
957 " proof.default_stale_source_policy = {:?}\n",
958 d.proof.default_stale_source_policy
959 ));
960 s.push_str(&format!(
961 " incident_ledger.max_entries = {}\n",
962 d.incident_ledger.max_entries
963 ));
964 s.push_str(&format!(
965 " incident_ledger.max_bytes = {}\n",
966 d.incident_ledger.max_bytes
967 ));
968 s.push_str(&format!(
969 " build_root.min_free_bytes = {}\n",
970 d.build_root.min_free_bytes
971 ));
972 s.push_str(&format!(
973 " build_root.min_free_inodes = {}\n",
974 d.build_root.min_free_inodes
975 ));
976 s.push_str(&format!(
977 " pooled_target.pooling_enabled = {}\n",
978 d.pooled_target.pooling_enabled
979 ));
980 s.push_str(&format!(
981 " pooled_target.reaper_enabled = {}\n",
982 d.pooled_target.reaper_enabled
983 ));
984 s.push_str(&format!(
985 " telemetry_freshness.max_age_secs = {}\n",
986 d.telemetry_freshness.max_age_secs
987 ));
988 s.push_str(&format!(
989 " log_retention.max_file_bytes = {}\n",
990 d.log_retention.max_file_bytes
991 ));
992 s.push_str(&format!(
993 " disk_pressure.warning_avail_pct = {}\n",
994 d.disk_pressure.warning_avail_pct
995 ));
996 s.push_str(&format!(
997 " disk_pressure.critical_avail_pct = {}\n",
998 d.disk_pressure.critical_avail_pct
999 ));
1000 s.push_str(&format!(
1001 " smoke.iterations = {}\n",
1002 d.smoke.iterations
1003 ));
1004 s.push_str(&format!(
1005 " smoke.seed = {}\n",
1006 d.smoke.seed
1007 ));
1008 s
1009 }
1010}
1011
1012#[must_use]
1014fn is_managed_path(path: &str) -> bool {
1015 if path.contains("/rch") {
1016 return true;
1017 }
1018 RCH_MANAGED_ROOT_PREFIXES
1019 .iter()
1020 .any(|prefix| path == *prefix || path.starts_with(&format!("{prefix}/")))
1021}
1022
1023fn check_managed_path(field: &str, path: &str, issues: &mut Vec<RemediationIssue>) {
1026 if path.trim().is_empty() {
1027 return;
1028 }
1029 let absolute = path.starts_with('/') || path.starts_with('~');
1031 if !absolute {
1032 issues.push(RemediationIssue::error(
1033 field,
1034 format!("path must be absolute, got {path:?}"),
1035 ));
1036 return;
1037 }
1038 if !is_managed_path(path) {
1039 issues.push(RemediationIssue::warning(
1040 field,
1041 format!(
1042 "{path:?} is outside the RCH-managed roots; ensure it is an intended operator path"
1043 ),
1044 ));
1045 }
1046}
1047
1048impl From<&TemporaryBypassConfig> for crate::bypass_record::BypassBackoff {
1055 fn from(cfg: &TemporaryBypassConfig) -> Self {
1056 Self {
1057 current_ms: cfg.backoff_initial_secs.saturating_mul(1000),
1058 attempts: 0,
1059 max_ms: cfg.backoff_max_secs.saturating_mul(1000),
1060 }
1061 }
1062}
1063
1064impl From<&AutoRejoinConfig> for crate::bypass_record::AutoRejoinCriteria {
1065 fn from(cfg: &AutoRejoinConfig) -> Self {
1066 Self {
1067 required_consecutive_passes: cfg.required_consecutive_passes,
1068 canary_required: cfg.canary_required,
1069 }
1070 }
1071}
1072
1073impl From<&IncidentLedgerPolicy> for IncidentLedgerConfig {
1074 fn from(cfg: &IncidentLedgerPolicy) -> Self {
1075 Self {
1076 path: cfg
1079 .path
1080 .as_deref()
1081 .map(str::trim)
1082 .filter(|path| !path.is_empty())
1083 .map_or_else(default_ledger_path, |path| {
1084 std::path::PathBuf::from(shellexpand::tilde(path).into_owned())
1085 }),
1086 max_entries: cfg.max_entries,
1087 max_bytes: cfg.max_bytes,
1088 }
1089 }
1090}
1091
1092impl From<&LogRetentionConfig> for crate::log_retention::LogRetentionPolicy {
1093 fn from(cfg: &LogRetentionConfig) -> Self {
1094 Self {
1095 max_file_bytes: cfg.max_file_bytes,
1096 keep_rotated: cfg.keep_rotated,
1097 warn_total_bytes: cfg.warn_total_bytes,
1098 critical_total_bytes: cfg.critical_total_bytes,
1099 }
1100 }
1101}
1102
1103impl From<&DiskPressureConfig> for crate::disk_pressure_report::PressureThresholds {
1104 fn from(cfg: &DiskPressureConfig) -> Self {
1105 Self {
1106 warning_avail_pct: cfg.warning_avail_pct,
1107 critical_avail_pct: cfg.critical_avail_pct,
1108 warning_avail_inodes: cfg.warning_avail_inodes,
1109 critical_avail_inodes: cfg.critical_avail_inodes,
1110 }
1111 }
1112}
1113
1114#[cfg(test)]
1115mod tests {
1116 use super::*;
1117 use crate::bypass_record::{AutoRejoinCriteria, BypassBackoff};
1118 use crate::disk_pressure_report::PressureThresholds;
1119 use crate::log_retention::LogRetentionPolicy;
1120
1121 #[test]
1125 fn pooled_store_base_is_opt_in_and_round_trips() {
1126 assert_eq!(RemediationConfig::default().pooled_target.store_base, None);
1127
1128 let parsed: RemediationConfig = toml::from_str(
1129 r#"
1130 [pooled_target]
1131 store_base = "/bigdisk/rch-pools"
1132 "#,
1133 )
1134 .expect("parse pooled_target.store_base");
1135 assert_eq!(
1136 parsed.pooled_target.store_base.as_deref(),
1137 Some("/bigdisk/rch-pools")
1138 );
1139 assert!(
1140 parsed
1141 .validate()
1142 .iter()
1143 .all(|issue| issue.field != "remediation.pooled_target.store_base"),
1144 "an absolute, managed store_base must validate cleanly"
1145 );
1146
1147 let round_trip: RemediationConfig =
1148 toml::from_str(&toml::to_string(&parsed).expect("serialize")).expect("reparse");
1149 assert_eq!(
1150 round_trip.pooled_target.store_base,
1151 parsed.pooled_target.store_base
1152 );
1153
1154 let default_toml =
1157 toml::to_string(&RemediationConfig::default()).expect("serialize default");
1158 assert!(
1159 !default_toml.contains("store_base"),
1160 "an unset store_base must not be emitted: {default_toml}"
1161 );
1162 }
1163
1164 #[test]
1170 fn gc_extra_roots_are_opt_in_and_round_trip() {
1171 assert!(
1172 RemediationConfig::default()
1173 .pooled_target
1174 .gc_extra_roots
1175 .is_empty()
1176 );
1177 let parsed: RemediationConfig = toml::from_str(
1178 r#"
1179 [pooled_target]
1180 gc_extra_roots = ["/mnt/big/rch", "/srv/rch"]
1181 gc_cargo_cache_idle_days = 21
1182 "#,
1183 )
1184 .expect("parse pooled_target.gc_extra_roots");
1185 assert_eq!(
1186 parsed.pooled_target.gc_extra_roots,
1187 vec!["/mnt/big/rch".to_string(), "/srv/rch".to_string()]
1188 );
1189 assert_eq!(parsed.pooled_target.gc_cargo_cache_idle_days, 21);
1190 let issues = parsed.validate();
1191 assert!(
1192 issues
1193 .iter()
1194 .all(|i| i.field != "remediation.pooled_target.gc_extra_roots"
1195 || i.severity != IssueSeverity::Error),
1196 "absolute, managed roots must validate cleanly: {issues:?}"
1197 );
1198
1199 let round_trip: RemediationConfig =
1200 toml::from_str(&toml::to_string(&parsed).expect("serialize")).expect("round trip");
1201 assert_eq!(
1202 round_trip.pooled_target.gc_extra_roots,
1203 parsed.pooled_target.gc_extra_roots
1204 );
1205
1206 let default_toml =
1208 toml::to_string(&RemediationConfig::default()).expect("serialize default");
1209 assert!(
1210 !default_toml.contains("gc_extra_roots"),
1211 "an empty gc_extra_roots must not be emitted: {default_toml}"
1212 );
1213 }
1214
1215 #[test]
1218 fn unsafe_gc_extra_roots_are_rejected() {
1219 for bad in ["/", "relative/x", "/tmp/../etc", "/tmp/$(whoami)", "/a b/c"] {
1220 let mut config = RemediationConfig::default();
1221 config.pooled_target.gc_extra_roots = vec![bad.to_string()];
1222 let issues = config.validate();
1223 assert!(
1224 issues
1225 .iter()
1226 .any(|i| i.field == "remediation.pooled_target.gc_extra_roots"
1227 && i.severity == IssueSeverity::Error),
1228 "{bad:?} must be rejected: {issues:?}"
1229 );
1230 }
1231 }
1232
1233 #[test]
1234 fn gc_extra_roots_are_redacted_like_every_other_operator_path() {
1235 let mut config = RemediationConfig::default();
1236 config.pooled_target.gc_extra_roots = vec!["/home/carol/builds".to_string()];
1237 assert_eq!(
1238 config.redacted().pooled_target.gc_extra_roots,
1239 vec!["/home/<redacted>/builds".to_string()]
1240 );
1241 }
1242
1243 #[test]
1244 fn unsafe_pooled_store_base_is_rejected() {
1245 for bad in ["relative/path", "/", "/tmp/../etc", "/toplevel"] {
1246 let mut config = RemediationConfig::default();
1247 config.pooled_target.store_base = Some(bad.to_string());
1248 assert!(
1249 config
1250 .validate()
1251 .iter()
1252 .any(|issue| issue.field == "remediation.pooled_target.store_base"),
1253 "expected {bad:?} to be rejected"
1254 );
1255 }
1256 }
1257
1258 #[test]
1259 fn default_construction_matches_documented_values() {
1260 let c = RemediationConfig::default();
1261 assert!(c.policy.hook_exec_fail_open);
1262 assert!(c.policy.proof_mode_fail_closed);
1263 assert_eq!(c.temporary_bypass.backoff_initial_secs, 30);
1264 assert_eq!(c.temporary_bypass.backoff_max_secs, 900);
1265 assert_eq!(c.auto_rejoin.required_consecutive_passes, 2);
1266 assert!(c.auto_rejoin.canary_required);
1267 assert_eq!(c.auto_rejoin.canary_command, "rustc --version");
1268 assert_eq!(c.reconciliation.max_attempts, 3);
1269 assert_eq!(c.incident_ledger.max_entries, 5_000);
1270 assert_eq!(c.incident_ledger.max_bytes, 4 * 1024 * 1024);
1271 assert_eq!(c.build_root.min_free_bytes, 2 * 1024 * 1024 * 1024);
1272 assert_eq!(c.telemetry_freshness.max_age_secs, 120);
1273 assert_eq!(c.smoke.iterations, 20);
1274 }
1275
1276 #[test]
1277 fn default_is_valid() {
1278 let issues = RemediationConfig::default().validate();
1279 assert!(issues.is_empty(), "default must be valid, got {issues:?}");
1280 }
1281
1282 #[test]
1283 fn serde_round_trip_via_toml() {
1284 let c = RemediationConfig::default();
1285 let toml_str = toml::to_string(&c).expect("serialize toml");
1286 let back: RemediationConfig = toml::from_str(&toml_str).expect("deserialize toml");
1287 assert_eq!(c, back);
1288 }
1289
1290 #[test]
1291 fn serde_round_trip_via_json() {
1292 let c = RemediationConfig::default();
1293 let json = serde_json::to_string(&c).expect("serialize json");
1294 let back: RemediationConfig = serde_json::from_str(&json).expect("deserialize json");
1295 assert_eq!(c, back);
1296 }
1297
1298 #[test]
1299 fn partial_toml_fills_missing_fields_from_default() {
1300 let partial = r#"
1302 [policy]
1303 hook_exec_fail_open = false
1304
1305 [auto_rejoin]
1306 check_interval_secs = 99
1307 "#;
1308 let c: RemediationConfig = toml::from_str(partial).expect("parse partial");
1309 assert!(!c.policy.hook_exec_fail_open);
1311 assert_eq!(c.auto_rejoin.check_interval_secs, 99);
1312 assert!(c.policy.proof_mode_fail_closed);
1314 assert_eq!(c.auto_rejoin.required_consecutive_passes, 2);
1315 assert_eq!(c.incident_ledger.max_entries, 5_000);
1316 }
1317
1318 #[test]
1319 fn malformed_values_are_rejected_by_validate() {
1320 let mut c = RemediationConfig::default();
1321 c.temporary_bypass.backoff_initial_secs = 0;
1322 c.auto_rejoin.check_interval_secs = 0;
1323 c.auto_rejoin.canary_command = " ".to_string();
1324 c.disk_pressure_force_invalid();
1325 c.incident_ledger.max_entries = 0;
1326 let issues = c.validate();
1327 let fields: Vec<&str> = issues.iter().map(|i| i.field.as_str()).collect();
1328 assert!(fields.contains(&"remediation.temporary_bypass.backoff_initial_secs"));
1329 assert!(fields.contains(&"remediation.auto_rejoin.check_interval_secs"));
1330 assert!(fields.contains(&"remediation.auto_rejoin.canary_command"));
1331 assert!(fields.contains(&"remediation.incident_ledger.max_entries"));
1332 assert!(issues.iter().any(|i| i.severity == IssueSeverity::Error
1333 && i.field.starts_with("remediation.disk_pressure")));
1334 }
1335
1336 #[test]
1337 fn malformed_toml_value_type_fails_to_parse() {
1338 let bad = r#"
1340 [temporary_bypass]
1341 backoff_initial_secs = "thirty"
1342 "#;
1343 assert!(toml::from_str::<RemediationConfig>(bad).is_err());
1344 }
1345
1346 #[test]
1347 fn relative_path_is_an_error_managed_path_is_clean() {
1348 let mut c = RemediationConfig::default();
1349 c.incident_ledger.path = Some("relative/incidents.jsonl".to_string());
1350 let issues = c.validate();
1351 assert!(issues.iter().any(|i| {
1352 i.field == "remediation.incident_ledger.path" && i.severity == IssueSeverity::Error
1353 }));
1354
1355 c.incident_ledger.path = Some("/tmp/rch/incidents.jsonl".to_string());
1357 let issues = c.validate();
1358 assert!(
1359 !issues
1360 .iter()
1361 .any(|i| i.field == "remediation.incident_ledger.path")
1362 );
1363 }
1364
1365 #[test]
1366 fn operator_path_outside_managed_roots_warns() {
1367 let mut c = RemediationConfig::default();
1368 c.incident_ledger.path = Some("/var/lib/custom/incidents.jsonl".to_string());
1369 let issues = c.validate();
1370 let issue = issues
1371 .iter()
1372 .find(|i| i.field == "remediation.incident_ledger.path")
1373 .expect("operator path produces an issue");
1374 assert_eq!(issue.severity, IssueSeverity::Warning);
1375 }
1376
1377 #[test]
1378 fn redaction_masks_home_and_user_segments() {
1379 let mut c = RemediationConfig::default();
1380 c.incident_ledger.path = Some("/home/alice/.local/state/rch/incidents.jsonl".to_string());
1381 c.proof.store_path = Some("/Users/bob/proofs.jsonl".to_string());
1382 c.auto_rejoin.disk_roots = vec!["/home/carol/builds".to_string(), "/tmp/rch".to_string()];
1383 let r = c.redacted();
1384 assert_eq!(
1385 r.incident_ledger.path.as_deref(),
1386 Some("/home/<redacted>/.local/state/rch/incidents.jsonl")
1387 );
1388 assert_eq!(
1389 r.proof.store_path.as_deref(),
1390 Some("/Users/<redacted>/proofs.jsonl")
1391 );
1392 assert_eq!(
1393 r.auto_rejoin.disk_roots,
1394 vec![
1395 "/home/<redacted>/builds".to_string(),
1396 "/tmp/rch".to_string()
1397 ]
1398 );
1399 assert_eq!(redact_path("/tmp/rch"), "/tmp/rch");
1401 assert_eq!(redact_path("/data/projects"), "/data/projects");
1402 }
1403
1404 #[test]
1405 fn schema_json_describes_the_struct() {
1406 let schema = RemediationConfig::schema_json();
1407 let props = schema
1409 .get("properties")
1410 .and_then(serde_json::Value::as_object)
1411 .expect("schema has properties");
1412 for section in [
1413 "policy",
1414 "temporary_bypass",
1415 "auto_rejoin",
1416 "reconciliation",
1417 "proof",
1418 "incident_ledger",
1419 "build_root",
1420 "pooled_target",
1421 "telemetry_freshness",
1422 "log_retention",
1423 "disk_pressure",
1424 "smoke",
1425 ] {
1426 assert!(
1427 props.contains_key(section),
1428 "schema missing section {section}"
1429 );
1430 }
1431 }
1432
1433 #[test]
1434 fn human_help_lists_key_defaults() {
1435 let help = RemediationConfig::human_help();
1436 assert!(help.contains("hook_exec_fail_open"));
1437 assert!(help.contains("proof_mode_fail_closed"));
1438 assert!(help.contains("backoff_initial_secs"));
1439 assert!(help.contains("incident_ledger.max_entries"));
1440 }
1441
1442 #[test]
1445 fn drift_guard_bypass_backoff() {
1446 let from_cfg = BypassBackoff::from(&TemporaryBypassConfig::default());
1447 assert_eq!(from_cfg, BypassBackoff::default());
1448 }
1449
1450 #[test]
1451 fn drift_guard_auto_rejoin_criteria() {
1452 let from_cfg = AutoRejoinCriteria::from(&AutoRejoinConfig::default());
1453 assert_eq!(from_cfg, AutoRejoinCriteria::default());
1454 }
1455
1456 #[test]
1457 fn drift_guard_log_retention() {
1458 let from_cfg = LogRetentionPolicy::from(&LogRetentionConfig::default());
1459 assert_eq!(from_cfg, LogRetentionPolicy::default());
1460 }
1461
1462 #[test]
1463 fn drift_guard_disk_pressure() {
1464 let from_cfg = PressureThresholds::from(&DiskPressureConfig::default());
1465 assert_eq!(from_cfg, PressureThresholds::default());
1466 }
1467
1468 #[test]
1469 fn drift_guard_incident_ledger() {
1470 let from_cfg = IncidentLedgerConfig::from(&IncidentLedgerPolicy::default());
1471 let runtime = IncidentLedgerConfig::default();
1472 assert_eq!(from_cfg.max_entries, runtime.max_entries);
1474 assert_eq!(from_cfg.max_bytes, runtime.max_bytes);
1475 assert_eq!(from_cfg.path, runtime.path);
1476 }
1477
1478 #[test]
1479 fn incident_ledger_path_expands_tilde_and_treats_blank_as_unset() {
1480 let with = |path: &str| {
1481 IncidentLedgerConfig::from(&IncidentLedgerPolicy {
1482 path: Some(path.to_owned()),
1483 ..IncidentLedgerPolicy::default()
1484 })
1485 .path
1486 };
1487 let expanded = with("~/state/incidents.jsonl");
1488 assert!(expanded.is_absolute(), "{expanded:?}");
1489 assert!(expanded.ends_with("state/incidents.jsonl"));
1490 assert_eq!(with(" "), IncidentLedgerConfig::default().path);
1491 assert_eq!(with("/x/i.jsonl"), std::path::PathBuf::from("/x/i.jsonl"));
1492 }
1493
1494 impl RemediationConfig {
1496 fn disk_pressure_force_invalid(&mut self) {
1497 self.disk_pressure.critical_avail_pct = 90.0;
1498 self.disk_pressure.warning_avail_pct = 10.0;
1499 }
1500 }
1501}