Skip to main content

rsigma_parser/lint/
fix.rs

1//! String-level auto-fix applier shared by the CLI, LSP, and MCP server.
2//!
3//! The linter attaches an optional [`Fix`](super::Fix) to each
4//! [`LintWarning`]. This module turns those fixes into
5//! concrete edits on a YAML source string using the `yamlpath`/`yamlpatch`
6//! crates, preserving comments and formatting outside the edited spans.
7//!
8//! Only [`FixDisposition::Safe`] fixes are applied; unsafe fixes are skipped so
9//! a batch fixer never makes a change that needs human judgement.
10//!
11//! # Usage
12//!
13//! ```rust
14//! use rsigma_parser::lint::{lint_yaml_str, fix::apply_fixes_to_source};
15//!
16//! let source = "title: Test\nStatus: test\nlogsource:\n    category: test\ndetection:\n    sel:\n        field: value\n    condition: sel\n";
17//! let warnings = lint_yaml_str(source);
18//! let fixable: Vec<_> = warnings.iter().filter(|w| w.fix.is_some()).collect();
19//! let outcome = apply_fixes_to_source(source, &fixable);
20//! assert!(outcome.fixed_source.contains("status: test"));
21//! ```
22
23use std::borrow::Cow;
24
25use yamlpath::{Component, Route};
26
27use super::{FixDisposition, FixPatch, LintWarning};
28
29/// Outcome of applying fixes to a single YAML source string.
30#[derive(Debug, Clone)]
31pub struct SourceFixOutcome {
32    /// The (possibly) rewritten source. Equal to the input when nothing
33    /// applied (no safe fixes, all conflicts, or an unparseable document).
34    pub fixed_source: String,
35    /// Number of safe fixes that applied cleanly.
36    pub applied: usize,
37    /// Number of safe fixes that could not be applied (patch conflicts or an
38    /// unparseable document).
39    pub failed: usize,
40}
41
42/// Convert an rsigma JSON-pointer path (e.g. "/tags/2", "/detection/sel/CommandLine|contains")
43/// into a `yamlpath::Route` with owned components.
44///
45/// JSON-pointer segments that parse as `usize` become `Index`, others become `Key`.
46/// Leading "/" is stripped; an empty/root path returns an empty route.
47pub fn json_pointer_to_route(path: &str) -> Route<'static> {
48    let trimmed = path.strip_prefix('/').unwrap_or(path);
49    if trimmed.is_empty() {
50        return Route::default();
51    }
52
53    let components: Vec<Component<'static>> = trimmed
54        .split('/')
55        .map(|segment| {
56            if let Ok(idx) = segment.parse::<usize>() {
57                Component::Index(idx)
58            } else {
59                Component::Key(Cow::Owned(segment.to_string()))
60            }
61        })
62        .collect();
63
64    Route::from(components)
65}
66
67/// Apply a single [`FixPatch`] to a `yamlpath::Document`, returning a new Document.
68///
69/// `ReplaceValue` and `Remove` delegate to yamlpatch.
70/// `ReplaceKey` is handled with a custom string-level rename since yamlpatch
71/// has no native "rename key" operation.
72pub fn apply_single_fix_patch(
73    doc: &yamlpath::Document,
74    patch: &FixPatch,
75) -> Result<yamlpath::Document, String> {
76    match patch {
77        FixPatch::ReplaceValue { path, new_value } => {
78            let yp = yamlpatch::Patch {
79                route: json_pointer_to_route(path),
80                operation: yamlpatch::Op::Replace(yaml_serde::Value::String(new_value.clone())),
81            };
82            yamlpatch::apply_yaml_patches(doc, &[yp]).map_err(|e| e.to_string())
83        }
84        FixPatch::ReplaceKey { path, new_key } => apply_rename_key(doc, path, new_key),
85        FixPatch::Remove { path } => {
86            let yp = yamlpatch::Patch {
87                route: json_pointer_to_route(path),
88                operation: yamlpatch::Op::Remove,
89            };
90            yamlpatch::apply_yaml_patches(doc, &[yp]).map_err(|e| e.to_string())
91        }
92    }
93}
94
95/// Rename a YAML key in-place using `query_key_only` to get the exact
96/// byte span of the key, then replacing it in the document source.
97pub fn apply_rename_key(
98    doc: &yamlpath::Document,
99    path: &str,
100    new_key: &str,
101) -> Result<yamlpath::Document, String> {
102    let route = json_pointer_to_route(path);
103
104    let key_feature = doc
105        .query_key_only(&route)
106        .map_err(|e| format!("route query failed for key rename: {e}"))?;
107
108    let (start, end) = key_feature.location.byte_span;
109    let mut patched = doc.source().to_string();
110    patched.replace_range(start..end, new_key);
111
112    yamlpath::Document::new(patched).map_err(|e| format!("re-parse after key rename failed: {e}"))
113}
114
115/// Apply every safe fix from `warnings` to `source`, returning the rewritten
116/// source plus applied/failed counts.
117///
118/// Each warning's patches are applied sequentially against a running document
119/// (so routes stay valid as the source mutates). A fix counts as `applied`
120/// only when all of its patches land; the first conflicting patch aborts that
121/// fix and counts it as `failed`. Unsafe fixes and warnings without a fix are
122/// ignored. An unparseable document fails every safe fix and returns the
123/// source unchanged.
124pub fn apply_fixes_to_source(source: &str, warnings: &[&LintWarning]) -> SourceFixOutcome {
125    let safe_fixes = || {
126        warnings.iter().filter(|w| {
127            w.fix
128                .as_ref()
129                .is_some_and(|f| f.disposition == FixDisposition::Safe)
130        })
131    };
132
133    let mut current_doc = match yamlpath::Document::new(source.to_string()) {
134        Ok(d) => d,
135        Err(_) => {
136            return SourceFixOutcome {
137                fixed_source: source.to_string(),
138                applied: 0,
139                failed: safe_fixes().count(),
140            };
141        }
142    };
143
144    let mut applied = 0usize;
145    let mut failed = 0usize;
146
147    for w in safe_fixes() {
148        let fix = w.fix.as_ref().expect("filtered to fixes above");
149        let mut ok = true;
150        for patch in &fix.patches {
151            match apply_single_fix_patch(&current_doc, patch) {
152                Ok(new_doc) => current_doc = new_doc,
153                Err(_) => {
154                    failed += 1;
155                    ok = false;
156                    break;
157                }
158            }
159        }
160        if ok {
161            applied += 1;
162        }
163    }
164
165    SourceFixOutcome {
166        fixed_source: current_doc.source().to_string(),
167        applied,
168        failed,
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use crate::lint::lint_yaml_str;
176    use insta::assert_snapshot;
177
178    #[test]
179    fn json_pointer_root() {
180        let route = json_pointer_to_route("/");
181        assert!(route.is_empty());
182    }
183
184    #[test]
185    fn json_pointer_empty() {
186        let route = json_pointer_to_route("");
187        assert!(route.is_empty());
188    }
189
190    #[test]
191    fn json_pointer_simple_key() {
192        let route = json_pointer_to_route("/status");
193        assert_snapshot!(format!("{route:?}"), @r#"Route { route: [Key("status")] }"#);
194    }
195
196    #[test]
197    fn json_pointer_nested() {
198        let route = json_pointer_to_route("/logsource/category");
199        assert_snapshot!(format!("{route:?}"), @r#"Route { route: [Key("logsource"), Key("category")] }"#);
200    }
201
202    #[test]
203    fn json_pointer_with_index() {
204        let route = json_pointer_to_route("/tags/2");
205        assert_snapshot!(format!("{route:?}"), @r#"Route { route: [Key("tags"), Index(2)] }"#);
206    }
207
208    #[test]
209    fn json_pointer_detection_path() {
210        let route = json_pointer_to_route("/detection/selection/CommandLine|contains");
211        assert_snapshot!(format!("{route:?}"), @r#"Route { route: [Key("detection"), Key("selection"), Key("CommandLine|contains")] }"#);
212    }
213
214    #[test]
215    fn fix_replace_value_on_file() {
216        let yaml = "title: Test\nstatus: experimetal\nlevel: medium\n";
217        let doc = yamlpath::Document::new(yaml.to_string()).unwrap();
218        let route = json_pointer_to_route("/status");
219        let patch = yamlpatch::Patch {
220            route,
221            operation: yamlpatch::Op::Replace(yaml_serde::Value::String(
222                "experimental".to_string(),
223            )),
224        };
225        let result = yamlpatch::apply_yaml_patches(&doc, &[patch]).unwrap();
226        assert_snapshot!(result.source(), @r"
227        title: Test
228        status: experimental
229        level: medium
230        ");
231    }
232
233    #[test]
234    fn fix_remove_on_file() {
235        let yaml = "title: Test\ntags:\n  - attack.execution\n  - attack.execution\n  - attack.defense_evasion\n";
236        let doc = yamlpath::Document::new(yaml.to_string()).unwrap();
237        let route = json_pointer_to_route("/tags/1");
238        let patch = yamlpatch::Patch {
239            route,
240            operation: yamlpatch::Op::Remove,
241        };
242        let result = yamlpatch::apply_yaml_patches(&doc, &[patch]).unwrap();
243        assert_snapshot!(result.source(), @r"
244        title: Test
245        tags:
246          - attack.execution
247          - attack.defense_evasion
248        ");
249    }
250
251    #[test]
252    fn fix_rename_key_top_level() {
253        let yaml = "title: Test\nStatus: experimental\nlevel: medium\n";
254        let doc = yamlpath::Document::new(yaml.to_string()).unwrap();
255        let result = apply_rename_key(&doc, "/Status", "status").unwrap();
256        assert_snapshot!(result.source(), @r"
257        title: Test
258        status: experimental
259        level: medium
260        ");
261    }
262
263    #[test]
264    fn fix_rename_key_nested() {
265        let yaml = "title: Test\nlogsource:\n    Category: test\n    product: windows\n";
266        let doc = yamlpath::Document::new(yaml.to_string()).unwrap();
267        let result = apply_rename_key(&doc, "/logsource/Category", "category").unwrap();
268        assert_snapshot!(result.source(), @r"
269        title: Test
270        logsource:
271            category: test
272            product: windows
273        ");
274    }
275
276    #[test]
277    fn fix_rename_detection_key_with_modifiers() {
278        let yaml = "title: Test\nlogsource:\n    category: test\ndetection:\n    sel:\n        Cmd|all|re:\n            - foo\n            - bar\n    condition: sel\n";
279        let doc = yamlpath::Document::new(yaml.to_string()).unwrap();
280        let result = apply_rename_key(&doc, "/detection/sel/Cmd|all|re", "Cmd|re").unwrap();
281        assert_snapshot!(result.source(), @r"
282        title: Test
283        logsource:
284            category: test
285        detection:
286            sel:
287                Cmd|re:
288                    - foo
289                    - bar
290            condition: sel
291        ");
292    }
293
294    #[test]
295    fn sequential_patches_reparse_correctly() {
296        let yaml = "title: Test\ntags:\n  - a\n  - a\n  - b\n  - b\n  - c\n";
297        let doc = yamlpath::Document::new(yaml.to_string()).unwrap();
298
299        let patch1 = yamlpatch::Patch {
300            route: json_pointer_to_route("/tags/1"),
301            operation: yamlpatch::Op::Remove,
302        };
303        let doc = yamlpatch::apply_yaml_patches(&doc, &[patch1]).unwrap();
304
305        // After removing index 1, the array is [a, b, b, c].
306        let patch2 = yamlpatch::Patch {
307            route: json_pointer_to_route("/tags/2"),
308            operation: yamlpatch::Op::Remove,
309        };
310        let doc = yamlpatch::apply_yaml_patches(&doc, &[patch2]).unwrap();
311
312        assert_snapshot!(doc.source(), @r"
313        title: Test
314        tags:
315          - a
316          - b
317          - c
318        ");
319    }
320
321    #[test]
322    fn apply_fixes_to_source_corrects_invalid_status() {
323        let source = "title: Test\nstatus: expreimental\nlogsource:\n    category: test\ndetection:\n    sel:\n        field: value\n    condition: sel\n";
324        let warnings = lint_yaml_str(source);
325        let fixable: Vec<&LintWarning> = warnings.iter().filter(|w| w.fix.is_some()).collect();
326        let outcome = apply_fixes_to_source(source, &fixable);
327        assert_eq!(outcome.applied, 1);
328        assert_eq!(outcome.failed, 0);
329        assert!(outcome.fixed_source.contains("status: experimental"));
330        assert!(!outcome.fixed_source.contains("expreimental"));
331    }
332
333    #[test]
334    fn apply_fixes_to_source_no_fixes_returns_input() {
335        let source = "title: Test\nstatus: test\nlogsource:\n    category: test\ndetection:\n    sel:\n        field: value\n    condition: sel\n";
336        let outcome = apply_fixes_to_source(source, &[]);
337        assert_eq!(outcome.applied, 0);
338        assert_eq!(outcome.failed, 0);
339        assert_eq!(outcome.fixed_source, source);
340    }
341
342    #[test]
343    fn apply_fixes_to_source_skips_unparseable() {
344        let warning_src = "title: Test\nStatus: test\n";
345        let warnings = lint_yaml_str(warning_src);
346        let fixable: Vec<&LintWarning> = warnings.iter().filter(|w| w.fix.is_some()).collect();
347        // An unparseable YAML document fails every safe fix and is returned as-is.
348        let broken = "title: [unterminated\n";
349        let outcome = apply_fixes_to_source(broken, &fixable);
350        assert_eq!(outcome.applied, 0);
351        assert_eq!(outcome.fixed_source, broken);
352    }
353}