1use crate::model::Rule;
7use crate::normalize::comparable;
8use elasticctl_core::{Error, ErrorKind, Result};
9use serde::Serialize;
10use serde_json::Value;
11use std::collections::BTreeMap;
12
13#[derive(Debug, Clone, PartialEq, Serialize)]
14pub struct FieldChange {
15 pub field: String,
16 pub before: Value,
17 pub after: Value,
18}
19
20#[derive(Debug, Clone, PartialEq, Serialize)]
21#[serde(tag = "change", rename_all = "snake_case")]
22pub enum Change {
23 Added {
24 rule_id: String,
25 name: String,
26 },
27 Modified {
28 rule_id: String,
29 name: String,
30 fields: Vec<FieldChange>,
31 },
32 Unchanged {
33 rule_id: String,
34 },
35 RemoteOnly {
38 rule_id: String,
39 name: String,
40 },
41}
42
43impl Change {
44 pub fn rule_id(&self) -> &str {
45 match self {
46 Change::Added { rule_id, .. }
47 | Change::Modified { rule_id, .. }
48 | Change::Unchanged { rule_id }
49 | Change::RemoteOnly { rule_id, .. } => rule_id,
50 }
51 }
52}
53
54#[derive(Debug, Clone, PartialEq, Serialize)]
55pub struct Drift {
56 pub changes: Vec<Change>,
57}
58
59fn field_changes(before: &Rule, after: &Rule) -> Vec<FieldChange> {
62 let (b, a) = (before.as_map(), after.as_map());
63 let mut keys: Vec<&String> = b.keys().chain(a.keys()).collect();
64 keys.sort();
65 keys.dedup();
66
67 keys.into_iter()
68 .filter_map(|k| {
69 let bv = b.get(k).cloned().unwrap_or(Value::Null);
70 let av = a.get(k).cloned().unwrap_or(Value::Null);
71 (bv != av).then(|| FieldChange {
72 field: k.clone(),
73 before: bv,
74 after: av,
75 })
76 })
77 .collect()
78}
79
80impl Drift {
81 pub fn compute(local: &[Rule], remote: &[Rule]) -> Result<Drift> {
82 let index = |rules: &[Rule], side: &str| -> Result<BTreeMap<String, Rule>> {
84 let mut map = BTreeMap::new();
85 for (idx, r) in rules.iter().enumerate() {
86 let id = r.rule_id().map_err(|_| {
87 Error::new(
88 ErrorKind::Error,
89 format!(
90 "{} rule at position {} has an unreadable rule_id",
91 side, idx
92 ),
93 )
94 })?;
95 if map.insert(id.to_string(), comparable(r)).is_some() {
96 return Err(Error::new(
97 ErrorKind::Conflict,
98 format!(
99 "{} has two rules with rule_id \"{}\"; rule_id must be unique",
100 side, id
101 ),
102 ));
103 }
104 }
105 Ok(map)
106 };
107 let (local, remote) = (index(local, "local")?, index(remote, "remote")?);
108
109 let mut changes = Vec::new();
110 let mut ids: Vec<&String> = local.keys().chain(remote.keys()).collect();
111 ids.sort();
112 ids.dedup();
113
114 for id in ids {
115 match (local.get(id), remote.get(id)) {
116 (Some(l), None) => changes.push(Change::Added {
117 rule_id: id.clone(),
118 name: l.name().to_string(),
119 }),
120 (None, Some(r)) => changes.push(Change::RemoteOnly {
121 rule_id: id.clone(),
122 name: r.name().to_string(),
123 }),
124 (Some(l), Some(r)) => {
125 let fields = field_changes(r, l);
126 if fields.is_empty() {
127 changes.push(Change::Unchanged {
128 rule_id: id.clone(),
129 });
130 } else {
131 changes.push(Change::Modified {
132 rule_id: id.clone(),
133 name: l.name().to_string(),
134 fields,
135 });
136 }
137 }
138 (None, None) => unreachable!("an id came from one of the two maps"),
139 }
140 }
141
142 Ok(Drift { changes })
143 }
144
145 pub fn actionable(&self) -> Vec<&Change> {
148 self.changes
149 .iter()
150 .filter(|c| matches!(c, Change::Added { .. } | Change::Modified { .. }))
151 .collect()
152 }
153
154 pub fn is_clean(&self) -> bool {
157 self.changes
158 .iter()
159 .all(|c| matches!(c, Change::Unchanged { .. }))
160 }
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166 use serde_json::json;
167
168 fn rule(id: &str, name: &str, risk: i64) -> Rule {
169 Rule::from_value(json!({
170 "rule_id": id, "name": name, "type": "query", "risk_score": risk,
171 "severity": "low"
172 }))
173 .unwrap()
174 }
175
176 #[test]
177 fn identical_sets_are_clean() {
178 let a = vec![rule("x", "X", 21)];
179 let d = Drift::compute(&a, &a).unwrap();
180 assert!(d.is_clean());
181 assert!(d.actionable().is_empty());
182 }
183
184 #[test]
185 fn a_local_only_rule_is_added() {
186 let d = Drift::compute(&[rule("x", "X", 21)], &[]).unwrap();
187 assert!(
188 matches!(&d.changes[0], Change::Added { rule_id, name } if rule_id == "x" && name == "X")
189 );
190 assert_eq!(d.actionable().len(), 1);
191 }
192
193 #[test]
194 fn a_remote_only_rule_is_reported_but_not_actionable() {
195 let d = Drift::compute(&[], &[rule("x", "X", 21)]).unwrap();
196 assert!(matches!(&d.changes[0], Change::RemoteOnly { rule_id, .. } if rule_id == "x"));
197 assert!(
198 d.actionable().is_empty(),
199 "push must never delete a remote rule"
200 );
201 assert!(
202 !d.is_clean(),
203 "drift exists even though nothing will be applied"
204 );
205 }
206
207 #[test]
208 fn a_changed_field_is_reported_with_before_and_after() {
209 let d = Drift::compute(&[rule("x", "X", 99)], &[rule("x", "X", 21)]).unwrap();
210 let Change::Modified { fields, .. } = &d.changes[0] else {
211 panic!("expected Modified, got {:?}", d.changes[0]);
212 };
213 assert_eq!(fields.len(), 1, "only the field that changed is reported");
214 assert_eq!(fields[0].field, "risk_score");
215 assert_eq!(fields[0].before, json!(21));
216 assert_eq!(fields[0].after, json!(99));
217 }
218
219 #[test]
220 fn multiple_changed_fields_are_reported_in_key_order() {
221 let d = Drift::compute(&[rule("x", "Renamed", 99)], &[rule("x", "X", 21)]).unwrap();
222 let Change::Modified { fields, .. } = &d.changes[0] else {
223 panic!("expected Modified")
224 };
225 let names: Vec<&str> = fields.iter().map(|f| f.field.as_str()).collect();
226 assert_eq!(names, vec!["name", "risk_score"]);
227 }
228
229 #[test]
230 fn a_field_added_locally_shows_null_as_its_before_value() {
231 let mut local = rule("x", "X", 21);
232 local.as_map_mut().insert("note".into(), json!("hello"));
233 let d = Drift::compute(&[local], &[rule("x", "X", 21)]).unwrap();
234 let Change::Modified { fields, .. } = &d.changes[0] else {
235 panic!("expected Modified")
236 };
237 assert_eq!(fields[0].field, "note");
238 assert_eq!(fields[0].before, json!(null));
239 assert_eq!(fields[0].after, json!("hello"));
240 }
241
242 #[test]
244 fn volatile_fields_never_produce_drift() {
245 let mut remote = rule("x", "X", 21);
246 remote
247 .as_map_mut()
248 .insert("id".into(), json!("server-uuid"));
249 remote
250 .as_map_mut()
251 .insert("updated_at".into(), json!("2026-08-12T00:00:00Z"));
252 remote.as_map_mut().insert("version".into(), json!(7));
253 assert!(
254 Drift::compute(&[rule("x", "X", 21)], &[remote])
255 .unwrap()
256 .is_clean()
257 );
258 }
259
260 #[test]
261 fn omitted_server_defaults_never_produce_drift() {
262 let mut remote = rule("x", "X", 21);
263 remote.as_map_mut().insert("max_signals".into(), json!(100));
264 remote.as_map_mut().insert("to".into(), json!("now"));
265 assert!(
266 Drift::compute(&[rule("x", "X", 21)], &[remote])
267 .unwrap()
268 .is_clean()
269 );
270 }
271
272 #[test]
273 fn changes_are_ordered_by_rule_id_so_reports_are_stable() {
274 let local = vec![rule("c", "C", 1), rule("a", "A", 1), rule("b", "B", 1)];
275 let d = Drift::compute(&local, &[]).unwrap();
276 let ids: Vec<&str> = d.changes.iter().map(Change::rule_id).collect();
277 assert_eq!(ids, vec!["a", "b", "c"]);
278 }
279
280 #[test]
281 fn a_local_rule_with_non_string_rule_id_produces_an_error() {
282 let err = Drift::compute(
283 &[serde_json::from_value::<Rule>(json!({
286 "rule_id": 123, "name": "X", "type": "query", "risk_score": 21,
287 "severity": "low"
288 }))
289 .unwrap()],
290 &[],
291 );
292 assert!(err.is_err());
293 let err = err.unwrap_err();
294 assert_eq!(err.kind, ErrorKind::Error);
295 assert!(err.message.contains("local"));
296 assert!(err.message.contains("position"));
297 assert!(err.message.contains("unreadable rule_id"));
298 }
299
300 #[test]
301 fn a_remote_rule_with_non_string_rule_id_produces_an_error() {
302 let err = Drift::compute(
303 &[],
304 &[serde_json::from_value::<Rule>(json!({
307 "rule_id": 123, "name": "X", "type": "query", "risk_score": 21,
308 "severity": "low"
309 }))
310 .unwrap()],
311 );
312 assert!(err.is_err());
313 let err = err.unwrap_err();
314 assert_eq!(err.kind, ErrorKind::Error);
315 assert!(err.message.contains("remote"));
316 assert!(err.message.contains("position"));
317 assert!(err.message.contains("unreadable rule_id"));
318 }
319
320 #[test]
321 fn duplicate_local_rule_ids_produce_a_conflict_error() {
322 let err = Drift::compute(&[rule("x", "X", 21), rule("x", "X", 99)], &[]);
323 assert!(err.is_err());
324 let err = err.unwrap_err();
325 assert_eq!(err.kind, ErrorKind::Conflict);
326 assert!(err.message.contains("local"));
327 assert!(err.message.contains("\"x\""));
328 assert!(err.message.contains("rule_id must be unique"));
329 }
330
331 #[test]
332 fn duplicate_remote_rule_ids_produce_a_conflict_error() {
333 let err = Drift::compute(&[], &[rule("x", "X", 21), rule("x", "X", 99)]);
334 assert!(err.is_err());
335 let err = err.unwrap_err();
336 assert_eq!(err.kind, ErrorKind::Conflict);
337 assert!(err.message.contains("remote"));
338 assert!(err.message.contains("\"x\""));
339 assert!(err.message.contains("rule_id must be unique"));
340 }
341}