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] = &["/tmp/rch", "/tmp", "/data/projects", "/dp"];
165
166#[must_use]
168fn default_disk_roots() -> Vec<String> {
169 vec!["/tmp".to_string(), "/tmp/rch".to_string()]
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
179#[serde(default)]
180pub struct RemediationPolicy {
181 pub hook_exec_fail_open: bool,
183 pub proof_mode_fail_closed: bool,
185}
186
187impl Default for RemediationPolicy {
188 fn default() -> Self {
189 Self {
190 hook_exec_fail_open: true,
191 proof_mode_fail_closed: true,
192 }
193 }
194}
195
196#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
200#[serde(default)]
201pub struct TemporaryBypassConfig {
202 pub backoff_initial_secs: u64,
204 pub backoff_max_secs: u64,
206}
207
208impl Default for TemporaryBypassConfig {
209 fn default() -> Self {
210 Self {
211 backoff_initial_secs: DEFAULT_BACKOFF_INITIAL_SECS,
212 backoff_max_secs: DEFAULT_BACKOFF_MAX_SECS,
213 }
214 }
215}
216
217#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
221#[serde(default)]
222pub struct AutoRejoinConfig {
223 pub required_consecutive_passes: u32,
225 pub canary_required: bool,
227 pub check_interval_secs: u64,
229 pub probe_timeout_secs: u64,
231 pub min_disk_free_gb: f64,
233 pub min_disk_inodes: u64,
235 pub disk_roots: Vec<String>,
237 pub max_load_per_core: f64,
239 pub min_protocol: u32,
241 pub required_targets: Vec<String>,
243 pub required_toolchains: Vec<String>,
245 pub canary_command: String,
247}
248
249impl Default for AutoRejoinConfig {
250 fn default() -> Self {
251 Self {
252 required_consecutive_passes: DEFAULT_REQUIRED_CONSECUTIVE_PASSES,
253 canary_required: true,
254 check_interval_secs: DEFAULT_CHECK_INTERVAL_SECS,
255 probe_timeout_secs: DEFAULT_PROBE_TIMEOUT_SECS,
256 min_disk_free_gb: DEFAULT_MIN_DISK_FREE_GB,
257 min_disk_inodes: DEFAULT_MIN_DISK_INODES,
258 disk_roots: default_disk_roots(),
259 max_load_per_core: DEFAULT_MAX_LOAD_PER_CORE,
260 min_protocol: DEFAULT_MIN_PROTOCOL,
261 required_targets: Vec::new(),
262 required_toolchains: Vec::new(),
263 canary_command: DEFAULT_CANARY_COMMAND.to_string(),
264 }
265 }
266}
267
268#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
272#[serde(default)]
273pub struct ReconciliationConfig {
274 pub max_attempts: u32,
276 pub time_budget_secs: u64,
278 pub state_hysteresis_ms: u64,
280 pub max_transition_history: usize,
282 pub max_outcome_history: usize,
284 pub staleness_threshold_secs: u64,
286}
287
288impl Default for ReconciliationConfig {
289 fn default() -> Self {
290 Self {
291 max_attempts: DEFAULT_RECONCILE_MAX_ATTEMPTS,
292 time_budget_secs: DEFAULT_RECONCILE_TIME_BUDGET_SECS,
293 state_hysteresis_ms: DEFAULT_RECONCILE_HYSTERESIS_MS,
294 max_transition_history: DEFAULT_RECONCILE_MAX_TRANSITION_HISTORY,
295 max_outcome_history: DEFAULT_RECONCILE_MAX_OUTCOME_HISTORY,
296 staleness_threshold_secs: DEFAULT_RECONCILE_STALENESS_SECS,
297 }
298 }
299}
300
301#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
305#[serde(default)]
306pub struct ProofConfig {
307 #[serde(default, skip_serializing_if = "Option::is_none")]
310 pub store_path: Option<String>,
311 pub default_stale_source_policy: StaleSourcePolicy,
313 pub default_replay_constraints: ReplayConstraints,
317}
318
319impl Default for ProofConfig {
320 fn default() -> Self {
321 Self {
322 store_path: None,
323 default_replay_constraints: ReplayConstraints::default(),
324 default_stale_source_policy: StaleSourcePolicy::RejectIfChanged,
326 }
327 }
328}
329
330#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
334#[serde(default)]
335pub struct IncidentLedgerPolicy {
336 #[serde(default, skip_serializing_if = "Option::is_none")]
338 pub path: Option<String>,
339 pub max_entries: usize,
341 pub max_bytes: u64,
343}
344
345impl Default for IncidentLedgerPolicy {
346 fn default() -> Self {
347 Self {
348 path: None,
349 max_entries: DEFAULT_INCIDENT_MAX_ENTRIES,
350 max_bytes: DEFAULT_INCIDENT_MAX_BYTES,
351 }
352 }
353}
354
355#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
359#[serde(default)]
360pub struct BuildRootConfig {
361 pub min_free_bytes: u64,
363 pub min_free_inodes: u64,
365}
366
367impl Default for BuildRootConfig {
368 fn default() -> Self {
369 Self {
370 min_free_bytes: DEFAULT_BUILD_ROOT_MIN_FREE_BYTES,
371 min_free_inodes: DEFAULT_BUILD_ROOT_MIN_FREE_INODES,
372 }
373 }
374}
375
376#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
380#[serde(default)]
381pub struct PooledTargetConfig {
382 pub pooling_enabled: bool,
384 pub reaper_enabled: bool,
386 pub reaper_idle_hours: u32,
388 pub reaper_interval_mins: u64,
390 pub remote_base: String,
392 pub reaper_pooled_idle_hours: u32,
396 pub reaper_max_cache_gb: u32,
400 #[serde(default, skip_serializing_if = "Option::is_none")]
415 pub store_base: Option<String>,
416
417 #[serde(default, skip_serializing_if = "Vec::is_empty")]
432 pub gc_extra_roots: Vec<String>,
433
434 pub gc_cargo_cache_idle_days: u32,
438}
439
440impl Default for PooledTargetConfig {
441 fn default() -> Self {
442 Self {
443 pooling_enabled: true,
444 reaper_enabled: false,
445 reaper_idle_hours: DEFAULT_POOLED_REAPER_IDLE_HOURS,
446 reaper_interval_mins: DEFAULT_POOLED_REAPER_INTERVAL_MINS,
447 remote_base: DEFAULT_POOLED_REMOTE_BASE.to_string(),
448 reaper_pooled_idle_hours: DEFAULT_POOLED_REAPER_POOLED_IDLE_HOURS,
449 reaper_max_cache_gb: DEFAULT_POOLED_REAPER_MAX_CACHE_GB,
450 store_base: None,
451 gc_extra_roots: Vec::new(),
452 gc_cargo_cache_idle_days: DEFAULT_GC_CARGO_CACHE_IDLE_DAYS,
453 }
454 }
455}
456
457#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
465#[serde(default)]
466pub struct TelemetryFreshnessConfig {
467 pub max_age_secs: u64,
469}
470
471impl Default for TelemetryFreshnessConfig {
472 fn default() -> Self {
473 Self {
474 max_age_secs: DEFAULT_TELEMETRY_MAX_AGE_SECS,
475 }
476 }
477}
478
479#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
483#[serde(default)]
484pub struct LogRetentionConfig {
485 pub max_file_bytes: u64,
487 pub keep_rotated: usize,
489 pub warn_total_bytes: u64,
491 pub critical_total_bytes: u64,
493}
494
495impl Default for LogRetentionConfig {
496 fn default() -> Self {
497 Self {
498 max_file_bytes: DEFAULT_LOG_MAX_FILE_BYTES,
499 keep_rotated: DEFAULT_LOG_KEEP_ROTATED,
500 warn_total_bytes: DEFAULT_LOG_WARN_TOTAL_BYTES,
501 critical_total_bytes: DEFAULT_LOG_CRITICAL_TOTAL_BYTES,
502 }
503 }
504}
505
506#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)]
510#[serde(default)]
511pub struct DiskPressureConfig {
512 pub warning_avail_pct: f64,
514 pub critical_avail_pct: f64,
516 pub warning_avail_inodes: u64,
518 pub critical_avail_inodes: u64,
520}
521
522impl Default for DiskPressureConfig {
523 fn default() -> Self {
524 Self {
525 warning_avail_pct: DEFAULT_PRESSURE_WARNING_AVAIL_PCT,
526 critical_avail_pct: DEFAULT_PRESSURE_CRITICAL_AVAIL_PCT,
527 warning_avail_inodes: DEFAULT_PRESSURE_WARNING_AVAIL_INODES,
528 critical_avail_inodes: DEFAULT_PRESSURE_CRITICAL_AVAIL_INODES,
529 }
530 }
531}
532
533#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
537#[serde(default)]
538pub struct SmokeConfig {
539 pub iterations: usize,
541 pub seed: u64,
543}
544
545impl Default for SmokeConfig {
546 fn default() -> Self {
547 Self {
548 iterations: DEFAULT_SMOKE_ITERATIONS,
549 seed: DEFAULT_SMOKE_SEED,
550 }
551 }
552}
553
554#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
559#[serde(default)]
560pub struct RemediationConfig {
561 pub policy: RemediationPolicy,
563 pub temporary_bypass: TemporaryBypassConfig,
565 pub auto_rejoin: AutoRejoinConfig,
567 pub reconciliation: ReconciliationConfig,
569 pub proof: ProofConfig,
571 pub incident_ledger: IncidentLedgerPolicy,
573 pub build_root: BuildRootConfig,
575 pub pooled_target: PooledTargetConfig,
577 pub telemetry_freshness: TelemetryFreshnessConfig,
579 pub log_retention: LogRetentionConfig,
581 pub disk_pressure: DiskPressureConfig,
583 pub smoke: SmokeConfig,
585}
586
587#[derive(Debug, Clone, Copy, PartialEq, Eq)]
589pub enum IssueSeverity {
590 Error,
592 Warning,
595}
596
597#[derive(Debug, Clone, PartialEq, Eq)]
599pub struct RemediationIssue {
600 pub field: String,
602 pub severity: IssueSeverity,
604 pub message: String,
606}
607
608impl RemediationIssue {
609 fn error(field: impl Into<String>, message: impl Into<String>) -> Self {
610 Self {
611 field: field.into(),
612 severity: IssueSeverity::Error,
613 message: message.into(),
614 }
615 }
616
617 fn warning(field: impl Into<String>, message: impl Into<String>) -> Self {
618 Self {
619 field: field.into(),
620 severity: IssueSeverity::Warning,
621 message: message.into(),
622 }
623 }
624}
625
626impl RemediationConfig {
627 #[must_use]
629 pub fn hook_exec_is_fail_open(&self) -> bool {
630 self.policy.hook_exec_fail_open
631 }
632
633 #[must_use]
635 pub fn proof_mode_is_fail_closed(&self) -> bool {
636 self.policy.proof_mode_fail_closed
637 }
638
639 #[must_use]
645 pub fn validate(&self) -> Vec<RemediationIssue> {
646 let mut issues = Vec::new();
647
648 if self.temporary_bypass.backoff_initial_secs == 0 {
650 issues.push(RemediationIssue::error(
651 "remediation.temporary_bypass.backoff_initial_secs",
652 "backoff_initial_secs must be greater than 0",
653 ));
654 }
655 if self.temporary_bypass.backoff_max_secs < self.temporary_bypass.backoff_initial_secs {
656 issues.push(RemediationIssue::error(
657 "remediation.temporary_bypass.backoff_max_secs",
658 "backoff_max_secs must be >= backoff_initial_secs",
659 ));
660 }
661
662 if self.auto_rejoin.required_consecutive_passes == 0 {
664 issues.push(RemediationIssue::error(
665 "remediation.auto_rejoin.required_consecutive_passes",
666 "required_consecutive_passes must be at least 1",
667 ));
668 }
669 if self.auto_rejoin.check_interval_secs == 0 {
670 issues.push(RemediationIssue::error(
671 "remediation.auto_rejoin.check_interval_secs",
672 "check_interval_secs must be greater than 0",
673 ));
674 }
675 if self.auto_rejoin.probe_timeout_secs == 0 {
676 issues.push(RemediationIssue::error(
677 "remediation.auto_rejoin.probe_timeout_secs",
678 "probe_timeout_secs must be greater than 0",
679 ));
680 }
681 if !self.auto_rejoin.min_disk_free_gb.is_finite() || self.auto_rejoin.min_disk_free_gb < 0.0
682 {
683 issues.push(RemediationIssue::error(
684 "remediation.auto_rejoin.min_disk_free_gb",
685 "min_disk_free_gb must be finite and >= 0",
686 ));
687 }
688 if !self.auto_rejoin.max_load_per_core.is_finite()
689 || self.auto_rejoin.max_load_per_core <= 0.0
690 {
691 issues.push(RemediationIssue::error(
692 "remediation.auto_rejoin.max_load_per_core",
693 "max_load_per_core must be finite and > 0",
694 ));
695 }
696 if self.auto_rejoin.canary_command.trim().is_empty() {
697 issues.push(RemediationIssue::error(
698 "remediation.auto_rejoin.canary_command",
699 "canary_command must not be empty",
700 ));
701 }
702 for root in &self.auto_rejoin.disk_roots {
703 check_managed_path("remediation.auto_rejoin.disk_roots", root, &mut issues);
704 }
705
706 if self.reconciliation.max_attempts == 0 {
708 issues.push(RemediationIssue::error(
709 "remediation.reconciliation.max_attempts",
710 "max_attempts must be at least 1",
711 ));
712 }
713 if self.reconciliation.time_budget_secs == 0 {
714 issues.push(RemediationIssue::error(
715 "remediation.reconciliation.time_budget_secs",
716 "time_budget_secs must be greater than 0",
717 ));
718 }
719
720 if let Some(path) = &self.proof.store_path {
722 check_managed_path("remediation.proof.store_path", path, &mut issues);
723 }
724
725 if self.incident_ledger.max_entries == 0 {
727 issues.push(RemediationIssue::error(
728 "remediation.incident_ledger.max_entries",
729 "max_entries must be at least 1",
730 ));
731 }
732 if self.incident_ledger.max_bytes == 0 {
733 issues.push(RemediationIssue::error(
734 "remediation.incident_ledger.max_bytes",
735 "max_bytes must be greater than 0",
736 ));
737 }
738 if let Some(path) = &self.incident_ledger.path {
739 check_managed_path("remediation.incident_ledger.path", path, &mut issues);
740 }
741
742 if self.build_root.min_free_bytes == 0 {
744 issues.push(RemediationIssue::error(
745 "remediation.build_root.min_free_bytes",
746 "min_free_bytes must be greater than 0",
747 ));
748 }
749
750 if self.pooled_target.reaper_enabled && self.pooled_target.reaper_interval_mins == 0 {
752 issues.push(RemediationIssue::error(
753 "remediation.pooled_target.reaper_interval_mins",
754 "reaper_interval_mins must be > 0 when the reaper is enabled",
755 ));
756 }
757 check_managed_path(
758 "remediation.pooled_target.remote_base",
759 &self.pooled_target.remote_base,
760 &mut issues,
761 );
762 if let Some(store_base) = self.pooled_target.store_base.as_deref() {
763 check_managed_path(
764 "remediation.pooled_target.store_base",
765 store_base,
766 &mut issues,
767 );
768 if !crate::stale_target_reap::is_safe_reap_base(store_base) {
772 issues.push(RemediationIssue::error(
773 "remediation.pooled_target.store_base",
774 format!(
775 "{store_base:?} is not a usable pooled-store base (absolute, no `..`, at least two levels deep)"
776 ),
777 ));
778 }
779 }
780 if self.pooled_target.reaper_pooled_idle_hours != 0
781 && self.pooled_target.reaper_pooled_idle_hours < 24
782 {
783 issues.push(RemediationIssue::error(
784 "remediation.pooled_target.reaper_pooled_idle_hours",
785 "reaper_pooled_idle_hours must be 0 (disabled) or >= 24 — pooled dirs are warm caches, not short-TTL targets",
786 ));
787 }
788 for (idx, root) in self.pooled_target.gc_extra_roots.iter().enumerate() {
789 check_managed_path(
790 "remediation.pooled_target.gc_extra_roots",
791 root,
792 &mut issues,
793 );
794 if !crate::stale_target_reap::is_safe_reap_base(root) {
795 issues.push(RemediationIssue::error(
796 "remediation.pooled_target.gc_extra_roots",
797 format!(
798 "gc_extra_roots[{idx}] {root:?} is not a usable gc scan root (absolute, no `..`, no shell metacharacters)"
799 ),
800 ));
801 }
802 }
803
804 if self.telemetry_freshness.max_age_secs == 0 {
806 issues.push(RemediationIssue::error(
807 "remediation.telemetry_freshness.max_age_secs",
808 "max_age_secs must be greater than 0",
809 ));
810 }
811
812 if self.log_retention.max_file_bytes == 0 {
814 issues.push(RemediationIssue::error(
815 "remediation.log_retention.max_file_bytes",
816 "max_file_bytes must be greater than 0",
817 ));
818 }
819 if self.log_retention.critical_total_bytes < self.log_retention.warn_total_bytes {
820 issues.push(RemediationIssue::error(
821 "remediation.log_retention.critical_total_bytes",
822 "critical_total_bytes must be >= warn_total_bytes",
823 ));
824 }
825
826 let dp = &self.disk_pressure;
828 for (field, pct) in [
829 (
830 "remediation.disk_pressure.warning_avail_pct",
831 dp.warning_avail_pct,
832 ),
833 (
834 "remediation.disk_pressure.critical_avail_pct",
835 dp.critical_avail_pct,
836 ),
837 ] {
838 if !pct.is_finite() || !(0.0..=100.0).contains(&pct) {
839 issues.push(RemediationIssue::error(
840 field,
841 "available-percent thresholds must be within [0.0, 100.0]",
842 ));
843 }
844 }
845 if dp.critical_avail_pct > dp.warning_avail_pct {
846 issues.push(RemediationIssue::error(
847 "remediation.disk_pressure.critical_avail_pct",
848 "critical_avail_pct must be <= warning_avail_pct",
849 ));
850 }
851 if dp.critical_avail_inodes > dp.warning_avail_inodes {
852 issues.push(RemediationIssue::error(
853 "remediation.disk_pressure.critical_avail_inodes",
854 "critical_avail_inodes must be <= warning_avail_inodes",
855 ));
856 }
857
858 if self.smoke.iterations == 0 {
860 issues.push(RemediationIssue::error(
861 "remediation.smoke.iterations",
862 "iterations must be at least 1",
863 ));
864 }
865
866 issues
867 }
868
869 #[must_use]
872 pub fn redacted(&self) -> Self {
873 let mut out = self.clone();
874 out.auto_rejoin.disk_roots = out
875 .auto_rejoin
876 .disk_roots
877 .iter()
878 .map(|p| redact_path(p))
879 .collect();
880 out.proof.store_path = out.proof.store_path.as_deref().map(redact_path);
881 out.incident_ledger.path = out.incident_ledger.path.as_deref().map(redact_path);
882 out.pooled_target.remote_base = redact_path(&out.pooled_target.remote_base);
883 out.pooled_target.store_base = out.pooled_target.store_base.as_deref().map(redact_path);
884 out.pooled_target.gc_extra_roots = out
885 .pooled_target
886 .gc_extra_roots
887 .iter()
888 .map(|root| redact_path(root.as_str()))
889 .collect();
890 out
891 }
892
893 #[must_use]
898 pub fn schema_json() -> serde_json::Value {
899 serde_json::to_value(
900 SchemaSettings::draft07()
901 .into_generator()
902 .into_root_schema_for::<RemediationConfig>(),
903 )
904 .expect("RemediationConfig schema serializes")
905 }
906
907 #[must_use]
909 pub fn human_help() -> String {
910 let d = RemediationConfig::default();
911 let mut s = String::new();
912 s.push_str("[remediation] — session-history remediation defaults\n");
913 s.push_str(" policy.hook_exec_fail_open = ");
914 s.push_str(&d.policy.hook_exec_fail_open.to_string());
915 s.push_str(" (ordinary hook/exec falls back to local on error)\n");
916 s.push_str(" policy.proof_mode_fail_closed = ");
917 s.push_str(&d.policy.proof_mode_fail_closed.to_string());
918 s.push_str(" (proof mode refuses local fallback)\n");
919 s.push_str(&format!(
920 " temporary_bypass.backoff_initial_secs = {}\n",
921 d.temporary_bypass.backoff_initial_secs
922 ));
923 s.push_str(&format!(
924 " temporary_bypass.backoff_max_secs = {}\n",
925 d.temporary_bypass.backoff_max_secs
926 ));
927 s.push_str(&format!(
928 " auto_rejoin.required_consecutive_passes = {}\n",
929 d.auto_rejoin.required_consecutive_passes
930 ));
931 s.push_str(&format!(
932 " auto_rejoin.canary_required = {}\n",
933 d.auto_rejoin.canary_required
934 ));
935 s.push_str(&format!(
936 " auto_rejoin.check_interval_secs = {}\n",
937 d.auto_rejoin.check_interval_secs
938 ));
939 s.push_str(&format!(
940 " auto_rejoin.probe_timeout_secs = {}\n",
941 d.auto_rejoin.probe_timeout_secs
942 ));
943 s.push_str(&format!(
944 " reconciliation.max_attempts = {}\n",
945 d.reconciliation.max_attempts
946 ));
947 s.push_str(&format!(
948 " reconciliation.time_budget_secs = {}\n",
949 d.reconciliation.time_budget_secs
950 ));
951 s.push_str(&format!(
952 " proof.default_stale_source_policy = {:?}\n",
953 d.proof.default_stale_source_policy
954 ));
955 s.push_str(&format!(
956 " incident_ledger.max_entries = {}\n",
957 d.incident_ledger.max_entries
958 ));
959 s.push_str(&format!(
960 " incident_ledger.max_bytes = {}\n",
961 d.incident_ledger.max_bytes
962 ));
963 s.push_str(&format!(
964 " build_root.min_free_bytes = {}\n",
965 d.build_root.min_free_bytes
966 ));
967 s.push_str(&format!(
968 " build_root.min_free_inodes = {}\n",
969 d.build_root.min_free_inodes
970 ));
971 s.push_str(&format!(
972 " pooled_target.pooling_enabled = {}\n",
973 d.pooled_target.pooling_enabled
974 ));
975 s.push_str(&format!(
976 " pooled_target.reaper_enabled = {}\n",
977 d.pooled_target.reaper_enabled
978 ));
979 s.push_str(&format!(
980 " telemetry_freshness.max_age_secs = {}\n",
981 d.telemetry_freshness.max_age_secs
982 ));
983 s.push_str(&format!(
984 " log_retention.max_file_bytes = {}\n",
985 d.log_retention.max_file_bytes
986 ));
987 s.push_str(&format!(
988 " disk_pressure.warning_avail_pct = {}\n",
989 d.disk_pressure.warning_avail_pct
990 ));
991 s.push_str(&format!(
992 " disk_pressure.critical_avail_pct = {}\n",
993 d.disk_pressure.critical_avail_pct
994 ));
995 s.push_str(&format!(
996 " smoke.iterations = {}\n",
997 d.smoke.iterations
998 ));
999 s.push_str(&format!(
1000 " smoke.seed = {}\n",
1001 d.smoke.seed
1002 ));
1003 s
1004 }
1005}
1006
1007#[must_use]
1009fn is_managed_path(path: &str) -> bool {
1010 if path.contains("/rch") {
1011 return true;
1012 }
1013 RCH_MANAGED_ROOT_PREFIXES
1014 .iter()
1015 .any(|prefix| path == *prefix || path.starts_with(&format!("{prefix}/")))
1016}
1017
1018fn check_managed_path(field: &str, path: &str, issues: &mut Vec<RemediationIssue>) {
1021 if path.trim().is_empty() {
1022 return;
1023 }
1024 let absolute = path.starts_with('/') || path.starts_with('~');
1026 if !absolute {
1027 issues.push(RemediationIssue::error(
1028 field,
1029 format!("path must be absolute, got {path:?}"),
1030 ));
1031 return;
1032 }
1033 if !is_managed_path(path) {
1034 issues.push(RemediationIssue::warning(
1035 field,
1036 format!(
1037 "{path:?} is outside the RCH-managed roots; ensure it is an intended operator path"
1038 ),
1039 ));
1040 }
1041}
1042
1043impl From<&TemporaryBypassConfig> for crate::bypass_record::BypassBackoff {
1050 fn from(cfg: &TemporaryBypassConfig) -> Self {
1051 Self {
1052 current_ms: cfg.backoff_initial_secs.saturating_mul(1000),
1053 attempts: 0,
1054 max_ms: cfg.backoff_max_secs.saturating_mul(1000),
1055 }
1056 }
1057}
1058
1059impl From<&AutoRejoinConfig> for crate::bypass_record::AutoRejoinCriteria {
1060 fn from(cfg: &AutoRejoinConfig) -> Self {
1061 Self {
1062 required_consecutive_passes: cfg.required_consecutive_passes,
1063 canary_required: cfg.canary_required,
1064 }
1065 }
1066}
1067
1068impl From<&IncidentLedgerPolicy> for IncidentLedgerConfig {
1069 fn from(cfg: &IncidentLedgerPolicy) -> Self {
1070 Self {
1071 path: cfg
1074 .path
1075 .as_deref()
1076 .map(str::trim)
1077 .filter(|path| !path.is_empty())
1078 .map_or_else(default_ledger_path, |path| {
1079 std::path::PathBuf::from(shellexpand::tilde(path).into_owned())
1080 }),
1081 max_entries: cfg.max_entries,
1082 max_bytes: cfg.max_bytes,
1083 }
1084 }
1085}
1086
1087impl From<&LogRetentionConfig> for crate::log_retention::LogRetentionPolicy {
1088 fn from(cfg: &LogRetentionConfig) -> Self {
1089 Self {
1090 max_file_bytes: cfg.max_file_bytes,
1091 keep_rotated: cfg.keep_rotated,
1092 warn_total_bytes: cfg.warn_total_bytes,
1093 critical_total_bytes: cfg.critical_total_bytes,
1094 }
1095 }
1096}
1097
1098impl From<&DiskPressureConfig> for crate::disk_pressure_report::PressureThresholds {
1099 fn from(cfg: &DiskPressureConfig) -> Self {
1100 Self {
1101 warning_avail_pct: cfg.warning_avail_pct,
1102 critical_avail_pct: cfg.critical_avail_pct,
1103 warning_avail_inodes: cfg.warning_avail_inodes,
1104 critical_avail_inodes: cfg.critical_avail_inodes,
1105 }
1106 }
1107}
1108
1109#[cfg(test)]
1110mod tests {
1111 use super::*;
1112 use crate::bypass_record::{AutoRejoinCriteria, BypassBackoff};
1113 use crate::disk_pressure_report::PressureThresholds;
1114 use crate::log_retention::LogRetentionPolicy;
1115
1116 #[test]
1120 fn pooled_store_base_is_opt_in_and_round_trips() {
1121 assert_eq!(RemediationConfig::default().pooled_target.store_base, None);
1122
1123 let parsed: RemediationConfig = toml::from_str(
1124 r#"
1125 [pooled_target]
1126 store_base = "/bigdisk/rch-pools"
1127 "#,
1128 )
1129 .expect("parse pooled_target.store_base");
1130 assert_eq!(
1131 parsed.pooled_target.store_base.as_deref(),
1132 Some("/bigdisk/rch-pools")
1133 );
1134 assert!(
1135 parsed
1136 .validate()
1137 .iter()
1138 .all(|issue| issue.field != "remediation.pooled_target.store_base"),
1139 "an absolute, managed store_base must validate cleanly"
1140 );
1141
1142 let round_trip: RemediationConfig =
1143 toml::from_str(&toml::to_string(&parsed).expect("serialize")).expect("reparse");
1144 assert_eq!(
1145 round_trip.pooled_target.store_base,
1146 parsed.pooled_target.store_base
1147 );
1148
1149 let default_toml =
1152 toml::to_string(&RemediationConfig::default()).expect("serialize default");
1153 assert!(
1154 !default_toml.contains("store_base"),
1155 "an unset store_base must not be emitted: {default_toml}"
1156 );
1157 }
1158
1159 #[test]
1165 fn gc_extra_roots_are_opt_in_and_round_trip() {
1166 assert!(
1167 RemediationConfig::default()
1168 .pooled_target
1169 .gc_extra_roots
1170 .is_empty()
1171 );
1172 let parsed: RemediationConfig = toml::from_str(
1173 r#"
1174 [pooled_target]
1175 gc_extra_roots = ["/mnt/big/rch", "/srv/rch"]
1176 gc_cargo_cache_idle_days = 21
1177 "#,
1178 )
1179 .expect("parse pooled_target.gc_extra_roots");
1180 assert_eq!(
1181 parsed.pooled_target.gc_extra_roots,
1182 vec!["/mnt/big/rch".to_string(), "/srv/rch".to_string()]
1183 );
1184 assert_eq!(parsed.pooled_target.gc_cargo_cache_idle_days, 21);
1185 let issues = parsed.validate();
1186 assert!(
1187 issues
1188 .iter()
1189 .all(|i| i.field != "remediation.pooled_target.gc_extra_roots"
1190 || i.severity != IssueSeverity::Error),
1191 "absolute, managed roots must validate cleanly: {issues:?}"
1192 );
1193
1194 let round_trip: RemediationConfig =
1195 toml::from_str(&toml::to_string(&parsed).expect("serialize")).expect("round trip");
1196 assert_eq!(
1197 round_trip.pooled_target.gc_extra_roots,
1198 parsed.pooled_target.gc_extra_roots
1199 );
1200
1201 let default_toml =
1203 toml::to_string(&RemediationConfig::default()).expect("serialize default");
1204 assert!(
1205 !default_toml.contains("gc_extra_roots"),
1206 "an empty gc_extra_roots must not be emitted: {default_toml}"
1207 );
1208 }
1209
1210 #[test]
1213 fn unsafe_gc_extra_roots_are_rejected() {
1214 for bad in ["/", "relative/x", "/tmp/../etc", "/tmp/$(whoami)", "/a b/c"] {
1215 let mut config = RemediationConfig::default();
1216 config.pooled_target.gc_extra_roots = vec![bad.to_string()];
1217 let issues = config.validate();
1218 assert!(
1219 issues
1220 .iter()
1221 .any(|i| i.field == "remediation.pooled_target.gc_extra_roots"
1222 && i.severity == IssueSeverity::Error),
1223 "{bad:?} must be rejected: {issues:?}"
1224 );
1225 }
1226 }
1227
1228 #[test]
1229 fn gc_extra_roots_are_redacted_like_every_other_operator_path() {
1230 let mut config = RemediationConfig::default();
1231 config.pooled_target.gc_extra_roots = vec!["/home/carol/builds".to_string()];
1232 assert_eq!(
1233 config.redacted().pooled_target.gc_extra_roots,
1234 vec!["/home/<redacted>/builds".to_string()]
1235 );
1236 }
1237
1238 #[test]
1239 fn unsafe_pooled_store_base_is_rejected() {
1240 for bad in ["relative/path", "/", "/tmp/../etc", "/toplevel"] {
1241 let mut config = RemediationConfig::default();
1242 config.pooled_target.store_base = Some(bad.to_string());
1243 assert!(
1244 config
1245 .validate()
1246 .iter()
1247 .any(|issue| issue.field == "remediation.pooled_target.store_base"),
1248 "expected {bad:?} to be rejected"
1249 );
1250 }
1251 }
1252
1253 #[test]
1254 fn default_construction_matches_documented_values() {
1255 let c = RemediationConfig::default();
1256 assert!(c.policy.hook_exec_fail_open);
1257 assert!(c.policy.proof_mode_fail_closed);
1258 assert_eq!(c.temporary_bypass.backoff_initial_secs, 30);
1259 assert_eq!(c.temporary_bypass.backoff_max_secs, 900);
1260 assert_eq!(c.auto_rejoin.required_consecutive_passes, 2);
1261 assert!(c.auto_rejoin.canary_required);
1262 assert_eq!(c.auto_rejoin.canary_command, "rustc --version");
1263 assert_eq!(c.reconciliation.max_attempts, 3);
1264 assert_eq!(c.incident_ledger.max_entries, 5_000);
1265 assert_eq!(c.incident_ledger.max_bytes, 4 * 1024 * 1024);
1266 assert_eq!(c.build_root.min_free_bytes, 2 * 1024 * 1024 * 1024);
1267 assert_eq!(c.telemetry_freshness.max_age_secs, 120);
1268 assert_eq!(c.smoke.iterations, 20);
1269 }
1270
1271 #[test]
1272 fn default_is_valid() {
1273 let issues = RemediationConfig::default().validate();
1274 assert!(issues.is_empty(), "default must be valid, got {issues:?}");
1275 }
1276
1277 #[test]
1278 fn serde_round_trip_via_toml() {
1279 let c = RemediationConfig::default();
1280 let toml_str = toml::to_string(&c).expect("serialize toml");
1281 let back: RemediationConfig = toml::from_str(&toml_str).expect("deserialize toml");
1282 assert_eq!(c, back);
1283 }
1284
1285 #[test]
1286 fn serde_round_trip_via_json() {
1287 let c = RemediationConfig::default();
1288 let json = serde_json::to_string(&c).expect("serialize json");
1289 let back: RemediationConfig = serde_json::from_str(&json).expect("deserialize json");
1290 assert_eq!(c, back);
1291 }
1292
1293 #[test]
1294 fn partial_toml_fills_missing_fields_from_default() {
1295 let partial = r#"
1297 [policy]
1298 hook_exec_fail_open = false
1299
1300 [auto_rejoin]
1301 check_interval_secs = 99
1302 "#;
1303 let c: RemediationConfig = toml::from_str(partial).expect("parse partial");
1304 assert!(!c.policy.hook_exec_fail_open);
1306 assert_eq!(c.auto_rejoin.check_interval_secs, 99);
1307 assert!(c.policy.proof_mode_fail_closed);
1309 assert_eq!(c.auto_rejoin.required_consecutive_passes, 2);
1310 assert_eq!(c.incident_ledger.max_entries, 5_000);
1311 }
1312
1313 #[test]
1314 fn malformed_values_are_rejected_by_validate() {
1315 let mut c = RemediationConfig::default();
1316 c.temporary_bypass.backoff_initial_secs = 0;
1317 c.auto_rejoin.check_interval_secs = 0;
1318 c.auto_rejoin.canary_command = " ".to_string();
1319 c.disk_pressure_force_invalid();
1320 c.incident_ledger.max_entries = 0;
1321 let issues = c.validate();
1322 let fields: Vec<&str> = issues.iter().map(|i| i.field.as_str()).collect();
1323 assert!(fields.contains(&"remediation.temporary_bypass.backoff_initial_secs"));
1324 assert!(fields.contains(&"remediation.auto_rejoin.check_interval_secs"));
1325 assert!(fields.contains(&"remediation.auto_rejoin.canary_command"));
1326 assert!(fields.contains(&"remediation.incident_ledger.max_entries"));
1327 assert!(issues.iter().any(|i| i.severity == IssueSeverity::Error
1328 && i.field.starts_with("remediation.disk_pressure")));
1329 }
1330
1331 #[test]
1332 fn malformed_toml_value_type_fails_to_parse() {
1333 let bad = r#"
1335 [temporary_bypass]
1336 backoff_initial_secs = "thirty"
1337 "#;
1338 assert!(toml::from_str::<RemediationConfig>(bad).is_err());
1339 }
1340
1341 #[test]
1342 fn relative_path_is_an_error_managed_path_is_clean() {
1343 let mut c = RemediationConfig::default();
1344 c.incident_ledger.path = Some("relative/incidents.jsonl".to_string());
1345 let issues = c.validate();
1346 assert!(issues.iter().any(|i| {
1347 i.field == "remediation.incident_ledger.path" && i.severity == IssueSeverity::Error
1348 }));
1349
1350 c.incident_ledger.path = Some("/tmp/rch/incidents.jsonl".to_string());
1352 let issues = c.validate();
1353 assert!(
1354 !issues
1355 .iter()
1356 .any(|i| i.field == "remediation.incident_ledger.path")
1357 );
1358 }
1359
1360 #[test]
1361 fn operator_path_outside_managed_roots_warns() {
1362 let mut c = RemediationConfig::default();
1363 c.incident_ledger.path = Some("/var/lib/custom/incidents.jsonl".to_string());
1364 let issues = c.validate();
1365 let issue = issues
1366 .iter()
1367 .find(|i| i.field == "remediation.incident_ledger.path")
1368 .expect("operator path produces an issue");
1369 assert_eq!(issue.severity, IssueSeverity::Warning);
1370 }
1371
1372 #[test]
1373 fn redaction_masks_home_and_user_segments() {
1374 let mut c = RemediationConfig::default();
1375 c.incident_ledger.path = Some("/home/alice/.local/state/rch/incidents.jsonl".to_string());
1376 c.proof.store_path = Some("/Users/bob/proofs.jsonl".to_string());
1377 c.auto_rejoin.disk_roots = vec!["/home/carol/builds".to_string(), "/tmp/rch".to_string()];
1378 let r = c.redacted();
1379 assert_eq!(
1380 r.incident_ledger.path.as_deref(),
1381 Some("/home/<redacted>/.local/state/rch/incidents.jsonl")
1382 );
1383 assert_eq!(
1384 r.proof.store_path.as_deref(),
1385 Some("/Users/<redacted>/proofs.jsonl")
1386 );
1387 assert_eq!(
1388 r.auto_rejoin.disk_roots,
1389 vec![
1390 "/home/<redacted>/builds".to_string(),
1391 "/tmp/rch".to_string()
1392 ]
1393 );
1394 assert_eq!(redact_path("/tmp/rch"), "/tmp/rch");
1396 assert_eq!(redact_path("/data/projects"), "/data/projects");
1397 }
1398
1399 #[test]
1400 fn schema_json_describes_the_struct() {
1401 let schema = RemediationConfig::schema_json();
1402 let props = schema
1404 .get("properties")
1405 .and_then(serde_json::Value::as_object)
1406 .expect("schema has properties");
1407 for section in [
1408 "policy",
1409 "temporary_bypass",
1410 "auto_rejoin",
1411 "reconciliation",
1412 "proof",
1413 "incident_ledger",
1414 "build_root",
1415 "pooled_target",
1416 "telemetry_freshness",
1417 "log_retention",
1418 "disk_pressure",
1419 "smoke",
1420 ] {
1421 assert!(
1422 props.contains_key(section),
1423 "schema missing section {section}"
1424 );
1425 }
1426 }
1427
1428 #[test]
1429 fn human_help_lists_key_defaults() {
1430 let help = RemediationConfig::human_help();
1431 assert!(help.contains("hook_exec_fail_open"));
1432 assert!(help.contains("proof_mode_fail_closed"));
1433 assert!(help.contains("backoff_initial_secs"));
1434 assert!(help.contains("incident_ledger.max_entries"));
1435 }
1436
1437 #[test]
1440 fn drift_guard_bypass_backoff() {
1441 let from_cfg = BypassBackoff::from(&TemporaryBypassConfig::default());
1442 assert_eq!(from_cfg, BypassBackoff::default());
1443 }
1444
1445 #[test]
1446 fn drift_guard_auto_rejoin_criteria() {
1447 let from_cfg = AutoRejoinCriteria::from(&AutoRejoinConfig::default());
1448 assert_eq!(from_cfg, AutoRejoinCriteria::default());
1449 }
1450
1451 #[test]
1452 fn drift_guard_log_retention() {
1453 let from_cfg = LogRetentionPolicy::from(&LogRetentionConfig::default());
1454 assert_eq!(from_cfg, LogRetentionPolicy::default());
1455 }
1456
1457 #[test]
1458 fn drift_guard_disk_pressure() {
1459 let from_cfg = PressureThresholds::from(&DiskPressureConfig::default());
1460 assert_eq!(from_cfg, PressureThresholds::default());
1461 }
1462
1463 #[test]
1464 fn drift_guard_incident_ledger() {
1465 let from_cfg = IncidentLedgerConfig::from(&IncidentLedgerPolicy::default());
1466 let runtime = IncidentLedgerConfig::default();
1467 assert_eq!(from_cfg.max_entries, runtime.max_entries);
1469 assert_eq!(from_cfg.max_bytes, runtime.max_bytes);
1470 assert_eq!(from_cfg.path, runtime.path);
1471 }
1472
1473 #[test]
1474 fn incident_ledger_path_expands_tilde_and_treats_blank_as_unset() {
1475 let with = |path: &str| {
1476 IncidentLedgerConfig::from(&IncidentLedgerPolicy {
1477 path: Some(path.to_owned()),
1478 ..IncidentLedgerPolicy::default()
1479 })
1480 .path
1481 };
1482 let expanded = with("~/state/incidents.jsonl");
1483 assert!(expanded.is_absolute(), "{expanded:?}");
1484 assert!(expanded.ends_with("state/incidents.jsonl"));
1485 assert_eq!(with(" "), IncidentLedgerConfig::default().path);
1486 assert_eq!(with("/x/i.jsonl"), std::path::PathBuf::from("/x/i.jsonl"));
1487 }
1488
1489 impl RemediationConfig {
1491 fn disk_pressure_force_invalid(&mut self) {
1492 self.disk_pressure.critical_avail_pct = 90.0;
1493 self.disk_pressure.warning_avail_pct = 10.0;
1494 }
1495 }
1496}