use crate::error::Result;
use crate::query_job::QuerySettings;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueryPack {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub query: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub queries: Option<Vec<PackQuery>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub settings: Option<QuerySettings>,
#[serde(skip_serializing_if = "Option::is_none")]
pub workspaces: Option<WorkspaceScope>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackQuery {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub query: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "scope", rename_all = "lowercase")]
pub enum WorkspaceScope {
All,
Selected { ids: Vec<String> },
Pattern { pattern: String },
}
impl QueryPack {
pub fn load_from_file(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path)?;
if path.extension().and_then(|s| s.to_str()) == Some("json") {
Ok(serde_json::from_str(&content)?)
} else {
Ok(serde_yaml::from_str(&content)?)
}
}
#[allow(dead_code)]
pub fn save_to_file(&self, path: &Path) -> Result<()> {
let content = if path.extension().and_then(|s| s.to_str()) == Some("json") {
serde_json::to_string_pretty(self)?
} else {
serde_yaml::to_string(self)?
};
std::fs::write(path, content)?;
Ok(())
}
pub fn get_queries(&self) -> Vec<PackQuery> {
if let Some(queries) = &self.queries {
queries.clone()
} else if let Some(query) = &self.query {
vec![PackQuery {
name: self.name.clone(),
description: self.description.clone(),
query: query.clone(),
}]
} else {
vec![]
}
}
pub fn validate(&self) -> Result<()> {
if self.query.is_none() && self.queries.is_none() {
return Err(crate::error::KqlPanopticonError::QueryPackValidation(
"Query pack must contain either 'query' or 'queries' field".into(),
));
}
if self.query.is_some() && self.queries.is_some() {
return Err(crate::error::KqlPanopticonError::QueryPackValidation(
"Query pack cannot have both 'query' and 'queries' fields".into(),
));
}
if let Some(queries) = &self.queries {
if queries.is_empty() {
return Err(crate::error::KqlPanopticonError::QueryPackValidation(
"Query pack 'queries' array cannot be empty".into(),
));
}
}
Ok(())
}
pub fn get_library_path(relative_path: &str) -> Result<PathBuf> {
let home =
dirs::home_dir().ok_or(crate::error::KqlPanopticonError::HomeDirectoryNotFound)?;
Ok(home.join(".kql-panopticon/packs").join(relative_path))
}
pub fn list_library_packs() -> Result<Vec<PathBuf>> {
let packs_dir = Self::get_library_path("")?;
if !packs_dir.exists() {
std::fs::create_dir_all(&packs_dir)?;
return Ok(vec![]);
}
let mut packs = Vec::new();
for entry in walkdir::WalkDir::new(&packs_dir)
.into_iter()
.filter_map(|e| e.ok())
{
if entry.file_type().is_file() {
if let Some(ext) = entry.path().extension().and_then(|s| s.to_str()) {
if ext == "yaml" || ext == "yml" || ext == "json" {
packs.push(entry.path().to_path_buf());
}
}
}
}
Ok(packs)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_load_minimal_pack() {
let yaml = r#"
name: "Test Query"
query: "SecurityEvent | limit 10"
"#;
let pack: QueryPack = serde_yaml::from_str(yaml).unwrap();
assert_eq!(pack.name, "Test Query");
assert_eq!(pack.get_queries().len(), 1);
}
#[test]
fn test_load_full_pack() {
let yaml = r#"
name: "Security Hunt"
description: "Multi-query investigation"
queries:
- name: "Query 1"
query: "SecurityEvent | limit 5"
- name: "Query 2"
query: "SigninLogs | limit 5"
settings:
timeout: 60
workspaces:
scope: all
"#;
let pack: QueryPack = serde_yaml::from_str(yaml).unwrap();
assert_eq!(pack.get_queries().len(), 2);
pack.validate().unwrap();
}
#[test]
fn test_validate_empty_pack() {
let pack = QueryPack {
name: "Test".into(),
description: None,
author: None,
version: None,
query: None,
queries: None,
settings: None,
workspaces: None,
};
assert!(pack.validate().is_err());
}
#[test]
fn test_validate_both_query_and_queries() {
let pack = QueryPack {
name: "Test".into(),
description: None,
author: None,
version: None,
query: Some("SecurityEvent".into()),
queries: Some(vec![PackQuery {
name: "Q1".into(),
description: None,
query: "SigninLogs".into(),
}]),
settings: None,
workspaces: None,
};
assert!(pack.validate().is_err());
}
}