pub mod fixes;
#[cfg(test)]
mod tests_remediate;
use crate::core::config_hash::config_hash;
use crate::core::parser::{resource_field_value, violating_pairs};
use crate::core::types::{ForjarConfig, PolicyRule};
use crate::core::yaml_edit::{self, verify, AnchorError};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScalarFix {
pub policy_id: String,
pub resource_id: String,
pub field: String,
pub from: Option<String>,
pub to: String,
pub line: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Unfixable {
pub policy_id: String,
pub resource_id: String,
pub message: String,
pub severity: String,
pub rule_type: String,
pub remediation_hint: Option<String>,
pub reason: String,
}
#[derive(Debug, Clone)]
pub struct Report {
pub applied: Vec<ScalarFix>,
pub updated_yaml: String,
pub remaining: Vec<Unfixable>,
pub changed: bool,
pub hash_before: String,
pub hash_after: String,
pub scope_note: Option<String>,
}
struct Candidate {
rule_index: usize,
resource_id: String,
field: String,
value: String,
}
type ReasonMap = BTreeMap<(String, String), String>;
pub fn remediate(
source_text: &str,
config: &ForjarConfig,
policy_ids: Option<&[String]>,
) -> Result<Report, String> {
let hash_before = config_hash(config)?;
let mut reasons: ReasonMap = BTreeMap::new();
let candidates = derive_candidates(config, policy_ids, &mut reasons);
let mut updated = config.clone();
let outcome = apply_all(source_text, config, &candidates, &mut updated, &mut reasons);
let hash_after = config_hash(&updated)?;
Ok(Report {
changed: !outcome.applied.is_empty(),
remaining: remaining_violations(&updated, policy_ids, &reasons),
applied: outcome.applied,
updated_yaml: outcome.text,
hash_before,
hash_after,
scope_note: scope_note(config),
})
}
fn derive_candidates(
config: &ForjarConfig,
policy_ids: Option<&[String]>,
reasons: &mut ReasonMap,
) -> Vec<Candidate> {
let mut out = Vec::new();
for (rule_index, resource_id) in violating_pairs(config) {
let rule = &config.policies[rule_index];
if !selected(rule, policy_ids) {
record(reasons, &resource_id, rule, "not selected by policy_ids");
continue;
}
match fixes::derive(rule) {
Ok(spec) => out.push(Candidate {
rule_index,
resource_id,
field: spec.field,
value: spec.value,
}),
Err(reason) => record(reasons, &resource_id, rule, &reason),
}
}
out.sort_by(|a, b| {
(&a.resource_id, &a.field, &a.value).cmp(&(&b.resource_id, &b.field, &b.value))
});
drop_conflicts(config, out, reasons)
}
fn drop_conflicts(
config: &ForjarConfig,
candidates: Vec<Candidate>,
reasons: &mut ReasonMap,
) -> Vec<Candidate> {
let mut demanded: BTreeMap<(String, String), BTreeSet<String>> = BTreeMap::new();
for c in &candidates {
demanded
.entry((c.resource_id.clone(), c.field.clone()))
.or_default()
.insert(c.value.clone());
}
let key = |c: &Candidate| (c.resource_id.clone(), c.field.clone());
let (keep, conflicting): (Vec<_>, Vec<_>) = candidates
.into_iter()
.partition(|c| demanded[&key(c)].len() == 1);
for c in conflicting {
let values: Vec<String> = demanded[&key(&c)]
.iter()
.map(|v| format!("`{v}`"))
.collect();
let reason = format!(
"{} policy rules demand different values for `{}.{}` ({}) — forjar will not \
choose between them",
values.len(),
c.resource_id,
c.field,
values.join(" and ")
);
record(
reasons,
&c.resource_id,
&config.policies[c.rule_index],
&reason,
);
}
keep
}
fn selected(rule: &PolicyRule, policy_ids: Option<&[String]>) -> bool {
match policy_ids {
None => true,
Some([]) => true,
Some(ids) => ids.contains(&rule.display_id()),
}
}
fn record(reasons: &mut ReasonMap, resource_id: &str, rule: &PolicyRule, reason: &str) {
reasons.insert(
(resource_id.to_string(), rule.display_id()),
reason.to_string(),
);
}
struct Applied {
text: String,
applied: Vec<ScalarFix>,
}
fn apply_all(
source_text: &str,
config: &ForjarConfig,
candidates: &[Candidate],
updated: &mut ForjarConfig,
reasons: &mut ReasonMap,
) -> Applied {
let mut text = source_text.to_string();
let mut applied: Vec<ScalarFix> = Vec::new();
let mut expected: BTreeSet<Vec<String>> = BTreeSet::new();
for c in candidates {
let rule = &config.policies[c.rule_index];
match apply_one(&text, config, c) {
Ok((next, fix)) => {
expected.insert(vec![
"resources".to_string(),
c.resource_id.clone(),
c.field.clone(),
]);
text = next;
applied.push(fix);
}
Err(reason) => record(reasons, &c.resource_id, rule, &reason),
}
}
match verified(source_text, &text, &expected) {
Ok(()) => {
commit_to_config(updated, &applied);
Applied { text, applied }
}
Err(reason) => {
for fix in &applied {
reasons.insert(
(fix.resource_id.clone(), fix.policy_id.clone()),
reason.clone(),
);
}
Applied {
text: source_text.to_string(),
applied: Vec::new(),
}
}
}
}
fn apply_one(
text: &str,
config: &ForjarConfig,
c: &Candidate,
) -> Result<(String, ScalarFix), String> {
let rule = &config.policies[c.rule_index];
let resource = config
.resources
.get(&c.resource_id)
.ok_or_else(|| "the resource is not in the resolved config".to_string())?;
let path = ["resources", c.resource_id.as_str(), c.field.as_str()];
let span = yaml_edit::find_scalar(text, &path).map_err(|e| anchor_reason(e, config, c))?;
let in_text = yaml_edit::unquote(yaml_edit::scalar_text(text, &span));
let resolved = resource_field_value(resource, &c.field);
if resolved.as_deref() != Some(in_text.as_str()) {
return Err(format!(
"the document says `{in_text}` where the resolved config says `{}` — the value \
is produced by a recipe or a {{{{template}}}} expansion, so editing the literal \
would not change it",
resolved.as_deref().unwrap_or("<unset>")
));
}
let emitted = emit_in_style(yaml_edit::scalar_text(text, &span), &c.value)?;
Ok((
yaml_edit::splice(text, &span, &emitted),
ScalarFix {
policy_id: rule.display_id(),
resource_id: c.resource_id.clone(),
field: c.field.clone(),
from: resolved,
to: c.value.clone(),
line: span.line,
},
))
}
fn emit_in_style(existing: &str, value: &str) -> Result<String, String> {
let emitted = yaml_edit::emit_scalar(value).map_err(|e| e.reason().to_string())?;
if !existing.starts_with('"') || emitted.starts_with('"') {
return Ok(emitted);
}
let candidate = format!("\"{value}\"");
match serde_yaml_ng::from_str::<serde_yaml_ng::Value>(&candidate) {
Ok(parsed) if parsed.as_str() == Some(value) => Ok(candidate),
_ => Ok(emitted),
}
}
fn anchor_reason(e: AnchorError, config: &ForjarConfig, c: &Candidate) -> String {
let key = format!("resource:{}", c.resource_id);
match (e, config.include_provenance.get(&key)) {
(AnchorError::NotFound, Some(file)) => format!(
"`{}` is defined in the included file `{file}`, not in this document",
c.resource_id
),
_ => e.reason().to_string(),
}
}
fn verified(before: &str, after: &str, expected: &BTreeSet<Vec<String>>) -> Result<(), String> {
let changed = verify::changed_paths_of_text(before, after)?;
if &changed == expected {
return Ok(());
}
Err(format!(
"the edit could not be verified: it changed {} instead of {} — every fix in this \
batch was discarded",
render(&changed),
render(expected)
))
}
fn render(paths: &BTreeSet<Vec<String>>) -> String {
if paths.is_empty() {
return "nothing".to_string();
}
paths
.iter()
.map(|p| p.join("."))
.collect::<Vec<_>>()
.join(", ")
}
fn commit_to_config(config: &mut ForjarConfig, applied: &[ScalarFix]) {
for fix in applied {
if let Some(resource) = config.resources.get_mut(&fix.resource_id) {
fixes::set_field(resource, &fix.field, &fix.to);
}
}
}
fn remaining_violations(
updated: &ForjarConfig,
policy_ids: Option<&[String]>,
reasons: &ReasonMap,
) -> Vec<Unfixable> {
violating_pairs(updated)
.into_iter()
.map(|(rule_index, resource_id)| {
let rule = &updated.policies[rule_index];
Unfixable {
reason: reason_for(reasons, &resource_id, rule, policy_ids),
policy_id: rule.display_id(),
resource_id,
message: rule.message.clone(),
severity: format!("{:?}", rule.effective_severity()).to_lowercase(),
rule_type: format!("{:?}", rule.rule_type).to_lowercase(),
remediation_hint: rule.remediation.clone(),
}
})
.collect()
}
fn reason_for(
reasons: &ReasonMap,
resource_id: &str,
rule: &PolicyRule,
policy_ids: Option<&[String]>,
) -> String {
if let Some(reason) = reasons.get(&(resource_id.to_string(), rule.display_id())) {
return reason.clone();
}
if !selected(rule, policy_ids) {
return "not selected by policy_ids".to_string();
}
match fixes::derive(rule) {
Ok(_) => "the correction was written but the rule is still violated".to_string(),
Err(reason) => reason,
}
}
fn scope_note(config: &ForjarConfig) -> Option<String> {
if !config.policies.is_empty() {
return None;
}
Some(
"this config declares no `policies:` block. Remediation reads inline policy rules \
only — compliance packs record no resource id per failed check, so a pack failure \
cannot be anchored to a location in the document"
.to_string(),
)
}