Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ possible include a PR number for easier tracking.

## Next

* feat: add --replay mode for JSONL event replay without eBPF (#1010)
* feat(grpc): add exponential backoff for reconnection attempts (#789)
* feat: run integration tests on more platforms (#760)
* ROX-34502: reload mTLS certificates on each gRPC connection attempt (#788)
Expand Down
70 changes: 69 additions & 1 deletion fact-ebpf/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ use std::{error::Error, ffi::c_char, fmt::Display, hash::Hash, path::PathBuf};

use aya::{maps::lpm_trie, Pod};
use libc::memcpy;
use serde::{ser::SerializeStruct, Serialize};
use serde::{
de::{self, MapAccess, Visitor},
ser::SerializeStruct,
Deserialize, Serialize,
};

include!(concat!(env!("OUT_DIR"), "/bindings.rs"));

Expand Down Expand Up @@ -101,6 +105,51 @@ impl Serialize for inode_key_t {
}
}

impl<'de> Deserialize<'de> for inode_key_t {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: de::Deserializer<'de>,
{
struct InodeKeyVisitor;

impl<'de> Visitor<'de> for InodeKeyVisitor {
type Value = inode_key_t;

fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a struct with inode and dev fields")
}

fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
where
A: MapAccess<'de>,
{
let mut inode = None;
let mut dev = None;
while let Some(key) = map.next_key::<String>()? {
match key.as_str() {
"inode" => inode = Some(map.next_value()?),
"dev" => dev = Some(map.next_value()?),
_ => {
map.next_value::<de::IgnoredAny>()?;
}
}
}

let Some(inode) = inode else {
return Err(de::Error::missing_field("inode"));
};
let Some(dev) = dev else {
return Err(de::Error::missing_field("dev"));
};

Ok(inode_key_t { inode, dev })
}
}

deserializer.deserialize_struct("inode_key_t", &["inode", "dev"], InodeKeyVisitor)
}
}

unsafe impl Pod for inode_key_t {}

impl Default for monitored_t {
Expand All @@ -124,6 +173,25 @@ impl Serialize for monitored_t {
}
}

impl<'de> Deserialize<'de> for monitored_t {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: de::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
match s.as_str() {
"not monitored" => Ok(monitored_t::NOT_MONITORED),
"by inode" => Ok(monitored_t::MONITORED_BY_INODE),
"by path" => Ok(monitored_t::MONITORED_BY_PATH),
"by parent" => Ok(monitored_t::MONITORED_BY_PARENT),
_ => Err(de::Error::unknown_variant(
&s,
&["not monitored", "by inode", "by path", "by parent"],
)),
}
}
}

impl metrics_by_hook_t {
fn accumulate(mut self, other: &metrics_by_hook_t) -> metrics_by_hook_t {
self.total += other.total;
Expand Down
16 changes: 9 additions & 7 deletions fact/src/bpf/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use log::{error, info, warn};
use tokio::{
io::unix::AsyncFd,
sync::{mpsc, watch},
task::JoinHandle,
task::JoinSet,
};

use crate::{config::BpfConfig, event::Event, host_info, metrics::EventCounter};
Expand Down Expand Up @@ -225,12 +225,13 @@ impl Bpf {
// Gather events from the ring buffer and print them out.
pub fn start(
mut self,
task_set: &mut JoinSet<anyhow::Result<()>>,
mut running: watch::Receiver<bool>,
event_counter: EventCounter,
) -> JoinHandle<anyhow::Result<()>> {
) {
info!("Starting BPF worker...");

tokio::spawn(async move {
task_set.spawn(async move {
let rb = self.take_ringbuffer()?;
let mut fd = AsyncFd::new(rb)?;

Expand Down Expand Up @@ -283,7 +284,7 @@ impl Bpf {
}

Ok(())
})
});
}
}

Expand Down Expand Up @@ -322,9 +323,10 @@ mod bpf_tests {
Bpf::new(reloader.paths(), &reloader.config().bpf).expect("Failed to load BPF code");
let (run_tx, run_rx) = watch::channel(true);
// Create a metrics exporter, but don't start it
let exporter = Exporter::new(bpf.take_metrics().unwrap());
let exporter = Exporter::new(Some(bpf.take_metrics().unwrap()));
let mut task_set = JoinSet::new();

let handle = bpf.start(run_rx, exporter.metrics.bpf_worker.clone());
bpf.start(&mut task_set, run_rx, exporter.metrics.bpf_worker.clone());

tokio::time::sleep(Duration::from_millis(500)).await;

Expand Down Expand Up @@ -412,7 +414,7 @@ mod bpf_tests {

tokio::select! {
res = wait => res.unwrap(),
res = handle => res.unwrap().unwrap(),
res = task_set.join_next() => res.unwrap().unwrap().unwrap(),
}

run_tx.send(false).unwrap();
Expand Down
17 changes: 17 additions & 0 deletions fact/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ pub struct FactConfig {
hotreload: Option<bool>,
scan_interval: Option<Duration>,
rate_limit: Option<u64>,
replay: Option<PathBuf>,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Missing test coverage for the new replay schema field.

tests.rs only adds replay: None to two existing "fully-specified config" literals; there is no test case that parses a YAML replay: <path> value, exercises replay(), or verifies update() correctly merges a Some(path) value (all other fields have dedicated parsing/merge cases). This lines up with Codecov's report of low coverage on this file.

As per coding guidelines, "Add unit tests in fact/src/config/tests.rs for configuration schema changes in fact/src/config/mod.rs."

Also applies to: 82-114, 140-143

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@fact/src/config/mod.rs` at line 44, The new replay field in the configuration
schema is missing dedicated test coverage. Update fact/src/config/tests.rs to
add cases that parse a YAML replay: <path> value, exercise the replay()
accessor, and verify update() merges a Some(path) value correctly; use the
existing Config and update() test patterns as the reference point. Also extend
the fully specified config fixtures that already include replay: None so they
cover the new field consistently, but make sure there is at least one explicit
test for parsing and merging replay.

Source: Coding guidelines

}

impl FactConfig {
Expand Down Expand Up @@ -106,6 +107,10 @@ impl FactConfig {
if let Some(rate_limit) = from.rate_limit {
self.rate_limit = Some(rate_limit);
}

if let Some(ref replay) = from.replay {
self.replay = Some(replay.clone());
}
}

pub fn paths(&self) -> &[PathBuf] {
Expand All @@ -132,6 +137,10 @@ impl FactConfig {
self.rate_limit.unwrap_or(0)
}

pub fn replay(&self) -> Option<&Path> {
self.replay.as_deref()
}

#[cfg(test)]
pub fn set_paths(&mut self, paths: Vec<PathBuf>) {
self.paths = Some(paths);
Expand Down Expand Up @@ -708,6 +717,13 @@ pub struct FactCli {
/// Default value is 0 (unlimited)
#[arg(long, short = 'l', env = "FACT_RATE_LIMIT")]
rate_limit: Option<u64>,

/// Replay events from a JSONL file instead of loading eBPF programs.
///
/// This mode skips BPF loading and HostScanner, reading pre-recorded
/// events for profiling purposes (e.g., valgrind DHAT).
#[arg(long, env = "FACT_REPLAY")]
replay: Option<PathBuf>,
}

impl FactCli {
Expand Down Expand Up @@ -739,6 +755,7 @@ impl FactCli {
hotreload: resolve_bool_arg(self.hotreload, self.no_hotreload),
scan_interval: self.scan_interval,
rate_limit: self.rate_limit,
replay: self.replay.clone(),
}
}
}
Expand Down
15 changes: 9 additions & 6 deletions fact/src/config/reloader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use std::{
use log::{debug, info, warn};
use tokio::{
sync::{Notify, watch},
task::JoinHandle,
task::JoinSet,
time::interval,
};

Expand All @@ -31,13 +31,17 @@ impl Reloader {
///
/// If hotreload is disabled on startup the task will not be
/// spawned.
pub fn start(mut self, mut running: watch::Receiver<bool>) -> Option<JoinHandle<()>> {
pub fn start(
mut self,
task_set: &mut JoinSet<anyhow::Result<()>>,
mut running: watch::Receiver<bool>,
) {
if !self.config.hotreload() {
info!("Configuration hotreload is disabled, changes will require a restart.");
return None;
return;
}

let handle = tokio::spawn(async move {
task_set.spawn(async move {
let mut ticker = interval(Duration::from_secs(10));
loop {
tokio::select! {
Expand All @@ -46,13 +50,12 @@ impl Reloader {
_ = running.changed() => {
if !*running.borrow() {
info!("Stopping config reloader...");
return;
return Ok(());
}
}
}
}
});
Some(handle)
}

pub fn config(&self) -> &FactConfig {
Expand Down
3 changes: 3 additions & 0 deletions fact/src/config/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,7 @@ fn parsing() {
hotreload: Some(false),
scan_interval: Some(Duration::from_secs(60)),
rate_limit: None,
replay: None,
},
),
];
Expand Down Expand Up @@ -1555,6 +1556,7 @@ fn update() {
hotreload: Some(true),
scan_interval: Some(Duration::from_secs(30)),
rate_limit: None,
replay: None,
},
FactConfig {
paths: Some(vec![PathBuf::from("/etc")]),
Expand Down Expand Up @@ -1583,6 +1585,7 @@ fn update() {
hotreload: Some(false),
scan_interval: Some(Duration::from_secs(60)),
rate_limit: None,
replay: None,
},
),
];
Expand Down
25 changes: 13 additions & 12 deletions fact/src/event/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use std::{
};

use globset::GlobSet;
use serde::Serialize;
use serde::{Deserialize, Serialize};

use fact_ebpf::{
PATH_MAX, XATTR_NAME_MAX_LEN, event_t, file_activity_type_t, inode_key_t, monitored_t,
Expand Down Expand Up @@ -70,9 +70,10 @@ pub(crate) enum EventTestData {
Rename(PathBuf),
}

#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Event {
timestamp: u64,
#[serde(skip_deserializing)]
hostname: &'static str,
process: Process,
file: FileData,
Expand Down Expand Up @@ -370,7 +371,7 @@ impl PartialEq for Event {
}
}

#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum FileData {
Open(BaseFileData),
Creation(BaseFileData),
Expand Down Expand Up @@ -544,7 +545,7 @@ impl PartialEq for FileData {
}
}

#[derive(Debug, Clone, Serialize, Default)]
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct BaseFileData {
pub filename: PathBuf,
host_file: PathBuf,
Expand Down Expand Up @@ -586,7 +587,7 @@ impl From<BaseFileData> for fact_api::FileActivityBase {
}
}

#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChmodFileData {
inner: BaseFileData,
new_mode: u16,
Expand Down Expand Up @@ -617,7 +618,7 @@ impl PartialEq for ChmodFileData {
}
}

#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChownFileData {
inner: BaseFileData,
new_uid: u32,
Expand Down Expand Up @@ -656,13 +657,13 @@ impl From<ChownFileData> for fact_api::FileOwnershipChange {
}
}

#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RenameFileData {
new: BaseFileData,
old: BaseFileData,
}

#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum AclTag {
UserObj,
User,
Expand Down Expand Up @@ -694,7 +695,7 @@ impl AclTag {
}
}

#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AclEntry {
tag: AclTag,
perm: u16,
Expand All @@ -713,13 +714,13 @@ impl AclEntry {
}
}

#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum AclType {
Access,
Default,
}

#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AclSetFileData {
inner: BaseFileData,
acl_type: AclType,
Expand Down Expand Up @@ -788,7 +789,7 @@ impl PartialEq for RenameFileData {
}
}

#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct XattrFileData {
inner: BaseFileData,
xattr_name: String,
Expand Down
Loading
Loading