use std::borrow::Cow;
use yamlpath::{Component, Route};
use super::{FixDisposition, FixPatch, LintWarning};
#[derive(Debug, Clone)]
pub struct SourceFixOutcome {
pub fixed_source: String,
pub applied: usize,
pub failed: usize,
}
pub fn json_pointer_to_route(path: &str) -> Route<'static> {
let trimmed = path.strip_prefix('/').unwrap_or(path);
if trimmed.is_empty() {
return Route::default();
}
let components: Vec<Component<'static>> = trimmed
.split('/')
.map(|segment| {
if let Ok(idx) = segment.parse::<usize>() {
Component::Index(idx)
} else {
Component::Key(Cow::Owned(segment.to_string()))
}
})
.collect();
Route::from(components)
}
pub fn apply_single_fix_patch(
doc: &yamlpath::Document,
patch: &FixPatch,
) -> Result<yamlpath::Document, String> {
match patch {
FixPatch::ReplaceValue { path, new_value } => {
let yp = yamlpatch::Patch {
route: json_pointer_to_route(path),
operation: yamlpatch::Op::Replace(yaml_serde::Value::String(new_value.clone())),
};
yamlpatch::apply_yaml_patches(doc, &[yp]).map_err(|e| e.to_string())
}
FixPatch::ReplaceKey { path, new_key } => apply_rename_key(doc, path, new_key),
FixPatch::Remove { path } => {
let yp = yamlpatch::Patch {
route: json_pointer_to_route(path),
operation: yamlpatch::Op::Remove,
};
yamlpatch::apply_yaml_patches(doc, &[yp]).map_err(|e| e.to_string())
}
}
}
pub fn apply_rename_key(
doc: &yamlpath::Document,
path: &str,
new_key: &str,
) -> Result<yamlpath::Document, String> {
let route = json_pointer_to_route(path);
let key_feature = doc
.query_key_only(&route)
.map_err(|e| format!("route query failed for key rename: {e}"))?;
let (start, end) = key_feature.location.byte_span;
let mut patched = doc.source().to_string();
patched.replace_range(start..end, new_key);
yamlpath::Document::new(patched).map_err(|e| format!("re-parse after key rename failed: {e}"))
}
pub fn apply_fixes_to_source(source: &str, warnings: &[&LintWarning]) -> SourceFixOutcome {
let safe_fixes = || {
warnings.iter().filter(|w| {
w.fix
.as_ref()
.is_some_and(|f| f.disposition == FixDisposition::Safe)
})
};
let mut current_doc = match yamlpath::Document::new(source.to_string()) {
Ok(d) => d,
Err(_) => {
return SourceFixOutcome {
fixed_source: source.to_string(),
applied: 0,
failed: safe_fixes().count(),
};
}
};
let mut applied = 0usize;
let mut failed = 0usize;
for w in safe_fixes() {
let fix = w.fix.as_ref().expect("filtered to fixes above");
let mut ok = true;
for patch in &fix.patches {
match apply_single_fix_patch(¤t_doc, patch) {
Ok(new_doc) => current_doc = new_doc,
Err(_) => {
failed += 1;
ok = false;
break;
}
}
}
if ok {
applied += 1;
}
}
SourceFixOutcome {
fixed_source: current_doc.source().to_string(),
applied,
failed,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lint::lint_yaml_str;
use insta::assert_snapshot;
#[test]
fn json_pointer_root() {
let route = json_pointer_to_route("/");
assert!(route.is_empty());
}
#[test]
fn json_pointer_empty() {
let route = json_pointer_to_route("");
assert!(route.is_empty());
}
#[test]
fn json_pointer_simple_key() {
let route = json_pointer_to_route("/status");
assert_snapshot!(format!("{route:?}"), @r#"Route { route: [Key("status")] }"#);
}
#[test]
fn json_pointer_nested() {
let route = json_pointer_to_route("/logsource/category");
assert_snapshot!(format!("{route:?}"), @r#"Route { route: [Key("logsource"), Key("category")] }"#);
}
#[test]
fn json_pointer_with_index() {
let route = json_pointer_to_route("/tags/2");
assert_snapshot!(format!("{route:?}"), @r#"Route { route: [Key("tags"), Index(2)] }"#);
}
#[test]
fn json_pointer_detection_path() {
let route = json_pointer_to_route("/detection/selection/CommandLine|contains");
assert_snapshot!(format!("{route:?}"), @r#"Route { route: [Key("detection"), Key("selection"), Key("CommandLine|contains")] }"#);
}
#[test]
fn fix_replace_value_on_file() {
let yaml = "title: Test\nstatus: experimetal\nlevel: medium\n";
let doc = yamlpath::Document::new(yaml.to_string()).unwrap();
let route = json_pointer_to_route("/status");
let patch = yamlpatch::Patch {
route,
operation: yamlpatch::Op::Replace(yaml_serde::Value::String(
"experimental".to_string(),
)),
};
let result = yamlpatch::apply_yaml_patches(&doc, &[patch]).unwrap();
assert_snapshot!(result.source(), @r"
title: Test
status: experimental
level: medium
");
}
#[test]
fn fix_remove_on_file() {
let yaml = "title: Test\ntags:\n - attack.execution\n - attack.execution\n - attack.defense_evasion\n";
let doc = yamlpath::Document::new(yaml.to_string()).unwrap();
let route = json_pointer_to_route("/tags/1");
let patch = yamlpatch::Patch {
route,
operation: yamlpatch::Op::Remove,
};
let result = yamlpatch::apply_yaml_patches(&doc, &[patch]).unwrap();
assert_snapshot!(result.source(), @r"
title: Test
tags:
- attack.execution
- attack.defense_evasion
");
}
#[test]
fn fix_rename_key_top_level() {
let yaml = "title: Test\nStatus: experimental\nlevel: medium\n";
let doc = yamlpath::Document::new(yaml.to_string()).unwrap();
let result = apply_rename_key(&doc, "/Status", "status").unwrap();
assert_snapshot!(result.source(), @r"
title: Test
status: experimental
level: medium
");
}
#[test]
fn fix_rename_key_nested() {
let yaml = "title: Test\nlogsource:\n Category: test\n product: windows\n";
let doc = yamlpath::Document::new(yaml.to_string()).unwrap();
let result = apply_rename_key(&doc, "/logsource/Category", "category").unwrap();
assert_snapshot!(result.source(), @r"
title: Test
logsource:
category: test
product: windows
");
}
#[test]
fn fix_rename_detection_key_with_modifiers() {
let yaml = "title: Test\nlogsource:\n category: test\ndetection:\n sel:\n Cmd|all|re:\n - foo\n - bar\n condition: sel\n";
let doc = yamlpath::Document::new(yaml.to_string()).unwrap();
let result = apply_rename_key(&doc, "/detection/sel/Cmd|all|re", "Cmd|re").unwrap();
assert_snapshot!(result.source(), @r"
title: Test
logsource:
category: test
detection:
sel:
Cmd|re:
- foo
- bar
condition: sel
");
}
#[test]
fn sequential_patches_reparse_correctly() {
let yaml = "title: Test\ntags:\n - a\n - a\n - b\n - b\n - c\n";
let doc = yamlpath::Document::new(yaml.to_string()).unwrap();
let patch1 = yamlpatch::Patch {
route: json_pointer_to_route("/tags/1"),
operation: yamlpatch::Op::Remove,
};
let doc = yamlpatch::apply_yaml_patches(&doc, &[patch1]).unwrap();
let patch2 = yamlpatch::Patch {
route: json_pointer_to_route("/tags/2"),
operation: yamlpatch::Op::Remove,
};
let doc = yamlpatch::apply_yaml_patches(&doc, &[patch2]).unwrap();
assert_snapshot!(doc.source(), @r"
title: Test
tags:
- a
- b
- c
");
}
#[test]
fn apply_fixes_to_source_corrects_invalid_status() {
let source = "title: Test\nstatus: expreimental\nlogsource:\n category: test\ndetection:\n sel:\n field: value\n condition: sel\n";
let warnings = lint_yaml_str(source);
let fixable: Vec<&LintWarning> = warnings.iter().filter(|w| w.fix.is_some()).collect();
let outcome = apply_fixes_to_source(source, &fixable);
assert_eq!(outcome.applied, 1);
assert_eq!(outcome.failed, 0);
assert!(outcome.fixed_source.contains("status: experimental"));
assert!(!outcome.fixed_source.contains("expreimental"));
}
#[test]
fn apply_fixes_to_source_no_fixes_returns_input() {
let source = "title: Test\nstatus: test\nlogsource:\n category: test\ndetection:\n sel:\n field: value\n condition: sel\n";
let outcome = apply_fixes_to_source(source, &[]);
assert_eq!(outcome.applied, 0);
assert_eq!(outcome.failed, 0);
assert_eq!(outcome.fixed_source, source);
}
#[test]
fn apply_fixes_to_source_skips_unparseable() {
let warning_src = "title: Test\nStatus: test\n";
let warnings = lint_yaml_str(warning_src);
let fixable: Vec<&LintWarning> = warnings.iter().filter(|w| w.fix.is_some()).collect();
let broken = "title: [unterminated\n";
let outcome = apply_fixes_to_source(broken, &fixable);
assert_eq!(outcome.applied, 0);
assert_eq!(outcome.fixed_source, broken);
}
}