elasticctl-api 0.9.4

Typed detection-rule model and endpoint wrappers for Elastic Security.
Documentation
use crate::{Rule, normalize, rules, selection};
use elasticctl_core::{Error, ErrorKind, Result, Transport};
use serde_json::Value;
#[derive(Debug)]
pub struct ExecutionMetrics {
    pub total_search_duration_ms: Option<f64>,
    pub total_indexing_duration_ms: Option<f64>,
    pub total_enrichment_duration_ms: Option<f64>,
    pub execution_gap_duration_s: Option<f64>,
}
#[derive(Debug)]
pub struct LastExecution {
    pub status: String,
    pub date: String,
    pub status_order: Option<i64>,
    pub message: Option<String>,
    pub message_truncated: bool,
    pub metrics: ExecutionMetrics,
}
#[derive(Debug)]
pub struct RuleSnapshot {
    pub rule: Rule,
    pub execution: Option<LastExecution>,
}
fn invalid() -> Error {
    Error::new(ErrorKind::Http, "invalid rule execution response")
}
fn optional<T: serde::de::DeserializeOwned>(v: &Value, k: &str) -> Result<Option<T>> {
    v.get(k)
        .map(|v| serde_json::from_value(v.clone()).map_err(|_| invalid()))
        .transpose()
}
fn snapshot(rule: Rule) -> Result<RuleSnapshot> {
    // An explicit null summary means the same as an absent one: never run.
    let execution = if let Some(summary) = rule
        .as_map()
        .get("execution_summary")
        .filter(|summary| !summary.is_null())
    {
        let e = summary
            .get("last_execution")
            .filter(|v| v.is_object())
            .ok_or_else(invalid)?;
        let status = e
            .get("status")
            .and_then(Value::as_str)
            .ok_or_else(invalid)?
            .to_owned();
        let date = e
            .get("date")
            .and_then(Value::as_str)
            .ok_or_else(invalid)?
            .to_owned();
        let mut message: Option<String> = optional(e, "message")?;
        let mut message_truncated = false;
        if let Some(m) = &mut message
            && m.len() > super::MESSAGE_BYTES
        {
            let mut end = super::MESSAGE_BYTES;
            while !m.is_char_boundary(end) {
                end -= 1;
            }
            m.truncate(end);
            message_truncated = true;
        }
        let empty = serde_json::json!({});
        let metrics = e.get("metrics").unwrap_or(&empty);
        if !metrics.is_object() {
            return Err(invalid());
        }
        Some(LastExecution {
            status,
            date,
            status_order: optional(e, "status_order")?,
            message,
            message_truncated,
            metrics: ExecutionMetrics {
                total_search_duration_ms: optional(metrics, "total_search_duration_ms")?,
                total_indexing_duration_ms: optional(metrics, "total_indexing_duration_ms")?,
                total_enrichment_duration_ms: optional(metrics, "total_enrichment_duration_ms")?,
                execution_gap_duration_s: optional(metrics, "execution_gap_duration_s")?,
            },
        })
    } else {
        None
    };
    Ok(RuleSnapshot {
        rule: normalize::canonical(&rule),
        execution,
    })
}
pub async fn snapshot_by_id(t: &Transport, rule_id: &str) -> Result<RuleSnapshot> {
    let rule = rules::get(t, rule_id).await?;
    if rule.rule_id()? != rule_id {
        return Err(Error::new(ErrorKind::NotFound, "rule identity mismatch"));
    }
    snapshot(rule)
}
pub async fn snapshot_by_selector(t: &Transport, selector: &str) -> Result<RuleSnapshot> {
    let (_, rule) = selection::to_rule(t, selector).await?;
    snapshot(rule)
}

#[cfg(test)]
mod tests {
    use super::*;
    fn recorded() -> Rule {
        let value: Value = serde_json::from_str(include_str!(concat!(
            env!("CARGO_MANIFEST_DIR"),
            "/../../tests/fixtures/traditional-9.5.1/rules_get.json"
        )))
        .unwrap();
        Rule::from_value(value["response"].clone()).unwrap()
    }
    #[test]
    fn execution_decoder_and_character_boundary() {
        let mut rule = recorded();
        rule.as_map_mut().remove("execution_summary");
        assert!(snapshot(rule.clone()).unwrap().execution.is_none());
        rule.as_map_mut().insert("execution_summary".into(),serde_json::json!({"last_execution":{"status":"failed","date":"today","message":"界".repeat(1000),"metrics":{"unknown":"ignored","total_search_duration_ms":3}}}));
        let execution = snapshot(rule.clone()).unwrap().execution.unwrap();
        assert_eq!(execution.message.as_ref().unwrap().len(), 2046);
        assert!(execution.message_truncated);
        assert_eq!(execution.metrics.total_search_duration_ms, Some(3.0));
        for field in ["status", "date"] {
            let mut bad = rule.clone();
            bad.as_map_mut()["execution_summary"]["last_execution"]
                .as_object_mut()
                .unwrap()
                .remove(field);
            assert_eq!(snapshot(bad).unwrap_err().kind, ErrorKind::Http);
        }
        for field in [
            "total_search_duration_ms",
            "total_indexing_duration_ms",
            "total_enrichment_duration_ms",
            "execution_gap_duration_s",
        ] {
            let mut bad = rule.clone();
            bad.as_map_mut()["execution_summary"]["last_execution"]["metrics"][field] =
                serde_json::json!("bad");
            assert_eq!(snapshot(bad).unwrap_err().kind, ErrorKind::Http);
        }
    }
}