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