use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
const SBOM_EXTENSIONS: &[&str] = &[
".cdx.json",
".cdx.xml",
".spdx.json",
".spdx",
".spdx.rdf.xml",
".spdx.xml",
".spdx.yml",
".spdx.yaml",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum FileChange {
Added(PathBuf),
Modified(PathBuf),
Removed(PathBuf),
}
#[derive(Debug, Clone)]
struct FileState {
mtime: SystemTime,
size: u64,
content_hash: u64,
}
#[derive(Debug)]
pub(crate) struct FileMonitor {
tracked: HashMap<PathBuf, FileState>,
watch_dirs: Vec<PathBuf>,
}
impl FileMonitor {
pub(crate) fn new(watch_dirs: Vec<PathBuf>) -> Self {
Self {
tracked: HashMap::new(),
watch_dirs,
}
}
pub(crate) fn poll(&mut self) -> Vec<FileChange> {
let mut changes = Vec::new();
let mut seen = HashMap::new();
for dir in &self.watch_dirs {
Self::scan_dir_metadata(dir, &mut seen);
}
for (path, state) in &mut seen {
match self.tracked.get(path) {
None => {
state.content_hash = hash_file_content(path);
changes.push(FileChange::Added(path.clone()));
}
Some(prev) if prev.mtime != state.mtime || prev.size != state.size => {
state.content_hash = hash_file_content(path);
if prev.content_hash != state.content_hash {
changes.push(FileChange::Modified(path.clone()));
}
}
Some(prev) => {
state.content_hash = prev.content_hash;
}
}
}
for path in self.tracked.keys() {
if !seen.contains_key(path) {
changes.push(FileChange::Removed(path.clone()));
}
}
self.tracked = seen;
changes
}
pub(crate) fn tracked_count(&self) -> usize {
self.tracked.len()
}
fn scan_dir_metadata(dir: &Path, out: &mut HashMap<PathBuf, FileState>) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(e) => {
tracing::warn!("Cannot read directory {}: {}", dir.display(), e);
return;
}
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
Self::scan_dir_metadata(&path, out);
} else if is_sbom_file(&path)
&& let Ok(meta) = std::fs::metadata(&path)
{
let mtime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
out.insert(
path,
FileState {
mtime,
size: meta.len(),
content_hash: 0, },
);
}
}
}
}
fn hash_file_content(path: &Path) -> u64 {
match std::fs::read(path) {
Ok(data) => xxhash_rust::xxh3::xxh3_64(&data),
Err(_) => 0,
}
}
fn is_sbom_file(path: &Path) -> bool {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
let lower = name.to_lowercase();
SBOM_EXTENSIONS.iter().any(|ext| lower.ends_with(ext))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_sbom_file() {
assert!(is_sbom_file(Path::new("app.cdx.json")));
assert!(is_sbom_file(Path::new("firmware.spdx.json")));
assert!(is_sbom_file(Path::new("lib.spdx")));
assert!(is_sbom_file(Path::new("APP.CDX.JSON")));
assert!(!is_sbom_file(Path::new("readme.md")));
assert!(!is_sbom_file(Path::new("data.json")));
assert!(!is_sbom_file(Path::new("config.yaml")));
}
#[test]
fn test_monitor_empty_dir() {
let dir = tempfile::tempdir().expect("create temp dir");
let mut monitor = FileMonitor::new(vec![dir.path().to_path_buf()]);
let changes = monitor.poll();
assert!(changes.is_empty());
assert_eq!(monitor.tracked_count(), 0);
}
#[test]
fn test_monitor_detects_new_file() {
let dir = tempfile::tempdir().expect("create temp dir");
let mut monitor = FileMonitor::new(vec![dir.path().to_path_buf()]);
assert!(monitor.poll().is_empty());
let file_path = dir.path().join("test.cdx.json");
std::fs::write(&file_path, "{}").expect("write file");
let changes = monitor.poll();
assert_eq!(changes.len(), 1);
assert!(matches!(&changes[0], FileChange::Added(p) if p == &file_path));
}
#[test]
fn test_monitor_detects_modification() {
let dir = tempfile::tempdir().expect("create temp dir");
let file_path = dir.path().join("test.cdx.json");
std::fs::write(&file_path, "{}").expect("write file");
let mut monitor = FileMonitor::new(vec![dir.path().to_path_buf()]);
monitor.poll();
std::fs::write(&file_path, r#"{"components":[]}"#).expect("write file");
let changes = monitor.poll();
assert!(
changes
.iter()
.any(|c| matches!(c, FileChange::Modified(p) if p == &file_path)),
"expected Modified, got {changes:?}"
);
}
#[test]
fn test_monitor_detects_removal() {
let dir = tempfile::tempdir().expect("create temp dir");
let file_path = dir.path().join("test.cdx.json");
std::fs::write(&file_path, "{}").expect("write file");
let mut monitor = FileMonitor::new(vec![dir.path().to_path_buf()]);
monitor.poll(); assert_eq!(monitor.tracked_count(), 1);
std::fs::remove_file(&file_path).expect("remove file");
let changes = monitor.poll();
assert_eq!(changes.len(), 1);
assert!(matches!(&changes[0], FileChange::Removed(p) if p == &file_path));
assert_eq!(monitor.tracked_count(), 0);
}
#[test]
fn test_monitor_filters_by_extension() {
let dir = tempfile::tempdir().expect("create temp dir");
std::fs::write(dir.path().join("readme.md"), "# readme").expect("write");
std::fs::write(dir.path().join("data.json"), "{}").expect("write");
std::fs::write(dir.path().join("app.cdx.json"), "{}").expect("write");
let mut monitor = FileMonitor::new(vec![dir.path().to_path_buf()]);
let changes = monitor.poll();
assert_eq!(changes.len(), 1);
assert!(
matches!(&changes[0], FileChange::Added(p) if p.file_name().unwrap() == "app.cdx.json")
);
}
#[test]
fn test_monitor_no_change_on_repeated_poll() {
let dir = tempfile::tempdir().expect("create temp dir");
std::fs::write(dir.path().join("test.cdx.json"), "{}").expect("write");
let mut monitor = FileMonitor::new(vec![dir.path().to_path_buf()]);
monitor.poll(); let changes = monitor.poll(); assert!(changes.is_empty());
}
}