use std::collections::{BTreeMap, HashMap};
use rsigma_eval::LogSourceExtractor;
use rsigma_parser::LogSource;
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct ParsedLogsource {
pub product: Option<String>,
pub service: Option<String>,
pub category: Option<String>,
pub custom: Vec<(String, String)>,
}
pub(crate) fn parse_logsource_kv(input: &str) -> Result<ParsedLogsource, String> {
let mut out = ParsedLogsource::default();
for pair in input.split(',') {
let pair = pair.trim();
if pair.is_empty() {
continue;
}
let (key, value) = pair
.split_once('=')
.ok_or_else(|| format!("expected key=value, got '{pair}'"))?;
let key = key.trim();
let value = value.trim();
if value.is_empty() {
continue;
}
match key {
"product" => out.product = Some(value.to_string()),
"service" => out.service = Some(value.to_string()),
"category" => out.category = Some(value.to_string()),
other => {
if let Some(dimension) = other.strip_prefix("custom.") {
if dimension.is_empty() {
return Err(format!("empty custom dimension name in '{pair}'"));
}
out.custom.push((dimension.to_string(), value.to_string()));
} else {
return Err(format!(
"unknown logsource key '{other}' (expected product, service, category, or custom.<name>)"
));
}
}
}
}
Ok(out)
}
pub(crate) fn dims_to_kv(
product: Option<&str>,
service: Option<&str>,
category: Option<&str>,
custom: Option<&HashMap<String, String>>,
) -> Option<String> {
let mut parts = Vec::new();
if let Some(p) = product {
parts.push(format!("product={p}"));
}
if let Some(s) = service {
parts.push(format!("service={s}"));
}
if let Some(c) = category {
parts.push(format!("category={c}"));
}
if let Some(custom) = custom {
for (key, value) in custom.iter().collect::<BTreeMap<_, _>>() {
parts.push(format!("custom.{key}={value}"));
}
}
if parts.is_empty() {
None
} else {
Some(parts.join(","))
}
}
pub(crate) fn build_logsource_extractor(
enabled: bool,
field_map: Option<&str>,
event_logsource: Option<&str>,
evtx_default_product: bool,
) -> Result<Option<LogSourceExtractor>, String> {
if !enabled {
return Ok(None);
}
let mut extractor = LogSourceExtractor::new();
if let Some(map) = field_map {
let parsed =
parse_logsource_kv(map).map_err(|e| format!("invalid --logsource-field-map: {e}"))?;
extractor = extractor.with_field_names(
parsed.product.unwrap_or_else(|| "product".to_string()),
parsed.service.unwrap_or_else(|| "service".to_string()),
parsed.category.unwrap_or_else(|| "category".to_string()),
);
if !parsed.custom.is_empty() {
extractor = extractor.with_custom_fields(parsed.custom);
}
}
let mut defaults = LogSource::default();
if let Some(static_ls) = event_logsource {
let parsed =
parse_logsource_kv(static_ls).map_err(|e| format!("invalid --event-logsource: {e}"))?;
defaults.product = parsed.product;
defaults.service = parsed.service;
defaults.category = parsed.category;
for (dimension, value) in parsed.custom {
defaults.custom.insert(dimension, value);
}
}
if evtx_default_product && defaults.product.is_none() {
defaults.product = Some("windows".to_string());
}
if defaults.product.is_some()
|| defaults.service.is_some()
|| defaults.category.is_some()
|| !defaults.custom.is_empty()
{
extractor = extractor.with_defaults(defaults);
}
Ok(Some(extractor))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_known_keys() {
let parsed = parse_logsource_kv("product=windows,service=sysmon").unwrap();
assert_eq!(parsed.product.as_deref(), Some("windows"));
assert_eq!(parsed.service.as_deref(), Some("sysmon"));
assert_eq!(parsed.category, None);
assert!(parsed.custom.is_empty());
}
#[test]
fn parses_custom_dimensions() {
let parsed =
parse_logsource_kv("product=windows,custom.tenant=acme,custom.region=eu").unwrap();
assert_eq!(parsed.product.as_deref(), Some("windows"));
assert_eq!(
parsed.custom,
vec![
("tenant".to_string(), "acme".to_string()),
("region".to_string(), "eu".to_string()),
]
);
}
#[test]
fn rejects_unknown_bare_keys() {
let err = parse_logsource_kv("product=windows,os=linux").unwrap_err();
assert!(err.contains("unknown logsource key 'os'"), "got: {err}");
}
#[test]
fn rejects_empty_custom_name() {
let err = parse_logsource_kv("custom.=x").unwrap_err();
assert!(err.contains("empty custom dimension name"), "got: {err}");
}
#[test]
fn dims_to_kv_emits_sorted_custom() {
let mut custom = HashMap::new();
custom.insert("tenant".to_string(), "acme".to_string());
custom.insert("region".to_string(), "eu".to_string());
let kv = dims_to_kv(Some("windows"), None, None, Some(&custom)).unwrap();
assert_eq!(kv, "product=windows,custom.region=eu,custom.tenant=acme");
}
#[test]
fn disabled_returns_none() {
assert!(
build_logsource_extractor(false, None, None, false)
.unwrap()
.is_none()
);
}
#[test]
fn evtx_default_supplies_windows_when_unset() {
let extractor = build_logsource_extractor(true, None, None, true)
.unwrap()
.expect("routing enabled");
let ev = serde_json::json!({"CommandLine": "whoami"});
let event = rsigma_eval::event::JsonEvent::borrow(&ev);
assert_eq!(
extractor.extract(&event).product.as_deref(),
Some("windows")
);
}
#[test]
fn explicit_product_overrides_evtx_default() {
let extractor = build_logsource_extractor(true, None, Some("product=linux"), true)
.unwrap()
.expect("routing enabled");
let ev = serde_json::json!({});
let event = rsigma_eval::event::JsonEvent::borrow(&ev);
assert_eq!(extractor.extract(&event).product.as_deref(), Some("linux"));
}
#[test]
fn custom_field_map_and_static_default_resolve() {
let extractor = build_logsource_extractor(
true,
Some("custom.tenant=org"),
Some("custom.region=eu"),
false,
)
.unwrap()
.expect("routing enabled");
let ev = serde_json::json!({"org": "acme"});
let event = rsigma_eval::event::JsonEvent::borrow(&ev);
let ls = extractor.extract(&event);
assert_eq!(ls.custom.get("tenant").map(String::as_str), Some("acme"));
assert_eq!(ls.custom.get("region").map(String::as_str), Some("eu"));
}
}