elasticctl-api 0.10.0

Typed detection-rule model and endpoint wrappers for Elastic Security.
Documentation
use std::path::Path;

use elasticctl_core::Result;
use serde_json::Value;

use super::address::rewrite_bare_range;
use super::{Container, LocalPair, ValueListType, ValueSet, canonical_value, invalid};

pub fn decode_container(yaml: &str) -> Result<Container> {
    let value: serde_yaml_ng::Value = serde_yaml_ng::from_str(yaml)
        .map_err(|e| invalid(format!("invalid container YAML: {e}")))?;
    let value = serde_json::to_value(value)
        .map_err(|e| invalid(format!("invalid container object: {e}")))?;
    let object = value
        .as_object()
        .ok_or_else(|| invalid("container must be an object"))?;
    for key in object.keys() {
        if !matches!(
            key.as_str(),
            "id" | "type" | "name" | "description" | "meta"
        ) {
            return Err(invalid(format!("unknown container key {key:?}")));
        }
    }
    for key in ["id", "type", "name", "description"] {
        if !object.get(key).is_some_and(Value::is_string) {
            return Err(invalid(format!(
                "container key {key:?} is required and must be a string"
            )));
        }
    }
    if object.get("meta").is_some_and(|meta| !meta.is_object()) {
        return Err(invalid("container key \"meta\" must be an object"));
    }
    serde_json::from_value(value).map_err(|e| invalid(format!("invalid container: {e}")))
}

pub fn encode_container(container: &Container) -> Result<String> {
    serde_yaml_ng::to_string(container).map_err(|e| invalid(format!("encode container: {e}")))
}

pub fn decode_values(kind: &ValueListType, text: &str) -> Result<ValueSet> {
    kind.require_supported()?;
    let mut values = ValueSet::new();
    for (index, line) in text.split_terminator('\n').enumerate() {
        let value = line
            .strip_suffix('\r')
            .unwrap_or(line)
            .trim_matches([' ', '\t']);
        let fail = |message| invalid(format!("line {}: value {value:?}: {message}", index + 1));
        if value.is_empty() {
            return Err(fail("empty value".to_owned()));
        }
        if value.chars().any(char::is_control) {
            return Err(fail("control character".to_owned()));
        }
        canonical_value(kind, value).map_err(|e| fail(e.message))?;
        values.insert(rewrite_bare_range(kind, value));
    }
    Ok(values)
}

pub fn encode_values(values: &ValueSet) -> String {
    let mut text = String::new();
    for value in values {
        text.push_str(value);
        text.push('\n');
    }
    text
}

pub fn read_pair(path: &Path) -> Result<LocalPair> {
    if !matches!(
        path.extension().and_then(|e| e.to_str()),
        Some("yaml" | "txt")
    ) {
        return Err(invalid("value-list pair path must end in .yaml or .txt"));
    }
    let stem = path
        .file_stem()
        .and_then(|s| s.to_str())
        .ok_or_else(|| invalid("value-list file stem must be UTF-8"))?;
    let parent = path
        .parent()
        .filter(|p| !p.as_os_str().is_empty())
        .unwrap_or(Path::new("."));
    let metadata = std::fs::symlink_metadata(parent)
        .map_err(|e| invalid(format!("read directory {}: {e}", parent.display())))?;
    if !metadata.is_dir() {
        return Err(invalid(format!(
            "{} must be a real directory",
            parent.display()
        )));
    }
    let read = |extension: &str| -> Result<Vec<u8>> {
        let file = path.with_extension(extension);
        let metadata = std::fs::symlink_metadata(&file)
            .map_err(|e| invalid(format!("read {}: {e}", file.display())))?;
        if !metadata.is_file() {
            return Err(invalid(format!(
                "{} must be a regular file",
                file.display()
            )));
        }
        std::fs::read(&file).map_err(|e| invalid(format!("read {}: {e}", file.display())))
    };
    let (yaml, values) = (read("yaml")?, read("txt")?);
    let result = super::validate_local_pair(stem, &yaml, Some(&values));
    match result.pair {
        Some(pair) => Ok(pair),
        None => Err(result
            .findings
            .refusals
            .into_iter()
            .next()
            .unwrap_or_else(|| invalid("invalid value-list pair"))),
    }
}