use std::fs::{File, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use fs2::FileExt;
use tracing::{debug, error, info};
use crate::LibroError;
use crate::entry::AuditEntry;
use crate::store::AuditStore;
#[derive(Debug)]
pub struct FileStore {
path: PathBuf,
count: usize,
}
impl FileStore {
pub fn open(path: impl AsRef<Path>) -> crate::Result<Self> {
let path = path.as_ref().to_path_buf();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
OpenOptions::new().create(true).append(true).open(&path)?;
let count = Self::count_lines(&path)?;
info!(path = %path.display(), entries = count, "file store opened");
Ok(Self { path, count })
}
pub fn path(&self) -> &Path {
&self.path
}
fn count_lines(path: &Path) -> crate::Result<usize> {
let file = File::open(path)?;
let reader = BufReader::new(file);
let mut count = 0;
for line in reader.lines() {
let line = line?;
if !line.trim().is_empty() {
count += 1;
}
}
Ok(count)
}
}
impl AuditStore for FileStore {
fn append(&mut self, entry: &AuditEntry) -> crate::Result<()> {
let mut file = OpenOptions::new().append(true).open(&self.path)?;
file.lock_exclusive()?;
let json = serde_json::to_string(entry)?;
writeln!(file, "{json}")?;
file.unlock()?;
self.count += 1;
debug!(
hash = entry.hash(),
index = self.count - 1,
"entry appended to file store"
);
Ok(())
}
fn load_all(&self) -> crate::Result<Vec<AuditEntry>> {
let file = File::open(&self.path)?;
file.lock_shared()?;
let reader = BufReader::new(file);
let mut entries = Vec::new();
for (line_num, line) in reader.lines().enumerate() {
let line = line?;
if line.trim().is_empty() {
continue;
}
let entry: AuditEntry = serde_json::from_str(&line).map_err(|e| {
error!(line = line_num + 1, error = %e, "failed to parse entry from file store");
LibroError::Store(format!("line {}: {e}", line_num + 1))
})?;
entries.push(entry);
}
Ok(entries)
}
fn len(&self) -> usize {
self.count
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::entry::EventSeverity;
#[test]
fn file_store_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
let mut store = FileStore::open(&path).unwrap();
assert!(store.is_empty());
let e1 = AuditEntry::new(
EventSeverity::Info,
"daimon",
"agent.start",
serde_json::json!({}),
"",
);
let e2 = AuditEntry::new(
EventSeverity::Security,
"aegis",
"alert",
serde_json::json!({"ip": "10.0.0.1"}),
e1.hash(),
);
store.append(&e1).unwrap();
store.append(&e2).unwrap();
assert_eq!(store.len(), 2);
let loaded = store.load_all().unwrap();
assert_eq!(loaded.len(), 2);
assert_eq!(loaded[0].hash(), e1.hash());
assert_eq!(loaded[1].hash(), e2.hash());
assert_eq!(loaded[1].prev_hash(), e1.hash());
}
#[test]
fn file_store_persistence() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
{
let mut store = FileStore::open(&path).unwrap();
let entry =
AuditEntry::new(EventSeverity::Info, "src", "act", serde_json::json!({}), "");
store.append(&entry).unwrap();
}
let store = FileStore::open(&path).unwrap();
assert_eq!(store.len(), 1);
let loaded = store.load_all().unwrap();
assert_eq!(loaded.len(), 1);
assert!(loaded[0].verify());
}
#[test]
fn file_store_creates_parent_dirs() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nested").join("dir").join("audit.jsonl");
let store = FileStore::open(&path).unwrap();
assert!(store.is_empty());
assert!(path.exists());
}
#[test]
fn file_store_path_accessor() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
let store = FileStore::open(&path).unwrap();
assert_eq!(store.path(), path);
}
#[test]
fn file_store_malformed_line() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
std::fs::write(&path, "not valid json\n").unwrap();
let store = FileStore::open(&path).unwrap();
assert_eq!(store.len(), 1); let err = store.load_all().unwrap_err();
assert!(err.to_string().contains("line 1"));
}
#[test]
fn file_store_skips_blank_lines() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
let entry = AuditEntry::new(EventSeverity::Info, "src", "act", serde_json::json!({}), "");
let json = serde_json::to_string(&entry).unwrap();
std::fs::write(&path, format!("{json}\n\n{json}\n")).unwrap();
let store = FileStore::open(&path).unwrap();
assert_eq!(store.len(), 2); let loaded = store.load_all().unwrap();
assert_eq!(loaded.len(), 2);
}
#[test]
fn file_store_query() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
let mut store = FileStore::open(&path).unwrap();
let e1 = AuditEntry::new(
EventSeverity::Info,
"daimon",
"start",
serde_json::json!({}),
"",
);
let e2 = AuditEntry::new(
EventSeverity::Security,
"aegis",
"alert",
serde_json::json!({}),
e1.hash(),
);
store.append(&e1).unwrap();
store.append(&e2).unwrap();
let results = store
.query(&crate::QueryFilter::new().source("aegis"))
.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].action(), "alert");
}
#[test]
fn file_store_load_and_verify() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
let mut store = FileStore::open(&path).unwrap();
let e1 = AuditEntry::new(EventSeverity::Info, "s", "a", serde_json::json!({}), "");
let e2 = AuditEntry::new(
EventSeverity::Info,
"s",
"b",
serde_json::json!({}),
e1.hash(),
);
store.append(&e1).unwrap();
store.append(&e2).unwrap();
let entries = store.load_and_verify().unwrap();
assert_eq!(entries.len(), 2);
}
#[test]
fn file_store_query_combined() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
let mut store = FileStore::open(&path).unwrap();
let e1 = AuditEntry::new(
EventSeverity::Info,
"daimon",
"start",
serde_json::json!({}),
"",
)
.with_agent("agent-01");
let e2 = AuditEntry::new(
EventSeverity::Security,
"aegis",
"alert",
serde_json::json!({}),
e1.hash(),
)
.with_agent("agent-01");
let e3 = AuditEntry::new(
EventSeverity::Info,
"daimon",
"stop",
serde_json::json!({}),
e2.hash(),
)
.with_agent("agent-02");
store.append(&e1).unwrap();
store.append(&e2).unwrap();
store.append(&e3).unwrap();
let results = store
.query(
&crate::QueryFilter::new()
.source("daimon")
.agent_id("agent-01"),
)
.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].action(), "start");
}
#[test]
fn file_store_append_preserves_existing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
let e1 = AuditEntry::new(
EventSeverity::Info,
"src",
"first",
serde_json::json!({}),
"",
);
{
let mut store = FileStore::open(&path).unwrap();
store.append(&e1).unwrap();
}
let mut store = FileStore::open(&path).unwrap();
assert_eq!(store.len(), 1);
let e2 = AuditEntry::new(
EventSeverity::Warning,
"src",
"second",
serde_json::json!({}),
e1.hash(),
);
store.append(&e2).unwrap();
assert_eq!(store.len(), 2);
let loaded = store.load_all().unwrap();
assert_eq!(loaded[0].action(), "first");
assert_eq!(loaded[1].action(), "second");
}
}