1use crate::ir::IacResource;
41use crate::sexpr::{SExpr, SExprError, ToSExpr};
42use crate::sexpr_diff::{Edit, diff};
43use crate::transform::ops::ResourceOp;
44use crate::transform::{Transform, script};
45
46#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct Proposal {
50 pub reason: String,
53 pub script: String,
55}
56
57impl Proposal {
58 pub fn new(reason: impl Into<String>, script: impl Into<String>) -> Self {
60 Self {
61 reason: reason.into(),
62 script: script.into(),
63 }
64 }
65}
66
67#[derive(Debug)]
69pub enum RemediationError {
70 ScriptParse(String),
73 InvariantViolations(Vec<String>),
76 Sexpr(SExprError),
80}
81
82impl std::fmt::Display for RemediationError {
83 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84 match self {
85 Self::ScriptParse(s) => write!(f, "remediation script parse: {s}"),
86 Self::InvariantViolations(v) => {
87 write!(f, "remediation invariant violations: {v:?}")
88 }
89 Self::Sexpr(e) => write!(f, "remediation sexpr: {e}"),
90 }
91 }
92}
93
94impl std::error::Error for RemediationError {}
95
96impl From<SExprError> for RemediationError {
97 fn from(e: SExprError) -> Self {
98 Self::Sexpr(e)
99 }
100}
101
102#[derive(Debug, Clone)]
109pub struct Outcome {
110 pub before: IacResource,
112 pub after: IacResource,
114 pub before_hash: String,
116 pub after_hash: String,
118 pub ops: Vec<ResourceOp>,
120 pub edits: Vec<Edit>,
123 pub proposal_reason: String,
125}
126
127impl Outcome {
128 #[must_use]
130 pub fn changed(&self) -> bool {
131 self.before_hash != self.after_hash
132 }
133
134 #[must_use]
136 pub fn edit_count(&self) -> usize {
137 self.edits.len()
138 }
139}
140
141pub type Invariant = fn(&IacResource) -> Vec<String>;
144
145pub fn apply_proposal(
152 resource: &IacResource,
153 proposal: &Proposal,
154) -> Result<Outcome, RemediationError> {
155 apply_proposal_with_invariants(resource, proposal, &[])
156}
157
158pub fn apply_proposal_with_invariants(
164 resource: &IacResource,
165 proposal: &Proposal,
166 invariants: &[Invariant],
167) -> Result<Outcome, RemediationError> {
168 let ops = script::parse(&proposal.script).map_err(RemediationError::ScriptParse)?;
169
170 let before = resource.clone();
171 let before_sexpr = before.to_sexpr();
172 let before_hash = before_sexpr.content_hash().to_hex();
173
174 let after = ops.apply(before.clone());
175 let after_sexpr = after.to_sexpr();
176 let after_hash = after_sexpr.content_hash().to_hex();
177
178 let edits = diff(&before_sexpr, &after_sexpr);
180
181 let mut all_violations = Vec::new();
183 for inv in invariants {
184 all_violations.extend(inv(&after));
185 }
186 if !all_violations.is_empty() {
187 return Err(RemediationError::InvariantViolations(all_violations));
188 }
189
190 Ok(Outcome {
191 before,
192 after,
193 before_hash,
194 after_hash,
195 ops,
196 edits,
197 proposal_reason: proposal.reason.clone(),
198 })
199}
200
201#[must_use]
211pub fn outcome_sexpr(o: &Outcome) -> SExpr {
212 use crate::sexpr::struct_expr;
213 struct_expr(
214 "remediation-outcome",
215 vec![
216 ("reason", SExpr::String(o.proposal_reason.clone())),
217 ("before-hash", SExpr::String(o.before_hash.clone())),
218 ("after-hash", SExpr::String(o.after_hash.clone())),
219 ("edit-count", SExpr::Integer(o.edit_count() as i64)),
220 (
221 "op-count",
222 SExpr::Integer(i64::try_from(o.ops.len()).unwrap_or(i64::MAX)),
223 ),
224 ],
225 )
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231 use crate::ir::IacType;
232 use crate::testing::{TestAttributeBuilder, test_resource};
233
234 fn sample() -> IacResource {
235 let mut r = test_resource("widget");
236 r.attributes = vec![
237 TestAttributeBuilder::new("name", IacType::String)
238 .required()
239 .build(),
240 TestAttributeBuilder::new("database_url", IacType::String).build(),
241 ];
242 r
243 }
244
245 #[test]
246 fn apply_proposal_with_valid_script() {
247 let r = sample();
248 let p = Proposal::new(
249 "mark db url sensitive",
250 r#"(mark-sensitive "database_url")"#,
251 );
252 let o = apply_proposal(&r, &p).unwrap();
253 assert!(o.changed());
254 assert_ne!(o.before_hash, o.after_hash);
255 assert_eq!(o.ops.len(), 1);
256 assert!(!o.edits.is_empty());
257 assert_eq!(o.proposal_reason, "mark db url sensitive");
258 }
259
260 #[test]
261 fn apply_proposal_no_op_produces_unchanged_outcome() {
262 let r = sample();
263 let p = Proposal::new(
264 "mark nonexistent sensitive",
265 r#"(mark-sensitive "does_not_exist")"#,
266 );
267 let o = apply_proposal(&r, &p).unwrap();
268 assert!(!o.changed());
270 assert_eq!(o.before_hash, o.after_hash);
271 assert!(o.edits.is_empty());
272 }
273
274 #[test]
275 fn apply_proposal_rejects_malformed_script() {
276 let r = sample();
277 let p = Proposal::new("nope", "(mark-sensitive");
278 let err = apply_proposal(&r, &p).unwrap_err();
279 assert!(matches!(err, RemediationError::ScriptParse(_)));
280 }
281
282 #[test]
283 fn apply_proposal_rejects_unknown_op() {
284 let r = sample();
285 let p = Proposal::new("nope", r#"(nuke-everything "x")"#);
286 let err = apply_proposal(&r, &p).unwrap_err();
287 assert!(matches!(err, RemediationError::ScriptParse(s) if s.contains("unknown op")));
288 }
289
290 #[test]
291 fn apply_proposal_with_multiple_ops() {
292 let r = sample();
293 let p = Proposal::new(
294 "set description + sensitive",
295 r#"(set-description "v2") (mark-sensitive "database_url")"#,
296 );
297 let o = apply_proposal(&r, &p).unwrap();
298 assert_eq!(o.ops.len(), 2);
299 assert!(o.changed());
300 }
301
302 #[test]
303 fn content_hashes_are_stable_and_correct() {
304 use crate::sexpr::ToSExpr;
305 let r = sample();
306 let p = Proposal::new("noop", r#"(mark-sensitive "missing")"#);
307 let o = apply_proposal(&r, &p).unwrap();
308 assert_eq!(o.before_hash, r.content_hash().to_hex());
309 assert_eq!(o.after_hash, o.after.content_hash().to_hex());
310 }
311
312 #[test]
313 fn edit_list_reflects_sensitive_flip() {
314 let r = sample();
315 let p = Proposal::new("s", r#"(mark-sensitive "database_url")"#);
316 let o = apply_proposal(&r, &p).unwrap();
317 assert!(
320 o.edits.iter().any(|e| e.path().ends_with("sensitive")),
321 "expected a 'sensitive' edit, got {:?}",
322 o.edits,
323 );
324 }
325
326 fn no_unsensitive_dburl(r: &IacResource) -> Vec<String> {
329 let mut v = Vec::new();
330 for a in &r.attributes {
331 if a.canonical_name == "database_url" && !a.sensitive {
332 v.push("database_url must be sensitive".to_string());
333 }
334 }
335 v
336 }
337
338 #[test]
339 fn invariants_pass_when_remediation_fixes_them() {
340 let r = sample();
341 let p = Proposal::new("fix dburl", r#"(mark-sensitive "database_url")"#);
342 let o = apply_proposal_with_invariants(&r, &p, &[no_unsensitive_dburl]).unwrap();
343 assert!(o.changed());
344 }
345
346 #[test]
347 fn invariants_block_outcome_when_not_met() {
348 let r = sample();
349 let p = Proposal::new("wrong", r#"(mark-sensitive "other")"#);
352 let err = apply_proposal_with_invariants(&r, &p, &[no_unsensitive_dburl]).unwrap_err();
353 match err {
354 RemediationError::InvariantViolations(v) => {
355 assert_eq!(v.len(), 1);
356 assert!(v[0].contains("database_url"));
357 }
358 other => panic!("expected InvariantViolations, got {other:?}"),
359 }
360 }
361
362 #[test]
365 fn outcome_sexpr_has_audit_fields() {
366 let r = sample();
367 let p = Proposal::new("reason", r#"(mark-sensitive "database_url")"#);
368 let o = apply_proposal(&r, &p).unwrap();
369 let s = outcome_sexpr(&o).emit();
370 assert!(s.starts_with("(remediation-outcome"));
371 assert!(s.contains("(:reason \"reason\")"));
372 assert!(s.contains("(:before-hash"));
373 assert!(s.contains("(:after-hash"));
374 assert!(s.contains("(:edit-count"));
375 assert!(s.contains("(:op-count"));
376 }
377
378 #[test]
379 fn outcome_sexpr_is_deterministic() {
380 let r = sample();
381 let p = Proposal::new("x", r#"(mark-sensitive "database_url")"#);
382 let o = apply_proposal(&r, &p).unwrap();
383 assert_eq!(outcome_sexpr(&o).emit(), outcome_sexpr(&o).emit());
384 }
385
386 #[test]
389 fn error_displays_script_parse() {
390 let err = RemediationError::ScriptParse("bad".into());
391 assert!(err.to_string().contains("script parse"));
392 }
393
394 #[test]
395 fn error_displays_invariant_violations() {
396 let err = RemediationError::InvariantViolations(vec!["x".into()]);
397 assert!(err.to_string().contains("invariant violations"));
398 }
399
400 #[test]
403 fn apply_proposal_is_deterministic() {
404 let r = sample();
405 let p = Proposal::new(
406 "x",
407 r#"(set-description "new") (mark-sensitive "database_url")"#,
408 );
409 let o1 = apply_proposal(&r, &p).unwrap();
410 let o2 = apply_proposal(&r, &p).unwrap();
411 assert_eq!(o1.before_hash, o2.before_hash);
412 assert_eq!(o1.after_hash, o2.after_hash);
413 assert_eq!(o1.edits, o2.edits);
414 }
415}