use super::*; #[derive(Debug)] /// One rejected configuration path and its optional nearest known key. pub(super) struct UnknownConfigKey { /// Fully qualified configuration path. pub(super) path: String, /// Nearest known key when edit distance is sufficiently small. pub(super) suggestion: Option, } fn table_at<'a>(value: &'a toml::Value, path: &[&str]) -> Option<&'a toml::Table> { let mut current = value; for segment in path { current = current.get(*segment)?; } current.as_table() } /// Reads strict-key enforcement without deserializing the full configuration. pub(super) fn is_strict_config(parsed_toml: &toml::Value) -> bool { table_at(parsed_toml, &["general"]) .and_then(|table| table.get("config_strict")) .and_then(toml::Value::as_bool) .unwrap_or(false) } fn known_config_keys_for_suggestion() -> Vec<&'static str> { let mut keys = Vec::new(); for group in [ TOP_LEVEL_CONFIG_KEYS, GENERAL_CONFIG_KEYS, NETWORK_CONFIG_KEYS, SERVER_CONFIG_KEYS, API_CONFIG_KEYS, CONNTRACK_CONTROL_CONFIG_KEYS, LISTENER_CONFIG_KEYS, WEB_CONFIG_KEYS, WEB_LIMITS_CONFIG_KEYS, WEB_DEBUG_CONFIG_KEYS, WEB_TIMEOUTS_CONFIG_KEYS, WEB_VHOST_CONFIG_KEYS, WEB_DECOY_CONFIG_KEYS, WEB_PROFILE_CONFIG_KEYS, TIMEOUTS_CONFIG_KEYS, CENSORSHIP_CONFIG_KEYS, TLS_FETCH_CONFIG_KEYS, ACCESS_CONFIG_KEYS, RATE_LIMIT_BPS_CONFIG_KEYS, UPSTREAM_CONFIG_KEYS, PROXY_MODES_CONFIG_KEYS, TELEMETRY_CONFIG_KEYS, LINKS_CONFIG_KEYS, LOGGING_CONFIG_KEYS, ] { keys.extend_from_slice(group); } keys } fn levenshtein_distance(a: &str, b: &str) -> usize { let b_chars: Vec = b.chars().collect(); let mut prev: Vec = (0..=b_chars.len()).collect(); let mut curr = vec![0usize; b_chars.len() + 1]; for (i, ca) in a.chars().enumerate() { curr[0] = i + 1; for (j, cb) in b_chars.iter().enumerate() { let replace = if ca == *cb { prev[j] } else { prev[j] + 1 }; curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(replace); } std::mem::swap(&mut prev, &mut curr); } prev[b_chars.len()] } fn unknown_key_suggestion(key: &str, known_keys: &[&'static str]) -> Option { let normalized = key.to_ascii_lowercase(); let mut best: Option<(&str, usize)> = None; for known in known_keys { let distance = levenshtein_distance(&normalized, known); let is_better = match best { Some((_, best_distance)) => distance < best_distance, None => true, }; if distance <= 4 && is_better { best = Some((known, distance)); } } best.map(|(known, _)| known.to_string()) } fn push_unknown_keys( unknown: &mut Vec, known_for_suggestion: &[&'static str], path: &str, table: &toml::Table, allowed: &[&str], ) { for key in table.keys() { if !allowed.contains(&key.as_str()) { let full_path = if path.is_empty() { key.clone() } else { format!("{path}.{key}") }; unknown.push(UnknownConfigKey { path: full_path, suggestion: unknown_key_suggestion(key, known_for_suggestion), }); } } } fn check_known_table( parsed_toml: &toml::Value, unknown: &mut Vec, known_for_suggestion: &[&'static str], path: &[&str], allowed: &[&str], ) { if let Some(table) = table_at(parsed_toml, path) { push_unknown_keys( unknown, known_for_suggestion, &path.join("."), table, allowed, ); } } fn check_nested_table_value( unknown: &mut Vec, known_for_suggestion: &[&'static str], path: String, value: &toml::Value, allowed: &[&str], ) { if let Some(table) = value.as_table() { push_unknown_keys(unknown, known_for_suggestion, &path, table, allowed); } } /// Collects unknown keys across every supported nested configuration table. pub(super) fn collect_unknown_config_keys(parsed_toml: &toml::Value) -> Vec { let known_for_suggestion = known_config_keys_for_suggestion(); let mut unknown = Vec::new(); if let Some(root) = parsed_toml.as_table() { push_unknown_keys( &mut unknown, &known_for_suggestion, "", root, TOP_LEVEL_CONFIG_KEYS, ); } check_known_table( parsed_toml, &mut unknown, &known_for_suggestion, &["general"], GENERAL_CONFIG_KEYS, ); check_known_table( parsed_toml, &mut unknown, &known_for_suggestion, &["general", "modes"], PROXY_MODES_CONFIG_KEYS, ); check_known_table( parsed_toml, &mut unknown, &known_for_suggestion, &["general", "telemetry"], TELEMETRY_CONFIG_KEYS, ); check_known_table( parsed_toml, &mut unknown, &known_for_suggestion, &["general", "links"], LINKS_CONFIG_KEYS, ); check_known_table( parsed_toml, &mut unknown, &known_for_suggestion, &["logging"], LOGGING_CONFIG_KEYS, ); check_known_table( parsed_toml, &mut unknown, &known_for_suggestion, &["network"], NETWORK_CONFIG_KEYS, ); check_known_table( parsed_toml, &mut unknown, &known_for_suggestion, &["server"], SERVER_CONFIG_KEYS, ); check_known_table( parsed_toml, &mut unknown, &known_for_suggestion, &["server", "api"], API_CONFIG_KEYS, ); check_known_table( parsed_toml, &mut unknown, &known_for_suggestion, &["server", "admin_api"], API_CONFIG_KEYS, ); check_known_table( parsed_toml, &mut unknown, &known_for_suggestion, &["server", "conntrack_control"], CONNTRACK_CONTROL_CONFIG_KEYS, ); check_known_table( parsed_toml, &mut unknown, &known_for_suggestion, &["web"], WEB_CONFIG_KEYS, ); check_known_table( parsed_toml, &mut unknown, &known_for_suggestion, &["web", "limits"], WEB_LIMITS_CONFIG_KEYS, ); check_known_table( parsed_toml, &mut unknown, &known_for_suggestion, &["web", "debug"], WEB_DEBUG_CONFIG_KEYS, ); check_known_table( parsed_toml, &mut unknown, &known_for_suggestion, &["web", "timeouts"], WEB_TIMEOUTS_CONFIG_KEYS, ); check_known_table( parsed_toml, &mut unknown, &known_for_suggestion, &["timeouts"], TIMEOUTS_CONFIG_KEYS, ); check_known_table( parsed_toml, &mut unknown, &known_for_suggestion, &["censorship"], CENSORSHIP_CONFIG_KEYS, ); check_known_table( parsed_toml, &mut unknown, &known_for_suggestion, &["censorship", "tls_fetch"], TLS_FETCH_CONFIG_KEYS, ); check_known_table( parsed_toml, &mut unknown, &known_for_suggestion, &["access"], ACCESS_CONFIG_KEYS, ); if let Some(listeners) = table_at(parsed_toml, &["server"]) .and_then(|table| table.get("listeners")) .and_then(toml::Value::as_array) { for (idx, listener) in listeners.iter().enumerate() { check_nested_table_value( &mut unknown, &known_for_suggestion, format!("server.listeners[{idx}]"), listener, LISTENER_CONFIG_KEYS, ); } } if let Some(vhosts) = table_at(parsed_toml, &["web"]) .and_then(|table| table.get("vhosts")) .and_then(toml::Value::as_array) { for (vhost_idx, vhost) in vhosts.iter().enumerate() { check_nested_table_value( &mut unknown, &known_for_suggestion, format!("web.vhosts[{vhost_idx}]"), vhost, WEB_VHOST_CONFIG_KEYS, ); if let Some(vhost) = vhost.as_table() { if let Some(decoy) = vhost.get("decoy") { check_nested_table_value( &mut unknown, &known_for_suggestion, format!("web.vhosts[{vhost_idx}].decoy"), decoy, WEB_DECOY_CONFIG_KEYS, ); } if let Some(profiles) = vhost.get("profiles").and_then(toml::Value::as_array) { for (profile_idx, profile) in profiles.iter().enumerate() { check_nested_table_value( &mut unknown, &known_for_suggestion, format!("web.vhosts[{vhost_idx}].profiles[{profile_idx}]"), profile, WEB_PROFILE_CONFIG_KEYS, ); } } } } } if let Some(upstreams) = parsed_toml.get("upstreams").and_then(toml::Value::as_array) { for (idx, upstream) in upstreams.iter().enumerate() { check_nested_table_value( &mut unknown, &known_for_suggestion, format!("upstreams[{idx}]"), upstream, UPSTREAM_CONFIG_KEYS, ); } } for access_map in ["user_rate_limits", "cidr_rate_limits"] { if let Some(table) = table_at(parsed_toml, &["access"]) .and_then(|access| access.get(access_map)) .and_then(toml::Value::as_table) { for (entry_name, value) in table { check_nested_table_value( &mut unknown, &known_for_suggestion, format!("access.{access_map}.{entry_name}"), value, RATE_LIMIT_BPS_CONFIG_KEYS, ); } } } unknown }