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