1use crate::codec::{self, Format};
8use crate::model::{ListKey, Rule, exception_refs, server_defaults};
9use crate::normalize;
10use crate::ops::{DeleteOutcome, ExportOutcome, ImportPlan, ImportReport, MutationPlan};
11use crate::rules::{self, BulkAction, RuleFilter, RuleSource};
12use crate::selection;
13use elasticctl_core::{Error, ErrorKind, Result, Transport};
14use serde::Serialize;
15use serde_json::{Value, json};
16use std::path::Path;
17
18const EXCEPTION_POINTER_PLACEHOLDER: &str = "00000000-0000-0000-0000-000000000000";
22
23#[derive(Debug, Clone, PartialEq, Serialize)]
25pub struct RuleListReport {
26 pub total: usize,
27 pub rules: Vec<Rule>,
28}
29
30#[derive(Debug, Clone)]
32pub struct RulePage {
33 pub total: u64,
34 pub rules: Vec<Rule>,
35}
36
37#[derive(Debug, Clone, PartialEq, Serialize)]
41pub struct SetEnabledOutcome {
42 pub applied: bool,
43 pub succeeded: u64,
44 pub failed: u64,
45 pub skipped: u64,
46 pub total: u64,
47}
48
49#[derive(Debug, Clone, PartialEq, Serialize)]
51pub struct RuleValidation {
52 pub rule_id: String,
53 pub name: String,
54 #[serde(rename = "type")]
55 pub rule_type: String,
56 pub defaults_applied: Vec<String>,
57}
58
59#[derive(Debug, Clone, PartialEq, Serialize)]
61pub struct ValidateReport {
62 pub valid: bool,
63 pub count: usize,
64 pub rules: Vec<RuleValidation>,
65}
66
67#[derive(Debug, Clone, PartialEq, Serialize)]
69pub struct PreviewReport {
70 pub rule: String,
71 pub preview_id: Option<String>,
72 pub invocations: u32,
73 pub hits: Option<u64>,
74 pub errors: Vec<String>,
75 pub warnings: Vec<String>,
76 pub hits_error: Option<String>,
77 pub sample: Vec<Value>,
78}
79
80pub async fn list(t: &Transport, filter: &RuleFilter) -> Result<RuleListReport> {
81 let rules = rules::find_all(t, filter).await?;
82 if rules.is_empty() && is_unselected_source_query(filter) {
83 rules::verify_source_partition(t).await?;
84 }
85 let total = rules.len();
86 Ok(RuleListReport { total, rules })
87}
88
89pub async fn list_page(t: &Transport, filter: &RuleFilter, limit: u32) -> Result<RulePage> {
92 let (rules, total) = rules::find_first_by_name(t, filter, limit).await?;
93 if rules.is_empty() && is_unselected_source_query(filter) {
94 rules::verify_source_partition(t).await?;
95 }
96 Ok(RulePage { total, rules })
97}
98
99fn is_unselected_source_query(filter: &RuleFilter) -> bool {
102 matches!(filter.source, RuleSource::Custom | RuleSource::Prebuilt)
103 && filter.enabled.is_none()
104 && filter.rule_type.is_none()
105 && filter.severity.is_none()
106 && filter.tag.is_none()
107 && filter.name.is_none()
108 && filter.query.is_none()
109 && filter.search.is_none()
110 && filter.technique.is_none()
111 && filter.tactic.is_none()
112}
113
114pub async fn get_one(t: &Transport, selector: &str) -> Result<Rule> {
116 let (_, rule) = selection::to_rule(t, selector).await?;
117 Ok(normalize::canonical(&rule))
118}
119
120pub fn validate(path: &Path) -> Result<ValidateReport> {
126 let body = std::fs::read_to_string(path)
127 .map_err(|e| Error::new(ErrorKind::Error, format!("reading {}: {e}", path.display())))?;
128
129 let rules = match Format::from_path(path) {
130 Format::Yaml => codec::decode_yaml(&body)?,
131 Format::Ndjson => codec::decode_ndjson(&body)?.0,
132 };
133
134 let defaults = server_defaults();
135 let mut reports = Vec::with_capacity(rules.len());
136 let mut failures = Vec::new();
137
138 for (i, r) in rules.iter().enumerate() {
139 match r.rule_id() {
140 Ok(rule_id) => {
141 let mut applied: Vec<String> = defaults
143 .keys()
144 .filter(|k| !r.as_map().contains_key(*k))
145 .cloned()
146 .collect();
147 applied.sort();
148 reports.push(RuleValidation {
149 rule_id: rule_id.to_string(),
150 name: r.name().to_string(),
151 rule_type: r.rule_type().to_string(),
152 defaults_applied: applied,
153 });
154 }
155 Err(e) => failures.push(format!("rule at index {i}: {}", e.message)),
157 }
158 }
159
160 if !failures.is_empty() {
161 return Err(Error::new(ErrorKind::Error, failures.join("; ")));
164 }
165
166 Ok(ValidateReport {
167 valid: true,
168 count: rules.len(),
169 rules: reports,
170 })
171}
172
173async fn resolve_targets(t: &Transport, selectors: &[String]) -> Result<Vec<(String, Rule)>> {
176 let mut out = Vec::with_capacity(selectors.len());
177 for s in selectors {
178 out.push(selection::to_rule(t, s).await?);
179 }
180 Ok(out)
181}
182
183pub async fn plan_set_enabled(
184 t: &Transport,
185 selectors: &[String],
186 enable: bool,
187) -> Result<MutationPlan> {
188 let resolved = resolve_targets(t, selectors).await?;
189 let preview_details = resolved
190 .iter()
191 .map(|(id, r)| {
192 let from = if r.enabled() { "enabled" } else { "disabled" };
193 let to = if enable { "enabled" } else { "disabled" };
194 format!("{id} {} {from} -> {to}", r.name())
195 })
196 .collect();
197 let verb = if enable { "Enable" } else { "Disable" };
198 Ok(MutationPlan {
199 preview_action: format!("{verb} {} rule(s)", resolved.len()),
200 preview_details,
201 targets: resolved.into_iter().map(|(id, _)| id).collect(),
202 })
203}
204
205pub async fn apply_set_enabled(
206 t: &Transport,
207 plan: &MutationPlan,
208 enable: bool,
209) -> Result<SetEnabledOutcome> {
210 let action = if enable {
211 BulkAction::Enable
212 } else {
213 BulkAction::Disable
214 };
215 let o = rules::bulk_by_rule_ids(t, action, &plan.targets, false).await?;
216 Ok(SetEnabledOutcome {
217 applied: true,
218 succeeded: o.succeeded,
219 failed: o.failed,
220 skipped: o.skipped,
221 total: o.total,
222 })
223}
224
225pub async fn plan_delete(t: &Transport, selectors: &[String]) -> Result<MutationPlan> {
226 let resolved = resolve_targets(t, selectors).await?;
227 let preview_details = resolved
228 .iter()
229 .map(|(id, r)| format!("{id} {}", r.name()))
230 .collect();
231 Ok(MutationPlan {
232 preview_action: format!("Delete {} rule(s)", resolved.len()),
233 preview_details,
234 targets: resolved.into_iter().map(|(id, _)| id).collect(),
235 })
236}
237
238pub async fn apply_delete(t: &Transport, plan: &MutationPlan) -> Result<DeleteOutcome> {
241 let mut deleted = Vec::new();
242 let mut failed = Vec::new();
243 for id in &plan.targets {
244 match rules::delete(t, id).await {
245 Ok(_) => deleted.push(json!({"rule_id": id})),
246 Err(e) => failed.push(json!({"rule_id": id, "error": e.message})),
247 }
248 }
249 Ok(DeleteOutcome {
250 applied: true,
251 deleted,
252 failed,
253 total: plan.targets.len(),
254 })
255}
256
257pub async fn export_rules(
266 t: &Transport,
267 selectors: &[String],
268 tag: Option<&str>,
269 source: RuleSource,
270 format: Format,
271) -> Result<ExportOutcome> {
272 let selection: Option<Vec<String>> =
273 if selectors.is_empty() && tag.is_none() && source != RuleSource::All {
274 let scoped = rules::find_all(
275 t,
276 &RuleFilter {
277 source,
278 ..Default::default()
279 },
280 )
281 .await?;
282 if scoped.is_empty() {
283 if matches!(source, RuleSource::Custom | RuleSource::Prebuilt) {
284 rules::verify_source_partition(t).await?;
285 }
286 return Ok(ExportOutcome {
287 body: String::new(),
288 exported: 0,
289 missing: Vec::new(),
290 });
291 }
292 Some(
293 scoped
294 .iter()
295 .filter_map(|r| r.rule_id().ok().map(str::to_owned))
296 .collect(),
297 )
298 } else {
299 selection::resolve(t, selectors, tag, None, &[], "export").await?
301 };
302
303 let mut bundle = rules::export(t, selection.as_deref()).await?;
304 for r in &mut bundle.rules {
305 *r = normalize::canonical(r);
306 }
307 normalize::sort_rules(&mut bundle.rules);
308
309 let body = match format {
310 Format::Yaml => {
313 if !bundle.lists.is_empty() || !bundle.items.is_empty() {
314 return Err(Error::new(
315 ErrorKind::Unsupported,
316 format!(
317 "this export carries {} exception list(s) and {} item(s), which the \
318 YAML format cannot represent; re-run with --format-file ndjson",
319 bundle.lists.len(),
320 bundle.items.len()
321 ),
322 ));
323 }
324 codec::encode_yaml(&bundle.rules)?
325 }
326 Format::Ndjson => codec::encode_bundle(&bundle)?,
329 };
330
331 let missing = bundle
334 .summary
335 .as_ref()
336 .map(|s| s.missing_rules.clone())
337 .unwrap_or_default();
338
339 Ok(ExportOutcome {
340 body,
341 exported: bundle.rules.len() as u64,
342 missing,
343 })
344}
345
346pub async fn plan_import(
354 t: Option<&Transport>,
355 path: &Path,
356 overwrite: bool,
357 skip_existing: bool,
358) -> Result<ImportPlan> {
359 let body = std::fs::read_to_string(path)
360 .map_err(|e| Error::new(ErrorKind::Error, format!("reading {}: {e}", path.display())))?;
361
362 let format = Format::from_path(path);
363 let mut bundle = match format {
364 Format::Yaml => codec::Bundle {
367 rules: codec::decode_yaml(&body)?,
368 ..Default::default()
369 },
370 Format::Ndjson => codec::decode_bundle(&body)?,
374 };
375 let total = bundle.rules.len();
376
377 let mut skipped: Vec<Value> = Vec::new();
378
379 if skip_existing {
380 let t = t.ok_or_else(|| {
381 Error::new(ErrorKind::Error, "import --skip-existing needs a transport")
382 })?;
383 let ids: Vec<String> = bundle
384 .rules
385 .iter()
386 .filter_map(|r| r.rule_id().ok().map(str::to_owned))
387 .collect();
388 let existing = rules::existing_rule_ids(t, &ids).await?;
389
390 let mut keep = Vec::with_capacity(bundle.rules.len());
391 for rule in std::mem::take(&mut bundle.rules) {
392 match rule.rule_id() {
393 Ok(id) if existing.contains(id) => {
394 skipped.push(json!({"rule_id": id, "reason": "exists"}));
395 }
396 _ => keep.push(rule),
397 }
398 }
399 bundle.rules = keep;
400 }
401
402 if format == Format::Ndjson {
403 retain_referenced_exception_objects(&mut bundle);
404 }
405 add_upload_pointer_placeholders(&mut bundle);
406
407 let mut details: Vec<String> = bundle
408 .rules
409 .iter()
410 .map(|r| format!("{} {} import", r.rule_id().unwrap_or(""), r.name()))
411 .collect();
412 details.extend(skipped.iter().map(|s| {
413 format!(
414 "{} skip (already exists)",
415 s["rule_id"].as_str().unwrap_or("")
416 )
417 }));
418
419 let qualifier = if overwrite {
420 ", overwriting existing".to_string()
421 } else if skip_existing && !skipped.is_empty() {
422 format!(", skipping {} that already exist", skipped.len())
423 } else {
424 String::new()
425 };
426 let preview = MutationPlan {
427 preview_action: format!(
428 "Import {} rule(s) from {}{qualifier}",
429 bundle.rules.len(),
430 path.display()
431 ),
432 preview_details: details,
433 targets: bundle
434 .rules
435 .iter()
436 .filter_map(|r| r.rule_id().ok().map(str::to_owned))
437 .collect(),
438 };
439
440 let ndjson = match format {
444 Format::Yaml => codec::encode_ndjson(&bundle.rules)?,
445 Format::Ndjson => codec::encode_bundle(&bundle)?,
446 };
447
448 Ok(ImportPlan {
449 preview,
450 ndjson,
451 total,
452 skipped,
453 })
454}
455
456fn retain_referenced_exception_objects(bundle: &mut codec::Bundle) {
462 let wanted: std::collections::BTreeSet<ListKey> = bundle
463 .rules
464 .iter()
465 .flat_map(exception_refs)
466 .map(|reference| ListKey {
467 list_id: reference.list_id,
468 namespace_type: reference.namespace_type,
469 })
470 .collect();
471
472 bundle
473 .lists
474 .retain(|list| list.key().is_ok_and(|key| wanted.contains(&key)));
475 bundle.items.retain(|item| {
476 item.list_id().is_ok_and(|list_id| {
477 wanted.contains(&ListKey {
478 list_id: list_id.to_string(),
479 namespace_type: item.namespace_type().to_string(),
480 })
481 })
482 });
483}
484
485fn add_upload_pointer_placeholders(bundle: &mut codec::Bundle) {
489 for rule in &mut bundle.rules {
490 let Some(Value::Array(references)) = rule.as_map_mut().get_mut("exceptions_list") else {
491 continue;
492 };
493 for reference in references {
494 let Value::Object(reference) = reference else {
495 continue;
496 };
497 if reference.get("list_id").is_some_and(Value::is_string) {
498 reference.insert(
499 "id".to_string(),
500 Value::String(EXCEPTION_POINTER_PLACEHOLDER.to_string()),
501 );
502 }
503 }
504 }
505}
506
507pub async fn apply_import(t: &Transport, ndjson: &str, overwrite: bool) -> Result<ImportReport> {
509 if ndjson.is_empty() {
511 return Ok(ImportReport {
512 succeeded: json!(0),
513 failed: json!([]),
514 });
515 }
516
517 let response = rules::import(t, ndjson, overwrite).await?;
518
519 crate::ops::decode_import_report(&response, "rules")
522}
523
524async fn fetch_hits(
530 transport: &Transport,
531 space: &str,
532 preview_id: &str,
533 sample: usize,
534) -> Result<rules::PreviewHits> {
535 let first = rules::preview_hits(transport, space, preview_id, sample).await?;
536 if first.total > 0 {
537 return Ok(first);
538 }
539 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
540 rules::preview_hits(transport, space, preview_id, sample).await
541}
542
543pub async fn preview_rule(
546 t: &Transport,
547 source: &str,
548 invocations: u32,
549 sample: u32,
550 space: &str,
551) -> Result<PreviewReport> {
552 let path = Path::new(source);
553
554 let rule = if path.exists() {
555 let body = std::fs::read_to_string(path).map_err(|e| {
556 Error::new(ErrorKind::Error, format!("reading {}: {e}", path.display()))
557 })?;
558 let rules = match Format::from_path(path) {
559 Format::Yaml => codec::decode_yaml(&body)?,
560 Format::Ndjson => codec::decode_ndjson(&body)?.0,
561 };
562 rules.into_iter().next().ok_or_else(|| {
563 Error::new(
564 ErrorKind::Error,
565 format!("{} contains no rules", path.display()),
566 )
567 })?
568 } else {
569 selection::to_rule(t, source).await?.1
570 };
571
572 let timeframe_end = now_rfc3339();
574 let result = rules::preview(t, &rule, invocations, &timeframe_end).await?;
575
576 let (hits, hits_error, sample_hits) = match &result.preview_id {
579 None => (
580 None,
581 Some("the server returned no preview_id".to_string()),
582 Vec::new(),
583 ),
584 Some(preview_id) => match fetch_hits(t, space, preview_id, sample as usize).await {
585 Ok(h) => (Some(h.total), None, h.sample),
586 Err(e) => (None, Some(e.message), Vec::new()),
587 },
588 };
589
590 Ok(PreviewReport {
591 rule: rule.name().to_string(),
592 preview_id: result.preview_id,
593 invocations,
594 hits,
595 errors: result.errors,
596 warnings: result.warnings,
597 hits_error,
598 sample: sample_hits,
599 })
600}
601
602fn now_rfc3339() -> String {
603 use std::time::{SystemTime, UNIX_EPOCH};
604 let secs = SystemTime::now()
605 .duration_since(UNIX_EPOCH)
606 .unwrap_or_default()
607 .as_secs();
608 let days = secs / 86_400;
610 let rem = secs % 86_400;
611 let (y, m, d) = civil_from_days(days as i64);
612 format!(
613 "{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.000Z",
614 rem / 3600,
615 (rem % 3600) / 60,
616 rem % 60
617 )
618}
619
620fn civil_from_days(z: i64) -> (i64, u32, u32) {
622 let z = z + 719_468;
623 let era = z.div_euclid(146_097);
624 let doe = z.rem_euclid(146_097);
625 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
626 let y = yoe + era * 400;
627 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
628 let mp = (5 * doy + 2) / 153;
629 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
630 let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
631 (if m <= 2 { y + 1 } else { y }, m, d)
632}
633
634#[cfg(test)]
635mod date_tests {
636 use super::*;
637
638 #[test]
641 fn civil_from_days_matches_independently_computed_epoch_days() {
642 let cases = [
643 (0, (1970, 1, 1), "epoch"),
644 (19782, (2024, 2, 29), "leap day"),
645 (11017, (2000, 3, 1), "century leap year (2000 % 400 == 0)"),
646 (20818, (2026, 12, 31), "year end"),
647 (20819, (2027, 1, 1), "year rollover"),
648 (47541, (2100, 3, 1), "century non-leap (2100 % 400 != 0)"),
650 (
651 47540,
652 (2100, 2, 28),
653 "day before the century non-leap rollover",
654 ),
655 ];
656
657 for (day, expected, label) in cases {
658 assert_eq!(civil_from_days(day), expected, "{label}: day {day}");
659 }
660 }
661
662 #[test]
664 fn now_rfc3339_matches_the_shape_the_api_requires() {
665 let s = now_rfc3339();
666 let bytes = s.as_bytes();
667
668 assert_eq!(s.len(), 24, "{s}");
669 assert!(bytes[4] == b'-' && bytes[7] == b'-', "{s}");
670 assert_eq!(bytes[10], b'T', "{s}");
671 assert!(bytes[13] == b':' && bytes[16] == b':', "{s}");
672 assert_eq!(bytes[19], b'.', "{s}");
673 assert_eq!(&s[20..], "000Z", "{s}");
674 assert!(
675 s[..19]
676 .chars()
677 .enumerate()
678 .all(|(i, c)| { matches!(i, 4 | 7 | 10 | 13 | 16) || c.is_ascii_digit() }),
679 "every non-separator position must be a digit: {s}"
680 );
681 }
682}