use crate::ir::IacResource;
use crate::sexpr::{SExpr, SExprError, ToSExpr};
use crate::sexpr_diff::{Edit, diff};
use crate::transform::ops::ResourceOp;
use crate::transform::{Transform, script};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Proposal {
pub reason: String,
pub script: String,
}
impl Proposal {
pub fn new(reason: impl Into<String>, script: impl Into<String>) -> Self {
Self {
reason: reason.into(),
script: script.into(),
}
}
}
#[derive(Debug)]
pub enum RemediationError {
ScriptParse(String),
InvariantViolations(Vec<String>),
Sexpr(SExprError),
}
impl std::fmt::Display for RemediationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ScriptParse(s) => write!(f, "remediation script parse: {s}"),
Self::InvariantViolations(v) => {
write!(f, "remediation invariant violations: {v:?}")
}
Self::Sexpr(e) => write!(f, "remediation sexpr: {e}"),
}
}
}
impl std::error::Error for RemediationError {}
impl From<SExprError> for RemediationError {
fn from(e: SExprError) -> Self {
Self::Sexpr(e)
}
}
#[derive(Debug, Clone)]
pub struct Outcome {
pub before: IacResource,
pub after: IacResource,
pub before_hash: String,
pub after_hash: String,
pub ops: Vec<ResourceOp>,
pub edits: Vec<Edit>,
pub proposal_reason: String,
}
impl Outcome {
#[must_use]
pub fn changed(&self) -> bool {
self.before_hash != self.after_hash
}
#[must_use]
pub fn edit_count(&self) -> usize {
self.edits.len()
}
}
pub type Invariant = fn(&IacResource) -> Vec<String>;
pub fn apply_proposal(
resource: &IacResource,
proposal: &Proposal,
) -> Result<Outcome, RemediationError> {
apply_proposal_with_invariants(resource, proposal, &[])
}
pub fn apply_proposal_with_invariants(
resource: &IacResource,
proposal: &Proposal,
invariants: &[Invariant],
) -> Result<Outcome, RemediationError> {
let ops = script::parse(&proposal.script).map_err(RemediationError::ScriptParse)?;
let before = resource.clone();
let before_sexpr = before.to_sexpr();
let before_hash = before_sexpr.content_hash().to_hex();
let after = ops.apply(before.clone());
let after_sexpr = after.to_sexpr();
let after_hash = after_sexpr.content_hash().to_hex();
let edits = diff(&before_sexpr, &after_sexpr);
let mut all_violations = Vec::new();
for inv in invariants {
all_violations.extend(inv(&after));
}
if !all_violations.is_empty() {
return Err(RemediationError::InvariantViolations(all_violations));
}
Ok(Outcome {
before,
after,
before_hash,
after_hash,
ops,
edits,
proposal_reason: proposal.reason.clone(),
})
}
#[must_use]
pub fn outcome_sexpr(o: &Outcome) -> SExpr {
use crate::sexpr::struct_expr;
struct_expr(
"remediation-outcome",
vec![
("reason", SExpr::String(o.proposal_reason.clone())),
("before-hash", SExpr::String(o.before_hash.clone())),
("after-hash", SExpr::String(o.after_hash.clone())),
("edit-count", SExpr::Integer(o.edit_count() as i64)),
(
"op-count",
SExpr::Integer(i64::try_from(o.ops.len()).unwrap_or(i64::MAX)),
),
],
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ir::IacType;
use crate::testing::{TestAttributeBuilder, test_resource};
fn sample() -> IacResource {
let mut r = test_resource("widget");
r.attributes = vec![
TestAttributeBuilder::new("name", IacType::String)
.required()
.build(),
TestAttributeBuilder::new("database_url", IacType::String).build(),
];
r
}
#[test]
fn apply_proposal_with_valid_script() {
let r = sample();
let p = Proposal::new(
"mark db url sensitive",
r#"(mark-sensitive "database_url")"#,
);
let o = apply_proposal(&r, &p).unwrap();
assert!(o.changed());
assert_ne!(o.before_hash, o.after_hash);
assert_eq!(o.ops.len(), 1);
assert!(!o.edits.is_empty());
assert_eq!(o.proposal_reason, "mark db url sensitive");
}
#[test]
fn apply_proposal_no_op_produces_unchanged_outcome() {
let r = sample();
let p = Proposal::new(
"mark nonexistent sensitive",
r#"(mark-sensitive "does_not_exist")"#,
);
let o = apply_proposal(&r, &p).unwrap();
assert!(!o.changed());
assert_eq!(o.before_hash, o.after_hash);
assert!(o.edits.is_empty());
}
#[test]
fn apply_proposal_rejects_malformed_script() {
let r = sample();
let p = Proposal::new("nope", "(mark-sensitive");
let err = apply_proposal(&r, &p).unwrap_err();
assert!(matches!(err, RemediationError::ScriptParse(_)));
}
#[test]
fn apply_proposal_rejects_unknown_op() {
let r = sample();
let p = Proposal::new("nope", r#"(nuke-everything "x")"#);
let err = apply_proposal(&r, &p).unwrap_err();
assert!(matches!(err, RemediationError::ScriptParse(s) if s.contains("unknown op")));
}
#[test]
fn apply_proposal_with_multiple_ops() {
let r = sample();
let p = Proposal::new(
"set description + sensitive",
r#"(set-description "v2") (mark-sensitive "database_url")"#,
);
let o = apply_proposal(&r, &p).unwrap();
assert_eq!(o.ops.len(), 2);
assert!(o.changed());
}
#[test]
fn content_hashes_are_stable_and_correct() {
use crate::sexpr::ToSExpr;
let r = sample();
let p = Proposal::new("noop", r#"(mark-sensitive "missing")"#);
let o = apply_proposal(&r, &p).unwrap();
assert_eq!(o.before_hash, r.content_hash().to_hex());
assert_eq!(o.after_hash, o.after.content_hash().to_hex());
}
#[test]
fn edit_list_reflects_sensitive_flip() {
let r = sample();
let p = Proposal::new("s", r#"(mark-sensitive "database_url")"#);
let o = apply_proposal(&r, &p).unwrap();
assert!(
o.edits.iter().any(|e| e.path().ends_with("sensitive")),
"expected a 'sensitive' edit, got {:?}",
o.edits,
);
}
fn no_unsensitive_dburl(r: &IacResource) -> Vec<String> {
let mut v = Vec::new();
for a in &r.attributes {
if a.canonical_name == "database_url" && !a.sensitive {
v.push("database_url must be sensitive".to_string());
}
}
v
}
#[test]
fn invariants_pass_when_remediation_fixes_them() {
let r = sample();
let p = Proposal::new("fix dburl", r#"(mark-sensitive "database_url")"#);
let o = apply_proposal_with_invariants(&r, &p, &[no_unsensitive_dburl]).unwrap();
assert!(o.changed());
}
#[test]
fn invariants_block_outcome_when_not_met() {
let r = sample();
let p = Proposal::new("wrong", r#"(mark-sensitive "other")"#);
let err = apply_proposal_with_invariants(&r, &p, &[no_unsensitive_dburl]).unwrap_err();
match err {
RemediationError::InvariantViolations(v) => {
assert_eq!(v.len(), 1);
assert!(v[0].contains("database_url"));
}
other => panic!("expected InvariantViolations, got {other:?}"),
}
}
#[test]
fn outcome_sexpr_has_audit_fields() {
let r = sample();
let p = Proposal::new("reason", r#"(mark-sensitive "database_url")"#);
let o = apply_proposal(&r, &p).unwrap();
let s = outcome_sexpr(&o).emit();
assert!(s.starts_with("(remediation-outcome"));
assert!(s.contains("(:reason \"reason\")"));
assert!(s.contains("(:before-hash"));
assert!(s.contains("(:after-hash"));
assert!(s.contains("(:edit-count"));
assert!(s.contains("(:op-count"));
}
#[test]
fn outcome_sexpr_is_deterministic() {
let r = sample();
let p = Proposal::new("x", r#"(mark-sensitive "database_url")"#);
let o = apply_proposal(&r, &p).unwrap();
assert_eq!(outcome_sexpr(&o).emit(), outcome_sexpr(&o).emit());
}
#[test]
fn error_displays_script_parse() {
let err = RemediationError::ScriptParse("bad".into());
assert!(err.to_string().contains("script parse"));
}
#[test]
fn error_displays_invariant_violations() {
let err = RemediationError::InvariantViolations(vec!["x".into()]);
assert!(err.to_string().contains("invariant violations"));
}
#[test]
fn apply_proposal_is_deterministic() {
let r = sample();
let p = Proposal::new(
"x",
r#"(set-description "new") (mark-sensitive "database_url")"#,
);
let o1 = apply_proposal(&r, &p).unwrap();
let o2 = apply_proposal(&r, &p).unwrap();
assert_eq!(o1.before_hash, o2.before_hash);
assert_eq!(o1.after_hash, o2.after_hash);
assert_eq!(o1.edits, o2.edits);
}
}