use serde::{Deserialize, Serialize};
use schemars::JsonSchema;
use schemars::generate::SchemaSettings;
use crate::incident_ledger::{IncidentLedgerConfig, default_ledger_path};
use crate::proof_intent::{ReplayConstraints, StaleSourcePolicy};
use crate::redaction::redact_path;
pub const DEFAULT_BACKOFF_INITIAL_SECS: u64 = 30;
pub const DEFAULT_BACKOFF_MAX_SECS: u64 = 900;
pub const DEFAULT_REQUIRED_CONSECUTIVE_PASSES: u32 = 2;
pub const DEFAULT_CHECK_INTERVAL_SECS: u64 = 30;
pub const DEFAULT_PROBE_TIMEOUT_SECS: u64 = 10;
pub const DEFAULT_MIN_DISK_FREE_GB: f64 = 5.0;
pub const DEFAULT_MIN_DISK_INODES: u64 = 10_000;
pub const DEFAULT_MAX_LOAD_PER_CORE: f64 = 4.0;
pub const DEFAULT_MIN_PROTOCOL: u32 = 0;
pub const DEFAULT_CANARY_COMMAND: &str = "rustc --version";
pub const DEFAULT_RECONCILE_MAX_ATTEMPTS: u32 = 3;
pub const DEFAULT_RECONCILE_TIME_BUDGET_SECS: u64 = 120;
pub const DEFAULT_RECONCILE_HYSTERESIS_MS: u64 = 5_000;
pub const DEFAULT_RECONCILE_MAX_TRANSITION_HISTORY: usize = 64;
pub const DEFAULT_RECONCILE_MAX_OUTCOME_HISTORY: usize = 256;
pub const DEFAULT_RECONCILE_STALENESS_SECS: u64 = 300;
pub const DEFAULT_INCIDENT_MAX_ENTRIES: usize = 5_000;
pub const DEFAULT_INCIDENT_MAX_BYTES: u64 = 4 * 1024 * 1024;
pub const DEFAULT_BUILD_ROOT_MIN_FREE_BYTES: u64 = 2 * 1024 * 1024 * 1024;
pub const DEFAULT_BUILD_ROOT_MIN_FREE_INODES: u64 = 50_000;
pub const DEFAULT_POOLED_REAPER_IDLE_HOURS: u32 = 12;
pub const DEFAULT_POOLED_REAPER_INTERVAL_MINS: u64 = 120;
pub const DEFAULT_POOLED_REAPER_POOLED_IDLE_HOURS: u32 = 168;
pub const DEFAULT_POOLED_REAPER_MAX_CACHE_GB: u32 = 0;
pub const DEFAULT_POOLED_REMOTE_BASE: &str = "/data/projects";
pub const DEFAULT_GC_CARGO_CACHE_IDLE_DAYS: u32 = 14;
pub const DEFAULT_TELEMETRY_MAX_AGE_SECS: u64 = 120;
pub const DEFAULT_LOG_MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
pub const DEFAULT_LOG_KEEP_ROTATED: usize = 3;
pub const DEFAULT_LOG_WARN_TOTAL_BYTES: u64 = 64 * 1024 * 1024;
pub const DEFAULT_LOG_CRITICAL_TOTAL_BYTES: u64 = 256 * 1024 * 1024;
pub const DEFAULT_PRESSURE_WARNING_AVAIL_PCT: f64 = 15.0;
pub const DEFAULT_PRESSURE_CRITICAL_AVAIL_PCT: f64 = 5.0;
pub const DEFAULT_PRESSURE_WARNING_AVAIL_INODES: u64 = 100_000;
pub const DEFAULT_PRESSURE_CRITICAL_AVAIL_INODES: u64 = 10_000;
pub const DEFAULT_SMOKE_ITERATIONS: usize = 20;
pub const DEFAULT_SMOKE_SEED: u64 = 42;
const RCH_MANAGED_ROOT_PREFIXES: &[&str] = &["/tmp/rch", "/tmp", "/data/projects", "/dp"];
#[must_use]
fn default_disk_roots() -> Vec<String> {
vec!["/tmp".to_string(), "/tmp/rch".to_string()]
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct RemediationPolicy {
pub hook_exec_fail_open: bool,
pub proof_mode_fail_closed: bool,
}
impl Default for RemediationPolicy {
fn default() -> Self {
Self {
hook_exec_fail_open: true,
proof_mode_fail_closed: true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct TemporaryBypassConfig {
pub backoff_initial_secs: u64,
pub backoff_max_secs: u64,
}
impl Default for TemporaryBypassConfig {
fn default() -> Self {
Self {
backoff_initial_secs: DEFAULT_BACKOFF_INITIAL_SECS,
backoff_max_secs: DEFAULT_BACKOFF_MAX_SECS,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct AutoRejoinConfig {
pub required_consecutive_passes: u32,
pub canary_required: bool,
pub check_interval_secs: u64,
pub probe_timeout_secs: u64,
pub min_disk_free_gb: f64,
pub min_disk_inodes: u64,
pub disk_roots: Vec<String>,
pub max_load_per_core: f64,
pub min_protocol: u32,
pub required_targets: Vec<String>,
pub required_toolchains: Vec<String>,
pub canary_command: String,
}
impl Default for AutoRejoinConfig {
fn default() -> Self {
Self {
required_consecutive_passes: DEFAULT_REQUIRED_CONSECUTIVE_PASSES,
canary_required: true,
check_interval_secs: DEFAULT_CHECK_INTERVAL_SECS,
probe_timeout_secs: DEFAULT_PROBE_TIMEOUT_SECS,
min_disk_free_gb: DEFAULT_MIN_DISK_FREE_GB,
min_disk_inodes: DEFAULT_MIN_DISK_INODES,
disk_roots: default_disk_roots(),
max_load_per_core: DEFAULT_MAX_LOAD_PER_CORE,
min_protocol: DEFAULT_MIN_PROTOCOL,
required_targets: Vec::new(),
required_toolchains: Vec::new(),
canary_command: DEFAULT_CANARY_COMMAND.to_string(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct ReconciliationConfig {
pub max_attempts: u32,
pub time_budget_secs: u64,
pub state_hysteresis_ms: u64,
pub max_transition_history: usize,
pub max_outcome_history: usize,
pub staleness_threshold_secs: u64,
}
impl Default for ReconciliationConfig {
fn default() -> Self {
Self {
max_attempts: DEFAULT_RECONCILE_MAX_ATTEMPTS,
time_budget_secs: DEFAULT_RECONCILE_TIME_BUDGET_SECS,
state_hysteresis_ms: DEFAULT_RECONCILE_HYSTERESIS_MS,
max_transition_history: DEFAULT_RECONCILE_MAX_TRANSITION_HISTORY,
max_outcome_history: DEFAULT_RECONCILE_MAX_OUTCOME_HISTORY,
staleness_threshold_secs: DEFAULT_RECONCILE_STALENESS_SECS,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct ProofConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub store_path: Option<String>,
pub default_stale_source_policy: StaleSourcePolicy,
pub default_replay_constraints: ReplayConstraints,
}
impl Default for ProofConfig {
fn default() -> Self {
Self {
store_path: None,
default_replay_constraints: ReplayConstraints::default(),
default_stale_source_policy: StaleSourcePolicy::RejectIfChanged,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct IncidentLedgerPolicy {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
pub max_entries: usize,
pub max_bytes: u64,
}
impl Default for IncidentLedgerPolicy {
fn default() -> Self {
Self {
path: None,
max_entries: DEFAULT_INCIDENT_MAX_ENTRIES,
max_bytes: DEFAULT_INCIDENT_MAX_BYTES,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct BuildRootConfig {
pub min_free_bytes: u64,
pub min_free_inodes: u64,
}
impl Default for BuildRootConfig {
fn default() -> Self {
Self {
min_free_bytes: DEFAULT_BUILD_ROOT_MIN_FREE_BYTES,
min_free_inodes: DEFAULT_BUILD_ROOT_MIN_FREE_INODES,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct PooledTargetConfig {
pub pooling_enabled: bool,
pub reaper_enabled: bool,
pub reaper_idle_hours: u32,
pub reaper_interval_mins: u64,
pub remote_base: String,
pub reaper_pooled_idle_hours: u32,
pub reaper_max_cache_gb: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub store_base: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub gc_extra_roots: Vec<String>,
pub gc_cargo_cache_idle_days: u32,
}
impl Default for PooledTargetConfig {
fn default() -> Self {
Self {
pooling_enabled: true,
reaper_enabled: false,
reaper_idle_hours: DEFAULT_POOLED_REAPER_IDLE_HOURS,
reaper_interval_mins: DEFAULT_POOLED_REAPER_INTERVAL_MINS,
remote_base: DEFAULT_POOLED_REMOTE_BASE.to_string(),
reaper_pooled_idle_hours: DEFAULT_POOLED_REAPER_POOLED_IDLE_HOURS,
reaper_max_cache_gb: DEFAULT_POOLED_REAPER_MAX_CACHE_GB,
store_base: None,
gc_extra_roots: Vec::new(),
gc_cargo_cache_idle_days: DEFAULT_GC_CARGO_CACHE_IDLE_DAYS,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct TelemetryFreshnessConfig {
pub max_age_secs: u64,
}
impl Default for TelemetryFreshnessConfig {
fn default() -> Self {
Self {
max_age_secs: DEFAULT_TELEMETRY_MAX_AGE_SECS,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct LogRetentionConfig {
pub max_file_bytes: u64,
pub keep_rotated: usize,
pub warn_total_bytes: u64,
pub critical_total_bytes: u64,
}
impl Default for LogRetentionConfig {
fn default() -> Self {
Self {
max_file_bytes: DEFAULT_LOG_MAX_FILE_BYTES,
keep_rotated: DEFAULT_LOG_KEEP_ROTATED,
warn_total_bytes: DEFAULT_LOG_WARN_TOTAL_BYTES,
critical_total_bytes: DEFAULT_LOG_CRITICAL_TOTAL_BYTES,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct DiskPressureConfig {
pub warning_avail_pct: f64,
pub critical_avail_pct: f64,
pub warning_avail_inodes: u64,
pub critical_avail_inodes: u64,
}
impl Default for DiskPressureConfig {
fn default() -> Self {
Self {
warning_avail_pct: DEFAULT_PRESSURE_WARNING_AVAIL_PCT,
critical_avail_pct: DEFAULT_PRESSURE_CRITICAL_AVAIL_PCT,
warning_avail_inodes: DEFAULT_PRESSURE_WARNING_AVAIL_INODES,
critical_avail_inodes: DEFAULT_PRESSURE_CRITICAL_AVAIL_INODES,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct SmokeConfig {
pub iterations: usize,
pub seed: u64,
}
impl Default for SmokeConfig {
fn default() -> Self {
Self {
iterations: DEFAULT_SMOKE_ITERATIONS,
seed: DEFAULT_SMOKE_SEED,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
#[serde(default)]
pub struct RemediationConfig {
pub policy: RemediationPolicy,
pub temporary_bypass: TemporaryBypassConfig,
pub auto_rejoin: AutoRejoinConfig,
pub reconciliation: ReconciliationConfig,
pub proof: ProofConfig,
pub incident_ledger: IncidentLedgerPolicy,
pub build_root: BuildRootConfig,
pub pooled_target: PooledTargetConfig,
pub telemetry_freshness: TelemetryFreshnessConfig,
pub log_retention: LogRetentionConfig,
pub disk_pressure: DiskPressureConfig,
pub smoke: SmokeConfig,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IssueSeverity {
Error,
Warning,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemediationIssue {
pub field: String,
pub severity: IssueSeverity,
pub message: String,
}
impl RemediationIssue {
fn error(field: impl Into<String>, message: impl Into<String>) -> Self {
Self {
field: field.into(),
severity: IssueSeverity::Error,
message: message.into(),
}
}
fn warning(field: impl Into<String>, message: impl Into<String>) -> Self {
Self {
field: field.into(),
severity: IssueSeverity::Warning,
message: message.into(),
}
}
}
impl RemediationConfig {
#[must_use]
pub fn hook_exec_is_fail_open(&self) -> bool {
self.policy.hook_exec_fail_open
}
#[must_use]
pub fn proof_mode_is_fail_closed(&self) -> bool {
self.policy.proof_mode_fail_closed
}
#[must_use]
pub fn validate(&self) -> Vec<RemediationIssue> {
let mut issues = Vec::new();
if self.temporary_bypass.backoff_initial_secs == 0 {
issues.push(RemediationIssue::error(
"remediation.temporary_bypass.backoff_initial_secs",
"backoff_initial_secs must be greater than 0",
));
}
if self.temporary_bypass.backoff_max_secs < self.temporary_bypass.backoff_initial_secs {
issues.push(RemediationIssue::error(
"remediation.temporary_bypass.backoff_max_secs",
"backoff_max_secs must be >= backoff_initial_secs",
));
}
if self.auto_rejoin.required_consecutive_passes == 0 {
issues.push(RemediationIssue::error(
"remediation.auto_rejoin.required_consecutive_passes",
"required_consecutive_passes must be at least 1",
));
}
if self.auto_rejoin.check_interval_secs == 0 {
issues.push(RemediationIssue::error(
"remediation.auto_rejoin.check_interval_secs",
"check_interval_secs must be greater than 0",
));
}
if self.auto_rejoin.probe_timeout_secs == 0 {
issues.push(RemediationIssue::error(
"remediation.auto_rejoin.probe_timeout_secs",
"probe_timeout_secs must be greater than 0",
));
}
if !self.auto_rejoin.min_disk_free_gb.is_finite() || self.auto_rejoin.min_disk_free_gb < 0.0
{
issues.push(RemediationIssue::error(
"remediation.auto_rejoin.min_disk_free_gb",
"min_disk_free_gb must be finite and >= 0",
));
}
if !self.auto_rejoin.max_load_per_core.is_finite()
|| self.auto_rejoin.max_load_per_core <= 0.0
{
issues.push(RemediationIssue::error(
"remediation.auto_rejoin.max_load_per_core",
"max_load_per_core must be finite and > 0",
));
}
if self.auto_rejoin.canary_command.trim().is_empty() {
issues.push(RemediationIssue::error(
"remediation.auto_rejoin.canary_command",
"canary_command must not be empty",
));
}
for root in &self.auto_rejoin.disk_roots {
check_managed_path("remediation.auto_rejoin.disk_roots", root, &mut issues);
}
if self.reconciliation.max_attempts == 0 {
issues.push(RemediationIssue::error(
"remediation.reconciliation.max_attempts",
"max_attempts must be at least 1",
));
}
if self.reconciliation.time_budget_secs == 0 {
issues.push(RemediationIssue::error(
"remediation.reconciliation.time_budget_secs",
"time_budget_secs must be greater than 0",
));
}
if let Some(path) = &self.proof.store_path {
check_managed_path("remediation.proof.store_path", path, &mut issues);
}
if self.incident_ledger.max_entries == 0 {
issues.push(RemediationIssue::error(
"remediation.incident_ledger.max_entries",
"max_entries must be at least 1",
));
}
if self.incident_ledger.max_bytes == 0 {
issues.push(RemediationIssue::error(
"remediation.incident_ledger.max_bytes",
"max_bytes must be greater than 0",
));
}
if let Some(path) = &self.incident_ledger.path {
check_managed_path("remediation.incident_ledger.path", path, &mut issues);
}
if self.build_root.min_free_bytes == 0 {
issues.push(RemediationIssue::error(
"remediation.build_root.min_free_bytes",
"min_free_bytes must be greater than 0",
));
}
if self.pooled_target.reaper_enabled && self.pooled_target.reaper_interval_mins == 0 {
issues.push(RemediationIssue::error(
"remediation.pooled_target.reaper_interval_mins",
"reaper_interval_mins must be > 0 when the reaper is enabled",
));
}
check_managed_path(
"remediation.pooled_target.remote_base",
&self.pooled_target.remote_base,
&mut issues,
);
if let Some(store_base) = self.pooled_target.store_base.as_deref() {
check_managed_path(
"remediation.pooled_target.store_base",
store_base,
&mut issues,
);
if !crate::stale_target_reap::is_safe_reap_base(store_base) {
issues.push(RemediationIssue::error(
"remediation.pooled_target.store_base",
format!(
"{store_base:?} is not a usable pooled-store base (absolute, no `..`, at least two levels deep)"
),
));
}
}
if self.pooled_target.reaper_pooled_idle_hours != 0
&& self.pooled_target.reaper_pooled_idle_hours < 24
{
issues.push(RemediationIssue::error(
"remediation.pooled_target.reaper_pooled_idle_hours",
"reaper_pooled_idle_hours must be 0 (disabled) or >= 24 — pooled dirs are warm caches, not short-TTL targets",
));
}
for (idx, root) in self.pooled_target.gc_extra_roots.iter().enumerate() {
check_managed_path(
"remediation.pooled_target.gc_extra_roots",
root,
&mut issues,
);
if !crate::stale_target_reap::is_safe_reap_base(root) {
issues.push(RemediationIssue::error(
"remediation.pooled_target.gc_extra_roots",
format!(
"gc_extra_roots[{idx}] {root:?} is not a usable gc scan root (absolute, no `..`, no shell metacharacters)"
),
));
}
}
if self.telemetry_freshness.max_age_secs == 0 {
issues.push(RemediationIssue::error(
"remediation.telemetry_freshness.max_age_secs",
"max_age_secs must be greater than 0",
));
}
if self.log_retention.max_file_bytes == 0 {
issues.push(RemediationIssue::error(
"remediation.log_retention.max_file_bytes",
"max_file_bytes must be greater than 0",
));
}
if self.log_retention.critical_total_bytes < self.log_retention.warn_total_bytes {
issues.push(RemediationIssue::error(
"remediation.log_retention.critical_total_bytes",
"critical_total_bytes must be >= warn_total_bytes",
));
}
let dp = &self.disk_pressure;
for (field, pct) in [
(
"remediation.disk_pressure.warning_avail_pct",
dp.warning_avail_pct,
),
(
"remediation.disk_pressure.critical_avail_pct",
dp.critical_avail_pct,
),
] {
if !pct.is_finite() || !(0.0..=100.0).contains(&pct) {
issues.push(RemediationIssue::error(
field,
"available-percent thresholds must be within [0.0, 100.0]",
));
}
}
if dp.critical_avail_pct > dp.warning_avail_pct {
issues.push(RemediationIssue::error(
"remediation.disk_pressure.critical_avail_pct",
"critical_avail_pct must be <= warning_avail_pct",
));
}
if dp.critical_avail_inodes > dp.warning_avail_inodes {
issues.push(RemediationIssue::error(
"remediation.disk_pressure.critical_avail_inodes",
"critical_avail_inodes must be <= warning_avail_inodes",
));
}
if self.smoke.iterations == 0 {
issues.push(RemediationIssue::error(
"remediation.smoke.iterations",
"iterations must be at least 1",
));
}
issues
}
#[must_use]
pub fn redacted(&self) -> Self {
let mut out = self.clone();
out.auto_rejoin.disk_roots = out
.auto_rejoin
.disk_roots
.iter()
.map(|p| redact_path(p))
.collect();
out.proof.store_path = out.proof.store_path.as_deref().map(redact_path);
out.incident_ledger.path = out.incident_ledger.path.as_deref().map(redact_path);
out.pooled_target.remote_base = redact_path(&out.pooled_target.remote_base);
out.pooled_target.store_base = out.pooled_target.store_base.as_deref().map(redact_path);
out.pooled_target.gc_extra_roots = out
.pooled_target
.gc_extra_roots
.iter()
.map(|root| redact_path(root.as_str()))
.collect();
out
}
#[must_use]
pub fn schema_json() -> serde_json::Value {
serde_json::to_value(
SchemaSettings::draft07()
.into_generator()
.into_root_schema_for::<RemediationConfig>(),
)
.expect("RemediationConfig schema serializes")
}
#[must_use]
pub fn human_help() -> String {
let d = RemediationConfig::default();
let mut s = String::new();
s.push_str("[remediation] — session-history remediation defaults\n");
s.push_str(" policy.hook_exec_fail_open = ");
s.push_str(&d.policy.hook_exec_fail_open.to_string());
s.push_str(" (ordinary hook/exec falls back to local on error)\n");
s.push_str(" policy.proof_mode_fail_closed = ");
s.push_str(&d.policy.proof_mode_fail_closed.to_string());
s.push_str(" (proof mode refuses local fallback)\n");
s.push_str(&format!(
" temporary_bypass.backoff_initial_secs = {}\n",
d.temporary_bypass.backoff_initial_secs
));
s.push_str(&format!(
" temporary_bypass.backoff_max_secs = {}\n",
d.temporary_bypass.backoff_max_secs
));
s.push_str(&format!(
" auto_rejoin.required_consecutive_passes = {}\n",
d.auto_rejoin.required_consecutive_passes
));
s.push_str(&format!(
" auto_rejoin.canary_required = {}\n",
d.auto_rejoin.canary_required
));
s.push_str(&format!(
" auto_rejoin.check_interval_secs = {}\n",
d.auto_rejoin.check_interval_secs
));
s.push_str(&format!(
" auto_rejoin.probe_timeout_secs = {}\n",
d.auto_rejoin.probe_timeout_secs
));
s.push_str(&format!(
" reconciliation.max_attempts = {}\n",
d.reconciliation.max_attempts
));
s.push_str(&format!(
" reconciliation.time_budget_secs = {}\n",
d.reconciliation.time_budget_secs
));
s.push_str(&format!(
" proof.default_stale_source_policy = {:?}\n",
d.proof.default_stale_source_policy
));
s.push_str(&format!(
" incident_ledger.max_entries = {}\n",
d.incident_ledger.max_entries
));
s.push_str(&format!(
" incident_ledger.max_bytes = {}\n",
d.incident_ledger.max_bytes
));
s.push_str(&format!(
" build_root.min_free_bytes = {}\n",
d.build_root.min_free_bytes
));
s.push_str(&format!(
" build_root.min_free_inodes = {}\n",
d.build_root.min_free_inodes
));
s.push_str(&format!(
" pooled_target.pooling_enabled = {}\n",
d.pooled_target.pooling_enabled
));
s.push_str(&format!(
" pooled_target.reaper_enabled = {}\n",
d.pooled_target.reaper_enabled
));
s.push_str(&format!(
" telemetry_freshness.max_age_secs = {}\n",
d.telemetry_freshness.max_age_secs
));
s.push_str(&format!(
" log_retention.max_file_bytes = {}\n",
d.log_retention.max_file_bytes
));
s.push_str(&format!(
" disk_pressure.warning_avail_pct = {}\n",
d.disk_pressure.warning_avail_pct
));
s.push_str(&format!(
" disk_pressure.critical_avail_pct = {}\n",
d.disk_pressure.critical_avail_pct
));
s.push_str(&format!(
" smoke.iterations = {}\n",
d.smoke.iterations
));
s.push_str(&format!(
" smoke.seed = {}\n",
d.smoke.seed
));
s
}
}
#[must_use]
fn is_managed_path(path: &str) -> bool {
if path.contains("/rch") {
return true;
}
RCH_MANAGED_ROOT_PREFIXES
.iter()
.any(|prefix| path == *prefix || path.starts_with(&format!("{prefix}/")))
}
fn check_managed_path(field: &str, path: &str, issues: &mut Vec<RemediationIssue>) {
if path.trim().is_empty() {
return;
}
let absolute = path.starts_with('/') || path.starts_with('~');
if !absolute {
issues.push(RemediationIssue::error(
field,
format!("path must be absolute, got {path:?}"),
));
return;
}
if !is_managed_path(path) {
issues.push(RemediationIssue::warning(
field,
format!(
"{path:?} is outside the RCH-managed roots; ensure it is an intended operator path"
),
));
}
}
impl From<&TemporaryBypassConfig> for crate::bypass_record::BypassBackoff {
fn from(cfg: &TemporaryBypassConfig) -> Self {
Self {
current_ms: cfg.backoff_initial_secs.saturating_mul(1000),
attempts: 0,
max_ms: cfg.backoff_max_secs.saturating_mul(1000),
}
}
}
impl From<&AutoRejoinConfig> for crate::bypass_record::AutoRejoinCriteria {
fn from(cfg: &AutoRejoinConfig) -> Self {
Self {
required_consecutive_passes: cfg.required_consecutive_passes,
canary_required: cfg.canary_required,
}
}
}
impl From<&IncidentLedgerPolicy> for IncidentLedgerConfig {
fn from(cfg: &IncidentLedgerPolicy) -> Self {
Self {
path: cfg
.path
.as_deref()
.map(str::trim)
.filter(|path| !path.is_empty())
.map_or_else(default_ledger_path, |path| {
std::path::PathBuf::from(shellexpand::tilde(path).into_owned())
}),
max_entries: cfg.max_entries,
max_bytes: cfg.max_bytes,
}
}
}
impl From<&LogRetentionConfig> for crate::log_retention::LogRetentionPolicy {
fn from(cfg: &LogRetentionConfig) -> Self {
Self {
max_file_bytes: cfg.max_file_bytes,
keep_rotated: cfg.keep_rotated,
warn_total_bytes: cfg.warn_total_bytes,
critical_total_bytes: cfg.critical_total_bytes,
}
}
}
impl From<&DiskPressureConfig> for crate::disk_pressure_report::PressureThresholds {
fn from(cfg: &DiskPressureConfig) -> Self {
Self {
warning_avail_pct: cfg.warning_avail_pct,
critical_avail_pct: cfg.critical_avail_pct,
warning_avail_inodes: cfg.warning_avail_inodes,
critical_avail_inodes: cfg.critical_avail_inodes,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bypass_record::{AutoRejoinCriteria, BypassBackoff};
use crate::disk_pressure_report::PressureThresholds;
use crate::log_retention::LogRetentionPolicy;
#[test]
fn pooled_store_base_is_opt_in_and_round_trips() {
assert_eq!(RemediationConfig::default().pooled_target.store_base, None);
let parsed: RemediationConfig = toml::from_str(
r#"
[pooled_target]
store_base = "/bigdisk/rch-pools"
"#,
)
.expect("parse pooled_target.store_base");
assert_eq!(
parsed.pooled_target.store_base.as_deref(),
Some("/bigdisk/rch-pools")
);
assert!(
parsed
.validate()
.iter()
.all(|issue| issue.field != "remediation.pooled_target.store_base"),
"an absolute, managed store_base must validate cleanly"
);
let round_trip: RemediationConfig =
toml::from_str(&toml::to_string(&parsed).expect("serialize")).expect("reparse");
assert_eq!(
round_trip.pooled_target.store_base,
parsed.pooled_target.store_base
);
let default_toml =
toml::to_string(&RemediationConfig::default()).expect("serialize default");
assert!(
!default_toml.contains("store_base"),
"an unset store_base must not be emitted: {default_toml}"
);
}
#[test]
fn gc_extra_roots_are_opt_in_and_round_trip() {
assert!(
RemediationConfig::default()
.pooled_target
.gc_extra_roots
.is_empty()
);
let parsed: RemediationConfig = toml::from_str(
r#"
[pooled_target]
gc_extra_roots = ["/mnt/big/rch", "/srv/rch"]
gc_cargo_cache_idle_days = 21
"#,
)
.expect("parse pooled_target.gc_extra_roots");
assert_eq!(
parsed.pooled_target.gc_extra_roots,
vec!["/mnt/big/rch".to_string(), "/srv/rch".to_string()]
);
assert_eq!(parsed.pooled_target.gc_cargo_cache_idle_days, 21);
let issues = parsed.validate();
assert!(
issues
.iter()
.all(|i| i.field != "remediation.pooled_target.gc_extra_roots"
|| i.severity != IssueSeverity::Error),
"absolute, managed roots must validate cleanly: {issues:?}"
);
let round_trip: RemediationConfig =
toml::from_str(&toml::to_string(&parsed).expect("serialize")).expect("round trip");
assert_eq!(
round_trip.pooled_target.gc_extra_roots,
parsed.pooled_target.gc_extra_roots
);
let default_toml =
toml::to_string(&RemediationConfig::default()).expect("serialize default");
assert!(
!default_toml.contains("gc_extra_roots"),
"an empty gc_extra_roots must not be emitted: {default_toml}"
);
}
#[test]
fn unsafe_gc_extra_roots_are_rejected() {
for bad in ["/", "relative/x", "/tmp/../etc", "/tmp/$(whoami)", "/a b/c"] {
let mut config = RemediationConfig::default();
config.pooled_target.gc_extra_roots = vec![bad.to_string()];
let issues = config.validate();
assert!(
issues
.iter()
.any(|i| i.field == "remediation.pooled_target.gc_extra_roots"
&& i.severity == IssueSeverity::Error),
"{bad:?} must be rejected: {issues:?}"
);
}
}
#[test]
fn gc_extra_roots_are_redacted_like_every_other_operator_path() {
let mut config = RemediationConfig::default();
config.pooled_target.gc_extra_roots = vec!["/home/carol/builds".to_string()];
assert_eq!(
config.redacted().pooled_target.gc_extra_roots,
vec!["/home/<redacted>/builds".to_string()]
);
}
#[test]
fn unsafe_pooled_store_base_is_rejected() {
for bad in ["relative/path", "/", "/tmp/../etc", "/toplevel"] {
let mut config = RemediationConfig::default();
config.pooled_target.store_base = Some(bad.to_string());
assert!(
config
.validate()
.iter()
.any(|issue| issue.field == "remediation.pooled_target.store_base"),
"expected {bad:?} to be rejected"
);
}
}
#[test]
fn default_construction_matches_documented_values() {
let c = RemediationConfig::default();
assert!(c.policy.hook_exec_fail_open);
assert!(c.policy.proof_mode_fail_closed);
assert_eq!(c.temporary_bypass.backoff_initial_secs, 30);
assert_eq!(c.temporary_bypass.backoff_max_secs, 900);
assert_eq!(c.auto_rejoin.required_consecutive_passes, 2);
assert!(c.auto_rejoin.canary_required);
assert_eq!(c.auto_rejoin.canary_command, "rustc --version");
assert_eq!(c.reconciliation.max_attempts, 3);
assert_eq!(c.incident_ledger.max_entries, 5_000);
assert_eq!(c.incident_ledger.max_bytes, 4 * 1024 * 1024);
assert_eq!(c.build_root.min_free_bytes, 2 * 1024 * 1024 * 1024);
assert_eq!(c.telemetry_freshness.max_age_secs, 120);
assert_eq!(c.smoke.iterations, 20);
}
#[test]
fn default_is_valid() {
let issues = RemediationConfig::default().validate();
assert!(issues.is_empty(), "default must be valid, got {issues:?}");
}
#[test]
fn serde_round_trip_via_toml() {
let c = RemediationConfig::default();
let toml_str = toml::to_string(&c).expect("serialize toml");
let back: RemediationConfig = toml::from_str(&toml_str).expect("deserialize toml");
assert_eq!(c, back);
}
#[test]
fn serde_round_trip_via_json() {
let c = RemediationConfig::default();
let json = serde_json::to_string(&c).expect("serialize json");
let back: RemediationConfig = serde_json::from_str(&json).expect("deserialize json");
assert_eq!(c, back);
}
#[test]
fn partial_toml_fills_missing_fields_from_default() {
let partial = r#"
[policy]
hook_exec_fail_open = false
[auto_rejoin]
check_interval_secs = 99
"#;
let c: RemediationConfig = toml::from_str(partial).expect("parse partial");
assert!(!c.policy.hook_exec_fail_open);
assert_eq!(c.auto_rejoin.check_interval_secs, 99);
assert!(c.policy.proof_mode_fail_closed);
assert_eq!(c.auto_rejoin.required_consecutive_passes, 2);
assert_eq!(c.incident_ledger.max_entries, 5_000);
}
#[test]
fn malformed_values_are_rejected_by_validate() {
let mut c = RemediationConfig::default();
c.temporary_bypass.backoff_initial_secs = 0;
c.auto_rejoin.check_interval_secs = 0;
c.auto_rejoin.canary_command = " ".to_string();
c.disk_pressure_force_invalid();
c.incident_ledger.max_entries = 0;
let issues = c.validate();
let fields: Vec<&str> = issues.iter().map(|i| i.field.as_str()).collect();
assert!(fields.contains(&"remediation.temporary_bypass.backoff_initial_secs"));
assert!(fields.contains(&"remediation.auto_rejoin.check_interval_secs"));
assert!(fields.contains(&"remediation.auto_rejoin.canary_command"));
assert!(fields.contains(&"remediation.incident_ledger.max_entries"));
assert!(issues.iter().any(|i| i.severity == IssueSeverity::Error
&& i.field.starts_with("remediation.disk_pressure")));
}
#[test]
fn malformed_toml_value_type_fails_to_parse() {
let bad = r#"
[temporary_bypass]
backoff_initial_secs = "thirty"
"#;
assert!(toml::from_str::<RemediationConfig>(bad).is_err());
}
#[test]
fn relative_path_is_an_error_managed_path_is_clean() {
let mut c = RemediationConfig::default();
c.incident_ledger.path = Some("relative/incidents.jsonl".to_string());
let issues = c.validate();
assert!(issues.iter().any(|i| {
i.field == "remediation.incident_ledger.path" && i.severity == IssueSeverity::Error
}));
c.incident_ledger.path = Some("/tmp/rch/incidents.jsonl".to_string());
let issues = c.validate();
assert!(
!issues
.iter()
.any(|i| i.field == "remediation.incident_ledger.path")
);
}
#[test]
fn operator_path_outside_managed_roots_warns() {
let mut c = RemediationConfig::default();
c.incident_ledger.path = Some("/var/lib/custom/incidents.jsonl".to_string());
let issues = c.validate();
let issue = issues
.iter()
.find(|i| i.field == "remediation.incident_ledger.path")
.expect("operator path produces an issue");
assert_eq!(issue.severity, IssueSeverity::Warning);
}
#[test]
fn redaction_masks_home_and_user_segments() {
let mut c = RemediationConfig::default();
c.incident_ledger.path = Some("/home/alice/.local/state/rch/incidents.jsonl".to_string());
c.proof.store_path = Some("/Users/bob/proofs.jsonl".to_string());
c.auto_rejoin.disk_roots = vec!["/home/carol/builds".to_string(), "/tmp/rch".to_string()];
let r = c.redacted();
assert_eq!(
r.incident_ledger.path.as_deref(),
Some("/home/<redacted>/.local/state/rch/incidents.jsonl")
);
assert_eq!(
r.proof.store_path.as_deref(),
Some("/Users/<redacted>/proofs.jsonl")
);
assert_eq!(
r.auto_rejoin.disk_roots,
vec![
"/home/<redacted>/builds".to_string(),
"/tmp/rch".to_string()
]
);
assert_eq!(redact_path("/tmp/rch"), "/tmp/rch");
assert_eq!(redact_path("/data/projects"), "/data/projects");
}
#[test]
fn schema_json_describes_the_struct() {
let schema = RemediationConfig::schema_json();
let props = schema
.get("properties")
.and_then(serde_json::Value::as_object)
.expect("schema has properties");
for section in [
"policy",
"temporary_bypass",
"auto_rejoin",
"reconciliation",
"proof",
"incident_ledger",
"build_root",
"pooled_target",
"telemetry_freshness",
"log_retention",
"disk_pressure",
"smoke",
] {
assert!(
props.contains_key(section),
"schema missing section {section}"
);
}
}
#[test]
fn human_help_lists_key_defaults() {
let help = RemediationConfig::human_help();
assert!(help.contains("hook_exec_fail_open"));
assert!(help.contains("proof_mode_fail_closed"));
assert!(help.contains("backoff_initial_secs"));
assert!(help.contains("incident_ledger.max_entries"));
}
#[test]
fn drift_guard_bypass_backoff() {
let from_cfg = BypassBackoff::from(&TemporaryBypassConfig::default());
assert_eq!(from_cfg, BypassBackoff::default());
}
#[test]
fn drift_guard_auto_rejoin_criteria() {
let from_cfg = AutoRejoinCriteria::from(&AutoRejoinConfig::default());
assert_eq!(from_cfg, AutoRejoinCriteria::default());
}
#[test]
fn drift_guard_log_retention() {
let from_cfg = LogRetentionPolicy::from(&LogRetentionConfig::default());
assert_eq!(from_cfg, LogRetentionPolicy::default());
}
#[test]
fn drift_guard_disk_pressure() {
let from_cfg = PressureThresholds::from(&DiskPressureConfig::default());
assert_eq!(from_cfg, PressureThresholds::default());
}
#[test]
fn drift_guard_incident_ledger() {
let from_cfg = IncidentLedgerConfig::from(&IncidentLedgerPolicy::default());
let runtime = IncidentLedgerConfig::default();
assert_eq!(from_cfg.max_entries, runtime.max_entries);
assert_eq!(from_cfg.max_bytes, runtime.max_bytes);
assert_eq!(from_cfg.path, runtime.path);
}
#[test]
fn incident_ledger_path_expands_tilde_and_treats_blank_as_unset() {
let with = |path: &str| {
IncidentLedgerConfig::from(&IncidentLedgerPolicy {
path: Some(path.to_owned()),
..IncidentLedgerPolicy::default()
})
.path
};
let expanded = with("~/state/incidents.jsonl");
assert!(expanded.is_absolute(), "{expanded:?}");
assert!(expanded.ends_with("state/incidents.jsonl"));
assert_eq!(with(" "), IncidentLedgerConfig::default().path);
assert_eq!(with("/x/i.jsonl"), std::path::PathBuf::from("/x/i.jsonl"));
}
impl RemediationConfig {
fn disk_pressure_force_invalid(&mut self) {
self.disk_pressure.critical_avail_pct = 90.0;
self.disk_pressure.warning_avail_pct = 10.0;
}
}
}